Files
IronStorage/apps/tui/src/lib.rs
2026-08-10 06:07:05 +00:00

124 lines
4.2 KiB
Rust

#![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<StartupData, String> {
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<ironstorage::read::TreeModel, String> {
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,
}
}