diff --git a/apps/tui/src/action.rs b/apps/tui/src/action.rs new file mode 100644 index 0000000..94c1282 --- /dev/null +++ b/apps/tui/src/action.rs @@ -0,0 +1,185 @@ +//! Central action metadata shared by dispatch, contextual help, and command mode. + +use crossterm::event::{KeyCode, KeyModifiers}; + +use crate::app::Mode; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Action { + Quit, + Help, + Command, + Cancel, + Lock, + FocusNext, + FocusPrevious, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct KeyBinding { + pub code: KeyCode, + pub modifiers: KeyModifiers, + pub display: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ActionSpec { + pub action: Action, + pub label: &'static str, + pub command: &'static str, + pub binding: KeyBinding, + modes: &'static [Mode], +} + +impl ActionSpec { + pub fn is_available(self, mode: Mode) -> bool { + self.modes.contains(&mode) + } +} + +const BROWSER_LIKE: &[Mode] = &[Mode::Browser, Mode::Viewer]; +const UNLOCKED: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Editor]; +const OVERLAYS: &[Mode] = &[Mode::Help, Mode::Command, Mode::Dialog]; +#[cfg(test)] +const ALL: &[Mode] = &[ + Mode::Browser, + Mode::Viewer, + Mode::Editor, + Mode::Dialog, + Mode::Help, + Mode::Command, + Mode::Locked, +]; + +pub static ACTIONS: &[ActionSpec] = &[ + ActionSpec { + action: Action::Quit, + label: "quit", + command: "quit", + binding: KeyBinding { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, + display: "q", + }, + modes: BROWSER_LIKE, + }, + ActionSpec { + action: Action::Help, + label: "help", + command: "help", + binding: KeyBinding { + code: KeyCode::Char('?'), + modifiers: KeyModifiers::NONE, + display: "?", + }, + modes: UNLOCKED, + }, + ActionSpec { + action: Action::Command, + label: "command", + command: "command", + binding: KeyBinding { + code: KeyCode::Char(':'), + modifiers: KeyModifiers::NONE, + display: ":", + }, + modes: UNLOCKED, + }, + ActionSpec { + action: Action::Cancel, + label: "back", + command: "cancel", + binding: KeyBinding { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + display: "Esc", + }, + modes: OVERLAYS, + }, + ActionSpec { + action: Action::Lock, + label: "lock", + command: "lock", + binding: KeyBinding { + code: KeyCode::Char('l'), + modifiers: KeyModifiers::CONTROL, + display: "C-l", + }, + modes: UNLOCKED, + }, + ActionSpec { + action: Action::FocusNext, + label: "next pane", + command: "focus-next", + binding: KeyBinding { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + display: "Tab", + }, + modes: UNLOCKED, + }, + ActionSpec { + action: Action::FocusPrevious, + label: "previous pane", + command: "focus-previous", + binding: KeyBinding { + code: KeyCode::BackTab, + modifiers: KeyModifiers::SHIFT, + display: "S-Tab", + }, + modes: UNLOCKED, + }, +]; + +pub fn resolve_key(mode: Mode, code: KeyCode, modifiers: KeyModifiers) -> Option { + ACTIONS + .iter() + .find(|spec| { + spec.is_available(mode) + && spec.binding.code == code + && spec.binding.modifiers == modifiers + }) + .map(|spec| spec.action) +} + +pub fn available_actions(mode: Mode) -> impl Iterator { + ACTIONS.iter().filter(move |spec| spec.is_available(mode)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_has_unique_commands_and_bindings_per_mode() { + for (index, action) in ACTIONS.iter().enumerate() { + assert!(!action.command.is_empty()); + assert!(!action.label.is_empty()); + assert!(!action.modes.is_empty()); + for other in &ACTIONS[index + 1..] { + assert_ne!(action.command, other.command); + for mode in ALL { + if action.is_available(*mode) && other.is_available(*mode) { + assert_ne!(action.binding, other.binding); + } + } + } + } + } + + #[test] + fn resolution_and_help_consume_the_same_registry() { + for mode in ALL { + for spec in available_actions(*mode) { + assert_eq!( + resolve_key(*mode, spec.binding.code, spec.binding.modifiers), + Some(spec.action) + ); + } + } + assert_eq!( + resolve_key(Mode::Editor, KeyCode::Char('q'), KeyModifiers::NONE), + None + ); + } +} diff --git a/apps/tui/src/app.rs b/apps/tui/src/app.rs new file mode 100644 index 0000000..7ee8b3d --- /dev/null +++ b/apps/tui/src/app.rs @@ -0,0 +1,341 @@ +//! Pure UI state machine. Storage behavior is represented only by typed results. + +use std::collections::BTreeSet; + +use crate::action::Action; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum Mode { + Browser, + Viewer, + Editor, + Dialog, + Help, + Command, + Locked, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Transition { + OpenEntry, + EditEntry, + CloseEntry, + OpenDialog, + OpenHelp, + OpenCommand, + Dismiss, + Lock, + Unlock, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PaneFocus { + Sidebar, + Main, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RequestToken { + id: u64, + generation: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StartupSummary { + pub configuration: String, + pub vault: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AsyncPayload { + Startup(StartupSummary), + Refreshed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AsyncResult { + pub token: RequestToken, + pub payload: Result, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResultDisposition { + Applied, + Stale, +} + +#[derive(Debug)] +pub struct App { + mode: Mode, + suspended_mode: Option, + focus: PaneFocus, + status: String, + startup: Option, + terminal_size: (u16, u16), + ticks: u64, + should_quit: bool, + generation: u64, + next_request_id: u64, + pending: BTreeSet, +} + +impl Default for App { + fn default() -> Self { + Self::new() + } +} + +impl App { + pub fn new() -> Self { + Self { + mode: Mode::Browser, + suspended_mode: None, + focus: PaneFocus::Sidebar, + status: "Starting…".to_owned(), + startup: None, + terminal_size: (0, 0), + ticks: 0, + should_quit: false, + generation: 0, + next_request_id: 0, + pending: BTreeSet::new(), + } + } + + pub fn mode(&self) -> Mode { + self.mode + } + + pub fn focus(&self) -> PaneFocus { + self.focus + } + + pub fn status(&self) -> &str { + &self.status + } + + pub fn startup(&self) -> Option<&StartupSummary> { + self.startup.as_ref() + } + + pub fn terminal_size(&self) -> (u16, u16) { + self.terminal_size + } + + pub fn ticks(&self) -> u64 { + self.ticks + } + + pub fn is_busy(&self) -> bool { + !self.pending.is_empty() + } + + pub fn should_quit(&self) -> bool { + self.should_quit + } + + pub fn resize(&mut self, width: u16, height: u16) { + self.terminal_size = (width, height); + } + + pub fn tick(&mut self) { + self.ticks = self.ticks.wrapping_add(1); + } + + pub fn begin_request(&mut self) -> RequestToken { + let token = RequestToken { + id: self.next_request_id, + generation: self.generation, + }; + self.next_request_id = self.next_request_id.wrapping_add(1); + self.pending.insert(token.id); + token + } + + pub fn apply_result(&mut self, result: AsyncResult) -> ResultDisposition { + if result.token.generation != self.generation || !self.pending.remove(&result.token.id) { + return ResultDisposition::Stale; + } + match result.payload { + Ok(AsyncPayload::Startup(summary)) => { + self.status = format!("Vault: {}", summary.vault); + self.startup = Some(summary); + } + Ok(AsyncPayload::Refreshed) => self.status = "Password store refreshed".to_owned(), + Err(error) => self.status = error, + } + ResultDisposition::Applied + } + + pub fn dispatch(&mut self, action: Action) { + match action { + Action::Quit if matches!(self.mode, Mode::Browser | Mode::Viewer) => { + self.should_quit = true; + } + Action::Help => { + self.transition(Transition::OpenHelp); + } + Action::Command => { + self.transition(Transition::OpenCommand); + } + Action::Cancel => { + self.transition(Transition::Dismiss); + } + Action::Lock => { + self.transition(Transition::Lock); + } + Action::FocusNext | Action::FocusPrevious => { + self.focus = match self.focus { + PaneFocus::Sidebar => PaneFocus::Main, + PaneFocus::Main => PaneFocus::Sidebar, + }; + } + Action::Quit => {} + } + } + + pub fn transition(&mut self, transition: Transition) -> bool { + let current = self.mode; + let destination = match (current, transition) { + (Mode::Browser, Transition::OpenEntry) => Some(Mode::Viewer), + (Mode::Viewer, Transition::EditEntry) => Some(Mode::Editor), + (Mode::Viewer | Mode::Editor, Transition::CloseEntry) => Some(Mode::Browser), + (Mode::Browser | Mode::Viewer | Mode::Editor, Transition::OpenDialog) => { + Some(Mode::Dialog) + } + (Mode::Browser | Mode::Viewer | Mode::Editor, Transition::OpenHelp) => Some(Mode::Help), + (Mode::Browser | Mode::Viewer | Mode::Editor, Transition::OpenCommand) => { + Some(Mode::Command) + } + (Mode::Dialog | Mode::Help | Mode::Command, Transition::Dismiss) => { + self.suspended_mode.take() + } + (Mode::Browser | Mode::Viewer | Mode::Editor, Transition::Lock) => Some(Mode::Locked), + (Mode::Locked, Transition::Unlock) => Some(Mode::Browser), + _ => None, + }; + + let Some(destination) = destination else { + return false; + }; + if matches!(destination, Mode::Dialog | Mode::Help | Mode::Command) { + self.suspended_mode = Some(current); + } else if destination == Mode::Locked { + self.suspended_mode = None; + self.focus = PaneFocus::Sidebar; + self.invalidate_requests(); + self.startup = None; + self.status = "Locked".to_owned(); + } else if current == Mode::Locked { + self.status = "Authentication required".to_owned(); + } + self.mode = destination; + true + } + + fn invalidate_requests(&mut self) { + self.generation = self.generation.wrapping_add(1); + self.pending.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn success(token: RequestToken, payload: AsyncPayload) -> AsyncResult { + AsyncResult { + token, + payload: Ok(payload), + } + } + + #[test] + fn every_legal_transition_reaches_its_destination() { + let cases = [ + (Mode::Browser, Transition::OpenEntry, Mode::Viewer), + (Mode::Viewer, Transition::EditEntry, Mode::Editor), + (Mode::Viewer, Transition::CloseEntry, Mode::Browser), + (Mode::Editor, Transition::CloseEntry, Mode::Browser), + (Mode::Locked, Transition::Unlock, Mode::Browser), + ]; + for (start, transition, expected) in cases { + let mut app = App::new(); + app.mode = start; + assert!(app.transition(transition)); + assert_eq!(app.mode(), expected); + } + + for start in [Mode::Browser, Mode::Viewer, Mode::Editor] { + for (transition, overlay) in [ + (Transition::OpenDialog, Mode::Dialog), + (Transition::OpenHelp, Mode::Help), + (Transition::OpenCommand, Mode::Command), + ] { + let mut app = App::new(); + app.mode = start; + assert!(app.transition(transition)); + assert_eq!(app.mode(), overlay); + assert!(app.transition(Transition::Dismiss)); + assert_eq!(app.mode(), start); + } + let mut app = App::new(); + app.mode = start; + assert!(app.transition(Transition::Lock)); + assert_eq!(app.mode(), Mode::Locked); + } + } + + #[test] + fn illegal_transitions_do_not_change_state() { + let mut app = App::new(); + assert!(!app.transition(Transition::EditEntry)); + assert_eq!(app.mode(), Mode::Browser); + assert!(!app.transition(Transition::Dismiss)); + assert_eq!(app.mode(), Mode::Browser); + } + + #[test] + fn stale_and_duplicate_async_results_are_rejected() { + let mut app = App::new(); + let token = app.begin_request(); + let result = success(token, AsyncPayload::Refreshed); + assert_eq!(app.apply_result(result.clone()), ResultDisposition::Applied); + assert_eq!(app.apply_result(result), ResultDisposition::Stale); + + let stale = app.begin_request(); + assert!(app.transition(Transition::Lock)); + assert_eq!( + app.apply_result(success(stale, AsyncPayload::Refreshed)), + ResultDisposition::Stale + ); + assert_eq!(app.status(), "Locked"); + } + + #[test] + fn storage_results_are_applied_without_domain_inference() { + let mut app = App::new(); + let token = app.begin_request(); + let summary = StartupSummary { + configuration: "/config.toml".to_owned(), + vault: "/vault".to_owned(), + }; + assert_eq!( + app.apply_result(success(token, AsyncPayload::Startup(summary.clone()))), + ResultDisposition::Applied + ); + assert_eq!(app.startup(), Some(&summary)); + assert_eq!(app.status(), "Vault: /vault"); + } + + #[test] + fn ticks_resize_and_focus_are_independent_ui_state() { + let mut app = App::new(); + app.resize(100, 30); + app.tick(); + app.dispatch(Action::FocusNext); + assert_eq!(app.terminal_size(), (100, 30)); + assert_eq!(app.ticks(), 1); + assert_eq!(app.focus(), PaneFocus::Main); + } +} diff --git a/apps/tui/src/lib.rs b/apps/tui/src/lib.rs new file mode 100644 index 0000000..b19a18a --- /dev/null +++ b/apps/tui/src/lib.rs @@ -0,0 +1,64 @@ +#![forbid(unsafe_code)] +#![deny(clippy::disallowed_types)] + +//! Presentation-only state, rendering, and event plumbing for the terminal UI. + +pub mod action; +pub mod app; +pub mod runtime; +pub mod terminal; +pub mod ui; + +use std::{io, time::Duration}; + +use crossterm::event::{self, Event, KeyEventKind}; +use ratatui::DefaultTerminal; + +use crate::{ + action::resolve_key, + app::{App, AsyncPayload, StartupSummary}, + runtime::AsyncExecutor, +}; + +const TICK_INTERVAL: Duration = Duration::from_millis(250); + +/// Run the interactive event loop. Polling keeps input responsive while storage +/// work runs on the executor and periodic repaints are pending. +pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { + let mut app = App::new(); + let executor = AsyncExecutor::new(); + let startup = app.begin_request(); + executor.submit(startup, || { + ironstorage::config::Config::load(None) + .map(|config| { + AsyncPayload::Startup(StartupSummary { + configuration: config.source().display().to_string(), + vault: config.vault().display().to_string(), + }) + }) + .map_err(|error| error.to_string()) + }); + + while !app.should_quit() { + for result in executor.drain() { + app.apply_result(result); + } + + terminal.draw(|frame| ui::draw(frame, &app))?; + if !event::poll(TICK_INTERVAL)? { + app.tick(); + continue; + } + + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if let Some(action) = resolve_key(app.mode(), key.code, key.modifiers) { + app.dispatch(action); + } + } + Event::Resize(width, height) => app.resize(width, height), + _ => {} + } + } + Ok(()) +} diff --git a/apps/tui/src/main.rs b/apps/tui/src/main.rs index f9cadd7..b6e3cc0 100644 --- a/apps/tui/src/main.rs +++ b/apps/tui/src/main.rs @@ -3,47 +3,10 @@ use std::io; -use crossterm::event::{self, Event, KeyCode}; -use ratatui::{ - DefaultTerminal, Frame, - widgets::{Block, Paragraph}, -}; - fn main() -> io::Result<()> { - ratatui::run(run) -} - -fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { - loop { - terminal.draw(draw)?; - if let Event::Key(key) = event::read()? - && is_quit(key.code) - { - return Ok(()); - } - } -} - -fn draw(frame: &mut Frame) { - frame.render_widget( - Paragraph::new("No password store is open. Press q to quit.") - .block(Block::bordered().title(ironstorage::PRODUCT_NAME)), - frame.area(), - ); -} - -fn is_quit(key: KeyCode) -> bool { - matches!(key, KeyCode::Char('q') | KeyCode::Esc) -} - -#[cfg(test)] -mod tests { - use crossterm::event::KeyCode; - - #[test] - fn q_and_escape_quit() { - assert!(super::is_quit(KeyCode::Char('q'))); - assert!(super::is_quit(KeyCode::Esc)); - assert!(!super::is_quit(KeyCode::Enter)); - } + ironstorage_tui::terminal::run( + ratatui::try_init, + ratatui::try_restore, + ironstorage_tui::run, + ) } diff --git a/apps/tui/src/runtime.rs b/apps/tui/src/runtime.rs new file mode 100644 index 0000000..51fce84 --- /dev/null +++ b/apps/tui/src/runtime.rs @@ -0,0 +1,65 @@ +//! Small in-process executor for storage calls. It never launches a process. + +use std::{ + sync::mpsc::{self, Receiver, Sender}, + thread, +}; + +use crate::app::{AsyncPayload, AsyncResult, RequestToken}; + +pub struct AsyncExecutor { + sender: Sender, + receiver: Receiver, +} + +impl Default for AsyncExecutor { + fn default() -> Self { + Self::new() + } +} + +impl AsyncExecutor { + pub fn new() -> Self { + let (sender, receiver) = mpsc::channel(); + Self { sender, receiver } + } + + pub fn submit(&self, token: RequestToken, work: F) + where + F: FnOnce() -> Result + Send + 'static, + { + let sender = self.sender.clone(); + thread::spawn(move || { + let _ignored = sender.send(AsyncResult { + token, + payload: work(), + }); + }); + } + + pub fn drain(&self) -> impl Iterator + '_ { + self.receiver.try_iter() + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::app::{App, ResultDisposition}; + + #[test] + fn work_completes_off_the_input_thread() { + let executor = AsyncExecutor::new(); + let mut app = App::new(); + let token = app.begin_request(); + executor.submit(token, || Ok(AsyncPayload::Refreshed)); + + let result = executor + .receiver + .recv_timeout(Duration::from_secs(2)) + .expect("worker should return a typed result"); + assert_eq!(app.apply_result(result), ResultDisposition::Applied); + } +} diff --git a/apps/tui/src/terminal.rs b/apps/tui/src/terminal.rs new file mode 100644 index 0000000..0dd8f2f --- /dev/null +++ b/apps/tui/src/terminal.rs @@ -0,0 +1,120 @@ +//! Terminal lifecycle wrapper that restores state on success, error, and panic. + +use std::{ + io, + panic::{AssertUnwindSafe, catch_unwind, resume_unwind}, +}; + +pub fn run(initialize: I, mut restore: C, operation: F) -> io::Result +where + I: FnOnce() -> io::Result, + C: FnMut() -> io::Result<()>, + F: FnOnce(&mut T) -> io::Result, +{ + let mut terminal = initialize()?; + let outcome = catch_unwind(AssertUnwindSafe(|| operation(&mut terminal))); + let restore_result = restore(); + + match outcome { + Ok(Ok(value)) => restore_result.map(|()| value), + Ok(Err(error)) => Err(error), + Err(payload) => resume_unwind(payload), + } +} + +#[cfg(test)] +mod tests { + use std::{ + cell::Cell, + io::{self, ErrorKind}, + panic, + rc::Rc, + }; + + use super::run; + + #[test] + fn restores_after_normal_exit() { + let restored = Rc::new(Cell::new(false)); + let observed = Rc::clone(&restored); + let result = run( + || Ok(()), + move || { + observed.set(true); + Ok(()) + }, + |_| Ok(42), + ); + assert_eq!(result.expect("normal run"), 42); + assert!(restored.get()); + } + + #[test] + fn restores_after_application_error() { + let restored = Rc::new(Cell::new(false)); + let observed = Rc::clone(&restored); + let result = run( + || Ok(()), + move || { + observed.set(true); + Ok(()) + }, + |_| Err::<(), _>(io::Error::other("application failed")), + ); + assert_eq!( + result.expect_err("error must be preserved").kind(), + ErrorKind::Other + ); + assert!(restored.get()); + } + + #[test] + fn restores_before_resuming_a_panic() { + let restored = Rc::new(Cell::new(false)); + let observed = Rc::clone(&restored); + let outcome = panic::catch_unwind(panic::AssertUnwindSafe(|| { + let _ = run( + || Ok(()), + move || { + observed.set(true); + Ok(()) + }, + |_| -> io::Result<()> { panic!("application panicked") }, + ); + })); + assert!(outcome.is_err()); + assert!(restored.get()); + } + + #[test] + fn initialization_failure_does_not_claim_an_active_terminal() { + let restored = Rc::new(Cell::new(false)); + let observed = Rc::clone(&restored); + let result = run( + || Err::<(), _>(io::Error::new(ErrorKind::NotFound, "no terminal")), + move || { + observed.set(true); + Ok(()) + }, + |_| Ok(()), + ); + assert_eq!( + result.expect_err("initialization must fail").kind(), + ErrorKind::NotFound + ); + assert!(!restored.get()); + } + + #[test] + fn restoration_failure_is_reported_after_success() { + let result = run( + || Ok(()), + || Err(io::Error::new(ErrorKind::BrokenPipe, "restore failed")), + |_| Ok(()), + ); + assert_eq!( + result.expect_err("restore must fail").kind(), + ErrorKind::BrokenPipe + ); + } +} diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs new file mode 100644 index 0000000..681cd0d --- /dev/null +++ b/apps/tui/src/ui.rs @@ -0,0 +1,271 @@ +//! 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}, +}; + +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 lines = crate::action::ACTIONS.iter().map(|spec| { + Line::from(vec![ + Span::styled( + format!("{:>7}", spec.binding.display), + Style::default().fg(Color::Cyan), + ), + Span::raw(format!(" {:<18} :{}", spec.label, spec.command)), + ]) + }); + 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_text(app)) + .block(pane_block("Passwords", app.focus() == PaneFocus::Sidebar)) + .wrap(Wrap { trim: true }), + panes[0], + ); + frame.render_widget( + Paragraph::new(main_text(app)) + .block(pane_block( + mode_title(app.mode()), + app.focus() == PaneFocus::Main, + )) + .wrap(Wrap { trim: true }), + 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_text(app: &App) -> String { + if app.is_busy() { + "Loading password-store tree…".to_owned() + } else if let Some(startup) = app.startup() { + format!("Vault\n{}", startup.vault) + } else { + "No password store is open.".to_owned() + } +} + +fn main_text(app: &App) -> &'static str { + match app.mode() { + Mode::Browser => "Select an entry from the sidebar.", + Mode::Viewer => "Structured entry viewer", + Mode::Editor => "Structured entry editor", + Mode::Dialog => "Complete or cancel the active dialog.", + Mode::Command => "Enter a command on the bottom line.", + Mode::Help | Mode::Locked => "", + } +} + +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 { "" }; + Paragraph::new(Line::from(vec![ + Span::styled( + " status ", + Style::default().bg(Color::Blue).fg(Color::White), + ), + Span::raw(format!(" {}{busy}", app.status())), + ])) +} + +fn context_line(app: &App) -> Paragraph<'static> { + let text = available_actions(app.mode()) + .map(|spec| format!("{} {}", spec.binding.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)), + _ => Paragraph::new(""), + } +} + +#[cfg(test)] +mod tests { + use ratatui::{Terminal, backend::TestBackend}; + + use super::*; + use crate::app::Transition; + + 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(100, 20, &app); + for spec in crate::action::ACTIONS { + assert!(output.contains(spec.label)); + assert!(output.contains(spec.command)); + } + } +}