fix: restore grouped keybinding overview with bold section headers

This commit is contained in:
2026-10-11 18:21:33 +02:00
parent df63eb0d85
commit c3f713001d
2 changed files with 174 additions and 74 deletions
+1 -1
View File
@@ -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.** **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 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. 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.
+173 -73
View File
@@ -424,52 +424,172 @@ impl Bindings {
} }
pub fn help_text(&self) -> Text<'static> { pub fn help_text(&self) -> Text<'static> {
let mut text = Text::from(Line::from(Span::raw("Keyboard bindings").bold())); use Action::*;
text.push_line(""); type Row = (&'static [Action], &'static str);
for definition in DEFINITIONS { let sections: &[(&str, &[Row])] = &[
let aliases = &self.labels[definition.action as usize]; (
let mut body = String::from(": "); "VIEWS AND VIDEOS",
if aliases.is_empty() { &[
body.push_str("unbound"); (&[VideoNext, VideoPrevious], "Select next / previous video"),
} else { (&[VideoPlay], "Play the selected video in Terebi"),
for (index, alias) in aliases.iter().enumerate() { (&[SearchEdit], "Edit the search query"),
if index != 0 { (
body.push_str(", "); &[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 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<Action> { fn overlap_winner(context: Context, first: Action, second: Action) -> Option<Action> {
@@ -534,55 +654,35 @@ mod tests {
} }
#[test] #[test]
fn help_headings_are_bold_but_bindings_and_descriptions_are_not() { fn only_section_headers_are_bold_in_the_grouped_overview() {
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::Rect, layout::Rect,
style::Modifier, style::Modifier,
widgets::{Paragraph, Widget, Wrap}, 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] { 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); let mut buffer = Buffer::empty(area);
Paragraph::new(bindings.help_text()) Paragraph::new(bindings.help_text())
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.render(area, &mut buffer); .render(area, &mut buffer);
assert!(buffer[(0, 0)].modifier.contains(Modifier::BOLD)); assert!(buffer[(0, 1)].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 { for y in 2..area.height {
let row = (0..width) for x in 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)); 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::<String>()
.contains("F24")
});
assert!( assert!(
found_binding, binding_visible,
"configured binding must remain visible after wrapping" "custom shortcut must remain visible after wrapping"
); );
} }
} }