feat: emphasize keybinding overview headings

This commit is contained in:
2026-10-11 18:08:50 +02:00
parent aaa13ab905
commit df63eb0d85
2 changed files with 86 additions and 17 deletions
+1
View File
@@ -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.
+85 -17
View File
@@ -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::<String>();
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();