use ratatui::{
Frame,
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::Line,
};
use crate::edit_buffer::EditBuffer;
use crate::glyphs::{BorderScratch, GlyphSet, Meaning, bordered_interior};
use crate::keys::{Action, BindingTable, Context};
use crate::scroll::scroll_after;
use crate::theme::{Role, Theme};
const BORDER_WIDTH: u16 = 2;
const BORDER_HEIGHT: u16 = 2;
const MIN_CONTENT_WIDTH: u16 = 1;
const MIN_CONTENT_HEIGHT: u16 = 1;
const MIN_BORDERED_WIDTH: u16 = BORDER_WIDTH + MIN_CONTENT_WIDTH;
const MIN_BORDERED_HEIGHT: u16 = BORDER_HEIGHT + MIN_CONTENT_HEIGHT;
const COLUMN_GUTTER: u16 = 4;
pub(crate) const BORDER_TITLE: &str = " help (esc or q closes) ";
fn version_title() -> String {
format!("repon {}", env!("CARGO_PKG_VERSION"))
}
pub(crate) const NO_MATCHES_MESSAGE: &str = "no matches";
pub(crate) const GLOBAL_HEADING: &str = "Global";
pub(crate) const LEGEND_HEADING: &str = "Glyphs";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HelpLine {
Heading(&'static str),
Blank,
Binding {
keys: String,
description: &'static str,
},
Legend {
glyph: String,
meaning: &'static str,
},
}
fn meaning_text(meaning: Meaning) -> &'static str {
match meaning {
Meaning::Fresh => "Fresh (gutter)",
Meaning::Stale => "Stale (gutter)",
Meaning::Unknown => "Unknown (gutter)",
Meaning::Failed => "Failed (gutter)",
Meaning::Loading => "Loading (gutter, and a cell)",
Meaning::InSync => "in sync",
Meaning::Clean => "clean, a known zero",
Meaning::NoUpstream => "no upstream, or no branch at all",
Meaning::NoRemote => "no remote at all",
Meaning::Ahead => "ahead by n",
Meaning::Behind => "behind by n",
Meaning::Changed => "n changed files",
Meaning::ChildRow => "child row",
Meaning::OrphanChildRow => "child with no visible parent",
Meaning::Checked => "checked (the Selection's own marker)",
Meaning::Ignored => "ignored",
Meaning::Truncated => "truncated name",
}
}
fn context_heading(context: Context) -> &'static str {
match context {
Context::Global => GLOBAL_HEADING,
Context::List => "List",
Context::Detail => "Detail",
Context::Input => "Input",
Context::Overlay => "Overlay",
Context::Confirm => "Confirm",
Context::Sort => "Sort",
}
}
fn bindings_to_lines(rows: Vec<(String, &'static str)>) -> Vec<HelpLine> {
rows.into_iter()
.map(|(keys, description)| HelpLine::Binding { keys, description })
.collect()
}
fn legend_to_lines(rows: Vec<(String, &'static str)>) -> Vec<HelpLine> {
rows.into_iter()
.map(|(glyph, meaning)| HelpLine::Legend { glyph, meaning })
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HelpLayout {
Bordered,
Degraded,
}
impl HelpLayout {
pub(crate) fn compute(frame_area: Rect) -> HelpLayout {
if frame_area.width < MIN_BORDERED_WIDTH || frame_area.height < MIN_BORDERED_HEIGHT {
HelpLayout::Degraded
} else {
HelpLayout::Bordered
}
}
pub(crate) fn content_area(self, frame_area: Rect) -> Rect {
match self {
HelpLayout::Bordered => bordered_interior(frame_area),
HelpLayout::Degraded => frame_area,
}
}
}
fn line_display_width(line: &HelpLine, key_width: usize) -> usize {
match line {
HelpLine::Heading(text) => text.chars().count(),
HelpLine::Binding { description, .. } => description.chars().count() + key_width + 2,
HelpLine::Legend { meaning, .. } => meaning.chars().count() + key_width + 2,
HelpLine::Blank => 0,
}
}
fn max_key_width(lines: &[HelpLine]) -> usize {
lines
.iter()
.map(|line| match line {
HelpLine::Binding { keys, .. } => keys.chars().count(),
HelpLine::Legend { glyph, .. } => glyph.chars().count(),
HelpLine::Heading(_) | HelpLine::Blank => 0,
})
.max()
.unwrap_or(0)
}
fn column_width(lines: &[HelpLine], key_width: usize) -> u16 {
lines
.iter()
.map(|line| line_display_width(line, key_width) as u16)
.max()
.unwrap_or(0)
}
struct ColumnMetrics {
left_key_width: usize,
right_key_width: usize,
two_columns: bool,
column_offset: u16,
}
impl ColumnMetrics {
fn column_metrics(left: &[HelpLine], right: &[HelpLine]) -> (usize, usize, u16, u16) {
let left_key_width = max_key_width(left);
let right_key_width = max_key_width(right);
let left_width = column_width(left, left_key_width);
let right_width = column_width(right, right_key_width);
(left_key_width, right_key_width, left_width, right_width)
}
fn compute(
table: &BindingTable,
context: Context,
glyphs: &GlyphSet,
content_width: u16,
) -> ColumnMetrics {
let unfiltered = HelpOverlay::built_sections(table, context, glyphs, "");
let (left, right) = HelpOverlay::split_into_columns(unfiltered);
let (left_key_width, right_key_width, left_width, right_width) =
Self::column_metrics(&left, &right);
let two_column_min_width = left_width
.saturating_add(COLUMN_GUTTER)
.saturating_add(right_width);
ColumnMetrics {
left_key_width,
right_key_width,
two_columns: content_width >= two_column_min_width,
column_offset: left_width.saturating_add(COLUMN_GUTTER),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum Mode {
#[default]
Reading,
Searching,
}
#[derive(Default)]
pub(crate) struct HelpOverlay {
scroll: u16,
query: EditBuffer,
mode: Mode,
}
impl HelpOverlay {
#[cfg(test)]
pub(crate) fn content(table: &BindingTable, context: Context) -> Vec<(String, &'static str)> {
table.describe(context)
}
fn legend_rows(glyphs: &GlyphSet) -> Vec<(String, &'static str)> {
let interior = glyphs.row_interior();
Meaning::ALL
.into_iter()
.map(|meaning| {
let glyph: String = interior
.iter()
.filter(|(m, _)| *m == meaning)
.map(|(_, c)| *c)
.collect();
(glyph, meaning_text(meaning))
})
.collect()
}
fn assemble_sections(sections: Vec<(&'static str, Vec<HelpLine>)>) -> Vec<HelpLine> {
let mut lines = Vec::new();
for (heading, content) in sections
.into_iter()
.filter(|(_, content)| !content.is_empty())
{
if !lines.is_empty() {
lines.push(HelpLine::Blank);
}
lines.push(HelpLine::Heading(heading));
lines.extend(content);
}
lines
}
fn built_sections(
table: &BindingTable,
context: Context,
glyphs: &GlyphSet,
query: &str,
) -> Vec<(&'static str, Vec<HelpLine>)> {
let query = query.to_lowercase();
let binding_matches = |(keys, description): &(String, &'static str)| {
keys.to_lowercase().contains(&query) || description.to_lowercase().contains(&query)
};
let legend_matches = |(glyph, meaning): &(String, &'static str)| {
glyph.to_lowercase().contains(&query) || meaning.to_lowercase().contains(&query)
};
[
(
context_heading(context),
bindings_to_lines(
table
.describe_own(context)
.into_iter()
.filter(binding_matches)
.collect(),
),
),
(
GLOBAL_HEADING,
bindings_to_lines(
table
.describe_global(context)
.into_iter()
.filter(binding_matches)
.collect(),
),
),
(
LEGEND_HEADING,
legend_to_lines(
Self::legend_rows(glyphs)
.into_iter()
.filter(legend_matches)
.collect(),
),
),
]
.into_iter()
.filter(|(_, content)| !content.is_empty())
.collect()
}
#[cfg(test)]
fn lines(table: &BindingTable, context: Context, glyphs: &GlyphSet) -> Vec<HelpLine> {
Self::assemble_sections(Self::built_sections(table, context, glyphs, ""))
}
#[cfg(test)]
pub(crate) fn filtered_lines(
table: &BindingTable,
context: Context,
glyphs: &GlyphSet,
query: &str,
) -> Vec<HelpLine> {
Self::assemble_sections(Self::built_sections(table, context, glyphs, query))
}
pub(crate) fn visible_len(
table: &BindingTable,
context: Context,
glyphs: &GlyphSet,
query: &str,
frame_area: Rect,
) -> usize {
let sections = Self::built_sections(table, context, glyphs, query);
if sections.is_empty() {
return 0;
}
let content_width = HelpLayout::compute(frame_area)
.content_area(frame_area)
.width;
let metrics = ColumnMetrics::compute(table, context, glyphs, content_width);
let (left, right) = Self::laid_out(sections, &metrics);
left.len().max(right.len())
}
fn laid_out(
sections: Vec<(&'static str, Vec<HelpLine>)>,
metrics: &ColumnMetrics,
) -> (Vec<HelpLine>, Vec<HelpLine>) {
if metrics.two_columns {
Self::split_into_columns(sections)
} else {
(Self::assemble_sections(sections), Vec::new())
}
}
fn assembled_len(sections: &[(&'static str, Vec<HelpLine>)]) -> usize {
if sections.is_empty() {
return 0;
}
sections
.iter()
.map(|(_, content)| 1 + content.len())
.sum::<usize>()
+ sections.len()
- 1
}
fn split_into_columns(
mut sections: Vec<(&'static str, Vec<HelpLine>)>,
) -> (Vec<HelpLine>, Vec<HelpLine>) {
let split = (0..=sections.len())
.rev()
.min_by_key(|&s| {
Self::assembled_len(§ions[..s]).abs_diff(Self::assembled_len(§ions[s..]))
})
.unwrap_or(0);
let right = sections.split_off(split);
(
Self::assemble_sections(sections),
Self::assemble_sections(right),
)
}
fn shows_query_line(&self) -> bool {
self.mode == Mode::Searching || !self.query.is_empty()
}
pub(crate) fn viewport_height(&self, frame_area: Rect) -> u16 {
let interior = HelpLayout::compute(frame_area)
.content_area(frame_area)
.height;
if self.shows_query_line() {
interior.saturating_sub(1)
} else {
interior
}
}
pub(crate) fn query(&self) -> &str {
self.query.as_str()
}
pub(crate) fn is_searching(&self) -> bool {
self.mode == Mode::Searching
}
pub(crate) fn enter_search(&mut self) {
self.mode = Mode::Searching;
}
pub(crate) fn cancel_search(&mut self) {
self.mode = Mode::Reading;
self.query.clear();
self.scroll = 0;
}
pub(crate) fn commit_search(&mut self) {
self.mode = Mode::Reading;
}
pub(crate) fn push_query_char(&mut self, c: char) {
self.query.insert_char(c);
self.scroll = 0;
}
pub(crate) fn pop_query_char(&mut self) {
self.query.delete_previous_char();
self.scroll = 0;
}
pub(crate) fn delete_previous_word(&mut self) {
self.query.delete_previous_word();
self.scroll = 0;
}
pub(crate) fn apply(&mut self, action: Action, content_len: usize, viewport_height: u16) {
self.scroll = scroll_after(self.scroll, action, content_len, viewport_height);
}
fn draw_line(
buf: &mut Buffer,
x: u16,
y: u16,
end: u16,
line: &HelpLine,
key_width: usize,
theme: &Theme,
) {
let mut x = x;
match line {
HelpLine::Binding { keys, description } => {
let padded_keys = format!("{keys:<key_width$}");
paint_run(
buf,
&mut x,
y,
end,
&padded_keys,
theme.style_for(Role::Accent),
);
paint_run(buf, &mut x, y, end, " ", theme.style_for(Role::Dim));
paint_run(buf, &mut x, y, end, description, theme.style_for(Role::Dim));
}
HelpLine::Legend { glyph, meaning } => {
let padded_glyph = format!("{glyph:<key_width$}");
paint_run(
buf,
&mut x,
y,
end,
&padded_glyph,
theme.style_for(Role::Accent),
);
paint_run(buf, &mut x, y, end, " ", theme.style_for(Role::Dim));
paint_run(buf, &mut x, y, end, meaning, theme.style_for(Role::Dim));
}
HelpLine::Heading(text) => {
let heading_style = theme.style_for(Role::Accent).add_modifier(Modifier::BOLD);
paint_run(buf, &mut x, y, end, text, heading_style);
}
HelpLine::Blank => {}
}
}
pub(crate) fn draw(
&self,
frame: &mut Frame,
frame_area: Rect,
context: Context,
table: &BindingTable,
theme: &Theme,
glyphs: &'static GlyphSet,
) {
let layout = HelpLayout::compute(frame_area);
if layout == HelpLayout::Bordered {
let mut scratch = BorderScratch::new();
let block = glyphs
.bordered_block(&mut scratch)
.border_style(theme.style_for(Role::BorderFocused))
.title(BORDER_TITLE)
.title_bottom(Line::from(version_title()).right_aligned());
frame.render_widget(block, frame_area);
}
let content_area = layout.content_area(frame_area);
let buf = frame.buffer_mut();
let end = content_area.right();
let list_height = if self.shows_query_line() {
content_area.height.saturating_sub(1)
} else {
content_area.height
};
let list_area = Rect::new(
content_area.x,
content_area.y,
content_area.width,
list_height,
);
let sections = Self::built_sections(table, context, glyphs, self.query.as_str());
if sections.is_empty() {
let mut x = list_area.x;
paint_run(
buf,
&mut x,
list_area.y,
list_area.right(),
NO_MATCHES_MESSAGE,
theme.style_for(Role::Dim),
);
} else {
let metrics = ColumnMetrics::compute(table, context, glyphs, content_area.width);
let (left, right) = Self::laid_out(sections, &metrics);
let row_count = left.len().max(right.len());
for row in 0..(list_area.height as usize) {
let index = row + self.scroll as usize;
if index >= row_count {
break;
}
let y = list_area.y + row as u16;
if let Some(line) = left.get(index) {
Self::draw_line(
buf,
list_area.x,
y,
end,
line,
metrics.left_key_width,
theme,
);
}
if let Some(line) = right.get(index) {
Self::draw_line(
buf,
list_area.x + metrics.column_offset,
y,
end,
line,
metrics.right_key_width,
theme,
);
}
}
}
if self.shows_query_line() {
let mut qx = content_area.x;
let query_line = format!("/ {}", self.query.as_str());
let query_y = content_area.y + list_height;
paint_run(
buf,
&mut qx,
query_y,
end,
&query_line,
theme.style_for(Role::Text),
);
}
}
}
fn paint_run(buf: &mut Buffer, x: &mut u16, y: u16, end: u16, text: &str, style: Style) {
let (next_x, _) = buf.set_stringn(*x, y, text, end.saturating_sub(*x) as usize, style);
*x = next_x;
}
#[cfg(test)]
mod tests {
use ratatui::{Terminal, backend::TestBackend};
use super::*;
fn default_table() -> BindingTable {
BindingTable::compiled_default()
}
fn full_glyphs() -> &'static GlyphSet {
GlyphSet::for_config(crate::config::document::Glyphs::default())
}
fn ascii_glyphs() -> &'static GlyphSet {
GlyphSet::for_config(crate::config::document::Glyphs::Ascii)
}
const ROOMY_FRAME: Rect = Rect::new(0, 0, 100, 70);
fn render(
overlay: &HelpOverlay,
width: u16,
height: u16,
context: Context,
table: &BindingTable,
) -> Terminal<TestBackend> {
render_with_glyphs(overlay, width, height, context, table, full_glyphs())
}
fn render_with_glyphs(
overlay: &HelpOverlay,
width: u16,
height: u16,
context: Context,
table: &BindingTable,
glyphs: &'static GlyphSet,
) -> Terminal<TestBackend> {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
overlay.draw(
frame,
frame.area(),
context,
table,
&crate::theme::DEFAULT,
glyphs,
);
})
.expect("draw the frame");
terminal
}
fn find_text_start_x(buf: &ratatui::buffer::Buffer, area: Rect, y: u16, text: &str) -> u16 {
let want: Vec<char> = text.chars().collect();
for x in area.x..area.right() {
let got: Vec<char> = (x..area.right())
.take(want.len())
.map(|cx| buf[(cx, y)].symbol().chars().next().unwrap_or(' '))
.collect();
if got == want {
return x;
}
}
panic!("text {text:?} not found on row {y} within {area:?}");
}
fn list_area(overlay: &HelpOverlay, frame: Rect) -> Rect {
let content_area = HelpLayout::compute(frame).content_area(frame);
let height = if overlay.shows_query_line() {
content_area.height - 1
} else {
content_area.height
};
Rect::new(content_area.x, content_area.y, content_area.width, height)
}
fn leading_char(line: &HelpLine) -> char {
let text = match line {
HelpLine::Heading(text) => text,
HelpLine::Binding { keys, .. } => keys.as_str(),
HelpLine::Legend { glyph, .. } => glyph.as_str(),
HelpLine::Blank => panic!("expected a real line, not the blank separator"),
};
text.chars().next().expect("expected a non-empty line")
}
fn rendered_symbol(line: &HelpLine) -> String {
match line {
HelpLine::Blank => " ".to_string(),
other => leading_char(other).to_string(),
}
}
#[test]
fn content_is_exactly_the_tables_own_describe_with_no_reformatting() {
let table = default_table();
assert_eq!(
HelpOverlay::content(&table, Context::List),
table.describe(Context::List)
);
}
#[test]
fn content_shows_the_current_contexts_own_actions_before_global() {
let lines = HelpOverlay::content(&default_table(), Context::List);
let own = lines
.iter()
.position(|(_, description)| *description == "Move down")
.expect("List's own Move down must appear");
let global = lines
.iter()
.position(|(_, description)| *description == "Quit")
.expect("Global's Quit must appear alongside List");
assert!(own < global, "expected List before global, got {lines:?}");
}
#[test]
fn content_omits_bindings_not_live_in_the_given_context() {
let lines = HelpOverlay::content(&default_table(), Context::Confirm);
assert!(
!lines
.iter()
.any(|(_, description)| *description == "Move down")
);
assert!(!lines.iter().any(|(_, description)| *description == "Quit"));
assert!(lines.iter().any(|(_, description)| *description == "Run"));
}
#[test]
fn content_excludes_a_currently_unbuilt_binding() {
let unbuilt_context = Context::List;
let unbuilt_action = Action::DismissVanished;
let table = crate::keys::single_unbuilt_binding_table(
unbuilt_context,
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::NONE,
unbuilt_action,
);
let unbuilt_description = crate::keys::description(unbuilt_action);
let lines = HelpOverlay::content(&table, unbuilt_context);
assert!(
!lines
.iter()
.any(|(_, description)| *description == unbuilt_description),
"expected {unbuilt_description:?}, unbuilt in this synthetic table, to be \
absent from the help overlay, got: {lines:?}"
);
}
#[test]
fn visible_len_matches_filtered_lines_own_length_for_every_query_below_the_two_column_threshold()
{
let table = default_table();
for query in ["", "move", "zzz-nothing-matches-this-zzz"] {
assert_eq!(
HelpOverlay::visible_len(&table, Context::List, full_glyphs(), query, ROOMY_FRAME),
HelpOverlay::filtered_lines(&table, Context::List, full_glyphs(), query).len()
);
}
}
#[test]
fn content_reflects_whatever_table_it_is_handed_rather_than_a_fixed_default() {
let mut context_table = toml::Table::new();
context_table.insert(
"anchor_range".to_string(),
toml::Value::String("x".to_string()),
);
let mut document_keys = toml::Table::new();
document_keys.insert("list".to_string(), toml::Value::Table(context_table));
let (rebound, _) =
crate::keys::merge(&document_keys).expect("expected the merge to succeed");
let rows = HelpOverlay::content(&rebound, Context::List);
assert!(
rows.iter().any(|(keys, description)| keys == "x"
&& *description == "Anchor a range at the cursor, extended with `j` and `k`"),
"expected the rebound key to appear in the overlay's own content, got: {rows:?}"
);
assert!(
!rows.iter().any(|(keys, _)| keys == "v"),
"the old default key must not still appear once it has been rebound, got: {rows:?}"
);
}
#[test]
fn legend_rows_has_exactly_one_row_per_meaning_variant() {
let rows = HelpOverlay::legend_rows(full_glyphs());
assert_eq!(rows.len(), Meaning::ALL.len());
}
#[test]
fn glyph_legend_prose_matches_theming_mds_own_two_sets_table() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let spec = std::fs::read_to_string(manifest_dir.join("../../docs/spec/theming.md"))
.expect("read docs/spec/theming.md");
const HEADING: &str = "### The two sets";
let after_heading = &spec[spec
.find(HEADING)
.expect("theming.md must contain \"### The two sets\"")
+ HEADING.len()..];
let table_lines: Vec<&str> = after_heading
.lines()
.skip_while(|line| !line.trim_start().starts_with('|'))
.take_while(|line| line.trim_start().starts_with('|'))
.map(str::trim)
.collect();
assert!(
table_lines.len() > 2,
"theming.md's \"The two sets\" table has no data rows"
);
let spec_meanings: Vec<String> = table_lines[2..]
.iter()
.map(|line| {
let cells: Vec<&str> = line.trim_matches('|').split('|').map(str::trim).collect();
cells
.first()
.unwrap_or_else(|| panic!("malformed table row: {line:?}"))
.trim_matches('`')
.to_string()
})
.filter(|meaning| {
!matches!(
meaning.as_str(),
"panel border"
| "capture elision"
| "sort arrow (ascending, descending)"
| "scrollbar (track, thumb)"
)
})
.collect();
let legend_meanings: Vec<&'static str> =
Meaning::ALL.iter().map(|&m| meaning_text(m)).collect();
assert_eq!(
legend_meanings.len(),
spec_meanings.len(),
"the legend and theming.md's own table name a different number of meanings: \
legend {legend_meanings:?}, spec {spec_meanings:?}"
);
for spec_meaning in &spec_meanings {
assert!(
legend_meanings.contains(&spec_meaning.as_str()),
"theming.md's \"The two sets\" table names {spec_meaning:?}, which the help \
legend does not: {legend_meanings:?}"
);
}
}
#[test]
fn legend_glyphs_are_read_from_the_live_glyph_set_and_differ_between_full_and_ascii() {
let full_rows = HelpOverlay::legend_rows(full_glyphs());
let ascii_rows = HelpOverlay::legend_rows(ascii_glyphs());
let full_in_sync = full_rows
.iter()
.find(|(_, meaning)| *meaning == meaning_text(Meaning::InSync))
.expect("full legend must carry InSync")
.0
.clone();
let ascii_in_sync = ascii_rows
.iter()
.find(|(_, meaning)| *meaning == meaning_text(Meaning::InSync))
.expect("ascii legend must carry InSync")
.0
.clone();
assert_eq!(full_in_sync, full_glyphs().in_sync.to_string());
assert_eq!(ascii_in_sync, ascii_glyphs().in_sync.to_string());
assert_ne!(
full_in_sync, ascii_in_sync,
"expected the full and ascii legends to render InSync differently, got the same \
glyph {full_in_sync:?} for both"
);
}
#[test]
fn the_loading_legend_row_joins_every_spinner_frame_the_live_table_carries() {
let full_rows = HelpOverlay::legend_rows(full_glyphs());
let (glyph, _) = full_rows
.iter()
.find(|(_, meaning)| *meaning == meaning_text(Meaning::Loading))
.expect("full legend must carry Loading");
let expected: String = full_glyphs().loading.iter().collect();
assert_eq!(*glyph, expected);
let ascii_rows = HelpOverlay::legend_rows(ascii_glyphs());
let (ascii_glyph, _) = ascii_rows
.iter()
.find(|(_, meaning)| *meaning == meaning_text(Meaning::Loading))
.expect("ascii legend must carry Loading");
let ascii_expected: String = ascii_glyphs().loading.iter().collect();
assert_eq!(*ascii_glyph, ascii_expected);
}
#[test]
fn a_query_matching_a_binding_keeps_it_and_drops_bindings_that_do_not_match() {
let table = default_table();
let lines = HelpOverlay::filtered_lines(&table, Context::List, full_glyphs(), "move");
assert!(lines.iter().any(|line| matches!(
line,
HelpLine::Binding { description, .. } if *description == "Move down"
)));
assert!(!lines.iter().any(|line| matches!(
line,
HelpLine::Binding { description, .. } if *description == "Toggle this row's Selection"
)));
}
#[test]
fn a_query_matches_the_key_column_as_well_as_the_description() {
let table = default_table();
let lines = HelpOverlay::filtered_lines(&table, Context::List, full_glyphs(), "g");
assert!(lines.iter().any(|line| matches!(
line,
HelpLine::Binding { description, .. } if *description == "First row"
)));
}
#[test]
fn a_query_also_narrows_the_legend_to_glyph_or_meaning_matches() {
let table = default_table();
let lines = HelpOverlay::filtered_lines(&table, Context::List, full_glyphs(), "child row");
let legend_rows: Vec<&HelpLine> = lines
.iter()
.filter(|line| matches!(line, HelpLine::Legend { .. }))
.collect();
assert_eq!(
legend_rows.len(),
1,
"expected exactly ChildRow to survive: {lines:?}"
);
assert!(matches!(
legend_rows[0],
HelpLine::Legend { meaning, .. } if *meaning == "child row"
));
assert!(
lines
.iter()
.any(|line| matches!(line, HelpLine::Heading(text) if *text == LEGEND_HEADING)),
"the legend heading must survive alongside its one surviving row"
);
}
#[test]
fn an_empty_query_matches_every_binding_and_every_legend_row() {
let table = default_table();
let unfiltered = HelpOverlay::lines(&table, Context::List, full_glyphs());
let filtered = HelpOverlay::filtered_lines(&table, Context::List, full_glyphs(), "");
assert_eq!(unfiltered, filtered);
}
#[test]
fn a_query_matching_no_binding_and_no_legend_row_leaves_the_legend_heading_out_too() {
let table = default_table();
let lines = HelpOverlay::filtered_lines(
&table,
Context::List,
full_glyphs(),
"zzz-nothing-matches-this-zzz",
);
assert!(lines.is_empty(), "expected nothing to match, got {lines:?}");
}
#[test]
fn a_query_matching_nothing_renders_the_no_matches_message() {
let mut overlay = HelpOverlay::default();
overlay.enter_search();
for c in "zzz-z".chars() {
overlay.push_query_char(c);
}
let terminal = render(
&overlay,
ROOMY_FRAME.width,
ROOMY_FRAME.height,
Context::List,
&default_table(),
);
let buf = terminal.backend().buffer();
let area = list_area(&overlay, ROOMY_FRAME);
let row_text: String = (area.x..area.right())
.map(|x| buf[(x, area.y)].symbol())
.collect();
assert!(
row_text.contains(NO_MATCHES_MESSAGE),
"expected {NO_MATCHES_MESSAGE:?} on the first list row, got {row_text:?}"
);
}
#[test]
fn an_unfiltered_overlay_never_renders_the_no_matches_message() {
let overlay = HelpOverlay::default();
let terminal = render(
&overlay,
ROOMY_FRAME.width,
ROOMY_FRAME.height,
Context::List,
&default_table(),
);
let buf = terminal.backend().buffer();
let area = list_area(&overlay, ROOMY_FRAME);
let row_text: String = (area.x..area.right())
.map(|x| buf[(x, area.y)].symbol())
.collect();
assert!(!row_text.contains(NO_MATCHES_MESSAGE));
}
#[test]
fn the_typed_query_renders_on_the_overlays_own_last_row_while_searching() {
let mut overlay = HelpOverlay::default();
overlay.enter_search();
for c in "move".chars() {
overlay.push_query_char(c);
}
let terminal = render(
&overlay,
ROOMY_FRAME.width,
ROOMY_FRAME.height,
Context::List,
&default_table(),
);
let buf = terminal.backend().buffer();
let content_area = HelpLayout::compute(ROOMY_FRAME).content_area(ROOMY_FRAME);
let last_row = content_area.bottom() - 1;
let row_text: String = (content_area.x..content_area.right())
.map(|x| buf[(x, last_row)].symbol())
.collect();
assert!(
row_text.contains("/ move"),
"expected the query line to show what was typed on the interior's last row, got \
{row_text:?}"
);
}
#[test]
fn a_fresh_overlay_in_reading_mode_draws_no_query_line() {
let overlay = HelpOverlay::default();
assert!(!overlay.shows_query_line());
let terminal = render(
&overlay,
ROOMY_FRAME.width,
ROOMY_FRAME.height,
Context::List,
&default_table(),
);
let buf = terminal.backend().buffer();
let content_area = HelpLayout::compute(ROOMY_FRAME).content_area(ROOMY_FRAME);
let first_row: String = (content_area.x..content_area.right())
.map(|x| buf[(x, content_area.y)].symbol())
.collect();
assert!(
!first_row.trim_start().starts_with('/'),
"expected no query prompt on a fresh overlay's own first row, got {first_row:?}"
);
}
#[test]
fn a_fresh_overlay_opens_in_reading_mode() {
let overlay = HelpOverlay::default();
assert!(!overlay.is_searching());
assert_eq!(overlay.query(), "");
}
#[test]
fn enter_search_switches_to_searching_without_disturbing_an_existing_query() {
let mut overlay = HelpOverlay::default();
overlay.enter_search();
overlay.push_query_char('m');
overlay.commit_search();
assert!(!overlay.is_searching());
assert_eq!(overlay.query(), "m");
overlay.enter_search();
assert!(overlay.is_searching());
assert_eq!(overlay.query(), "m");
}
#[test]
fn cancel_search_returns_to_reading_mode_and_clears_the_query() {
let mut overlay = HelpOverlay::default();
overlay.enter_search();
overlay.push_query_char('m');
overlay.cancel_search();
assert!(!overlay.is_searching());
assert_eq!(overlay.query(), "");
}
#[test]
fn commit_search_returns_to_reading_mode_and_keeps_the_query() {
let mut overlay = HelpOverlay::default();
overlay.enter_search();
overlay.push_query_char('m');
overlay.push_query_char('v');
overlay.commit_search();
assert!(!overlay.is_searching());
assert_eq!(overlay.query(), "mv");
}
#[test]
fn typing_snaps_the_scroll_back_to_the_top() {
let mut overlay = HelpOverlay::default();
overlay.apply(Action::ScrollDown, 20, 5);
assert_eq!(overlay.scroll, 1);
overlay.enter_search();
overlay.push_query_char('m');
assert_eq!(overlay.scroll, 0);
}
#[test]
fn delete_previous_word_removes_one_trailing_whitespace_delimited_word() {
let mut overlay = HelpOverlay::default();
overlay.enter_search();
for c in "kind:worktree is:dirty".chars() {
overlay.push_query_char(c);
}
overlay.delete_previous_word();
assert_eq!(overlay.query(), "kind:worktree ");
}
#[test]
fn delete_previous_word_cuts_on_a_character_boundary_after_a_multi_byte_whitespace() {
let mut overlay = HelpOverlay::default();
overlay.enter_search();
for c in "café\u{00A0}naïve".chars() {
overlay.push_query_char(c);
}
overlay.delete_previous_word();
assert_eq!(overlay.query(), "café\u{00A0}");
for c in "naïve\u{2003}encore".chars() {
overlay.push_query_char(c);
}
overlay.delete_previous_word();
assert_eq!(overlay.query(), "café\u{00A0}naïve\u{2003}");
}
#[test]
fn delete_previous_word_on_an_empty_query_leaves_it_empty_and_does_not_panic() {
let mut overlay = HelpOverlay::default();
overlay.enter_search();
overlay.delete_previous_word();
assert_eq!(overlay.query(), "");
assert!(overlay.is_searching());
}
#[test]
fn delete_previous_word_snaps_the_scroll_back_to_the_top() {
let mut overlay = HelpOverlay::default();
overlay.enter_search();
for c in "move extra".chars() {
overlay.push_query_char(c);
}
overlay.apply(Action::ScrollDown, 20, 5);
assert_eq!(overlay.scroll, 1);
overlay.delete_previous_word();
assert_eq!(overlay.scroll, 0);
}
#[test]
fn scroll_down_then_up_returns_to_the_top() {
let mut overlay = HelpOverlay::default();
overlay.apply(Action::ScrollDown, 20, 5);
overlay.apply(Action::ScrollDown, 20, 5);
assert_eq!(overlay.scroll, 2);
overlay.apply(Action::ScrollUp, 20, 5);
assert_eq!(overlay.scroll, 1);
}
#[test]
fn scroll_up_from_the_top_stays_at_the_top() {
let mut overlay = HelpOverlay::default();
overlay.apply(Action::ScrollUp, 20, 5);
assert_eq!(overlay.scroll, 0);
}
#[test]
fn scroll_down_never_passes_the_last_line_reaching_the_viewport() {
let mut overlay = HelpOverlay::default();
for _ in 0..50 {
overlay.apply(Action::ScrollDown, 20, 5);
}
assert_eq!(
overlay.scroll, 15,
"20 lines in a 5-row viewport clamps at 15"
);
}
#[test]
fn top_and_bottom_jump_to_the_clamped_ends() {
let mut overlay = HelpOverlay::default();
overlay.apply(Action::Bottom, 20, 5);
assert_eq!(overlay.scroll, 15);
overlay.apply(Action::Top, 20, 5);
assert_eq!(overlay.scroll, 0);
}
#[test]
fn an_action_this_overlay_does_not_own_leaves_the_scroll_untouched() {
let mut overlay = HelpOverlay::default();
overlay.apply(Action::ScrollDown, 20, 5);
let scroll_before = overlay.scroll;
overlay.apply(Action::Close, 20, 5);
assert_eq!(overlay.scroll, scroll_before);
}
#[test]
fn viewport_height_in_reading_mode_with_no_query_only_pays_for_the_border() {
let overlay = HelpOverlay::default();
let frame = Rect::new(0, 0, 100, 15);
let interior = HelpLayout::compute(frame).content_area(frame).height;
assert_eq!(overlay.viewport_height(frame), interior);
}
#[test]
fn viewport_height_while_searching_pays_for_the_query_row_too() {
let mut overlay = HelpOverlay::default();
overlay.enter_search();
let frame = Rect::new(0, 0, 100, 15);
let interior = HelpLayout::compute(frame).content_area(frame).height;
assert_eq!(overlay.viewport_height(frame), interior - 1);
}
#[test]
fn scrolling_past_the_end_still_shows_the_last_line_inside_the_border() {
let table = default_table();
let context = Context::List;
let lines = HelpOverlay::lines(&table, context, full_glyphs());
let content_len = lines.len();
let frame = Rect::new(0, 0, 100, 15);
let mut overlay = HelpOverlay::default();
let viewport_height = overlay.viewport_height(frame);
assert!(
(viewport_height as usize) < content_len,
"fixture sanity: List's real content must exceed a 15-row frame's own interior"
);
for _ in 0..content_len {
overlay.apply(Action::ScrollDown, content_len, viewport_height);
}
let terminal = render(&overlay, frame.width, frame.height, context, &table);
let buf = terminal.backend().buffer();
let last_line = lines.last().expect("expected at least one content line");
let last_text = match last_line {
HelpLine::Binding { description, .. } => description,
HelpLine::Legend { meaning, .. } => meaning,
HelpLine::Heading(text) => text,
HelpLine::Blank => panic!(
"fixture sanity: the legend always has at least one row, so the last line is \
never the blank separator above a heading"
),
};
let area = list_area(&overlay, frame);
let last_row_y = area.bottom() - 1;
let row_text: String = (area.x..area.right())
.map(|x| buf[(x, last_row_y)].symbol())
.collect();
assert!(
row_text.contains(last_text),
"expected the last content line {last_text:?} on the panel's own last visible \
row, got {row_text:?}"
);
}
#[test]
fn draw_paints_a_lines_keys_in_accent_and_its_description_in_dim() {
let overlay = HelpOverlay::default();
let table = default_table();
let theme = crate::theme::DEFAULT;
let terminal = render(
&overlay,
ROOMY_FRAME.width,
ROOMY_FRAME.height,
Context::List,
&table,
);
let buf = terminal.backend().buffer();
let lines = HelpOverlay::lines(&table, Context::List, full_glyphs());
let (row, first_keys, first_description) = lines
.iter()
.enumerate()
.find_map(|(row, line)| match line {
HelpLine::Binding { keys, description } => Some((row, keys, *description)),
_ => None,
})
.expect("expected at least one binding row");
let area = list_area(&overlay, ROOMY_FRAME);
let y = area.y + row as u16;
assert!(!first_keys.is_empty(), "expected a non-empty first key");
assert_eq!(
buf[(area.x, y)].fg,
theme.role_color(Role::Accent),
"expected the first binding row's keys painted in the theme's accent role"
);
let value_x = find_text_start_x(buf, area, y, first_description);
assert!(!first_description.is_empty());
assert_eq!(
buf[(value_x, y)].fg,
theme.role_color(Role::Dim),
"expected the first binding row's description painted in the theme's dim role"
);
}
#[test]
fn the_legend_heading_paints_one_solid_colour_unlike_a_binding_rows_own_two_tone_line() {
let overlay = HelpOverlay::default();
let table = default_table();
let terminal = render(
&overlay,
ROOMY_FRAME.width,
ROOMY_FRAME.height,
Context::List,
&table,
);
let buf = terminal.backend().buffer();
let area = list_area(&overlay, ROOMY_FRAME);
let lines = HelpOverlay::lines(&table, Context::List, full_glyphs());
let heading_index = lines
.iter()
.position(|line| matches!(line, HelpLine::Heading(text) if *text == LEGEND_HEADING))
.expect("expected a legend heading line");
let heading_y = area.y + heading_index as u16;
let heading_row: String = (area.x..area.right())
.map(|x| buf[(x, heading_y)].symbol())
.collect();
assert!(heading_row.contains(LEGEND_HEADING));
let heading_start_x = find_text_start_x(buf, area, heading_y, LEGEND_HEADING);
assert!(
buf[(heading_start_x, heading_y)]
.modifier
.contains(ratatui::style::Modifier::BOLD),
"expected the legend heading painted bold, unlike any binding or legend row"
);
let binding_index = lines
.iter()
.position(|line| matches!(line, HelpLine::Binding { .. }))
.expect("expected at least one binding row");
let binding_y = area.y + binding_index as u16;
assert!(
!buf[(area.x, binding_y)]
.modifier
.contains(ratatui::style::Modifier::BOLD),
"expected an ordinary binding row's key cell to carry no bold modifier"
);
}
#[test]
fn the_three_sections_appear_in_order_each_under_its_own_heading_with_a_blank_row_between() {
let table = default_table();
let context = Context::List;
let lines = HelpOverlay::lines(&table, context, full_glyphs());
let own_heading = lines
.iter()
.position(
|line| matches!(line, HelpLine::Heading(text) if *text == context_heading(context)),
)
.expect("expected the current context's own heading");
let global_heading = lines
.iter()
.position(|line| matches!(line, HelpLine::Heading(text) if *text == GLOBAL_HEADING))
.expect("expected List's own `global` section, live alongside it per keybindings.md");
let legend_heading = lines
.iter()
.position(|line| matches!(line, HelpLine::Heading(text) if *text == LEGEND_HEADING))
.expect("expected a legend heading");
assert!(
own_heading < global_heading && global_heading < legend_heading,
"expected {}, then {GLOBAL_HEADING}, then {LEGEND_HEADING}, got {lines:?}",
context_heading(context)
);
assert_eq!(
own_heading, 0,
"expected no blank row above the very first heading"
);
assert!(
matches!(lines[global_heading - 1], HelpLine::Blank),
"expected a blank row between the own-context section and {GLOBAL_HEADING}'s own \
heading, got {:?}",
lines[global_heading - 1]
);
assert!(
matches!(lines[legend_heading - 1], HelpLine::Blank),
"expected a blank row between the `global` section and {LEGEND_HEADING}'s own \
heading, got {:?}",
lines[legend_heading - 1]
);
assert!(
lines[own_heading + 1..global_heading - 1]
.iter()
.all(|line| matches!(line, HelpLine::Binding { .. })),
"expected only binding rows between the own-context heading and the blank row \
above {GLOBAL_HEADING}, got {lines:?}"
);
assert!(
!lines[own_heading + 1..global_heading - 1].is_empty(),
"expected at least one of List's own bindings"
);
assert!(
lines[global_heading + 1..legend_heading - 1]
.iter()
.all(|line| matches!(line, HelpLine::Binding { .. })),
"expected only binding rows between {GLOBAL_HEADING}'s own heading and the blank \
row above {LEGEND_HEADING}, got {lines:?}"
);
assert!(
!lines[global_heading + 1..legend_heading - 1].is_empty(),
"expected at least one `global` binding"
);
assert!(
lines[legend_heading + 1..]
.iter()
.all(|line| matches!(line, HelpLine::Legend { .. })),
"expected only legend rows after the legend heading"
);
assert!(
!lines[legend_heading + 1..].is_empty(),
"expected at least one legend row"
);
}
#[test]
fn a_context_with_no_global_section_shows_no_global_heading() {
let table = default_table();
let lines = HelpOverlay::lines(&table, Context::Confirm, full_glyphs());
assert!(
!lines
.iter()
.any(|line| matches!(line, HelpLine::Heading(text) if *text == GLOBAL_HEADING)),
"expected Confirm, where global is suspended, to carry no {GLOBAL_HEADING} \
heading, got {lines:?}"
);
}
#[test]
fn draws_the_house_styles_border_and_a_title_naming_the_overlay_and_its_close_keys() {
let overlay = HelpOverlay::default();
let table = default_table();
let terminal = render(
&overlay,
ROOMY_FRAME.width,
ROOMY_FRAME.height,
Context::List,
&table,
);
let buf = terminal.backend().buffer();
let glyphs = full_glyphs();
crate::test_support::assert_bordered_frame_and_top_title_drawn_with(
buf,
ROOMY_FRAME,
glyphs.border,
BORDER_TITLE,
"the help overlay's frame",
);
}
#[test]
fn the_bottom_border_carries_the_crates_own_version_right_aligned() {
let overlay = HelpOverlay::default();
let table = default_table();
let terminal = render(
&overlay,
ROOMY_FRAME.width,
ROOMY_FRAME.height,
Context::List,
&table,
);
let buf = terminal.backend().buffer();
let outer = ROOMY_FRAME;
let bottom_y = outer.bottom() - 1;
let expected = version_title();
let expected_len = expected.chars().count() as u16;
let start_x = outer.right() - 1 - expected_len;
let got: String = (start_x..outer.right() - 1)
.map(|x| buf[(x, bottom_y)].symbol())
.collect();
assert_eq!(
got, expected,
"expected the version right-aligned on the bottom border, ending one cell before \
the right corner"
);
}
#[test]
fn content_draws_at_the_blocks_own_interior_origin_not_over_the_border_in_reading_mode() {
let overlay = HelpOverlay::default();
let table = default_table();
let terminal = render(
&overlay,
ROOMY_FRAME.width,
ROOMY_FRAME.height,
Context::List,
&table,
);
let buf = terminal.backend().buffer();
let glyphs = full_glyphs();
assert_eq!(
buf[(ROOMY_FRAME.x, ROOMY_FRAME.y)].symbol(),
glyphs.border.top_left.to_string(),
"expected the border's own corner untouched by content"
);
let lines = HelpOverlay::lines(&table, Context::List, full_glyphs());
let first_char = leading_char(&lines[0]);
assert_eq!(
buf[(ROOMY_FRAME.x + 1, ROOMY_FRAME.y + 1)].symbol(),
first_char.to_string(),
"expected the first line's first character at the block's own interior origin"
);
}
#[test]
fn the_query_line_takes_the_interiors_own_last_row_while_content_keeps_the_origin() {
let mut overlay = HelpOverlay::default();
overlay.enter_search();
let table = default_table();
let terminal = render(
&overlay,
ROOMY_FRAME.width,
ROOMY_FRAME.height,
Context::List,
&table,
);
let buf = terminal.backend().buffer();
let lines = HelpOverlay::lines(&table, Context::List, full_glyphs());
let first_char = leading_char(&lines[0]);
assert_eq!(
buf[(ROOMY_FRAME.x + 1, ROOMY_FRAME.y + 1)].symbol(),
first_char.to_string(),
"expected the first content line's own leading character still at the block's own \
interior origin while searching"
);
let content_area = HelpLayout::compute(ROOMY_FRAME).content_area(ROOMY_FRAME);
let last_row = content_area.bottom() - 1;
assert_eq!(
buf[(content_area.x, last_row)].symbol(),
"/",
"expected the query line's own leading mark on the interior's own last row"
);
}
#[test]
fn every_lines_description_starts_at_the_same_column_regardless_of_its_own_keys_length() {
let overlay = HelpOverlay::default();
let table = default_table();
let context = Context::List;
let lines = HelpOverlay::lines(&table, context, full_glyphs());
let bindings: Vec<(usize, &str, &str)> = lines
.iter()
.enumerate()
.filter_map(|(row, line)| match line {
HelpLine::Binding { keys, description } => Some((row, keys.as_str(), *description)),
_ => None,
})
.collect();
let (shortest_row, _, shortest_description) = *bindings
.iter()
.min_by_key(|(_, keys, _)| keys.chars().count())
.expect("expected at least one binding row");
let (longest_row, longest_keys, longest_description) = *bindings
.iter()
.max_by_key(|(_, keys, _)| keys.chars().count())
.expect("expected at least one binding row");
assert!(
bindings
.iter()
.any(|(_, keys, _)| keys.chars().count() < longest_keys.chars().count()),
"fixture sanity: List's own content must have two lines of different key length"
);
let terminal = render(
&overlay,
ROOMY_FRAME.width,
ROOMY_FRAME.height,
context,
&table,
);
let buf = terminal.backend().buffer();
let area = list_area(&overlay, ROOMY_FRAME);
let shortest_y = area.y + shortest_row as u16;
let longest_y = area.y + longest_row as u16;
let shortest_x = find_text_start_x(buf, area, shortest_y, shortest_description);
let longest_x = find_text_start_x(buf, area, longest_y, longest_description);
assert_eq!(
shortest_x, longest_x,
"expected both descriptions to start at the same column regardless of their own \
line's key length"
);
}
#[test]
fn the_gutter_stays_the_same_width_in_a_much_wider_frame_rather_than_stretching_to_fill_it() {
let overlay = HelpOverlay::default();
let table = default_table();
let context = Context::List;
let lines = HelpOverlay::lines(&table, context, full_glyphs());
let (first_row, first_description) = lines
.iter()
.enumerate()
.find_map(|(row, line)| match line {
HelpLine::Binding { description, .. } => Some((row, *description)),
_ => None,
})
.expect("expected at least one binding row");
let narrower = render(&overlay, 100, 40, context, &table);
let narrower_area = list_area(&overlay, Rect::new(0, 0, 100, 40));
let narrower_x = find_text_start_x(
narrower.backend().buffer(),
narrower_area,
narrower_area.y + first_row as u16,
first_description,
);
let wider = render(&overlay, 200, 40, context, &table);
let wider_area = list_area(&overlay, Rect::new(0, 0, 200, 40));
let wider_x = find_text_start_x(
wider.backend().buffer(),
wider_area,
wider_area.y + first_row as u16,
first_description,
);
assert_eq!(
narrower_x, wider_x,
"expected the gutter width to stay fixed rather than stretch with a wider frame"
);
}
#[test]
fn degrades_below_the_height_a_border_and_one_content_row_need_both_sides_of_the_boundary() {
let ample_width = 40;
let just_tall_enough = Rect::new(0, 0, ample_width, MIN_BORDERED_HEIGHT);
assert_eq!(HelpLayout::compute(just_tall_enough), HelpLayout::Bordered);
let one_row_short = Rect::new(0, 0, ample_width, MIN_BORDERED_HEIGHT - 1);
assert_eq!(HelpLayout::compute(one_row_short), HelpLayout::Degraded);
}
#[test]
fn degrades_below_the_width_a_border_and_one_content_column_need_both_sides_of_the_boundary() {
let ample_height = 20;
let just_wide_enough = Rect::new(0, 0, MIN_BORDERED_WIDTH, ample_height);
assert_eq!(HelpLayout::compute(just_wide_enough), HelpLayout::Bordered);
let one_column_short = Rect::new(0, 0, MIN_BORDERED_WIDTH - 1, ample_height);
assert_eq!(HelpLayout::compute(one_column_short), HelpLayout::Degraded);
}
#[test]
fn a_too_small_frame_degrades_to_flush_content_with_no_border_in_reading_mode() {
let overlay = HelpOverlay::default();
let table = default_table();
let tiny_frame = Rect::new(0, 0, 20, MIN_BORDERED_HEIGHT - 1);
let terminal = render(
&overlay,
tiny_frame.width,
tiny_frame.height,
Context::List,
&table,
);
let buf = terminal.backend().buffer();
let lines = HelpOverlay::lines(&table, Context::List, full_glyphs());
let first_char = leading_char(&lines[0]);
assert_eq!(buf[(0, 0)].symbol(), first_char.to_string());
}
#[test]
fn a_too_small_frame_degrades_to_flush_query_line_with_no_border_while_searching() {
let mut overlay = HelpOverlay::default();
overlay.enter_search();
let table = default_table();
let tiny_frame = Rect::new(0, 0, 20, MIN_BORDERED_HEIGHT - 1);
let terminal = render(
&overlay,
tiny_frame.width,
tiny_frame.height,
Context::List,
&table,
);
let buf = terminal.backend().buffer();
let query_y = tiny_frame.bottom() - 1;
assert_eq!(buf[(0, query_y)].symbol(), "/");
}
fn synthetic_section(heading: &'static str, row_count: usize) -> (&'static str, Vec<HelpLine>) {
(
heading,
(0..row_count)
.map(|i| HelpLine::Binding {
keys: i.to_string(),
description: "row",
})
.collect(),
)
}
fn two_column_threshold(table: &BindingTable, context: Context, glyphs: &GlyphSet) -> u16 {
let unfiltered = HelpOverlay::built_sections(table, context, glyphs, "");
let (left, right) = HelpOverlay::split_into_columns(unfiltered);
let (_, _, left_width, right_width) = ColumnMetrics::column_metrics(&left, &right);
left_width + COLUMN_GUTTER + right_width
}
#[test]
fn assembled_len_counts_each_sections_own_heading_and_content_plus_one_blank_between_sections()
{
let sections = vec![synthetic_section("A", 2), synthetic_section("B", 3)];
assert_eq!(HelpOverlay::assembled_len(§ions), 8);
assert_eq!(HelpOverlay::assembled_len(§ions[..1]), 3);
assert_eq!(HelpOverlay::assembled_len(§ions[..0]), 0);
}
fn assert_section_whole_in(column: &[HelpLine], heading: &'static str, content: &[HelpLine]) {
let heading_index = column
.iter()
.position(|line| matches!(line, HelpLine::Heading(h) if *h == heading))
.unwrap_or_else(|| panic!("expected heading {heading:?} in {column:?}"));
assert_eq!(
&column[heading_index + 1..heading_index + 1 + content.len()],
content,
"expected {heading:?}'s own content immediately after its own heading in {column:?}"
);
}
#[test]
fn split_into_columns_keeps_every_sections_heading_together_with_its_own_content() {
let sections = vec![
synthetic_section("A", 2),
synthetic_section("B", 20),
synthetic_section("C", 3),
];
let (left, right) = HelpOverlay::split_into_columns(sections.clone());
for (heading, content) in §ions {
let column = if left
.iter()
.any(|line| matches!(line, HelpLine::Heading(h) if h == heading))
{
&left
} else {
&right
};
assert_section_whole_in(column, heading, content);
}
}
#[test]
fn split_into_columns_chooses_the_section_boundary_that_balances_total_line_count_most_closely()
{
let sections = vec![
synthetic_section("A", 2),
synthetic_section("B", 20),
synthetic_section("C", 3),
];
let (left, right) = HelpOverlay::split_into_columns(sections);
assert!(
left.iter()
.any(|line| matches!(line, HelpLine::Heading("A")))
);
assert!(
left.iter()
.any(|line| matches!(line, HelpLine::Heading("B")))
);
assert!(
right
.iter()
.any(|line| matches!(line, HelpLine::Heading("C")))
);
assert!(
!right
.iter()
.any(|line| matches!(line, HelpLine::Heading("A") | HelpLine::Heading("B")))
);
assert_eq!(
left.len(),
25,
"expected A and B assembled together: {left:?}"
);
assert_eq!(right.len(), 4, "expected C alone: {right:?}");
}
#[test]
fn split_into_columns_puts_a_lone_surviving_section_whole_in_the_left_column() {
let sections = vec![synthetic_section("Only", 30)];
let expected = HelpOverlay::assemble_sections(sections.clone());
let (left, right) = HelpOverlay::split_into_columns(sections);
assert_eq!(left, expected);
assert!(
right.is_empty(),
"expected the right column empty: {right:?}"
);
}
#[test]
fn column_metrics_stays_one_column_below_the_threshold_and_switches_to_two_right_at_it() {
let table = default_table();
let context = Context::List;
let glyphs = full_glyphs();
let threshold = two_column_threshold(&table, context, glyphs);
let below = ColumnMetrics::compute(&table, context, glyphs, threshold - 1);
assert!(
!below.two_columns,
"expected one column just under the threshold ({threshold})"
);
let at = ColumnMetrics::compute(&table, context, glyphs, threshold);
assert!(
at.two_columns,
"expected two columns right at the threshold ({threshold})"
);
}
#[test]
fn a_real_161_column_terminal_lays_out_the_default_list_content_in_two_columns() {
let table = default_table();
let context = Context::List;
let glyphs = full_glyphs();
let frame = Rect::new(0, 0, 161, 40);
let content_width = HelpLayout::compute(frame).content_area(frame).width;
let metrics = ColumnMetrics::compute(&table, context, glyphs, content_width);
assert!(
metrics.two_columns,
"expected a 161-column terminal ({content_width} content columns) to reach two \
columns for List's own content"
);
let sections = HelpOverlay::built_sections(&table, context, glyphs, "");
let (left, right) = HelpOverlay::laid_out(sections, &metrics);
assert!(
!left.is_empty() && !right.is_empty(),
"expected both columns to hold content, got left={left:?} right={right:?}"
);
}
#[test]
fn a_query_leaving_only_the_legend_stays_one_column_even_at_a_frame_wide_enough_for_two() {
let table = default_table();
let context = Context::List;
let glyphs = full_glyphs();
let threshold = two_column_threshold(&table, context, glyphs);
let frame = Rect::new(0, 0, threshold + BORDER_WIDTH, 40);
let sections = HelpOverlay::built_sections(&table, context, glyphs, "child row");
assert_eq!(
sections.len(),
1,
"fixture sanity: \"child row\" must match only the legend's own ChildRow row"
);
let content_width = HelpLayout::compute(frame).content_area(frame).width;
let metrics = ColumnMetrics::compute(&table, context, glyphs, content_width);
assert!(
metrics.two_columns,
"fixture sanity: the frame must be wide enough for two columns"
);
let (left, right) = HelpOverlay::laid_out(sections, &metrics);
assert!(
left.iter()
.any(|line| matches!(line, HelpLine::Heading(h) if *h == LEGEND_HEADING)),
"expected the lone surviving section whole in the left column: {left:?}"
);
assert!(
right.is_empty(),
"expected the right column empty: {right:?}"
);
}
#[test]
fn draw_lays_two_columns_side_by_side_at_a_frame_wide_enough_for_them() {
let table = default_table();
let context = Context::List;
let glyphs = full_glyphs();
let threshold = two_column_threshold(&table, context, glyphs);
let frame = Rect::new(0, 0, threshold + BORDER_WIDTH, 60);
let sections = HelpOverlay::built_sections(&table, context, glyphs, "");
let content_width = HelpLayout::compute(frame).content_area(frame).width;
let metrics = ColumnMetrics::compute(&table, context, glyphs, content_width);
assert!(
metrics.two_columns,
"fixture sanity: this frame must fit two columns"
);
let (left, right) = HelpOverlay::laid_out(sections, &metrics);
assert!(
!right.is_empty(),
"fixture sanity: the legend must land in its own column"
);
assert!(
matches!(right[0], HelpLine::Heading(LEGEND_HEADING)),
"expected no wasted blank row above the right column's own first heading: {right:?}"
);
assert!(
left.len() > right.len(),
"fixture sanity: List's own bindings plus global must outlast the legend alone"
);
let overlay = HelpOverlay::default();
let terminal = render(&overlay, frame.width, frame.height, context, &table);
let buf = terminal.backend().buffer();
let area = list_area(&overlay, frame);
let first_left_char = leading_char(&left[0]);
assert_eq!(
buf[(area.x, area.y)].symbol(),
first_left_char.to_string(),
"expected the left column's own first line at the list's own origin"
);
let first_right_char = leading_char(&right[0]);
assert_eq!(
buf[(area.x + metrics.column_offset, area.y)].symbol(),
first_right_char.to_string(),
"expected the right column's own first line one column_offset to the right"
);
let exhausted_row = right.len();
assert!(
exhausted_row < left.len(),
"fixture sanity: the left column must outlast the right one"
);
let y = area.y + exhausted_row as u16;
assert_eq!(
buf[(area.x + metrics.column_offset, y)].symbol(),
" ",
"expected nothing painted in the right column once it runs out of rows"
);
assert_eq!(
buf[(area.x, y)].symbol(),
rendered_symbol(&left[exhausted_row]),
"expected the left column to keep going past where the right one ran out"
);
}
#[test]
fn visible_len_at_a_wide_frame_is_the_taller_columns_own_row_count_not_the_flat_total() {
let table = default_table();
let context = Context::List;
let glyphs = full_glyphs();
let threshold = two_column_threshold(&table, context, glyphs);
let frame = Rect::new(0, 0, threshold + BORDER_WIDTH, 60);
let flat_total = HelpOverlay::filtered_lines(&table, context, glyphs, "").len();
let visible = HelpOverlay::visible_len(&table, context, glyphs, "", frame);
assert!(
visible < flat_total,
"expected the two-column row count ({visible}) below the flat total \
({flat_total})"
);
let sections = HelpOverlay::built_sections(&table, context, glyphs, "");
let content_width = HelpLayout::compute(frame).content_area(frame).width;
let metrics = ColumnMetrics::compute(&table, context, glyphs, content_width);
let (left, right) = HelpOverlay::laid_out(sections, &metrics);
assert_eq!(visible, left.len().max(right.len()));
}
}