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.**
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.
+174 -74
View File
@@ -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()));
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("");
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");
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
));
}
} else {
for (index, alias) in aliases.iter().enumerate() {
if index != 0 {
body.push_str(", ");
}
body.push_str(alias);
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<Action> {
@@ -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)
for x in 0..width {
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>();
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));
}
}
}
.collect::<String>()
.contains("F24")
});
assert!(
found_binding,
"configured binding must remain visible after wrapping"
binding_visible,
"custom shortcut must remain visible after wrapping"
);
}
}