Improve bottom panel TUI design

This commit is contained in:
2026-10-27 23:04:52 +02:00
parent 4b53aad20c
commit d49721ff8e
3 changed files with 367 additions and 27 deletions
+290 -26
View File
@@ -32,26 +32,32 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
);
return;
}
let compact_height = area.height <= 8;
let compact_height = area.height <= 9;
let header_height = if compact_height { 2 } else { 3 };
let footer_hints = keyboard_hints(state, forms);
let packed = pack_hints(&footer_hints, area.width.saturating_sub(2) as usize);
let max_footer_rows = area.height.saturating_sub(header_height + 3 + 2).max(1) as usize;
let (footer_lines, omitted) = fit_hint_rows(
packed,
max_footer_rows,
area.width.saturating_sub(2) as usize,
);
let footer_height = footer_lines
.len()
.saturating_add(2)
.min(area.height.saturating_sub(header_height + 3) as usize)
.max(3) as u16;
let hint_width = area.width.saturating_sub(2) as usize;
let (footer_lines, omitted, footer_height) = if compact_height {
let packed = pack_hints(&footer_hints, hint_width);
let (lines, omitted) = fit_hint_rows(
packed,
area.height.saturating_sub(header_height + 3 + 2).max(1) as usize,
hint_width,
);
let height = lines
.len()
.saturating_add(2)
.min(area.height.saturating_sub(header_height + 3) as usize)
.max(3) as u16;
(lines, omitted, height)
} else {
let (lines, omitted) = standard_hint_rows(&footer_hints, hint_width);
(lines, omitted, 4)
};
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(header_height),
Constraint::Min(3),
Constraint::Min(1),
Constraint::Length(footer_height),
])
.split(area);
@@ -337,10 +343,70 @@ fn fit_hint_rows(
(rows, true)
}
fn standard_hint_rows(hints: &[Hint], width: usize) -> (Vec<Vec<Hint>>, bool) {
const MORE: &str = "… more";
let mut prioritized = hints.iter().enumerate().collect::<Vec<_>>();
prioritized.sort_by_key(|(index, hint)| (hint_priority(hint), *index));
let pack = |reserve_more: bool| {
let mut rows = vec![Vec::new(), Vec::new()];
let mut used = [0usize; 2];
let mut omitted = false;
for (_, hint) in &prioritized {
let cost = hint.key.chars().count() + 1 + hint.action.chars().count();
let mut placed = false;
for row_index in 0..2 {
let gap = usize::from(!rows[row_index].is_empty()) * 2;
let reserve = if reserve_more && row_index == 1 {
MORE.chars().count() + 2
} else {
0
};
if used[row_index] + gap + cost + reserve <= width {
used[row_index] += gap + cost;
rows[row_index].push((*hint).clone());
placed = true;
break;
}
}
if !placed {
omitted = true;
}
}
if !omitted {
rows.retain(|row| !row.is_empty());
}
(rows, omitted)
};
let (rows, omitted) = pack(false);
if omitted { pack(true) } else { (rows, false) }
}
fn hint_priority(hint: &Hint) -> u8 {
match hint.key {
"↑/↓" => 0,
"Enter" => 1,
"?" => 2,
"n" => 3,
"r" => 4,
"d" => 5,
"p" => 6,
"c" => 7,
"R" => 8,
"x" => 9,
"q" => 10,
"Esc" => 0,
"Tab" => 1,
_ => 20,
}
}
fn render_keyboard_panel(frame: &mut Frame<'_>, area: Rect, rows: &[Vec<Hint>], omitted: bool) {
let lines = rows
.iter()
.map(|row| {
.enumerate()
.map(|(row_index, row)| {
let mut spans = Vec::new();
for (index, hint) in row.iter().enumerate() {
if index > 0 {
@@ -358,7 +424,7 @@ fn render_keyboard_panel(frame: &mut Frame<'_>, area: Rect, rows: &[Vec<Hint>],
spans.push(Span::raw(" "));
spans.push(Span::raw(hint.action));
}
if omitted {
if omitted && row_index + 1 == rows.len() {
spans.push(Span::raw(" … more"));
}
Line::from(spans)
@@ -1375,27 +1441,225 @@ mod tests {
"↑/↓ select",
"Enter details",
"? Help",
"q exit",
"n new",
"r rename",
"d delete",
"p preview",
"c Commit",
"x discard",
"R restart",
"o first file",
"O password reissue",
"b backups / restore",
"B backup",
"i import",
"! issues",
] {
assert!(
text.contains(expected),
"missing {expected:?} in 80x24 render:\n{text}"
"missing priority hint {expected:?}:\n{text}"
);
}
assert!(text.contains("R restart") && text.contains("r rename"));
assert!(
text.contains("… more"),
"omitted actions need an affordance: {text}"
);
assert!(!text.contains("O password reissue"));
assert!(
keyboard_hints(&state, &FormController::default())
.iter()
.any(|hint| hint.key == "O" && hint.action == "password reissue")
);
}
#[test]
fn standard_footer_is_four_rows_and_list_height_is_stable_across_states() {
let identity = PublicKey::from_bytes([4; 32]);
let states = [
UiState {
view: Some(empty_view()),
..UiState::default()
},
UiState {
view: Some(saved_client_view(identity)),
..UiState::default()
},
UiState {
selected_identity: Some(identity),
view: Some(saved_client_view(identity)),
..UiState::default()
},
];
let mut header_rows = Vec::new();
for state in &states {
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| draw(frame, state, &FormController::default()))
.unwrap();
let buffer = terminal.backend().buffer();
let footer_top = 20;
assert!(buffer[(0, footer_top)].symbol().contains('┌'));
assert!(buffer[(79, footer_top)].symbol().contains('┐'));
assert!(buffer[(0, 23)].symbol().contains('└'));
assert!(buffer[(79, 23)].symbol().contains('┘'));
assert!(buffer[(0, 21)].symbol().contains('│'));
assert!(buffer[(0, 22)].symbol().contains('│'));
let text = rendered_text(&terminal);
assert!(text.lines().any(|line| line.contains("Keys")));
let header_y = (0..buffer.area.height)
.find(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, *y)].symbol())
.collect::<String>()
.contains("State")
})
.unwrap();
header_rows.push(header_y);
}
assert!(header_rows.windows(2).all(|pair| pair[0] == pair[1]));
}
#[test]
fn standard_narrow_footer_keeps_help_priority_and_never_wraps_or_clips() {
let identity = PublicKey::from_bytes([4; 32]);
for width in [34, 36, 40] {
for view in [empty_view(), saved_client_view(identity)] {
let state = UiState {
view: Some(view),
..UiState::default()
};
let hints = keyboard_hints(&state, &FormController::default());
let (expected_rows, omitted) = standard_hint_rows(&hints, (width - 2) as usize);
let mut terminal = Terminal::new(TestBackend::new(width, 12)).unwrap();
terminal
.draw(|frame| draw(frame, &state, &FormController::default()))
.unwrap();
let buffer = terminal.backend().buffer();
assert!(buffer[(0, 8)].symbol().contains('┌'));
assert!(buffer[(0, 11)].symbol().contains('└'));
let footer_rows = [9, 10];
let footer_text = footer_rows
.iter()
.map(|y| {
(1..width - 1)
.map(|x| buffer[(x, *y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>();
let joined = footer_text.join("\n");
assert!(
joined.contains("? Help"),
"Help missing at {width}: {joined}"
);
assert!(
joined.contains("↑/↓ select"),
"navigation missing: {joined}"
);
assert!(
joined.contains("… more"),
"omission marker missing: {joined}"
);
assert!(
footer_text
.iter()
.all(|line| Span::raw(line.as_str()).width() <= (width - 2) as usize)
);
for (row_index, expected_row) in expected_rows.iter().enumerate() {
for hint in expected_row {
let full_hint = format!("{} {}", hint.key, hint.action);
assert!(
footer_text[row_index].contains(&full_hint),
"full hint {full_hint:?} was clipped at {width}: {footer_text:?}"
);
}
}
if omitted {
assert!(
footer_text[expected_rows.len() - 1].contains("… more"),
"reserved marker was clipped at {width}: {footer_text:?}"
);
assert!(
footer_text[..expected_rows.len() - 1]
.iter()
.all(|line| !line.contains("… more"))
);
}
if state.selected_client_mutable() {
assert!(joined.contains("r rename") && joined.contains("d delete"));
} else {
assert!(!joined.contains("r rename") && !joined.contains("d delete"));
}
}
}
}
#[test]
fn standard_footer_does_not_reserve_marker_when_all_actions_fit() {
let identity = PublicKey::from_bytes([4; 32]);
let state = UiState {
selected_identity: Some(identity),
view: Some(saved_client_view(identity)),
..UiState::default()
};
let hints = keyboard_hints(&state, &FormController::default());
for width in [102, 103] {
let (expected_rows, omitted) = standard_hint_rows(&hints, (width - 2) as usize);
assert!(!omitted, "unexpected omissions at width {width}");
assert_eq!(expected_rows.len(), 2, "expected two hint rows at {width}");
let mut terminal = Terminal::new(TestBackend::new(width, 24)).unwrap();
terminal
.draw(|frame| draw(frame, &state, &FormController::default()))
.unwrap();
let buffer = terminal.backend().buffer();
let rendered_rows = [21, 22].map(|y| {
(1..width - 1)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
});
for (row_index, expected_row) in expected_rows.iter().enumerate() {
for hint in expected_row {
let full_hint = format!("{} {}", hint.key, hint.action);
assert!(
rendered_rows[row_index].contains(&full_hint),
"full hint {full_hint:?} missing at width {width}: {rendered_rows:?}"
);
}
}
assert!(
rendered_rows.iter().all(|line| !line.contains("… more")),
"false omission marker at width {width}: {rendered_rows:?}"
);
assert!(rendered_rows.join("\n").contains("! issues"));
}
}
#[test]
fn nine_row_terminal_shows_client_header_and_row_with_compact_footer() {
let identity = PublicKey::from_bytes([4; 32]);
let state = UiState {
view: Some(saved_client_view(identity)),
..UiState::default()
};
let mut terminal = Terminal::new(TestBackend::new(80, 9)).unwrap();
terminal
.draw(|frame| draw(frame, &state, &FormController::default()))
.unwrap();
let buffer = terminal.backend().buffer();
let main_text = (0..3)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y + 2)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>();
assert!(main_text[0].contains("State") && main_text[0].contains("Name"));
assert!(
main_text[1].contains("synthetic"),
"client row missing: {main_text:?}"
);
assert!(main_text[0..2].iter().all(|line| !line.contains('┌')));
assert!(buffer[(0, 5)].symbol().contains('┌'));
assert!(buffer[(0, 8)].symbol().contains('└'));
let footer_header = (0..buffer.area.width)
.map(|x| buffer[(x, 5)].symbol())
.collect::<String>();
assert!(footer_header.contains("Keys"));
}
#[test]