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.**
|
**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
@@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user