diff --git a/README.md b/README.md index 6be5dfe..ed1b534 100644 --- a/README.md +++ b/README.md @@ -77,6 +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. 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 31b7b47..a2495be 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -1,6 +1,10 @@ use std::{fmt::Write as _, io}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + style::Stylize, + text::{Line, Span, Text}, +}; use toml_edit::{Array, Item}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -419,25 +423,28 @@ impl Bindings { .map_or("unbound", String::as_str) } - pub fn help_text(&self) -> String { - let mut text = String::from("Keyboard bindings\n\n"); + 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 bindings = if aliases.is_empty() { - "unbound".to_owned() + let mut body = String::from(": "); + if aliases.is_empty() { + body.push_str("unbound"); } else { - aliases.join(", ") - }; - let _ = write!( - text, - "{}: {} — {} [", - definition.name, bindings, definition.description - ); + for (index, alias) in aliases.iter().enumerate() { + if index != 0 { + body.push_str(", "); + } + body.push_str(alias); + } + } + let _ = write!(body, " — {} [", definition.description); for (index, context) in definition.contexts.iter().enumerate() { if index != 0 { - text.push_str(", "); + body.push_str(", "); } - text.push_str(match context { + body.push_str(match context { Context::Normal => "video list", Context::Info => "video overview", Context::Description => "video description", @@ -449,11 +456,18 @@ impl Bindings { Context::Help => "help", }); } - text.push_str("]\n"); + body.push(']'); + text.push_line(Line::from(vec![ + Span::raw(definition.name).bold(), + Span::raw(body), + ])); } - text.push_str("\nText input inserts literal characters; normal-mode commands do not run there.\n\ -Shared overview/description bindings cycle overview → description → overview; distinct bindings open either view directly.\n\ -Preview close takes priority over history back. Description scrolling takes priority over video selection on shared bindings.\n"); + 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 } } @@ -519,6 +533,60 @@ mod tests { bindings.resolve(context, KeyEvent::new(code, modifiers)) } + #[test] + fn help_headings_are_bold_but_bindings_and_descriptions_are_not() { + use ratatui::{ + buffer::Buffer, + layout::Rect, + style::Modifier, + widgets::{Paragraph, Widget, Wrap}, + }; + let bindings = configured("[keys]\napp.interrupt = 'f24'\n").unwrap(); + for width in [21, 64] { + let area = Rect::new(0, 0, width, 8); + 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; + 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 { + 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)); + } + } + } + assert!( + found_binding, + "configured binding must remain visible after wrapping" + ); + } + } + #[test] fn contextual_cycle_and_direct_preview_bindings() { let defaults = Bindings::default();