a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv
chromecast
mpris
navidrome
jellyfin
upnp
tui
1//! Keyboard-shortcuts help modal. `?` toggles it; Esc closes when open.
2//!
3//! Rendered as a centered `Clear`-backed popup over the current screen.
4//! Content is grouped into sections (Navigation / Playback / …) and each
5//! row is a two-column `key -> description` layout so the reader can scan
6//! either side at a glance.
7
8use ratatui::buffer::Buffer;
9use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
10use ratatui::style::{Modifier, Style};
11use ratatui::text::{Line, Span};
12use ratatui::widgets::{Clear, Paragraph, Widget, Wrap};
13
14use crate::theme::{border_style, muted_style, title_style, Palette};
15use crate::widgets::neon_block;
16
17/// One row in the help list — a key (or key combo) and what it does.
18pub struct HelpEntry {
19 pub key: &'static str,
20 pub description: &'static str,
21}
22
23/// A section header + its rows.
24pub struct HelpSection {
25 pub title: &'static str,
26 pub entries: &'static [HelpEntry],
27}
28
29/// Every shortcut fin knows about, grouped for readability. Static data so
30/// tests can lock the layout and the compiler catches typos.
31pub const HELP_SECTIONS: &[HelpSection] = &[
32 HelpSection {
33 title: "Navigation",
34 entries: &[
35 HelpEntry { key: "Tab / Shift+Tab", description: "next / prev screen" },
36 HelpEntry { key: "1 … 8", description: "jump to Music / Videos / Playlists / Favorites / Queue / Search / Devices / Settings (Videos is Jellyfin-only — the tab is hidden on Subsonic servers)" },
37 HelpEntry { key: "↑ ↓ / k j", description: "move selection" },
38 HelpEntry { key: "PgUp / PgDown", description: "jump 10 rows" },
39 HelpEntry { key: "Enter", description: "drill in / play leaf / connect (Devices) / switch server (Settings)" },
40 HelpEntry { key: "Esc", description: "pop drill-in, close search or this help" },
41 HelpEntry { key: "/", description: "focus Search input" },
42 HelpEntry { key: "r", description: "refresh current screen" },
43 HelpEntry { key: "t", description: "cycle to next saved server (Jellyfin or Subsonic)" },
44 HelpEntry { key: "m", description: "switch to local (rockbox-playback + mpv) renderer" },
45 HelpEntry { key: "q / Ctrl+C", description: "quit" },
46 ],
47 },
48 HelpSection {
49 title: "Playback",
50 entries: &[
51 HelpEntry { key: "x", description: "play the highlighted container without drilling in" },
52 HelpEntry { key: "Shift+X", description: "shuffle-play — the highlighted container or the whole view (album / playlist / favorites / videos)" },
53 HelpEntry { key: "a", description: "enqueue the highlighted item" },
54 HelpEntry { key: "n", description: "play the highlighted item next" },
55 HelpEntry { key: "Space / p", description: "pause / resume" },
56 HelpEntry { key: "s", description: "stop" },
57 HelpEntry { key: "< / > (or h / l)", description: "previous / next track" },
58 HelpEntry { key: "+ / −", description: "volume up / down" },
59 HelpEntry { key: "Shift+L", description: "like — favorite/star the highlighted item (or the playing track)" },
60 HelpEntry { key: "Shift+D", description: "dislike — remove it from favorites" },
61 ],
62 },
63 HelpSection {
64 title: "Queue screen",
65 entries: &[
66 HelpEntry { key: "Enter", description: "jump playhead to the highlighted entry (preserves the queue)" },
67 HelpEntry { key: "d", description: "remove the highlighted entry" },
68 HelpEntry { key: "Shift+C", description: "clear the whole queue" },
69 ],
70 },
71 HelpSection {
72 title: "Modes & effects",
73 entries: &[
74 HelpEntry { key: "z", description: "toggle shuffle" },
75 HelpEntry { key: "Shift+R", description: "cycle repeat mode: off → all → one" },
76 HelpEntry { key: "g", description: "cycle ReplayGain: off → track → album" },
77 HelpEntry { key: "f", description: "cycle crossfade mode: off → crossfade → mixed" },
78 HelpEntry { key: "Shift+F", description: "cycle crossfade duration (3, 5, 8, 12 s)" },
79 ],
80 },
81 HelpSection {
82 title: "Equalizer & tone (Settings screen)",
83 entries: &[
84 HelpEntry { key: "Shift+E", description: "toggle the 10-band Rockbox EQ" },
85 HelpEntry { key: "[ / ]", description: "select previous / next EQ band" },
86 HelpEntry { key: "Shift+↑ / Shift+↓", description: "nudge selected band's gain by ±1 dB" },
87 HelpEntry { key: "b / Shift+B", description: "bass shelf −1 dB / +1 dB" },
88 HelpEntry { key: "y / Shift+Y", description: "treble shelf −1 dB / +1 dB" },
89 ],
90 },
91 HelpSection {
92 title: "Help",
93 entries: &[
94 HelpEntry { key: "?", description: "show / hide this help" },
95 ],
96 },
97];
98
99/// The modal itself — clears the underlying area, draws a bordered block,
100/// then renders every section stacked inside with a consistent
101/// `key → description` column layout.
102pub struct HelpModal;
103
104impl HelpModal {
105 /// Compute the ideal centered rect. The popup consumes as much
106 /// vertical space as the terminal offers (up to a comfortable ceiling)
107 /// so nothing gets clipped — every current section plus its footer
108 /// fits inside ~42 rows.
109 pub fn area_for(screen: Rect) -> Rect {
110 let w = screen.width.saturating_sub(8).clamp(60, 120);
111 // Leave a 2-row margin above + below so the popup doesn't slam
112 // into the terminal edges but still shows every section.
113 let h = screen.height.saturating_sub(4).clamp(20, 60);
114 Rect::new(
115 screen.x + (screen.width.saturating_sub(w)) / 2,
116 screen.y + (screen.height.saturating_sub(h)) / 2,
117 w,
118 h,
119 )
120 }
121}
122
123impl Widget for HelpModal {
124 fn render(self, area: Rect, buf: &mut Buffer) {
125 // Blank the underlying content first — otherwise the screen
126 // bleeds through the semi-transparent Terminal cells.
127 Clear.render(area, buf);
128
129 let block = neon_block(" ? Keyboard shortcuts ", true)
130 .border_style(border_style(true))
131 .title_style(title_style());
132 let inner = block.inner(area);
133 block.render(area, buf);
134
135 // Split into as many horizontal rows as the widget occupies, one
136 // Line per row. We rely on Paragraph's wrapping for width overflow.
137 let lines = build_lines(inner.width as usize);
138 let footer = Line::from(vec![Span::styled(" ? or Esc to close", muted_style())]);
139
140 let rows = Layout::default()
141 .direction(Direction::Vertical)
142 .constraints([Constraint::Min(1), Constraint::Length(1)])
143 .split(inner);
144
145 Paragraph::new(lines)
146 .wrap(Wrap { trim: false })
147 .style(Style::default().bg(Palette::BG))
148 .render(rows[0], buf);
149 Paragraph::new(footer)
150 .alignment(Alignment::Left)
151 .style(Style::default().bg(Palette::BG))
152 .render(rows[1], buf);
153 }
154}
155
156/// Build every visible line in the popup: section headers, entries,
157/// blank spacers. `width` is the inner area width; used to right-pad the
158/// key column so descriptions line up regardless of key length.
159fn build_lines(width: usize) -> Vec<Line<'static>> {
160 // Widest key across ALL sections drives the column width so every row
161 // stays column-aligned even if one section has "Enter" and the next has
162 // "Shift+↑ / Shift+↓".
163 let key_col_max = HELP_SECTIONS
164 .iter()
165 .flat_map(|s| s.entries.iter())
166 .map(|e| unicode_width::UnicodeWidthStr::width(e.key))
167 .max()
168 .unwrap_or(0);
169 let key_col_max = key_col_max.min(width.saturating_sub(6)).max(1);
170
171 let mut lines: Vec<Line<'static>> = Vec::new();
172
173 for (idx, section) in HELP_SECTIONS.iter().enumerate() {
174 if idx > 0 {
175 lines.push(Line::from(""));
176 }
177 lines.push(Line::from(vec![Span::styled(
178 format!(" {}", section.title),
179 Style::default()
180 .fg(Palette::ACCENT)
181 .add_modifier(Modifier::BOLD),
182 )]));
183 for entry in section.entries {
184 let key_pad =
185 key_col_max.saturating_sub(unicode_width::UnicodeWidthStr::width(entry.key));
186 lines.push(Line::from(vec![
187 Span::raw(" "),
188 Span::styled(
189 entry.key,
190 Style::default()
191 .fg(Palette::HIGHLIGHT)
192 .add_modifier(Modifier::BOLD),
193 ),
194 Span::raw(" ".repeat(key_pad)),
195 Span::styled(" ", muted_style()),
196 Span::styled(entry.description, Style::default().fg(Palette::FG)),
197 ]));
198 }
199 }
200 lines
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn every_section_has_at_least_one_entry() {
209 for s in HELP_SECTIONS {
210 assert!(
211 !s.entries.is_empty(),
212 "section '{}' has no entries",
213 s.title
214 );
215 }
216 }
217
218 #[test]
219 fn every_entry_has_non_empty_key_and_description() {
220 for s in HELP_SECTIONS {
221 for e in s.entries {
222 assert!(
223 !e.key.trim().is_empty(),
224 "empty key in section '{}'",
225 s.title
226 );
227 assert!(
228 !e.description.trim().is_empty(),
229 "empty description for '{}'",
230 e.key
231 );
232 }
233 }
234 }
235
236 #[test]
237 fn help_advertises_its_own_toggle_key() {
238 // Would be very confusing to open a help panel and NOT tell the
239 // user how to close it. Guard against a future refactor dropping
240 // the toggle documentation.
241 let has_toggle = HELP_SECTIONS
242 .iter()
243 .flat_map(|s| s.entries.iter())
244 .any(|e| e.key == "?");
245 assert!(has_toggle);
246 }
247
248 #[test]
249 fn build_lines_starts_with_a_section_header() {
250 // First rendered row is a section title — not a blank spacer, so
251 // the popup doesn't waste a row at the top.
252 let lines = build_lines(80);
253 // A `Line` with a single styled span; peek the raw content.
254 let first = lines
255 .first()
256 .expect("build_lines produced no output")
257 .spans
258 .iter()
259 .map(|s| s.content.as_ref())
260 .collect::<String>();
261 assert!(first.trim_start().starts_with("Navigation"));
262 }
263}