fix: restore grouped keybinding overview with bold section headers
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
+173
-73
@@ -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<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)
|
||||
.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 {
|
||||
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::<String>()
|
||||
.contains("F24")
|
||||
});
|
||||
assert!(
|
||||
found_binding,
|
||||
"configured binding must remain visible after wrapping"
|
||||
binding_visible,
|
||||
"custom shortcut must remain visible after wrapping"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user