//! Responsive Ratatui rendering for the application shell. use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph, Wrap}, }; use crate::{ action::available_actions, app::{App, Mode, PaneFocus}, editor::EntryEditor, viewer::EntryViewer, }; const MINIMUM_WIDTH: u16 = 40; const MINIMUM_HEIGHT: u16 = 8; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum LayoutClass { TooSmall, Narrow, Normal, Wide, } pub fn layout_class(area: Rect) -> LayoutClass { if area.width < MINIMUM_WIDTH || area.height < MINIMUM_HEIGHT { LayoutClass::TooSmall } else if area.width < 80 { LayoutClass::Narrow } else if area.width < 120 { LayoutClass::Normal } else { LayoutClass::Wide } } pub fn draw(frame: &mut Frame, app: &App) { let area = frame.area(); if layout_class(area) == LayoutClass::TooSmall { frame.render_widget( Paragraph::new(format!( "Terminal too small ({}×{}). Need at least {MINIMUM_WIDTH}×{MINIMUM_HEIGHT}.", area.width, area.height )) .block(Block::bordered().title(ironstorage::PRODUCT_NAME)) .wrap(Wrap { trim: true }), area, ); return; } let rows = Layout::vertical([ Constraint::Min(3), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ]) .split(area); render_content(frame, app, rows[0]); frame.render_widget(status_line(app), rows[1]); frame.render_widget(context_line(app), rows[2]); frame.render_widget(prompt_line(app), rows[3]); } fn render_content(frame: &mut Frame, app: &App, area: Rect) { if app.mode() == Mode::Locked { frame.render_widget( Paragraph::new("The password store is locked. Authentication is required.") .block(Block::bordered().title("Locked")) .wrap(Wrap { trim: true }), area, ); return; } if app.mode() == Mode::Help { let available_rows = usize::from(area.height.saturating_sub(2)).max(1); let lines = (0..available_rows).map(|row| { let mut spans = Vec::new(); for index in (row..crate::action::ACTIONS.len()).step_by(available_rows) { let spec = &crate::action::ACTIONS[index]; spans.push(Span::styled( format!("{:>6}", spec.bindings[0].display), Style::default().fg(Color::Cyan), )); spans.push(Span::raw(format!( " {:<16} :{:<16}", spec.label, spec.command ))); } Line::from(spans) }); frame.render_widget( Paragraph::new(lines.collect::>()) .block(Block::bordered().title("Contextual help")) .wrap(Wrap { trim: false }), area, ); return; } let class = layout_class(frame.area()); let (direction, constraints) = match class { LayoutClass::Narrow => ( Direction::Vertical, [Constraint::Percentage(40), Constraint::Percentage(60)], ), LayoutClass::Normal => ( Direction::Horizontal, [Constraint::Percentage(35), Constraint::Percentage(65)], ), LayoutClass::Wide => ( Direction::Horizontal, [Constraint::Percentage(25), Constraint::Percentage(75)], ), LayoutClass::TooSmall => return, }; let panes = Layout::new(direction, constraints).split(area); frame.render_widget( Paragraph::new(sidebar_lines(app)) .block(pane_block("Passwords", app.focus() == PaneFocus::Sidebar)) .wrap(Wrap { trim: false }), panes[0], ); let main = match app.mode() { Mode::Viewer => app.viewer().map_or_else( || Paragraph::new(main_text(app)), |viewer| { Paragraph::new(viewer_lines(viewer)) .scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0)) }, ), Mode::Editor => app.editor().map_or_else( || Paragraph::new(main_text(app)), |editor| { Paragraph::new(editor_lines(editor)).scroll(( u16::try_from(editor.focused_index().unwrap_or_default()).unwrap_or(u16::MAX), 0, )) }, ), _ => Paragraph::new(main_text(app)), }; frame.render_widget( main.block(pane_block( mode_title(app.mode()), app.focus() == PaneFocus::Main, )) .wrap(Wrap { trim: false }), panes[1], ); } fn pane_block(title: &'static str, focused: bool) -> Block<'static> { let style = if focused { Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD) } else { Style::default() }; Block::default() .borders(Borders::ALL) .title(title) .border_style(style) } fn sidebar_lines(app: &App) -> Vec> { let selected = app.sidebar().selected(); let rows = app.sidebar().visible_window(); if rows.is_empty() { if app.is_busy() { return vec![Line::from("Loading password-store tree…")]; } return vec![Line::from("No password entries.")]; } rows.into_iter() .map(|row| { let marker = if row.id.is_directory() { if row.expanded { "▼" } else { "▶" } } else { "•" }; let mut indicators = String::new(); if row.indicators.is_locked() { indicators.push_str(" L"); } if row.indicators.is_changed() { indicators.push_str(" *"); } if row.indicators.has_conflict() { indicators.push_str(" !"); } let style = if selected == Some(&row.id) { Style::default().bg(Color::Blue).fg(Color::White) } else { Style::default() }; Line::styled( format!( "{}{} {}{}", " ".repeat(row.depth), marker, row.name, indicators ), style, ) }) .collect() } fn main_text(app: &App) -> String { match app.mode() { Mode::Browser => "Select an entry from the sidebar.".to_owned(), Mode::Viewer => app.selected_entry().map_or_else( || "Structured entry viewer".to_owned(), |path| format!("Opening {path}…"), ), Mode::Editor => "Saving structured entry…".to_owned(), Mode::Dialog if app.discard_confirmation() => { "Discard all unsaved edits? Press y to discard, n or Esc to keep editing.".to_owned() } Mode::Dialog => "Complete or cancel the active dialog.".to_owned(), Mode::Command => "Enter a command on the bottom line.".to_owned(), Mode::Help | Mode::Locked => String::new(), } } fn viewer_lines(viewer: &EntryViewer) -> Vec> { let focused = viewer.focused_index(); if viewer.document().fields().is_empty() { return vec![Line::from("This entry is empty.")]; } viewer .document() .fields() .iter() .enumerate() .map(|(index, field)| { let metadata = field.metadata(); let label = metadata.name().map_or_else( || match metadata.kind() { ironstorage::document::EntryFieldKind::Note => format!("note {}", index + 1), ironstorage::document::EntryFieldKind::Blank => format!("blank {}", index + 1), kind => format!("{kind:?}").to_ascii_lowercase(), }, str::to_owned, ); let value = if metadata.sensitivity() == ironstorage::document::EntrySensitivity::Sensitive && !viewer.is_revealed(field.id()) { Span::styled("••••••••", Style::default().fg(Color::DarkGray)) } else if field.value().is_empty() { Span::styled("(empty)", Style::default().fg(Color::DarkGray)) } else { match std::str::from_utf8(field.value()) { Ok(value) => Span::raw(value), Err(_) => Span::styled("[non-UTF-8 value]", Style::default().fg(Color::Yellow)), } }; let mut spans = vec![ Span::styled( format!("{label}: "), Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), ), value, ]; if let Some(otp) = metadata.otp() { let timing = otp.period().map_or_else( || format!("counter {}", otp.counter().unwrap_or_default()), |period| format!("period {period}s"), ); spans.push(Span::styled( format!( " [{:?}, {}, {:?}, {}, {} digits, {timing}]", otp.kind(), otp.issuer().unwrap_or("no issuer"), otp.algorithm(), otp.account(), otp.digits(), ), Style::default().fg(Color::DarkGray), )); } let line = Line::from(spans); if focused == Some(index) { line.style(Style::default().bg(Color::Blue).fg(Color::White)) } else { line } }) .collect() } fn editor_lines(editor: &EntryEditor) -> Vec> { let focused = editor.focused_index(); if editor.document().fields().is_empty() { return vec![Line::from( "This entry is empty. Press a to add its first field.", )]; } editor .document() .fields() .iter() .enumerate() .map(|(index, field)| { let selected = focused == Some(index); let contents = editor .focused_contents(field.id()) .unwrap_or_else(|| field.contents().expose()); let label = field.metadata().name().map_or_else( || format!("{:?}", field.metadata().kind()), |name| format!("{:?} ({name})", field.metadata().kind()), ); let masked = field.metadata().sensitivity() == ironstorage::document::EntrySensitivity::Sensitive && !editor.is_revealed(field.id()); let mut spans = vec![Span::styled( format!("#{:02} {label}: ", index + 1), Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), )]; if masked { spans.push(Span::styled( "••••••••", Style::default().fg(Color::DarkGray), )); if selected && editor.is_input_active() { spans.push(Span::styled( " [hidden input]", Style::default().fg(Color::Yellow), )); } } else if let Ok(value) = std::str::from_utf8(contents) { if selected && editor.is_input_active() && value.is_char_boundary(editor.cursor()) { let (before, after) = value.split_at(editor.cursor()); spans.push(Span::raw(before)); spans.push(Span::styled("▏", Style::default().fg(Color::Yellow))); spans.push(Span::raw(after)); } else if value.is_empty() { spans.push(Span::styled( "(empty)", Style::default().fg(Color::DarkGray), )); } else { spans.push(Span::raw(value)); } } else { spans.push(Span::styled( "[non-UTF-8 field; editing will preserve bytes]", Style::default().fg(Color::Yellow), )); } let line = Line::from(spans); if selected { line.style(Style::default().bg(Color::Blue).fg(Color::White)) } else { line } }) .collect() } fn mode_title(mode: Mode) -> &'static str { match mode { Mode::Browser => "Browser", Mode::Viewer => "Viewer", Mode::Editor => "Editor", Mode::Dialog => "Dialog", Mode::Help => "Help", Mode::Command => "Command", Mode::Locked => "Locked", } } fn status_line(app: &App) -> Paragraph<'_> { let busy = if app.is_busy() { " [working]" } else { "" }; let warning = app .remaining_lease() .filter(|remaining| remaining.as_secs() <= 30) .map_or_else(String::new, |remaining| { format!(" [locks in {}s]", remaining.as_secs()) }); Paragraph::new(Line::from(vec![ Span::styled( " status ", Style::default().bg(Color::Blue).fg(Color::White), ), Span::raw(format!(" {}{busy}{warning}", app.status())), ])) } fn context_line(app: &App) -> Paragraph<'static> { let text = available_actions(app.mode()) .map(|spec| format!("{} {}", spec.bindings[0].display, spec.label)) .collect::>() .join(" "); Paragraph::new(text) } fn prompt_line(app: &App) -> Paragraph<'static> { match app.mode() { Mode::Command => Paragraph::new(":").style(Style::default().fg(Color::Yellow)), Mode::Dialog => Paragraph::new("dialog> ").style(Style::default().fg(Color::Yellow)), Mode::Editor if app.editor().is_some_and(EntryEditor::is_input_active) => { Paragraph::new("-- INSERT -- Esc stops input; Tab changes field; C-s saves") .style(Style::default().fg(Color::Yellow)) } _ if app.sidebar().is_editing_filter() => { Paragraph::new(format!("/{}", app.sidebar().filter_query())) .style(Style::default().fg(Color::Yellow)) } _ => Paragraph::new(""), } } #[cfg(test)] mod tests { use ratatui::{Terminal, backend::TestBackend}; use super::*; use crate::app::Transition; use crate::sidebar::TestTreeNode; use crate::viewer::test_support::fixture_document; fn render(width: u16, height: u16, app: &App) -> String { let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).expect("test terminal"); terminal.draw(|frame| draw(frame, app)).expect("draw"); let buffer = terminal.backend().buffer(); (0..height) .map(|y| { (0..width) .map(|x| buffer[(x, y)].symbol()) .collect::() }) .collect::>() .join("\n") } #[test] fn sizes_have_explicit_layout_classes() { assert_eq!(layout_class(Rect::new(0, 0, 39, 20)), LayoutClass::TooSmall); assert_eq!(layout_class(Rect::new(0, 0, 40, 7)), LayoutClass::TooSmall); assert_eq!(layout_class(Rect::new(0, 0, 60, 20)), LayoutClass::Narrow); assert_eq!(layout_class(Rect::new(0, 0, 100, 20)), LayoutClass::Normal); assert_eq!(layout_class(Rect::new(0, 0, 140, 20)), LayoutClass::Wide); } #[test] fn minimum_size_message_is_clear() { let output = render(39, 8, &App::new()); assert!(output.contains("Terminal too small")); assert!(output.contains("40×8")); } #[test] fn normal_shell_contains_every_required_region() { let output = render(100, 20, &App::new()); assert!(output.contains("Passwords")); assert!(output.contains("Browser")); assert!(output.contains("status")); assert!(output.contains("? help")); } #[test] fn narrow_and_wide_shells_render_without_losing_panes() { for width in [60, 140] { let output = render(width, 20, &App::new()); assert!(output.contains("Passwords")); assert!(output.contains("Browser")); } } #[test] fn help_is_generated_from_the_action_registry() { let mut app = App::new(); assert!(app.transition(Transition::OpenHelp)); let output = render(140, 35, &app); for spec in crate::action::ACTIONS { assert!(output.contains(spec.label)); assert!(output.contains(spec.command)); } } #[test] fn hierarchy_selection_and_storage_indicators_have_stable_rendering() { let mut app = App::new(); app.resize(100, 20); app.sidebar_mut().replace_test_tree(vec![TestTreeNode { path: "personal".to_owned(), name: "personal".to_owned(), directory: true, indicators: ironstorage::read::TreeNodeIndicators::default(), children: vec![TestTreeNode { path: "personal/咖啡".to_owned(), name: "咖啡".to_owned(), directory: false, indicators: ironstorage::read::TreeNodeIndicators::new(true, true, true), children: vec![], }], }]); app.sidebar_mut().move_child(); let output = render(100, 20, &app); assert!(output.contains("▼ personal")); assert!(output.contains('咖')); assert!(output.contains("L * !")); } #[test] fn scrolling_focus_filter_prompt_and_resize_are_rendered() { let mut app = App::new(); app.resize(60, 10); app.sidebar_mut().replace_test_tree( (0..20) .map(|index| TestTreeNode { path: format!("entry-{index:02}"), name: format!("entry-{index:02}"), directory: false, indicators: ironstorage::read::TreeNodeIndicators::default(), children: vec![], }) .collect(), ); app.sidebar_mut().move_last(); app.sidebar_mut().begin_filter(); app.sidebar_mut().push_filter_character('咖'); let narrow = render(60, 10, &app); assert!(narrow.contains("entry-19")); assert!(narrow.contains("/咖")); assert!(!narrow.contains("entry-00")); app.dispatch(crate::action::Action::FocusNext); let wide = render(140, 20, &app); assert!(wide.contains("Browser")); } #[test] fn lease_warning_is_concise_and_never_contains_entry_state() { let mut app = App::new(); app.update_remaining_lease(Some(std::time::Duration::from_secs(30))); let output = render(100, 20, &app); assert!(output.contains("locks in 30s")); } #[test] fn viewer_masks_storage_sensitive_fields_and_renders_dynamic_unicode_fields() { let mut app = App::new(); app.open_test_document("unicode/咖啡", fixture_document("unicode/咖啡")); for width in [60, 100, 140] { let output = render(width, 20, &app); assert!(output.contains("password: ••••••••")); assert!(output.contains("login:")); assert!(output.contains('用')); assert!(output.contains("@example.test")); assert!(output.contains("notes: ••••••••")); assert!(!output.contains("pässwörd-猫")); } assert!(!format!("{app:?}").contains("pässwörd-猫")); } #[test] fn reveal_is_explicit_and_focus_change_or_lock_removes_secret_from_rendering() { let mut app = App::new(); app.open_test_document("email/personal", fixture_document("email/personal")); app.dispatch(crate::action::Action::Reveal); let revealed = render(100, 20, &app); assert!(revealed.contains("correct horse fixture")); app.dispatch(crate::action::Action::FocusNext); let hidden = render(100, 20, &app); assert!(!hidden.contains("correct horse fixture")); app.dispatch(crate::action::Action::FocusPrevious); app.dispatch(crate::action::Action::Reveal); app.dispatch(crate::action::Action::Help); app.dispatch(crate::action::Action::Cancel); let after_overlay = render(100, 20, &app); assert!(!after_overlay.contains("correct horse fixture")); app.dispatch(crate::action::Action::Reveal); app.forced_relock("test lock"); let locked = render(100, 20, &app); assert!(!locked.contains("correct horse fixture")); assert!(locked.contains("locked")); } #[test] fn otp_metadata_is_visible_while_the_secret_uri_stays_masked() { let mut app = App::new(); app.open_test_document("otp/totp", fixture_document("otp/totp")); app.dispatch(crate::action::Action::FocusNext); let output = render(120, 20, &app); assert!(output.contains("Totp")); assert!(output.contains("IronStorage")); assert!(output.contains("alice@example.test")); assert!(output.contains("period 30s")); assert!(!output.contains("JBSWY3DPEHPK3PXP")); } #[test] fn closing_a_document_preserves_the_sidebar_selection() { let mut app = App::new(); app.sidebar_mut().replace_test_tree(vec![TestTreeNode { path: "email/personal".to_owned(), name: "personal".to_owned(), directory: false, indicators: ironstorage::read::TreeNodeIndicators::default(), children: vec![], }]); let selected = app.sidebar().selected().cloned(); app.open_test_document("email/personal", fixture_document("email/personal")); app.dispatch(crate::action::Action::CloseEntry); assert_eq!(app.mode(), Mode::Browser); assert_eq!(app.sidebar().selected(), selected.as_ref()); assert!(app.viewer().is_none()); } #[test] fn editor_keeps_sensitive_input_masked_until_explicit_reveal() { let mut app = App::new(); app.open_test_document("email/personal", fixture_document("email/personal")); app.dispatch(crate::action::Action::EditEntry); app.dispatch(crate::action::Action::BeginInput); app.handle_editor_input(crossterm::event::KeyCode::Char('x')); for width in [60, 100, 140] { let hidden = render(width, 20, &app); assert!(hidden.contains("hidden input")); assert!(!hidden.contains("correct horse fixturex")); } app.handle_editor_input(crossterm::event::KeyCode::Esc); app.dispatch(crate::action::Action::Reveal); let revealed = render(100, 20, &app); assert!(revealed.contains("correct horse fixturex")); assert!(!format!("{app:?}").contains("correct horse fixturex")); } #[test] fn dirty_editor_requires_explicit_discard_and_relock_discards_from_dialog() { let mut app = App::new(); app.open_test_document("email/personal", fixture_document("email/personal")); app.dispatch(crate::action::Action::EditEntry); app.dispatch(crate::action::Action::AddField); app.dispatch(crate::action::Action::Cancel); assert_eq!(app.mode(), Mode::Dialog); assert!(app.discard_confirmation()); assert!(render(100, 20, &app).contains("Discard all unsaved edits")); app.dispatch(crate::action::Action::KeepEditing); assert_eq!(app.mode(), Mode::Editor); app.dispatch(crate::action::Action::Cancel); app.dispatch(crate::action::Action::ConfirmDiscard); assert_eq!(app.mode(), Mode::Browser); assert!(app.editor().is_none()); app.open_test_document("email/personal", fixture_document("email/personal")); app.dispatch(crate::action::Action::EditEntry); app.dispatch(crate::action::Action::AddField); app.dispatch(crate::action::Action::Cancel); app.forced_relock("test expiry"); assert_eq!(app.mode(), Mode::Locked); assert!(app.editor().is_none()); assert!(app.status().contains("unsaved edits were discarded")); } }