a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv chromecast mpris navidrome jellyfin upnp tui
0

Configure Feed

Select the types of activity you want to include in your feed.

fin / crates / fin-tui / src / widgets / help.rs
11 kB 263 lines
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" }, 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 (symphonia + 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}