#![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 sidebar; 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, AppEffect, AsyncPayload, StartupData}, 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_latest_request(); executor.submit(startup, || load_startup().map(AsyncPayload::Startup)); while !app.should_quit() { for result in executor.drain() { app.apply_result(result); } let size = terminal.size()?; app.resize(size.width, size.height); 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 app.sidebar().is_editing_filter() { if handle_filter_key(&mut app, key.code) { submit_filter(&mut app, &executor); } } else if let Some(action) = resolve_key(app.mode(), key.code, key.modifiers) && app.dispatch(action) == AppEffect::RefreshTree && let Some(config) = app.config().cloned() { let token = app.begin_latest_request(); executor.submit(token, move || { load_tree(&config).map(AsyncPayload::Refreshed) }); } } Event::Resize(width, height) => app.resize(width, height), _ => {} } } Ok(()) } fn load_startup() -> Result { let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?; let tree = load_tree(&config)?; Ok(StartupData { config, tree }) } fn load_tree(config: &ironstorage::config::Config) -> Result { let repository = ironstorage::repository::Repository::open(config.vault()) .map_err(|error| error.to_string())?; let keys = ironstorage::crypto::KeyStore::load(config.key_material()) .map_err(|error| error.to_string())?; ironstorage::read::VaultReader::new(&repository, &keys) .list(&ironstorage::repository::DirectoryPath::root()) .map_err(|error| error.to_string()) } fn submit_filter(app: &mut App, executor: &AsyncExecutor) { let query = app.sidebar().filter_query().to_owned(); if query.is_empty() { app.sidebar_mut().reset_filter_results(); return; } let Some(config) = app.config().cloned() else { return; }; let token = app.begin_latest_request(); executor.submit(token, move || { let repository = ironstorage::repository::Repository::open(config.vault()) .map_err(|error| error.to_string())?; let keys = ironstorage::crypto::KeyStore::load(config.key_material()) .map_err(|error| error.to_string())?; let results = ironstorage::read::VaultReader::new(&repository, &keys) .find(std::slice::from_ref(&query)) .map_err(|error| error.to_string())?; Ok(AsyncPayload::Filtered { query, results }) }); } fn handle_filter_key(app: &mut App, code: crossterm::event::KeyCode) -> bool { use crossterm::event::KeyCode; match code { KeyCode::Esc => { app.sidebar_mut().clear_filter_results(); false } KeyCode::Enter => { app.sidebar_mut().finish_filter(); false } KeyCode::Backspace => app.sidebar_mut().pop_filter_character(), KeyCode::Char(character) => app.sidebar_mut().push_filter_character(character), _ => false, } }