Build the Mutt-style TUI shell and action model
This commit is contained in:
185
apps/tui/src/action.rs
Normal file
185
apps/tui/src/action.rs
Normal 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
341
apps/tui/src/app.rs
Normal 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
64
apps/tui/src/lib.rs
Normal 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(())
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
65
apps/tui/src/runtime.rs
Normal file
65
apps/tui/src/runtime.rs
Normal 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
120
apps/tui/src/terminal.rs
Normal 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
271
apps/tui/src/ui.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user