Improve bottom panel TUI design
This commit is contained in:
+290
-26
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user