Build the Mutt-style TUI shell and action model

This commit is contained in:
Hermes Agent
2026-08-10 05:46:32 +00:00
parent f03fdc063c
commit 42e6a82b2d
7 changed files with 1051 additions and 42 deletions

185
apps/tui/src/action.rs Normal file
View File

@@ -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<Action> {
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<Item = &'static ActionSpec> {
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
);
}
}

341
apps/tui/src/app.rs Normal file
View File

@@ -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<AsyncPayload, String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ResultDisposition {
Applied,
Stale,
}
#[derive(Debug)]
pub struct App {
mode: Mode,
suspended_mode: Option<Mode>,
focus: PaneFocus,
status: String,
startup: Option<StartupSummary>,
terminal_size: (u16, u16),
ticks: u64,
should_quit: bool,
generation: u64,
next_request_id: u64,
pending: BTreeSet<u64>,
}
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);
}
}

64
apps/tui/src/lib.rs Normal file
View File

@@ -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(())
}

View File

@@ -3,47 +3,10 @@
use std::io; use std::io;
use crossterm::event::{self, Event, KeyCode};
use ratatui::{
DefaultTerminal, Frame,
widgets::{Block, Paragraph},
};
fn main() -> io::Result<()> { fn main() -> io::Result<()> {
ratatui::run(run) ironstorage_tui::terminal::run(
} ratatui::try_init,
ratatui::try_restore,
fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { ironstorage_tui::run,
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));
}
} }

65
apps/tui/src/runtime.rs Normal file
View File

@@ -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<AsyncResult>,
receiver: Receiver<AsyncResult>,
}
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<F>(&self, token: RequestToken, work: F)
where
F: FnOnce() -> Result<AsyncPayload, String> + Send + 'static,
{
let sender = self.sender.clone();
thread::spawn(move || {
let _ignored = sender.send(AsyncResult {
token,
payload: work(),
});
});
}
pub fn drain(&self) -> impl Iterator<Item = AsyncResult> + '_ {
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);
}
}

120
apps/tui/src/terminal.rs Normal file
View File

@@ -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<T, R, I, C, F>(initialize: I, mut restore: C, operation: F) -> io::Result<R>
where
I: FnOnce() -> io::Result<T>,
C: FnMut() -> io::Result<()>,
F: FnOnce(&mut T) -> io::Result<R>,
{
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
);
}
}

271
apps/tui/src/ui.rs Normal file
View File

@@ -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::<Vec<_>>())
.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::<Vec<_>>()
.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::<String>()
})
.collect::<Vec<_>>()
.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));
}
}
}