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
+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]);