Build the Mutt-style TUI shell and action model
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user