Add visible styled keyboard quick reference in bottom panel
This commit is contained in:
@@ -39,6 +39,14 @@ pub enum FormAction {
|
||||
Feedback(String),
|
||||
}
|
||||
|
||||
/// Public, secret-free summary of keyboard capabilities for the active form.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct FormKeyContext {
|
||||
pub enter_action: Option<&'static str>,
|
||||
pub supports_tab: bool,
|
||||
pub supports_selection: bool,
|
||||
}
|
||||
|
||||
enum Form {
|
||||
Setup {
|
||||
password: SecretInput,
|
||||
@@ -153,6 +161,67 @@ impl FormController {
|
||||
matches!(self.active, Some(Form::DraftReview))
|
||||
}
|
||||
|
||||
/// Describes only keys handled by the active form; never exposes form data.
|
||||
pub fn key_context(&self) -> Option<FormKeyContext> {
|
||||
self.active.as_ref().map(|form| match form {
|
||||
Form::Setup { .. } => FormKeyContext {
|
||||
enter_action: Some("next / submit"),
|
||||
supports_tab: true,
|
||||
supports_selection: false,
|
||||
},
|
||||
Form::Create { .. } => FormKeyContext {
|
||||
enter_action: Some("next / submit"),
|
||||
supports_tab: true,
|
||||
supports_selection: false,
|
||||
},
|
||||
Form::Rename { .. } => FormKeyContext {
|
||||
enter_action: Some("submit"),
|
||||
supports_tab: false,
|
||||
supports_selection: false,
|
||||
},
|
||||
Form::Import { .. } => FormKeyContext {
|
||||
enter_action: Some("submit"),
|
||||
supports_tab: false,
|
||||
supports_selection: false,
|
||||
},
|
||||
Form::Confirm(_) => FormKeyContext {
|
||||
enter_action: Some("confirm"),
|
||||
supports_tab: false,
|
||||
supports_selection: false,
|
||||
},
|
||||
Form::DraftReview => FormKeyContext {
|
||||
enter_action: None,
|
||||
supports_tab: false,
|
||||
supports_selection: false,
|
||||
},
|
||||
Form::DeliveryPath { .. } => FormKeyContext {
|
||||
enter_action: Some("prepare file"),
|
||||
supports_tab: true,
|
||||
supports_selection: false,
|
||||
},
|
||||
Form::ReissuePassword { .. } => FormKeyContext {
|
||||
enter_action: Some("submit password"),
|
||||
supports_tab: false,
|
||||
supports_selection: false,
|
||||
},
|
||||
Form::Backups { items, .. } => FormKeyContext {
|
||||
enter_action: (!items.is_empty()).then_some("preview restore"),
|
||||
supports_tab: false,
|
||||
supports_selection: !items.is_empty(),
|
||||
},
|
||||
Form::Restore(_) => FormKeyContext {
|
||||
enter_action: Some("confirm restore"),
|
||||
supports_tab: false,
|
||||
supports_selection: false,
|
||||
},
|
||||
Form::Disclosure { .. } => FormKeyContext {
|
||||
enter_action: None,
|
||||
supports_tab: false,
|
||||
supports_selection: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn qr_output(&self) -> Option<&QrCodeOutput> {
|
||||
match self.active.as_ref() {
|
||||
Some(Form::Disclosure { qr, .. }) => Some(qr),
|
||||
@@ -888,6 +957,64 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::backup::{BackupReason, HistoricalApplyStatus};
|
||||
|
||||
#[test]
|
||||
fn key_context_matches_tabbed_forms_and_inert_draft_review() {
|
||||
let mut form = FormController::default();
|
||||
form.open_setup();
|
||||
assert_eq!(
|
||||
form.key_context(),
|
||||
Some(FormKeyContext {
|
||||
enter_action: Some("next / submit"),
|
||||
supports_tab: true,
|
||||
supports_selection: false,
|
||||
})
|
||||
);
|
||||
form.open_create();
|
||||
assert!(form.key_context().unwrap().supports_tab);
|
||||
assert_eq!(
|
||||
form.key_context().unwrap().enter_action,
|
||||
Some("next / submit")
|
||||
);
|
||||
form.open_draft_review();
|
||||
assert_eq!(
|
||||
form.key_context(),
|
||||
Some(FormKeyContext {
|
||||
enter_action: None,
|
||||
supports_tab: false,
|
||||
supports_selection: false,
|
||||
})
|
||||
);
|
||||
assert!(matches!(form.handle_key(KeyCode::Enter), FormAction::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivery_path_and_backup_selection_expose_their_actual_keys() {
|
||||
let identity = PublicKey::from_bytes([0x61; 32]);
|
||||
let mut form = FormController::default();
|
||||
form.open_delivery(identity, false);
|
||||
assert!(form.key_context().unwrap().supports_tab);
|
||||
assert_eq!(
|
||||
form.key_context().unwrap().enter_action,
|
||||
Some("prepare file")
|
||||
);
|
||||
form.open_backups(vec![ArchiveSummary {
|
||||
file_name: "backup.zip".to_owned(),
|
||||
created_at_utc: "2026-01-01T00:00:00Z".to_owned(),
|
||||
reason: BackupReason::Manual,
|
||||
historical_apply: HistoricalApplyStatus::Unknown,
|
||||
}]);
|
||||
assert_eq!(
|
||||
form.key_context(),
|
||||
Some(FormKeyContext {
|
||||
enter_action: Some("preview restore"),
|
||||
supports_tab: false,
|
||||
supports_selection: true,
|
||||
})
|
||||
);
|
||||
form.open_backups(Vec::new());
|
||||
assert_eq!(form.key_context().unwrap().enter_action, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_password_is_masked_confirmation_required_and_secret_command_has_no_debug() {
|
||||
let mut form = FormController::default();
|
||||
|
||||
+440
-6
@@ -1,7 +1,7 @@
|
||||
//! Responsive, secret-free rendering for the client list and detail screens.
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
|
||||
@@ -33,12 +33,27 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
let compact_height = area.height <= 8;
|
||||
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 rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(header_height),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(footer_height),
|
||||
])
|
||||
.split(area);
|
||||
let status = state
|
||||
@@ -108,10 +123,257 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
|
||||
Screen::ClientList => render_list(frame, rows[1], state),
|
||||
}
|
||||
}
|
||||
render_keyboard_panel(frame, rows[2], &footer_lines, omitted);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum HintKind {
|
||||
Navigation,
|
||||
Action,
|
||||
Destructive,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct Hint {
|
||||
key: &'static str,
|
||||
action: &'static str,
|
||||
kind: HintKind,
|
||||
}
|
||||
|
||||
fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
|
||||
let mut hints = Vec::new();
|
||||
let mut add = |key, action, kind| hints.push(Hint { key, action, kind });
|
||||
if forms.is_active() {
|
||||
if forms.qr_output().is_some() {
|
||||
add("Esc", "clear QR", HintKind::Destructive);
|
||||
} else {
|
||||
add("Esc", "cancel", HintKind::Destructive);
|
||||
if let Some(context) = forms.key_context() {
|
||||
if let Some(action) = context.enter_action {
|
||||
add("Enter", action, HintKind::Action);
|
||||
}
|
||||
if context.supports_tab {
|
||||
add("Tab", "change field", HintKind::Navigation);
|
||||
}
|
||||
if context.supports_selection {
|
||||
add("↑/↓", "select archive", HintKind::Navigation);
|
||||
}
|
||||
}
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
if state.pending.is_some() || state.screen == Screen::Progress {
|
||||
add("…", "busy", HintKind::Navigation);
|
||||
return hints;
|
||||
}
|
||||
match state.screen {
|
||||
Screen::Help => {
|
||||
add("Esc", "return to list", HintKind::Navigation);
|
||||
add("?", "Help", HintKind::Navigation);
|
||||
}
|
||||
Screen::Message => {
|
||||
add("Esc", "return to list", HintKind::Navigation);
|
||||
add("↑/↓", "scroll", HintKind::Navigation);
|
||||
}
|
||||
Screen::ClientDetails => {
|
||||
add("Esc", "client list", HintKind::Navigation);
|
||||
add("↑/↓", "scroll", HintKind::Navigation);
|
||||
add("?", "Help", HintKind::Navigation);
|
||||
add("n", "new", HintKind::Action);
|
||||
add("i", "import", HintKind::Action);
|
||||
add("p", "preview", HintKind::Action);
|
||||
add("c", "Commit", HintKind::Action);
|
||||
add("x", "discard", HintKind::Destructive);
|
||||
add("R", "restart", HintKind::Destructive);
|
||||
add("b", "backups / restore", HintKind::Action);
|
||||
add("B", "backup", HintKind::Action);
|
||||
add("!", "issues", HintKind::Action);
|
||||
if state
|
||||
.view
|
||||
.as_ref()
|
||||
.is_some_and(|view| !view.vault_initialized)
|
||||
{
|
||||
add("s", "setup", HintKind::Action);
|
||||
}
|
||||
if state.selected_client_mutable() {
|
||||
add("r", "rename", HintKind::Action);
|
||||
add("d", "delete", HintKind::Destructive);
|
||||
let saved = state
|
||||
.selected_identity
|
||||
.and_then(|id| state.view.as_ref()?.client_states.get(&id))
|
||||
.is_some_and(|row| row.saved.is_some());
|
||||
if saved {
|
||||
add("o", "first file", HintKind::Action);
|
||||
add("O", "password reissue", HintKind::Action);
|
||||
}
|
||||
}
|
||||
}
|
||||
Screen::ClientList => {
|
||||
let has_view = state.view.is_some();
|
||||
let selected = state.selected_client_mutable();
|
||||
add("↑/↓", "select", HintKind::Navigation);
|
||||
if state
|
||||
.view
|
||||
.as_ref()
|
||||
.is_some_and(|view| !view.clients.is_empty())
|
||||
{
|
||||
add("Enter", "details", HintKind::Navigation);
|
||||
}
|
||||
add("?", "Help", HintKind::Navigation);
|
||||
if has_view {
|
||||
add("n", "new", HintKind::Action);
|
||||
if selected {
|
||||
add("r", "rename", HintKind::Action);
|
||||
add("d", "delete", HintKind::Destructive);
|
||||
}
|
||||
add("p", "preview", HintKind::Action);
|
||||
add("c", "Commit", HintKind::Action);
|
||||
add("x", "discard", HintKind::Destructive);
|
||||
add("R", "restart", HintKind::Destructive);
|
||||
if let Some(id) = state.selected_identity {
|
||||
if state
|
||||
.view
|
||||
.as_ref()
|
||||
.and_then(|view| view.client_states.get(&id))
|
||||
.is_some_and(|row| row.saved.is_some())
|
||||
&& selected
|
||||
{
|
||||
add("o", "first file", HintKind::Action);
|
||||
add("O", "password reissue", HintKind::Action);
|
||||
}
|
||||
}
|
||||
add("b", "backups / restore", HintKind::Action);
|
||||
add("B", "backup", HintKind::Action);
|
||||
if state
|
||||
.view
|
||||
.as_ref()
|
||||
.is_some_and(|view| !view.vault_initialized)
|
||||
{
|
||||
add("s", "setup", HintKind::Action);
|
||||
}
|
||||
}
|
||||
add("i", "import", HintKind::Action);
|
||||
if has_view {
|
||||
add("!", "issues", HintKind::Action);
|
||||
}
|
||||
}
|
||||
Screen::Progress => unreachable!(),
|
||||
}
|
||||
add("q", "exit", HintKind::Destructive);
|
||||
hints
|
||||
}
|
||||
|
||||
fn pack_hints(hints: &[Hint], width: usize) -> Vec<Vec<Hint>> {
|
||||
let mut rows = Vec::<Vec<Hint>>::new();
|
||||
let mut used = 0;
|
||||
for hint in hints {
|
||||
let cost = hint.key.len() + hint.action.len() + 3;
|
||||
if rows.is_empty() || used + cost > width {
|
||||
rows.push(Vec::new());
|
||||
used = 0;
|
||||
}
|
||||
if cost > width {
|
||||
continue;
|
||||
}
|
||||
if !rows.last().is_some_and(Vec::is_empty) {
|
||||
used += 2;
|
||||
}
|
||||
used += cost;
|
||||
rows.last_mut().expect("row was created").push(hint.clone());
|
||||
}
|
||||
rows.retain(|row| !row.is_empty());
|
||||
rows
|
||||
}
|
||||
|
||||
fn fit_hint_rows(
|
||||
mut rows: Vec<Vec<Hint>>,
|
||||
max_rows: usize,
|
||||
width: usize,
|
||||
) -> (Vec<Vec<Hint>>, bool) {
|
||||
if max_rows == 1 && width < 40 {
|
||||
let hints = rows.into_iter().flatten().collect::<Vec<_>>();
|
||||
let hint_count = hints.len();
|
||||
let list_navigation = hints.iter().find(|hint| hint.key == "↑/↓");
|
||||
let help = hints.iter().find(|hint| hint.key == "?");
|
||||
let mut compact = Vec::new();
|
||||
if let Some(navigation) = list_navigation {
|
||||
compact.push(navigation.clone());
|
||||
}
|
||||
if let Some(help) = help {
|
||||
compact.push(help.clone());
|
||||
}
|
||||
if compact.is_empty() {
|
||||
let candidates = hints
|
||||
.into_iter()
|
||||
.map(|mut hint| {
|
||||
hint.action = match hint.key {
|
||||
"Enter" => "continue",
|
||||
"Tab" => "field",
|
||||
_ => hint.action,
|
||||
};
|
||||
hint
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let total_width = candidates
|
||||
.iter()
|
||||
.map(|hint| hint.key.len() + hint.action.len() + 1)
|
||||
.sum::<usize>()
|
||||
+ candidates.len().saturating_sub(1) * 2;
|
||||
if total_width <= width {
|
||||
return (vec![candidates], false);
|
||||
}
|
||||
let available = width.saturating_sub(" … more".len());
|
||||
let mut used = 0;
|
||||
for hint in candidates {
|
||||
let cost = hint.key.len() + hint.action.len() + 1;
|
||||
let with_gap = cost + usize::from(!compact.is_empty()) * 2;
|
||||
if used + with_gap <= available {
|
||||
used += with_gap;
|
||||
compact.push(hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
let omitted = compact.len() < hint_count;
|
||||
return (vec![compact], omitted);
|
||||
}
|
||||
if rows.len() <= max_rows {
|
||||
return (rows, false);
|
||||
}
|
||||
rows.truncate(max_rows);
|
||||
(rows, true)
|
||||
}
|
||||
|
||||
fn render_keyboard_panel(frame: &mut Frame<'_>, area: Rect, rows: &[Vec<Hint>], omitted: bool) {
|
||||
let lines = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let mut spans = Vec::new();
|
||||
for (index, hint) in row.iter().enumerate() {
|
||||
if index > 0 {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
let color = match hint.kind {
|
||||
HintKind::Navigation => Color::Cyan,
|
||||
HintKind::Action => Color::Green,
|
||||
HintKind::Destructive => Color::Red,
|
||||
};
|
||||
spans.push(Span::styled(
|
||||
hint.key,
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::raw(hint.action));
|
||||
}
|
||||
if omitted {
|
||||
spans.push(Span::raw(" … more"));
|
||||
}
|
||||
Line::from(spans)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
frame.render_widget(
|
||||
Paragraph::new("n new r rename d delete p preview c Commit R restart o first file O reissue b backups B backup s setup i import x discard q exit")
|
||||
.block(Block::default().borders(Borders::ALL)),
|
||||
rows[2],
|
||||
Paragraph::new(Text::from(lines))
|
||||
.block(Block::default().borders(Borders::ALL).title("Keys")),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -545,6 +807,178 @@ mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn rendered_text(terminal: &Terminal<TestBackend>) -> String {
|
||||
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")
|
||||
}
|
||||
|
||||
fn empty_view() -> SessionView {
|
||||
SessionView {
|
||||
generation: 0,
|
||||
clients: Vec::new(),
|
||||
client_states: BTreeMap::new(),
|
||||
document_issues: Vec::new(),
|
||||
applied: super::super::state::AppliedState::Unknown,
|
||||
reserved_addresses: Default::default(),
|
||||
statistics: RuntimeObservation::unknown(),
|
||||
has_draft: true,
|
||||
vault_initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_keyboard_panel_has_visible_interior_and_styled_key_labels() {
|
||||
let state = UiState {
|
||||
view: Some(empty_view()),
|
||||
..UiState::default()
|
||||
};
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &state, &FormController::default()))
|
||||
.unwrap();
|
||||
let text = rendered_text(&terminal);
|
||||
assert!(text.contains("Keys"));
|
||||
assert!(text.contains("new"));
|
||||
assert!(text.contains("Commit"));
|
||||
for hint in [
|
||||
"R restart",
|
||||
"b backups / restore",
|
||||
"B backup",
|
||||
"s setup",
|
||||
"i import",
|
||||
"x discard",
|
||||
] {
|
||||
assert!(text.contains(hint), "missing list hint: {hint}");
|
||||
}
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert!(buffer[(0, 23)].symbol().contains('└'));
|
||||
let key = (0..buffer.area.height)
|
||||
.flat_map(|y| (0..buffer.area.width).map(move |x| (x, y)))
|
||||
.find(|(x, y)| {
|
||||
let style = buffer[(*x, *y)].style();
|
||||
style.fg == Some(Color::Green) && style.add_modifier.contains(Modifier::BOLD)
|
||||
})
|
||||
.unwrap();
|
||||
let style = buffer[key].style();
|
||||
assert!(style.add_modifier.contains(Modifier::BOLD));
|
||||
assert_eq!(style.fg, Some(Color::Green));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_layout_keeps_footer_text_and_navigation_help_priority() {
|
||||
let state = UiState {
|
||||
view: Some(empty_view()),
|
||||
..UiState::default()
|
||||
};
|
||||
let mut terminal = Terminal::new(TestBackend::new(34, 8)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &state, &FormController::default()))
|
||||
.unwrap();
|
||||
let text = rendered_text(&terminal);
|
||||
assert!(text.contains("Keys"));
|
||||
assert!(text.contains("select"));
|
||||
assert!(text.contains("? Help"));
|
||||
assert!(text.contains("… more"));
|
||||
assert!(text.lines().last().unwrap().contains('└'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_form_hints_follow_the_active_form_without_leaking_input() {
|
||||
let state = UiState::default();
|
||||
let mut forms = FormController::default();
|
||||
forms.open_setup();
|
||||
for character in "secret-value".chars() {
|
||||
forms.handle_key(crossterm::event::KeyCode::Char(character));
|
||||
}
|
||||
let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap();
|
||||
terminal.draw(|frame| draw(frame, &state, &forms)).unwrap();
|
||||
let text = rendered_text(&terminal);
|
||||
assert!(text.contains("Esc cancel"));
|
||||
assert!(text.contains("Enter continue"));
|
||||
assert!(text.contains("Tab field"));
|
||||
assert!(!text.contains("secret-value"));
|
||||
assert!(!text.contains("↑/↓"));
|
||||
assert!(!text.contains("? Help"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_pending_panel_does_not_offer_commands() {
|
||||
let state = UiState {
|
||||
screen: Screen::Progress,
|
||||
..UiState::default()
|
||||
};
|
||||
let mut terminal = Terminal::new(TestBackend::new(34, 8)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &state, &FormController::default()))
|
||||
.unwrap();
|
||||
let text = rendered_text(&terminal);
|
||||
assert!(text.contains("busy"));
|
||||
assert!(!text.contains("↑/↓"));
|
||||
assert!(!text.contains("? Help"));
|
||||
assert!(!text.contains("Enter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_hints_only_offer_saved_delivery_for_mutable_saved_selection() {
|
||||
let identity = PublicKey::from_bytes([3; 32]);
|
||||
let client = crate::domain::Client::new(
|
||||
identity,
|
||||
"client",
|
||||
crate::domain::ClientAddresses {
|
||||
ipv4: Ipv4Addr::new(10, 0, 0, 2),
|
||||
ipv6: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let view = SessionView {
|
||||
generation: 0,
|
||||
clients: vec![ClientDisplay {
|
||||
peer_index: 0,
|
||||
line: 1,
|
||||
identity: Some(identity),
|
||||
label: "client".to_owned(),
|
||||
allowed_ips: vec![],
|
||||
issues: vec![],
|
||||
mutable_identity: Some(identity),
|
||||
}],
|
||||
client_states: BTreeMap::from([(
|
||||
identity,
|
||||
super::super::state::ClientStateRow {
|
||||
saved: Some(client.clone()),
|
||||
draft: Some(client),
|
||||
},
|
||||
)]),
|
||||
document_issues: Vec::new(),
|
||||
applied: super::super::state::AppliedState::Unknown,
|
||||
reserved_addresses: Default::default(),
|
||||
statistics: RuntimeObservation::unknown(),
|
||||
has_draft: false,
|
||||
vault_initialized: true,
|
||||
};
|
||||
let state = UiState {
|
||||
screen: Screen::ClientDetails,
|
||||
selected_identity: Some(identity),
|
||||
view: Some(view),
|
||||
..UiState::default()
|
||||
};
|
||||
let labels = keyboard_hints(&state, &FormController::default())
|
||||
.iter()
|
||||
.map(|hint| hint.key)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(labels.contains(&"Esc"));
|
||||
assert!(labels.contains(&"o"));
|
||||
assert!(labels.contains(&"O"));
|
||||
assert!(labels.contains(&"r"));
|
||||
assert!(labels.contains(&"d"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_controls_are_escaped_for_display() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user