From c3f713001d14c57fb34f388bf0e2ebc6c1a38f38 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 18:21:33 +0200 Subject: [PATCH] fix: restore grouped keybinding overview with bold section headers --- README.md | 2 +- src/keys.rs | 246 ++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 174 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index ed1b534..638b545 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin **Ctrl-C quits from any mode or popup.** The bottom bar shows essential actions for the current context. Press `?` in normal mode—including selectors and previews—for the full reference. Scroll help with `j` / `k`, arrows, Ctrl-J / Ctrl-K, or PageDown / PageUp; `?` or Esc closes it without changing the underlying view or dialog. In text fields, `?` remains ordinary input. -The overview title and action headings are bold; binding values, descriptions, and context labels remain normal weight. +The overview groups shortcut/action rows under bold section headers such as `VIEWS AND VIDEOS`. Shortcuts and action descriptions remain normal weight; the rows reflect configured bindings rather than showing internal action paths. Back and forward navigation restore cached videos, selection, and pagination, including replaced temporary tabs. Removed views are skipped; empty history leaves the current view unchanged. Opening a different view after going back discards the old forward branch. Esc first closes a dialog or leaves insert mode without navigating. Minus and plus remain ordinary text inside input fields. diff --git a/src/keys.rs b/src/keys.rs index a2495be..363621e 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -424,52 +424,172 @@ impl Bindings { } pub fn help_text(&self) -> Text<'static> { - let mut text = Text::from(Line::from(Span::raw("Keyboard bindings").bold())); - text.push_line(""); - for definition in DEFINITIONS { - let aliases = &self.labels[definition.action as usize]; - let mut body = String::from(": "); - if aliases.is_empty() { - body.push_str("unbound"); - } else { - for (index, alias) in aliases.iter().enumerate() { - if index != 0 { - body.push_str(", "); + use Action::*; + type Row = (&'static [Action], &'static str); + let sections: &[(&str, &[Row])] = &[ + ( + "VIEWS AND VIDEOS", + &[ + (&[VideoNext, VideoPrevious], "Select next / previous video"), + (&[VideoPlay], "Play the selected video in Terebi"), + (&[SearchEdit], "Edit the search query"), + ( + &[InfoOverview, InfoDescription], + "Info → full description → info", + ), + (&[VideoChannel], "Browse the selected video's channel"), + (&[ChannelBrowse], "Choose a saved channel"), + (&[CategoryBrowse], "Choose a saved category"), + (&[ChannelTags], "Edit the selected channel's tags"), + (&[CategoryTags], "Edit all channels in a category"), + (&[ViewTabNext, ViewTabPrevious], "Cycle visible tabs"), + (&[ViewPrevious], "Back (preview close takes priority)"), + (&[ViewNext], "Forward after going back"), + (&[VideoFirst], "First loaded video"), + (&[VideoLast], "Last loaded video"), + (&[ViewRefresh], "Reload config and refresh"), + (&[Help], "Open / close this reference"), + (&[Quit, Interrupt], "Quit"), + ], + ), + ( + "FULL DESCRIPTION", + &[ + (&[DescriptionNext, DescriptionPrevious], "Scroll lines"), + ( + &[DescriptionPageNext, DescriptionPagePrevious], + "Scroll pages", + ), + (&[VideoNext, VideoPrevious], "Change the selected video"), + (&[InfoOverview], "Return to video info"), + (&[InfoHide], "Close the preview"), + ], + ), + ( + "CHANNEL / CATEGORY SELECTOR", + &[ + (&[SelectorNext, SelectorPrevious], "Move in NORMAL mode"), + (&[SelectorEdit], "Enter filter INSERT mode"), + ( + &[SelectorInputNext, SelectorInputPrevious], + "Move while filtering", + ), + (&[SelectorOpen, InputSubmit], "Browse the selected match"), + (&[SelectorPin], "Pin / unpin a category (NORMAL)"), + (&[InputCancel, SelectorClose], "INSERT → NORMAL → close"), + ], + ), + ( + "CHANNEL TAGS", + &[ + (&[TagNext, TagPrevious], "Move between tags"), + (&[TagToggle], "Toggle a tag"), + (&[TagNew], "Enter a new tag name"), + (&[TagSave], "Save tags"), + (&[InputSubmit], "Stage a new tag name"), + (&[TagCancel], "Cancel tag changes"), + (&[InputCancel], "Abandon a new tag name"), + ], + ), + ( + "TEXT INPUT", + &[ + (&[InputSubmit], "Submit; search returns to NORMAL"), + (&[InputCancel], "Leave INSERT / abandon tag name"), + (&[DeleteCharacter], "Delete one character"), + (&[DeleteWord], "Delete previous word"), + (&[DeleteLine], "Clear the line"), + ], + ), + ( + "HELP WINDOW", + &[ + (&[HelpNext, HelpPrevious], "Scroll lines"), + (&[HelpPageNext, HelpPagePrevious], "Scroll pages"), + (&[Help, HelpClose], "Close help without changing view"), + (&[Interrupt], "Quit from any mode or popup"), + ], + ), + ]; + let mut text = Text::default(); + for &(heading, rows) in sections { + text.push_line(""); + text.push_line(Line::from(Span::raw(heading).bold())); + for &(actions, description) in rows { + if actions == [InfoOverview, InfoDescription] + && self.labels[InfoOverview as usize] != self.labels[InfoDescription as usize] + { + for (action, description) in [ + (InfoOverview, "Open video info"), + (InfoDescription, "Open the full description"), + ] { + text.push_line(format!( + "{:<18} {}", + self.help_keys(&[action]), + description + )); } - body.push_str(alias); + } else { + text.push_line(format!("{:<18} {}", self.help_keys(actions), description)); } } - let _ = write!(body, " — {} [", definition.description); - for (index, context) in definition.contexts.iter().enumerate() { - if index != 0 { - body.push_str(", "); - } - body.push_str(match context { - Context::Normal => "video list", - Context::Info => "video overview", - Context::Description => "video description", - Context::SearchInput => "search input", - Context::SelectorNormal => "category/channel picker", - Context::SelectorInput => "picker filter input", - Context::TagList => "category tag picker", - Context::TagInput => "new tag input", - Context::Help => "help", - }); - } - body.push(']'); - text.push_line(Line::from(vec![ - Span::raw(definition.name).bold(), - Span::raw(body), - ])); } - text.push_line(""); - text.push_line( - "Text input inserts literal characters; normal-mode commands do not run there.", - ); - text.push_line("Shared overview/description bindings cycle overview → description → overview; distinct bindings open either view directly."); - text.push_line("Preview close takes priority over history back. Description scrolling takes priority over video selection on shared bindings."); text } + + fn help_keys(&self, actions: &[Action]) -> String { + use Action::*; + let matches = |action: Action, expected: &[&str]| { + self.labels[action as usize] + .iter() + .map(String::as_str) + .eq(expected.iter().copied()) + }; + let movement = match actions { + [VideoNext, VideoPrevious] | [HelpNext, HelpPrevious] => { + matches(actions[0], &["j", "↓", "Ctrl+j"]) + && matches(actions[1], &["k", "↑", "Ctrl+k"]) + } + [SelectorNext, SelectorPrevious] | [TagNext, TagPrevious] => { + matches(actions[0], &["j", "↓"]) && matches(actions[1], &["k", "↑"]) + } + _ => false, + }; + if movement { + return if actions == [HelpNext, HelpPrevious] { + "j/k ↑/↓ Ctrl-J/K".into() + } else { + "j / k or arrows".into() + }; + } + if matches!( + actions, + [DescriptionNext, DescriptionPrevious] | [SelectorInputNext, SelectorInputPrevious] + ) && matches(actions[0], &["↓", "Ctrl+j"]) + && matches(actions[1], &["↑", "Ctrl+k"]) + { + return "↑/↓, Ctrl-J/K".into(); + } + if actions == [ViewTabNext, ViewTabPrevious] + && matches(ViewTabNext, &["Tab", "Ctrl+Tab"]) + && matches(ViewTabPrevious, &["Shift+Tab"]) + { + return "Tab / Shift-Tab".into(); + } + let mut groups = Vec::new(); + for &action in actions { + let labels = &self.labels[action as usize]; + let group = if labels.is_empty() { + "unbound".into() + } else { + labels.join(", ") + }; + if !groups.contains(&group) { + groups.push(group); + } + } + groups.join(" / ") + } } fn overlap_winner(context: Context, first: Action, second: Action) -> Option { @@ -534,55 +654,35 @@ mod tests { } #[test] - fn help_headings_are_bold_but_bindings_and_descriptions_are_not() { + fn only_section_headers_are_bold_in_the_grouped_overview() { use ratatui::{ buffer::Buffer, layout::Rect, style::Modifier, widgets::{Paragraph, Widget, Wrap}, }; - let bindings = configured("[keys]\napp.interrupt = 'f24'\n").unwrap(); + let bindings = configured("[keys]\nvideo.play = 'f24'\n").unwrap(); for width in [21, 64] { - let area = Rect::new(0, 0, width, 8); + let area = Rect::new(0, 0, width, 16); let mut buffer = Buffer::empty(area); Paragraph::new(bindings.help_text()) .wrap(Wrap { trim: false }) .render(area, &mut buffer); - assert!(buffer[(0, 0)].modifier.contains(Modifier::BOLD)); - for x in 0.."app.interrupt".len() as u16 { - assert!(buffer[(x, 2)].modifier.contains(Modifier::BOLD)); - } - assert!( - !buffer[("app.interrupt".len() as u16, 2)] - .modifier - .contains(Modifier::BOLD) - ); - let mut found_binding = false; + assert!(buffer[(0, 1)].modifier.contains(Modifier::BOLD)); for y in 2..area.height { - let row = (0..width) - .map(|x| buffer[(x, y)].symbol()) - .collect::(); - if y > 2 && row.starts_with("app.quit") { - break; - } - let body_start = if y == 2 { - "app.interrupt".len() as u16 - } else { - 0 - }; - for x in body_start..width { + for x in 0..width { assert!(!buffer[(x, y)].modifier.contains(Modifier::BOLD)); } - if let Some(x) = row.find("F24") { - found_binding = true; - for x in x as u16..x as u16 + 3 { - assert!(!buffer[(x, y)].modifier.contains(Modifier::BOLD)); - } - } } + let binding_visible = (2..area.height).any(|y| { + (0..width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + .contains("F24") + }); assert!( - found_binding, - "configured binding must remain visible after wrapping" + binding_visible, + "custom shortcut must remain visible after wrapping" ); } }