Improve application help

This commit is contained in:
2026-10-29 00:01:35 +02:00
parent 4960c65f65
commit 23d6180f24
6 changed files with 1126 additions and 48 deletions
+711 -41
View File
@@ -32,35 +32,8 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
);
return;
}
let compact_height = area.height <= 9;
let header_height = if compact_height { 2 } else { 3 };
let footer_hints = keyboard_hints(state, forms);
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(1),
Constraint::Length(footer_height),
])
.split(area);
let areas = screen_areas(area, &footer_hints);
let status = state
.view
.as_ref()
@@ -73,7 +46,7 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
.title("WireGuard Admin"),
)
.wrap(Wrap { trim: true }),
rows[0],
areas.header,
);
if forms.is_active() {
@@ -93,12 +66,17 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
)
.wrap(Wrap { trim: false })
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
rows[1],
areas.content,
);
} else {
match state.screen {
Screen::Help => render_help(frame, rows[1]),
Screen::ClientDetails => render_details(frame, rows[1], state),
Screen::Help => render_help(
frame,
areas.content,
state.scroll_offset,
help_navigation_metrics(area.width, area.height),
),
Screen::ClientDetails => render_details(frame, areas.content, state),
Screen::Progress => {
let text = state
.status_message
@@ -108,7 +86,7 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
Paragraph::new(safe_text(text))
.block(Block::default().borders(Borders::ALL).title("Progress"))
.wrap(Wrap { trim: false }),
rows[1],
areas.content,
);
}
Screen::Message => {
@@ -118,13 +96,59 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
.block(Block::default().borders(Borders::ALL).title("Status"))
.wrap(Wrap { trim: false })
.scroll((state.scroll_offset.min(u16::MAX as usize) as u16, 0)),
rows[1],
areas.content,
);
}
Screen::ClientList => render_client_screen(frame, rows[1], state),
Screen::ClientList => render_client_screen(frame, areas.content, state),
}
}
render_keyboard_panel(frame, rows[2], &footer_lines, omitted);
render_keyboard_panel(frame, areas.footer, &areas.footer_lines, areas.omitted);
}
struct ScreenAreas {
header: Rect,
content: Rect,
footer: Rect,
footer_lines: Vec<Vec<Hint>>,
omitted: bool,
}
fn screen_areas(area: Rect, footer_hints: &[Hint]) -> ScreenAreas {
let compact_height = area.height <= 9;
let header_height = if compact_height { 2 } else { 3 };
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(1),
Constraint::Length(footer_height),
])
.split(area);
ScreenAreas {
header: rows[0],
content: rows[1],
footer: rows[2],
footer_lines,
omitted,
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -169,8 +193,7 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
}
match state.screen {
Screen::Help => {
add("Esc", "return to list", HintKind::Navigation);
add("?", "Help", HintKind::Navigation);
return help_hints();
}
Screen::Message => {
add("Esc", "return to list", HintKind::Navigation);
@@ -293,6 +316,40 @@ fn fit_hint_rows(
if max_rows == 1 && width < 40 {
let hints = rows.into_iter().flatten().collect::<Vec<_>>();
let hint_count = hints.len();
// Help needs an explicit way back to the client list even when the
// compact footer can show only one short row. Preserve scroll guidance
// too, and use the existing overflow marker for the remaining keys.
if hints.iter().any(|hint| hint.key == "PgUp/PgDn") {
let mut compact = ["↑/↓", "Esc", "?"]
.into_iter()
.filter_map(|key| hints.iter().find(|hint| hint.key == key).cloned())
.collect::<Vec<_>>();
let mut omitted = compact.len() < hint_count;
let row_width = |hints: &[Hint], omitted: bool| {
let entries = hints
.iter()
.map(|hint| Span::raw(hint.key).width() + 1 + Span::raw(hint.action).width())
.sum::<usize>();
let gaps = hints.len().saturating_sub(1) * 2;
let more = if omitted {
Span::raw(" … more").width()
} else {
0
};
entries + gaps + more
};
while row_width(&compact, omitted) > width && compact.len() > 1 {
compact.pop();
omitted = true;
}
if row_width(&compact, omitted) > width {
compact.truncate(1);
omitted = true;
}
return (vec![compact], omitted);
}
let list_navigation = hints.iter().find(|hint| hint.key == "↑/↓");
let help = hints.iter().find(|hint| hint.key == "?");
let mut compact = Vec::new();
@@ -1776,10 +1833,433 @@ fn issue_summary(view: &SessionView) -> String {
)
}
fn render_help(frame: &mut Frame<'_>, area: ratatui::layout::Rect) {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum HelpRowKind {
Body,
Heading,
Key,
Workflow,
Warning,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct HelpRow {
text: String,
kind: HelpRowKind,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct HelpNavigationMetrics {
pub(crate) viewport_rows: usize,
pub(crate) total_rows: usize,
pub(crate) max_offset: usize,
pub(crate) page_rows: usize,
}
fn help_hints() -> Vec<Hint> {
[
("↑/↓", "scroll", HintKind::Navigation),
("PgUp/PgDn", "page", HintKind::Navigation),
("Esc", "list", HintKind::Navigation),
("?", "top", HintKind::Navigation),
("q", "exit", HintKind::Destructive),
]
.into_iter()
.map(|(key, action, kind)| Hint { key, action, kind })
.collect()
}
/// Return the exact wrapped row counts used by Help and by terminal scrolling.
pub(crate) fn help_navigation_metrics(width: u16, height: u16) -> HelpNavigationMetrics {
let area = Rect::new(0, 0, width, height);
let areas = screen_areas(area, &help_hints());
let inner_width = areas.content.width.saturating_sub(2) as usize;
let viewport_rows = areas.content.height.saturating_sub(2) as usize;
let total_rows = help_document(inner_width).len();
let page_rows = viewport_rows.max(1);
let max_offset = total_rows.saturating_sub(page_rows);
HelpNavigationMetrics {
viewport_rows,
total_rows,
max_offset,
page_rows,
}
}
fn help_document(width: usize) -> Vec<HelpRow> {
let mut rows = Vec::new();
let width = width.max(1);
let body = |rows: &mut Vec<HelpRow>, text: &str, kind| {
let indent = text.len() - text.trim_start().len();
let prefix = &text[..indent];
let wrap_width = width.saturating_sub(Span::raw(prefix).width()).max(1);
rows.extend(
wrap_help_text(text.trim_start(), wrap_width)
.into_iter()
.map(|line| HelpRow {
text: format!("{prefix}{line}"),
kind,
}),
);
};
let blank = |rows: &mut Vec<HelpRow>| {
rows.push(HelpRow {
text: String::new(),
kind: HelpRowKind::Body,
});
};
let heading = |rows: &mut Vec<HelpRow>, text: &str| {
blank(rows);
body(rows, text, HelpRowKind::Heading);
};
let key = |rows: &mut Vec<HelpRow>, shortcut: &str, action: &str, explanation: &str| {
body(rows, &format!("{shortcut} {action}"), HelpRowKind::Key);
body(rows, &format!(" {explanation}"), HelpRowKind::Body);
};
body(&mut rows, "WireGuard Admin — Help", HelpRowKind::Heading);
body(
&mut rows,
"Use this guide to inspect peers, prepare changes, save them, and apply saved configuration only through an explicit verified restart. Shortcuts are case-sensitive. Help navigation never changes client or server state.",
HelpRowKind::Body,
);
heading(&mut rows, "1. Quick start");
key(
&mut rows,
"↑/↓",
"Select a peer",
"The list selection wraps at either end.",
);
key(
&mut rows,
"Enter",
"Open details",
"Inspect a selected peer and its configuration state.",
);
key(
&mut rows,
"?",
"Open Help",
"From Help, ? returns to the beginning.",
);
body(&mut rows, "Typical new-client path:", HelpRowKind::Workflow);
body(
&mut rows,
"1. Press n, enter an alias, and leave both address fields blank for automatic allocation.",
HelpRowKind::Workflow,
);
body(
&mut rows,
"2. Review the draft with p, then save with c. Commit makes a backup and saves configuration and vault files only.",
HelpRowKind::Workflow,
);
body(
&mut rows,
"3. Use o to deliver the first client file. Applying saved changes is a separate, explicit R operation.",
HelpRowKind::Workflow,
);
heading(&mut rows, "2. Client list and selected details");
key(
&mut rows,
"↑/↓",
"Move selection",
"Only the visible selection changes; it does not edit a peer.",
);
key(
&mut rows,
"Enter",
"Show details",
"Esc returns to the list and retains the selected identity.",
);
key(
&mut rows,
"!",
"Show issues",
"Review configuration and peer issues when the saved view is loaded.",
);
body(
&mut rows,
"Rows remain available for inspection when configuration is malformed, duplicated, or outside supported management capabilities. Mutations require an unambiguous supported public key. Aliases are labels and may repeat.",
HelpRowKind::Body,
);
heading(&mut rows, "3. Draft, saved, applied, and observations");
body(
&mut rows,
"Draft is the in-memory working copy. Saved is the configuration and vault on disk. Applied is the saved peer set whose restart completed and whose runtime interface and peers were verified. These are separate states.",
HelpRowKind::Body,
);
body(
&mut rows,
"A draft is not applied. Commit updates saved files but does not change the running VPN. Restore replaces saved files but does not restart. Only a successful explicit restart plus complete verification records the saved generation as applied.",
HelpRowKind::Body,
);
body(
&mut rows,
"Applied: not verified means there is no current successful verification record for the saved generation. Observation unavailable means the current runtime observation could not be obtained. Neither label alone proves a saved/runtime mismatch or that restart is pending.",
HelpRowKind::Body,
);
body(
&mut rows,
"Handshake time and current RX/TX counters are present-time, read-only observations. They do not indicate online status, provide historical traffic totals, or prove that the saved generation was applied. Deleted peer addresses remain reserved until restart completion and full runtime verification.",
HelpRowKind::Warning,
);
heading(&mut rows, "4. Create, rename, and delete clients");
key(
&mut rows,
"n",
"New client",
"Available after the session view is loaded. Enter an alias; leave both IPv4 and IPv6 blank for automatic allocation, or enter both addresses.",
);
key(
&mut rows,
"r",
"Rename client",
"Available for a selected supported peer; submitting opens a confirmation before preparing the draft change.",
);
key(
&mut rows,
"d",
"Delete client",
"Available for a selected supported peer; requires confirmation and changes the draft only.",
);
body(
&mut rows,
"Create, rename, and delete prepare draft changes. Use p to review, c to save, and R separately to apply saved configuration.",
HelpRowKind::Body,
);
heading(&mut rows, "5. Preview, Commit, discard, and restart");
key(
&mut rows,
"p",
"Preview draft",
"Review proposed changes before saving.",
);
key(
&mut rows,
"c",
"Commit",
"After confirmation, creates a mandatory backup and saves configuration and vault files only. Backup failure stops before protected files change.",
);
key(
&mut rows,
"x",
"Discard draft",
"Opens a confirmation before dropping unsaved changes.",
);
key(
&mut rows,
"R",
"Preview and restart",
"Previews the saved generation and asks for explicit confirmation. After confirmation, creates a mandatory backup before calling the configured service.",
);
body(
&mut rows,
"Restart does not Commit a draft. If the draft must be discarded to proceed, that choice is explicit. Applied state is recorded only after successful service completion and complete runtime verification; failures remain unapplied.",
HelpRowKind::Warning,
);
heading(&mut rows, "6. File delivery, password reissue, and QR");
key(
&mut rows,
"o",
"First file delivery",
"Available for a selected saved client that has not yet completed delivery in this creation session. Enter a destination path; retry is available after a file failure in that session. First delivery follows Commit.",
);
key(
&mut rows,
"O",
"Password reissue",
"Available for a selected saved client. Authorizes this reissue with the vault password each time.",
);
body(
&mut rows,
"Delivery writes the client configuration file. QR display is optional and follows successful file delivery. It is an explicit disclosure of that configuration; Esc clears the QR. If it cannot fit completely, use the saved client file. A newly delivered client's access is not active until its saved generation is restarted and fully verified. For delivery, o needs no password in the creation session; O asks for the vault password for that reissue only. Password entry is masked while typing during setup and reissue.",
HelpRowKind::Warning,
);
heading(&mut rows, "7. Backups and previewed restore");
key(
&mut rows,
"b",
"List backups",
"Choose a backup to preview a full saved-state replacement.",
);
key(
&mut rows,
"B",
"Create backup",
"Creates a manual backup without changing saved or running configuration.",
);
body(
&mut rows,
"Restore shows a preview and requires confirmation. A mandatory backup is completed before replacement. Restore changes saved files and vault only; it requires no vault password and never restarts the service. Use R separately if you intend to apply the restored saved generation.",
HelpRowKind::Warning,
);
heading(
&mut rows,
"8. Vault setup, import, and configuration issues",
);
key(
&mut rows,
"s",
"Initial vault setup",
"Available only when the vault is not initialized. Setup protects its secret key with a password.",
);
key(
&mut rows,
"i",
"Import one file",
"Selects one file from the configured import directory. The original imported file is preserved.",
);
body(
&mut rows,
"Import reads client material; it does not silently repair server configuration. Enter submits the selected filename; review the scan report and any listed issues. The original source file is preserved.",
HelpRowKind::Body,
);
heading(&mut rows, "9. Navigation, cancellation, and exit");
key(
&mut rows,
"↑/↓",
"Scroll Help",
"Move one displayed row at a time.",
);
key(
&mut rows,
"PgUp/PgDn",
"Page Help",
"Move by the visible Help page.",
);
key(
&mut rows,
"Esc",
"Return to list",
"In an active form, Esc cancels that form; on the QR screen, it clears the disclosure.",
);
key(
&mut rows,
"Tab",
"Move form field",
"Changes fields in forms and toggles the optional QR choice in delivery.",
);
key(
&mut rows,
"y / Enter",
"Confirm",
"Accepts the action shown in a confirmation. Read the prompt before confirming.",
);
key(
&mut rows,
"n / Esc",
"Cancel confirmation",
"Declines the pending action and keeps the current draft unless the prompt says otherwise.",
);
key(
&mut rows,
"q / Ctrl-C",
"Request exit outside forms",
"Outside a form, exit is immediate with no draft and asks whether to discard when a draft exists. In forms, Ctrl-C is translated to q and handled by that form; it may be input, ignored, or form-specific. Esc cancels a form.",
);
body(
&mut rows,
"Canceling a form preserves existing draft changes. Leaving the application never silently Commits. Confirmation prompts identify actions that change or discard state.",
HelpRowKind::Warning,
);
rows
}
fn wrap_help_text(text: &str, width: usize) -> Vec<String> {
let width = width.max(1);
let mut lines = Vec::new();
let mut line = String::new();
let mut line_width = 0;
for word in text.split_whitespace() {
let word_width = Span::raw(word).width();
let gap = usize::from(!line.is_empty());
if line_width + gap + word_width <= width {
if gap != 0 {
line.push(' ');
line_width += 1;
}
line.push_str(word);
line_width += word_width;
continue;
}
if !line.is_empty() {
lines.push(std::mem::take(&mut line));
}
if word_width <= width {
line.push_str(word);
line_width = word_width;
} else {
let mut chunk = String::new();
let mut chunk_width = 0;
for character in word.chars() {
let char_width = Span::raw(character.to_string()).width();
if chunk_width + char_width > width && !chunk.is_empty() {
lines.push(std::mem::take(&mut chunk));
chunk_width = 0;
}
chunk.push(character);
chunk_width += char_width;
}
line = chunk;
line_width = chunk_width;
}
}
if !line.is_empty() || lines.is_empty() {
lines.push(line);
}
lines
}
fn render_help(
frame: &mut Frame<'_>,
area: Rect,
scroll_offset: usize,
metrics: HelpNavigationMetrics,
) {
let document = help_document(area.width.saturating_sub(2) as usize);
let start = scroll_offset.min(metrics.max_offset);
let end = start
.saturating_add(metrics.viewport_rows)
.min(metrics.total_rows);
let lines = document[start..end]
.iter()
.map(|row| {
let style = match row.kind {
HelpRowKind::Body => Style::default(),
HelpRowKind::Heading => {
Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
}
HelpRowKind::Key => Style::default().add_modifier(Modifier::BOLD),
HelpRowKind::Workflow => Style::default().add_modifier(Modifier::ITALIC),
HelpRowKind::Warning => {
Style::default().add_modifier(Modifier::BOLD | Modifier::ITALIC)
}
};
Line::from(Span::styled(row.text.as_str(), style))
})
.collect::<Vec<_>>();
let title = if metrics.total_rows == 0 {
"Help 0/0".to_owned()
} else {
format!(
"Help {}-{}/{}",
start + 1,
end.max(start + 1),
metrics.total_rows
)
};
frame.render_widget(
Paragraph::new("↑/↓ selects a peer. Enter opens details. ! opens document and peer issues. Esc cancels a form or returns to the list. ? opens help. q requests exit and warns about a draft.\n\nn new client, r rename, d delete, p preview draft, c Commit, x discard draft, R preview explicit restart, o first file delivery, O password reissue, b list backups/restore, B create manual backup, s initial vault setup, i import one selected source file.\n\nPeer rows with malformed, duplicate, or unsupported configuration remain visible for inspection. Mutation requires an unambiguous supported full public key.\n\nCommit and restore only change saved files after backup. Restart is explicit, previewed, backed up, and verified. Statistics are read-only observations; a refresh never marks changes applied.")
.block(Block::default().borders(Borders::ALL).title("Help"))
Paragraph::new(Text::from(lines))
.block(Block::default().borders(Borders::ALL).title(title))
.wrap(Wrap { trim: false }),
area,
);
@@ -1829,6 +2309,19 @@ mod tests {
.join("\n")
}
fn render_help_buffer(width: u16, height: u16, scroll_offset: usize) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
let state = UiState {
screen: Screen::Help,
scroll_offset,
..UiState::default()
};
terminal
.draw(|frame| draw(frame, &state, &FormController::default()))
.unwrap();
rendered_text(&terminal)
}
fn empty_view() -> SessionView {
SessionView {
generation: 0,
@@ -1844,6 +2337,183 @@ mod tests {
}
}
#[test]
fn help_document_covers_nine_sections_and_distinguishes_case_sensitive_actions() {
let rows = help_document(178);
let headings = rows
.iter()
.filter(|row| row.kind == HelpRowKind::Heading)
.map(|row| row.text.as_str())
.collect::<Vec<_>>();
assert!(headings.iter().any(|line| line.contains("Quick start")));
for section in [
"Client list and selected details",
"Draft, saved, applied, and observations",
"Create, rename, and delete clients",
"Preview, Commit, discard, and restart",
"File delivery, password reissue, and QR",
"Backups and previewed restore",
"Vault setup, import, and configuration issues",
"Navigation, cancellation, and exit",
] {
assert!(
headings.iter().any(|line| line.contains(section)),
"missing {section}"
);
}
let document = rows
.iter()
.map(|row| row.text.as_str())
.collect::<Vec<_>>()
.join("\n");
for entry in [
"n New client",
"r Rename client",
"d Delete client",
"p Preview draft",
"c Commit",
"x Discard draft",
"R Preview and restart",
"o First file delivery",
"O Password reissue",
"b List backups",
"B Create backup",
"s Initial vault setup",
"i Import one file",
"! Show issues",
"? Open Help",
"↑/↓ Select a peer",
"Enter Open details",
"Esc Return to list",
"q / Ctrl-C Request exit outside forms",
] {
assert!(document.contains(entry), "missing key entry {entry}");
}
assert!(document.contains("Commit updates saved files but does not change"));
assert!(document.contains("Handshake time and current RX/TX counters"));
assert!(!document.contains("clients are online"));
}
#[test]
fn help_explains_applied_observation_delivery_and_masked_password_semantics_at_narrow_width() {
let metrics = help_navigation_metrics(34, 8);
let visible_rows = (0..=metrics.max_offset)
.map(|offset| render_help_buffer(34, 8, offset))
.flat_map(|buffer| buffer.lines().map(str::to_owned).collect::<Vec<_>>())
.collect::<Vec<_>>();
let content_width = 32;
let help_rows = help_document(content_width);
let document = help_rows
.iter()
.map(|row| row.text.as_str())
.collect::<Vec<_>>()
.join(" ");
for claim in [
"no current successful verification record for the saved generation",
"current runtime observation could not be obtained",
"Neither label alone proves a saved/runtime mismatch or that restart is pending",
"current RX/TX counters are present-time, read-only observations",
"do not indicate online status, provide historical traffic totals",
"or prove that the saved generation was applied",
"access is not active until its saved generation is restarted and fully verified",
"Password entry is masked while typing during setup and reissue",
] {
assert!(document.contains(claim), "Help lacks claim {claim:?}");
}
assert!(!document.contains("restart is always pending"));
assert!(!document.contains("clients are online"));
for row in help_rows.iter().filter(|row| !row.text.is_empty()) {
assert!(
visible_rows
.iter()
.any(|visible| visible.contains(&row.text)),
"Help row is unreachable at 34×8: {:?}",
row.text
);
}
}
#[test]
fn help_rows_fit_display_cells_at_supported_content_widths() {
for width in [32, 38, 58, 78, 98, 178] {
for row in help_document(width) {
assert!(
Span::raw(row.text.as_str()).width() <= width,
"row exceeds {width} cells: {:?}",
row.text
);
}
}
}
#[test]
fn help_draws_at_planned_sizes_and_resize_clamps_without_state_mutation() {
for (width, height) in [(34, 8), (40, 9), (60, 16), (80, 24), (100, 30), (180, 50)] {
let metrics = help_navigation_metrics(width, height);
assert!(
metrics.viewport_rows > 0,
"no Help viewport at {width}x{height}"
);
assert!(metrics.total_rows >= 10);
assert_eq!(
metrics.max_offset,
metrics.total_rows.saturating_sub(metrics.viewport_rows)
);
let text = render_help_buffer(width, height, usize::MAX);
assert!(
text.contains("Help "),
"missing position title at {width}x{height}: {text}"
);
let section_index = help_document(width.saturating_sub(2) as usize)
.iter()
.position(|row| row.text.starts_with("9. Navigation"))
.unwrap();
let section_text = render_help_buffer(width, height, section_index);
assert!(
section_text.contains("9. Navigation"),
"last section unreachable at {width}x{height}: {section_text}"
);
}
let small = help_navigation_metrics(60, 16);
let large = help_navigation_metrics(100, 30);
assert!(small.max_offset > large.max_offset);
let final_small = render_help_buffer(60, 16, usize::MAX);
assert!(final_small.contains(&format!("Help {}-", small.max_offset + 1)));
let final_large = render_help_buffer(100, 30, usize::MAX);
assert!(final_large.contains(&format!("Help {}-", large.max_offset + 1)));
}
#[test]
fn help_footer_advertises_scroll_page_return_and_reset() {
let text = render_help_buffer(100, 30, 0);
for hint in ["↑/↓ scroll", "PgUp/PgDn page", "Esc list", "? top"] {
assert!(text.contains(hint), "missing {hint}: {text}");
}
let narrow = render_help_buffer(34, 8, 0);
assert!(narrow.contains("↑/↓ scroll"));
assert!(
narrow.contains("Esc list"),
"missing Help return key: {narrow}"
);
assert!(
narrow.contains("… more"),
"omitted Help navigation must be indicated: {narrow}"
);
assert_eq!(narrow.lines().count(), 8, "Help stays within 34×8 buffer");
let compact = screen_areas(Rect::new(0, 0, 34, 8), &help_hints());
assert_eq!(compact.footer_lines.len(), 1, "one reserved hint row");
assert_eq!(compact.footer.height, 3, "footer height stays fixed");
let short = render_help_buffer(40, 9, 0);
assert!(
short.contains("Esc list"),
"missing Help return key: {short}"
);
assert_eq!(short.lines().count(), 9, "Help stays within 40×9 buffer");
}
fn saved_client_view(identity: PublicKey) -> SessionView {
let client = crate::domain::Client::new(
identity,
+220 -1
View File
@@ -284,7 +284,21 @@ fn run_screen_loop(
&mut exiting,
);
} else {
handle_key(code, worker, state, forms, &mut exiting);
let (terminal_width, terminal_height) = if state.screen == Screen::Help {
let size = terminal.size().map_err(|_| TerminalError::Io)?;
(size.width, size.height)
} else {
(0, 0)
};
handle_key(
code,
worker,
state,
forms,
&mut exiting,
terminal_width,
terminal_height,
);
}
}
Event::Resize(_, _) => {}
@@ -726,10 +740,15 @@ fn handle_key(
state: &mut UiState,
forms: &mut FormController,
exiting: &mut bool,
terminal_width: u16,
terminal_height: u16,
) {
if state.pending.is_some() {
return;
}
if handle_help_navigation_key(code, state, terminal_width, terminal_height) {
return;
}
if handle_navigation_key(code, state) {
return;
}
@@ -838,6 +857,40 @@ fn handle_key(
}
}
fn handle_help_navigation_key(
code: KeyCode,
state: &mut UiState,
terminal_width: u16,
terminal_height: u16,
) -> bool {
if state.screen != Screen::Help {
return false;
}
let metrics = render::help_navigation_metrics(terminal_width, terminal_height);
state.scroll_offset = state.scroll_offset.min(metrics.max_offset);
match code {
KeyCode::Up => state.scroll_offset = state.scroll_offset.saturating_sub(1),
KeyCode::Down => {
state.scroll_offset = state
.scroll_offset
.saturating_add(1)
.min(metrics.max_offset)
}
KeyCode::PageUp => {
state.scroll_offset = state.scroll_offset.saturating_sub(metrics.page_rows)
}
KeyCode::PageDown => {
state.scroll_offset = state
.scroll_offset
.saturating_add(metrics.page_rows)
.min(metrics.max_offset)
}
KeyCode::Char('?') => state.scroll_offset = 0,
_ => return false,
}
true
}
fn issue_summary(view: &SessionView) -> String {
let mut issues = Vec::new();
issues.extend(
@@ -903,6 +956,9 @@ mod tests {
use std::process::Command;
use std::sync::Mutex;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use crate::app::ClientDisplay;
use crate::runtime::RuntimeObservation;
use crate::tui::events::OperationError;
@@ -984,6 +1040,169 @@ mod tests {
}
}
fn rendered_help(width: u16, height: u16, state: &UiState) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| render::draw(frame, state, &FormController::default()))
.unwrap();
let buffer = terminal.backend().buffer();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn help_navigation_uses_visual_rows_and_viewport_pages_with_bounded_edges() {
for (width, height) in [(34, 8), (40, 9), (60, 16), (80, 24), (100, 30), (180, 50)] {
let metrics = render::help_navigation_metrics(width, height);
let mut state = UiState {
screen: Screen::Help,
..UiState::default()
};
assert!(handle_help_navigation_key(
KeyCode::Up,
&mut state,
width,
height
));
assert_eq!(state.scroll_offset, 0, "top bound at {width}x{height}");
assert!(handle_help_navigation_key(
KeyCode::Down,
&mut state,
width,
height
));
assert_eq!(state.scroll_offset, usize::from(metrics.max_offset > 0));
assert!(handle_help_navigation_key(
KeyCode::PageUp,
&mut state,
width,
height
));
assert_eq!(state.scroll_offset, 0);
for _ in 0..metrics.max_offset.div_ceil(metrics.page_rows) {
assert!(handle_help_navigation_key(
KeyCode::PageDown,
&mut state,
width,
height
));
}
assert_eq!(state.scroll_offset, metrics.max_offset);
assert!(handle_help_navigation_key(
KeyCode::PageDown,
&mut state,
width,
height
));
assert_eq!(state.scroll_offset, metrics.max_offset);
assert!(handle_help_navigation_key(
KeyCode::Up,
&mut state,
width,
height
));
assert_eq!(state.scroll_offset, metrics.max_offset.saturating_sub(1));
}
}
#[test]
fn help_navigation_reaches_final_section_and_resize_clamps_current_position() {
let mut state = UiState {
screen: Screen::Help,
..UiState::default()
};
let (width, height) = (60, 16);
let metrics = render::help_navigation_metrics(width, height);
let mut reached_last_section = false;
loop {
let text = rendered_help(width, height, &state);
reached_last_section |= text.contains("Navigation, cancellation");
if state.scroll_offset == metrics.max_offset {
break;
}
handle_help_navigation_key(KeyCode::Down, &mut state, width, height);
}
assert_eq!(state.scroll_offset, metrics.max_offset);
assert!(
reached_last_section,
"one-row scrolling reaches the last section"
);
let text = rendered_help(width, height, &state);
assert!(text.contains(&format!("Help {}-", metrics.max_offset + 1)));
let small = render::help_navigation_metrics(34, 8);
let large = render::help_navigation_metrics(180, 50);
state.scroll_offset = small.max_offset;
assert!(handle_help_navigation_key(
KeyCode::PageDown,
&mut state,
180,
50
));
assert_eq!(state.scroll_offset, large.max_offset);
assert!(rendered_help(180, 50, &state).contains("Help "));
state.scroll_offset = large.max_offset;
assert!(handle_help_navigation_key(KeyCode::Down, &mut state, 34, 8));
assert_eq!(state.scroll_offset, large.max_offset + 1);
let small_text = rendered_help(34, 8, &state);
assert!(small_text.contains("Help "));
assert!(!small_text.contains("Terminal too small"));
assert!(state.scroll_offset <= small.max_offset);
}
#[test]
fn help_reset_and_escape_preserve_selection_and_application_view_without_worker_actions() {
let identity = crate::domain::PublicKey::from_bytes([1; 32]);
let mut view = navigation_view();
view.has_draft = true;
view.applied = AppliedState::Verified;
let mut state = UiState {
screen: Screen::Help,
selected_identity: Some(identity),
selected_peer_index: Some(2),
scroll_offset: 42,
view: Some(view),
..UiState::default()
};
assert!(handle_help_navigation_key(
KeyCode::Char('?'),
&mut state,
80,
24
));
assert_eq!(state.screen, Screen::Help);
assert_eq!(state.scroll_offset, 0);
handle_help_navigation_key(KeyCode::PageDown, &mut state, 80, 24);
let selected_identity = state.selected_identity;
let selected_peer_index = state.selected_peer_index;
let view_before = state.view.clone();
assert!(handle_navigation_key(KeyCode::Esc, &mut state));
assert_eq!(state.screen, Screen::ClientList);
assert_eq!(state.scroll_offset, 0);
assert_eq!(state.selected_identity, selected_identity);
assert_eq!(state.selected_peer_index, selected_peer_index);
assert_eq!(state.view, view_before);
assert!(state.view.as_ref().unwrap().has_draft);
assert_eq!(state.view.as_ref().unwrap().applied, AppliedState::Verified);
// The scrolling helper accepts no worker or command sink, so Help input
// cannot submit work or mutate saved/applied application state.
assert!(!handle_help_navigation_key(
KeyCode::Down,
&mut state,
80,
24
));
}
#[test]
fn arrows_highlight_only_enter_opens_selected_peer_and_escape_returns_to_list() {
let identity = crate::domain::PublicKey::from_bytes([1; 32]);