use std::fmt;
use ratatui::{Frame, buffer::Buffer, layout::Rect, style::Style};
use crate::{
degrade::{self, Priority},
keys::{Action, BindingTable, Context},
sort::SortColumn,
theme::{Role, Theme},
};
#[derive(Clone, Debug)]
struct Hint {
key: String,
label: String,
}
impl fmt::Display for Hint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.label.is_empty() {
write!(f, "{}", self.key)
} else {
write!(f, "{} {}", self.key, self.label)
}
}
}
struct Item {
hint: Hint,
priority: Priority,
built: bool,
}
fn hint_item(table: &BindingTable, context: Context, action: Action, label: &str) -> (Hint, bool) {
let (code, modifiers) = table.primary_chord(context, action).unwrap_or_else(|| {
panic!("{action:?} is not bound in {context:?}, but the footer names it")
});
(
Hint {
key: crate::keys::chord_label(code, modifiers),
label: label.to_string(),
},
table.is_built(context, action),
)
}
fn combined_hint_item(
table: &BindingTable,
context: Context,
first: Action,
second: Action,
label: &str,
) -> (Hint, bool) {
let chord = |action| {
let (code, modifiers) = table
.primary_chord(context, action)
.unwrap_or_else(|| panic!("{action:?} is not bound in {context:?}"));
crate::keys::chord_label(code, modifiers)
};
let hint = Hint {
key: format!("{}/{}", chord(first), chord(second)),
label: label.to_string(),
};
let built = table.is_built(context, first) && table.is_built(context, second);
(hint, built)
}
fn list_items(table: &BindingTable) -> Vec<Item> {
let item = |(hint, built), priority| Item {
hint,
priority,
built,
};
vec![
item(
combined_hint_item(
table,
Context::List,
Action::MoveDown,
Action::MoveUp,
"move",
),
Priority::Drop(2),
),
item(
hint_item(table, Context::List, Action::ToggleSelection, "select"),
Priority::Drop(5),
),
item(
hint_item(table, Context::List, Action::OpenDetail, "detail"),
Priority::Drop(3),
),
item(
hint_item(table, Context::Global, Action::EnterFilter, "filter"),
Priority::Drop(4),
),
item(
hint_item(table, Context::Global, Action::OpenLauncher, "launcher"),
Priority::Drop(6),
),
item(
hint_item(table, Context::Global, Action::OpenActionPalette, "action"),
Priority::Drop(6),
),
item(
hint_item(table, Context::Global, Action::RefreshAll, "refresh"),
Priority::Drop(1),
),
item(
hint_item(table, Context::Global, Action::OpenHelp, "help"),
Priority::Pinned,
),
]
}
fn detail_items(table: &BindingTable) -> Vec<Item> {
let item = |(hint, built), priority| Item {
hint,
priority,
built,
};
vec![
item(
combined_hint_item(
table,
Context::Detail,
Action::ScrollDown,
Action::ScrollUp,
"scroll",
),
Priority::Drop(2),
),
item(
hint_item(table, Context::Global, Action::EnterFilter, "filter"),
Priority::Drop(3),
),
item(
hint_item(table, Context::Global, Action::OpenLauncher, "launcher"),
Priority::Drop(4),
),
item(
hint_item(table, Context::Global, Action::OpenActionPalette, "action"),
Priority::Drop(4),
),
item(
hint_item(table, Context::Global, Action::RefreshAll, "refresh"),
Priority::Drop(1),
),
item(
hint_item(table, Context::Global, Action::OpenHelp, "help"),
Priority::Pinned,
),
]
}
fn confirm_items(table: &BindingTable) -> Vec<Item> {
let item = |(hint, built), priority| Item {
hint,
priority,
built,
};
vec![
item(
hint_item(table, Context::Confirm, Action::Run, "run"),
Priority::Pinned,
),
item(
hint_item(table, Context::Confirm, Action::Decline, "cancel"),
Priority::Pinned,
),
]
}
fn filter_items(table: &BindingTable) -> Vec<Item> {
let item = |(hint, built), priority| Item {
hint,
priority,
built,
};
vec![
item(
hint_item(table, Context::Input, Action::Apply, "apply"),
Priority::Pinned,
),
item(
hint_item(table, Context::Input, Action::Cancel, "cancel"),
Priority::Pinned,
),
item(
hint_item(table, Context::Input, Action::ClearFilter, "clear filter"),
Priority::Drop(1),
),
]
}
fn action_palette_items(table: &BindingTable) -> Vec<Item> {
let item = |(hint, built), priority| Item {
hint,
priority,
built,
};
vec![
item(
hint_item(table, Context::Input, Action::Apply, "run"),
Priority::Drop(3),
),
item(
hint_item(table, Context::Input, Action::InsertNewline, "newline"),
Priority::Drop(1),
),
item(
hint_item(table, Context::Input, Action::OpenInEditor, "editor"),
Priority::Drop(2),
),
item(
hint_item(table, Context::Input, Action::Cancel, "cancel"),
Priority::Pinned,
),
]
}
fn sort_items(table: &BindingTable) -> Vec<Item> {
let item = |(hint, built), priority| Item {
hint,
priority,
built,
};
let columns = SortColumn::ALL;
let mut items: Vec<Item> = columns
.iter()
.enumerate()
.map(|(index, column)| {
item(
hint_item(table, Context::Sort, column.action(), column.label()),
Priority::Drop((columns.len() - index) as u8),
)
})
.collect();
items.push(item(
hint_item(table, Context::Sort, Action::SortNatural, "natural"),
Priority::Drop((columns.len() + 1) as u8),
));
items.push(item(
hint_item(table, Context::Sort, Action::CloseSortMenu, "cancel"),
Priority::Pinned,
));
items
}
const ELLIPSIS: &str = " ...";
const SEPARATOR: &str = " ";
#[derive(Debug)]
struct FooterLine {
hints: Vec<Hint>,
truncated: bool,
}
impl fmt::Display for FooterLine {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let joined = self
.hints
.iter()
.map(Hint::to_string)
.collect::<Vec<_>>()
.join(SEPARATOR);
write!(f, "{joined}")?;
if self.truncated {
write!(f, "{ELLIPSIS}")?;
}
Ok(())
}
}
fn budget(items: &[Item], width: usize) -> FooterLine {
debug_assert!(
items
.iter()
.all(|item| item.hint.key.is_ascii() && item.hint.label.is_ascii()),
"a footer item must be ASCII, or its byte length is not its display width"
);
let generic_items: Vec<degrade::Item<Hint>> = items
.iter()
.map(|item| degrade::Item {
content: item.hint.clone(),
priority: item.priority,
})
.collect();
let line = degrade::budget(&generic_items, width, SEPARATOR, ELLIPSIS);
FooterLine {
hints: line.items,
truncated: line.truncated,
}
}
fn footer_line(table: &BindingTable, context: Context, width: u16) -> FooterLine {
let items = match context {
Context::List => list_items(table),
Context::Detail => detail_items(table),
Context::Confirm => confirm_items(table),
Context::Input => filter_items(table),
Context::Sort => sort_items(table),
Context::Global | Context::Overlay => {
panic!("no footer content is defined yet for {context:?}")
}
};
drop_unbuilt_then_budget(items, width)
}
fn drop_unbuilt_then_budget(items: Vec<Item>, width: u16) -> FooterLine {
let items: Vec<Item> = items.into_iter().filter(|item| item.built).collect();
budget(&items, width as usize)
}
#[allow(dead_code)] pub(crate) fn render(table: &BindingTable, context: Context, width: u16) -> String {
footer_line(table, context, width).to_string()
}
#[allow(dead_code)] pub(crate) fn render_action_palette(table: &BindingTable, width: u16) -> String {
drop_unbuilt_then_budget(action_palette_items(table), width).to_string()
}
fn paint_run(buf: &mut Buffer, x: &mut u16, y: u16, text: &str, style: Style) {
debug_assert!(text.is_ascii(), "a footer span must be ASCII: {text:?}");
buf.set_string(*x, y, text, style);
*x += text.len() as u16;
}
pub(crate) fn draw(
frame: &mut Frame,
area: Rect,
context: Context,
table: &BindingTable,
theme: &Theme,
) {
paint_line(frame, area, &footer_line(table, context, area.width), theme);
}
pub(crate) fn draw_action_palette(
frame: &mut Frame,
area: Rect,
table: &BindingTable,
theme: &Theme,
) {
let line = drop_unbuilt_then_budget(action_palette_items(table), area.width);
paint_line(frame, area, &line, theme);
}
fn paint_line(frame: &mut Frame, area: Rect, line: &FooterLine, theme: &Theme) {
let buf = frame.buffer_mut();
let mut x = area.x;
let mut first = true;
for hint in &line.hints {
if !first {
paint_run(buf, &mut x, area.y, SEPARATOR, theme.style_for(Role::Dim));
}
first = false;
paint_run(
buf,
&mut x,
area.y,
&hint.key,
theme.style_for(Role::Accent),
);
if !hint.label.is_empty() {
paint_run(buf, &mut x, area.y, " ", theme.style_for(Role::Dim));
paint_run(buf, &mut x, area.y, &hint.label, theme.style_for(Role::Dim));
}
}
if line.truncated {
paint_run(buf, &mut x, area.y, ELLIPSIS, theme.style_for(Role::Dim));
}
}
#[cfg(test)]
mod tests {
use ratatui::{Terminal, backend::TestBackend};
use super::*;
fn default_table() -> BindingTable {
BindingTable::compiled_default()
}
fn bare(text: &str) -> Hint {
Hint {
key: text.to_string(),
label: String::new(),
}
}
#[test]
fn budget_width_checks_the_first_item_not_only_later_ones() {
let items = [
Item {
hint: bare("XXXXXXXXXX"),
priority: Priority::Drop(1),
built: true,
},
Item {
hint: bare("Y"),
priority: Priority::Pinned,
built: true,
},
];
let rendered = budget(&items, 5).to_string();
assert_eq!(rendered, "Y ...");
assert!(rendered.len() <= 5, "must never overrun the given width");
}
#[test]
fn budget_reserves_the_ellipsis_inside_the_budget_rather_than_appending_it_after_a_fit_check() {
let items = [
Item {
hint: bare("AAAA"),
priority: Priority::Drop(1),
built: true,
},
Item {
hint: bare("BB"),
priority: Priority::Drop(2),
built: true,
},
Item {
hint: bare("C"),
priority: Priority::Pinned,
built: true,
},
];
let rendered = budget(&items, 8).to_string();
assert_eq!(rendered, "C ...");
assert!(rendered.len() <= 8, "must never overrun the given width");
}
#[test]
fn budget_drops_the_ellipsis_from_the_last_surviving_item_rather_than_dropping_that_item() {
let items = [
Item {
hint: bare("AAAA"),
priority: Priority::Drop(1),
built: true,
},
Item {
hint: bare("BB"),
priority: Priority::Pinned,
built: true,
},
];
assert_eq!(budget(&items, 5).to_string(), "BB");
}
#[test]
fn budget_renders_nothing_once_even_the_pinned_item_alone_cannot_fit() {
let items = [Item {
hint: bare("BB"),
priority: Priority::Pinned,
built: true,
}];
assert_eq!(budget(&items, 1).to_string(), "");
}
#[test]
fn budget_drops_a_shared_priority_group_together_never_one_item_alone() {
let items = [
Item {
hint: bare("LAUNCHER"),
priority: Priority::Drop(1),
built: true,
},
Item {
hint: bare("ACTION"),
priority: Priority::Drop(1),
built: true,
},
Item {
hint: bare("HELP"),
priority: Priority::Pinned,
built: true,
},
];
assert_eq!(budget(&items, 16).to_string(), "HELP ...");
}
#[test]
fn launcher_and_action_hints_are_never_present_without_each_other_at_any_width() {
let table = default_table();
let launcher = hint_item(&table, Context::Global, Action::OpenLauncher, "launcher")
.0
.to_string();
let action = hint_item(&table, Context::Global, Action::OpenActionPalette, "action")
.0
.to_string();
for (context, items) in [
(Context::List, list_items(&table)),
(Context::Detail, detail_items(&table)),
] {
let full_width = items
.iter()
.map(|item| item.hint.to_string())
.collect::<Vec<_>>()
.join(SEPARATOR)
.len();
for width in 0..=full_width {
let rendered = budget(&items, width).to_string();
let has_launcher = rendered.contains(&launcher);
let has_action = rendered.contains(&action);
assert_eq!(
has_launcher, has_action,
"{context:?} at width {width}: launcher present = {has_launcher}, action \
present = {has_action}, rendered {rendered:?}"
);
}
}
}
#[test]
fn a_survivors_key_and_label_stay_separate_fields_after_budget_selects_it() {
let line = budget(&list_items(&default_table()), 88);
let move_hint = line
.hints
.iter()
.find(|hint| hint.label == "move")
.expect("the move hint must survive at full width");
assert_eq!(move_hint.key, "j/k");
}
struct Row {
width: u16,
expected: String,
}
fn parse_degradation_table(spec: &str, after: &str) -> Vec<Row> {
let start = spec
.find(after)
.unwrap_or_else(|| panic!("keybindings.md no longer contains {after:?}"));
let rest = &spec[start..];
let fence_start = rest
.find("```\n")
.expect("a fenced code block must follow the marker");
let after_fence = &rest[fence_start + 4..];
let fence_end = after_fence
.find("```")
.expect("the fenced code block must close");
let block = &after_fence[..fence_end];
block
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
let trimmed = line.trim_start();
let (width_text, expected) = trimmed.split_once(" ").unwrap_or_else(|| {
panic!("degradation table row is not `<width> <text>`: {line:?}")
});
let width: u16 = width_text.trim().parse().unwrap_or_else(|_| {
panic!("degradation table row has no numeric width: {line:?}")
});
Row {
width,
expected: expected.trim_end().to_string(),
}
})
.collect()
}
fn read_spec() -> String {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(manifest_dir.join("../../docs/spec/keybindings.md"))
.expect("read the keybinding spec")
}
#[test]
fn list_footer_matches_the_documented_degradation_table_at_every_named_width() {
let spec = read_spec();
let rows = parse_degradation_table(
&spec,
"The list context's footer is 87 columns at full width",
);
assert!(!rows.is_empty(), "expected at least one documented width");
let table = default_table();
for row in rows {
assert_eq!(
budget(&list_items(&table), row.width as usize).to_string(),
row.expected,
"list footer mismatch at width {}",
row.width
);
}
}
#[test]
fn detail_footer_matches_the_documented_degradation_table_at_every_named_width() {
let spec = read_spec();
let rows = parse_degradation_table(
&spec,
"The detail context's footer is 61 columns at full width",
);
assert!(!rows.is_empty(), "expected at least one documented width");
let table = default_table();
for row in rows {
assert_eq!(
budget(&detail_items(&table), row.width as usize).to_string(),
row.expected,
"detail footer mismatch at width {}",
row.width
);
}
}
#[test]
fn sort_footer_matches_the_documented_degradation_table_at_every_named_width() {
let spec = read_spec();
let rows = parse_degradation_table(
&spec,
"The sort context's footer is 73 columns at full width",
);
assert!(!rows.is_empty(), "expected at least one documented width");
let table = default_table();
for row in rows {
assert_eq!(
budget(&sort_items(&table), row.width as usize).to_string(),
row.expected,
"sort footer mismatch at width {}",
row.width
);
}
}
#[test]
fn action_palette_footer_matches_the_documented_degradation_table_at_every_named_width() {
let spec = read_spec();
let rows = parse_degradation_table(
&spec,
"The Action palette's own footer is 55 columns at full width",
);
assert!(!rows.is_empty(), "expected at least one documented width");
let table = default_table();
for row in rows {
assert_eq!(
budget(&action_palette_items(&table), row.width as usize).to_string(),
row.expected,
"Action palette footer mismatch at width {}",
row.width
);
}
}
#[test]
fn the_action_palette_footer_gives_up_the_newline_hint_before_the_editor_hint() {
let table = default_table();
for width in 0..=60u16 {
let rendered = render_action_palette(&table, width);
assert!(
!rendered.contains("newline") || rendered.contains("editor"),
"width {width} kept the newline hint after dropping the editor hint: \
{rendered:?}"
);
}
}
#[test]
fn the_action_palette_footers_way_out_is_the_last_hint_to_go() {
let table = default_table();
for width in 10..=60u16 {
let rendered = render_action_palette(&table, width);
assert!(
rendered.contains("esc cancel"),
"width {width} drew {rendered:?} with no way out of the palette"
);
}
}
#[test]
fn the_sort_footers_way_out_is_the_last_hint_to_go() {
let table = default_table();
for width in 10..=80u16 {
let rendered = render(&table, Context::Sort, width);
assert!(
rendered.contains("esc cancel"),
"width {width} drew {rendered:?} with no way out of the menu"
);
}
}
#[test]
fn drop_unbuilt_then_budget_never_advertises_an_unbuilt_binding_at_any_width() {
fn items() -> Vec<Item> {
vec![
Item {
hint: Hint {
key: "x".to_string(),
label: "built".to_string(),
},
priority: Priority::Pinned,
built: true,
},
Item {
hint: Hint {
key: "y".to_string(),
label: "unbuilt".to_string(),
},
priority: Priority::Pinned,
built: false,
},
]
}
let full_width: usize = items()
.iter()
.map(|item| item.hint.to_string())
.collect::<Vec<_>>()
.join(SEPARATOR)
.len();
for width in 0..=full_width {
let rendered = drop_unbuilt_then_budget(items(), width as u16).to_string();
assert!(
!rendered.contains("unbuilt"),
"width {width} advertises the unbuilt hint: {rendered:?}"
);
}
}
#[test]
fn confirm_footer_matches_the_documented_text_at_its_full_width() {
assert_eq!(
render(&default_table(), Context::Confirm, 15),
"y run n cancel"
);
}
#[test]
fn confirm_footer_renders_nothing_once_even_the_pinned_pair_cannot_fit() {
assert_eq!(render(&default_table(), Context::Confirm, 14), "");
}
#[test]
fn filter_footer_matches_the_documented_degradation_table_at_every_named_width() {
let spec = read_spec();
let rows = parse_degradation_table(
&spec,
"The Filter line's own footer, which sits one row above the line itself",
);
assert!(!rows.is_empty(), "expected at least one documented width");
let table = default_table();
for row in rows {
assert_eq!(
budget(&filter_items(&table), row.width as usize).to_string(),
row.expected,
"filter footer mismatch at width {}",
row.width
);
}
}
#[test]
fn filter_line_footer_renders_nothing_once_even_the_pinned_pair_cannot_fit() {
assert_eq!(render(&default_table(), Context::Input, 22), "");
}
#[test]
#[should_panic(expected = "no footer content is defined yet for Global")]
fn footer_still_panics_for_global_and_overlay_which_own_no_footer_of_their_own() {
render(&default_table(), Context::Global, 80);
}
fn production_source() -> String {
crate::test_support::production_source(include_str!("footer.rs"))
}
#[test]
fn footer_never_calls_the_silently_truncating_set_stringn_helper() {
let source = production_source();
let offending: Vec<&str> = source
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.filter(|line| line.contains("set_stringn"))
.collect();
assert!(
offending.is_empty(),
"footer.rs must never call Buffer::set_stringn, found: {offending:?}"
);
}
#[test]
fn footer_never_reintroduces_the_first_item_exemption_guard() {
let banned = [
format!("{} {} 0", "i", ">"),
format!("{} {} 0", "index", ">"),
format!("{}(1)", ".skip"),
];
let source = production_source();
let offending: Vec<&str> = source
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.filter(|line| banned.iter().any(|needle| line.contains(needle.as_str())))
.collect();
assert!(
offending.is_empty(),
"found a first-item exemption guard: {offending:?}"
);
}
#[test]
fn draw_writes_the_rendered_text_at_the_areas_own_row() {
let table = default_table();
let backend = TestBackend::new(87, 3);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
let area = Rect::new(0, 2, 87, 1);
draw(frame, area, Context::List, &table, &crate::theme::DEFAULT);
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
let row: String = (0..87).map(|x| buf[(x, 2)].symbol().to_string()).collect();
assert_eq!(row.trim_end(), render(&table, Context::List, 87));
}
#[test]
fn draw_paints_a_hints_key_in_accent_and_its_label_in_dim() {
let table = default_table();
let backend = TestBackend::new(40, 1);
let mut terminal = Terminal::new(backend).expect("create test terminal");
let theme = crate::theme::DEFAULT;
terminal
.draw(|frame| {
draw(frame, frame.area(), Context::List, &table, &theme);
})
.expect("draw the frame");
let buf = terminal.backend().buffer();
assert_eq!(
buf[(0, 0)].fg,
theme.role_color(Role::Accent),
"expected the first hint's key painted in the theme's accent role"
);
let rendered = render(&table, Context::List, 40);
let first_space = rendered
.find(' ')
.expect("the first hint has a non-empty label after its key");
assert_eq!(
buf[(first_space as u16 + 1, 0)].fg,
theme.role_color(Role::Dim),
"expected the first hint's label, after the key and its separating space, \
painted in the theme's dim role"
);
}
}