pub mod detail;
pub mod input;
pub mod list;
pub mod theme;
use std::io::{self, Stdout};
use std::time::{Duration, Instant};
use crossterm::event::{self, Event};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::execute;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::widgets::Clear;
use ratatui::{Frame, Terminal};
use crate::app::{Action, App, Mode, PassKind};
use crate::error::Result;
type Tui = Terminal<CrosstermBackend<Stdout>>;
pub fn run(mut app: App) -> Result<()> {
enable_raw_mode().map_err(map_io)?;
let mut stdout = io::stdout();
if execute!(stdout, EnterAlternateScreen).is_err() {
let _ = disable_raw_mode();
return Err(crate::error::Error::Other("tui: enter alternate screen failed".into()));
}
let backend = CrosstermBackend::new(stdout);
let mut terminal = match Terminal::new(backend) {
Ok(t) => t,
Err(e) => {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
return Err(map_io(e));
}
};
let _guard = TerminalGuard;
let result = main_loop(&mut terminal, &mut app);
let _ = terminal.draw(|f| {
let _ = f;
});
result
}
fn main_loop(terminal: &mut Tui, app: &mut App) -> Result<()> {
let lock_secs = lock_timeout_secs();
let mut last_activity = Instant::now();
loop {
maybe_auto_lock(app, lock_secs, &last_activity);
if terminal.draw(|f| draw(f, app)).is_err() {
break;
}
let Ok(true) = event::poll(Duration::from_millis(250)) else {
continue;
};
let ev = match event::read() {
Ok(ev) => ev,
Err(_) => break,
};
if let Event::Key(key) = ev {
last_activity = Instant::now();
let action = app.handle_key(key)?;
if matches!(action, Action::Quit) || app.quit {
break;
}
}
}
Ok(())
}
fn lock_timeout_secs() -> u64 {
const DEFAULT: u64 = 300;
let Some(raw) = std::env::var_os("ZKV_LOCK_SECS") else {
return DEFAULT;
};
match raw.to_string_lossy().parse::<u64>() {
Ok(v) => v,
Err(_) => DEFAULT,
}
}
fn maybe_auto_lock(app: &mut App, lock_secs: u64, last_activity: &Instant) {
if lock_secs == 0 {
return;
}
if app.db.is_none() {
return;
}
if last_activity.elapsed() >= Duration::from_secs(lock_secs) {
app.lock();
}
}
fn draw(frame: &mut Frame, app: &App) {
if let Mode::PromptPassphrase(kind) = &app.mode {
draw_passphrase(frame, app, *kind);
return;
}
let whole = frame.area();
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.split(whole);
let header_area = vert[0];
let body_area = vert[1];
let footer_area = vert[2];
render_header(frame, header_area, app);
frame.render_widget(
ratatui::widgets::Block::default().style(theme::bg()),
body_area,
);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(42), Constraint::Percentage(58)])
.spacing(1)
.split(body_area);
list::render_list(frame, cols[0], app);
detail::render_detail(frame, cols[1], app);
render_footer(frame, footer_area, app);
match app.mode {
Mode::ConfirmDelete => draw_confirm_delete(frame, app),
Mode::PickTemplate => draw_pick_template(frame, app),
Mode::CategoryMgr => draw_category_mgr(frame, app),
Mode::TagMgr => draw_tag_mgr(frame, app),
Mode::Attachments => draw_attachments(frame, app),
_ => {}
}
}
fn draw_passphrase(frame: &mut Frame, app: &App, kind: PassKind) {
let area = centered_rect(60, 50, frame.area());
frame.render_widget(Clear, area);
let title = match kind {
PassKind::Open => "Unlock Vault",
PassKind::Create => "Create New Vault",
};
let path = app
.path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(no path)".into());
let inner = theme::panel_frame(frame, area, Some(title));
let field = input::InputField {
value: app.input.clone(),
mask: app.input_mask,
};
let msg = app.message.as_deref().unwrap_or("");
let lines = vec![
ratatui::text::Line::from(format!("file: {path}")).style(theme::muted()),
ratatui::text::Line::from(""),
ratatui::text::Line::from("passphrase:").style(theme::title_style()),
];
let info = ratatui::widgets::Paragraph::new(lines);
let sub = Layout::vertical([Constraint::Length(3), Constraint::Length(3), Constraint::Min(1)])
.split(inner);
frame.render_widget(info, sub[0]);
input::render_input(frame, sub[1], &field, " passphrase ");
let msg_p = ratatui::widgets::Paragraph::new(msg).style(theme::accent2());
frame.render_widget(msg_p, sub[2]);
}
fn draw_confirm_delete(frame: &mut Frame, app: &App) {
let area = centered_rect(50, 30, frame.area());
let title = app.selected_item().map(|i| i.title.clone()).unwrap_or_default();
let l1: std::borrow::Cow<str> = std::borrow::Cow::Owned(format!("Delete \"{}\"?", title));
let l2: std::borrow::Cow<str> = std::borrow::Cow::Borrowed("");
let l3: std::borrow::Cow<str> = std::borrow::Cow::Borrowed("press y to confirm · n/Esc to cancel");
let lines: [&str; 3] = [l1.as_ref(), l2.as_ref(), l3.as_ref()];
input::render_modal(frame, area, " Confirm Delete ", &lines);
}
fn draw_pick_template(frame: &mut Frame, app: &App) {
let entries: Vec<String> = crate::model::builtin_templates()
.iter()
.map(|t| t.name.clone())
.collect();
draw_mgr(
frame,
app,
"New Item · Pick Template",
&entries,
app.tpl_selected,
"j/k select Enter confirm Esc back",
"(no templates)",
);
}
fn draw_category_mgr(frame: &mut Frame, app: &App) {
let entries: Vec<String> = app
.categories
.iter()
.map(|c| {
if let Some(pid) = c.parent_id {
let pname = app
.categories
.iter()
.find(|p| p.id == Some(pid))
.map(|p| p.name.clone())
.unwrap_or_else(|| format!("#{pid}"));
format!("{} (parent: {})", c.name, pname)
} else {
c.name.clone()
}
})
.collect();
draw_mgr(
frame,
app,
"Categories",
&entries,
app.mgr_selected,
"a:add r:rename x:del Esc:back",
"(none, press a to add)",
);
}
fn draw_tag_mgr(frame: &mut Frame, app: &App) {
let entries: Vec<String> = app.tags.to_vec();
draw_mgr(
frame,
app,
"Tags",
&entries,
app.mgr_selected,
"a:add r:rename x:del Esc:back",
"(none, press a to add)",
);
}
fn draw_attachments(frame: &mut Frame, app: &App) {
let title = app
.items
.iter()
.find(|i| i.id == app.att_item_id)
.map(|i| i.title.clone())
.or_else(|| app.att_item_id.map(|id| format!("#{}", id)))
.unwrap_or_default();
let panel_title = format!("Attachments · {}", title);
let area = centered_rect(64, 60, frame.area());
frame.render_widget(Clear, area);
let editing = app.att_edit.is_some();
let inner = theme::panel_frame(frame, area, Some(&panel_title));
let constraints = if editing {
vec![
Constraint::Min(1),
Constraint::Length(3),
Constraint::Length(1),
]
} else {
vec![Constraint::Min(1), Constraint::Length(1)]
};
let chunks = Layout::vertical(constraints).split(inner);
let list_area = chunks[0];
if editing {
let field = input::InputField {
value: app.input.clone(),
mask: false,
};
let label = match app.att_edit {
Some(crate::app::AttEdit::Add) => " path: ",
Some(crate::app::AttEdit::Export) => " out: ",
None => " ",
};
input::render_input(frame, chunks[1], &field, label);
}
if app.att_list.is_empty() {
let empty = ratatui::widgets::Paragraph::new("(no attachments, press a to add)")
.style(theme::muted());
frame.render_widget(empty, list_area);
} else {
let items: Vec<ratatui::widgets::ListItem> = app
.att_list
.iter()
.enumerate()
.map(|(i, a)| {
let style = if i == app.att_selected {
theme::selected_bar()
} else {
theme::fg()
};
let mark = if i == app.att_selected { "▸ " } else { " " };
let mime = a.mime_type.clone().unwrap_or_else(|| "-".into());
let text = format!("{:<5} {:<24} {:<22} {}", a.id, a.filename, mime, a.size);
ratatui::widgets::ListItem::new(ratatui::text::Line::from(vec![
ratatui::text::Span::raw(mark),
ratatui::text::Span::styled(text, style),
]))
})
.collect();
let list = ratatui::widgets::List::new(items);
frame.render_widget(list, list_area);
}
let hint = if editing {
"Enter:confirm Esc:cancel"
} else {
"a:add e:export x:del Esc:back"
};
let hint_idx = if editing { 2 } else { 1 };
let hint_p = ratatui::widgets::Paragraph::new(hint).style(theme::muted());
frame.render_widget(hint_p, chunks[hint_idx]);
}
fn draw_mgr(
frame: &mut Frame,
app: &App,
title: &str,
entries: &[String],
selected: usize,
browse_hint: &str,
empty_hint: &str,
) {
let area = centered_rect(56, 56, frame.area());
frame.render_widget(Clear, area);
let editing = app.mgr_edit.is_some();
let inner = theme::panel_frame(frame, area, Some(title));
let constraints = if editing {
vec![
Constraint::Min(1),
Constraint::Length(3),
Constraint::Length(1),
]
} else {
vec![Constraint::Min(1), Constraint::Length(1)]
};
let chunks = Layout::vertical(constraints).split(inner);
let list_area = chunks[0];
if editing {
let field = input::InputField {
value: app.input.clone(),
mask: false,
};
let label = match app.mgr_edit {
Some(crate::app::MgrEdit::Add) => " add ",
Some(crate::app::MgrEdit::Rename) => " rename ",
None => " ",
};
input::render_input(frame, chunks[1], &field, label);
}
if entries.is_empty() {
let empty = ratatui::widgets::Paragraph::new(empty_hint).style(theme::muted());
frame.render_widget(empty, list_area);
} else {
let items: Vec<ratatui::widgets::ListItem> = entries
.iter()
.enumerate()
.map(|(i, name)| {
let style = if i == selected {
theme::selected_bar()
} else {
theme::fg()
};
let mark = if i == selected { "▸ " } else { " " };
ratatui::widgets::ListItem::new(ratatui::text::Line::from(vec![
ratatui::text::Span::raw(mark),
ratatui::text::Span::styled(name.clone(), style),
]))
})
.collect();
let list = ratatui::widgets::List::new(items);
frame.render_widget(list, list_area);
}
let hint = if editing {
"Enter:confirm Esc:cancel"
} else {
browse_hint
};
let hint_idx = if editing { 2 } else { 1 };
let hint_p = ratatui::widgets::Paragraph::new(hint).style(theme::muted());
frame.render_widget(hint_p, chunks[hint_idx]);
}
fn render_header(frame: &mut Frame, area: Rect, app: &App) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(6),
Constraint::Min(1),
Constraint::Length(34),
])
.split(area);
let brand = ratatui::widgets::Paragraph::new(
ratatui::text::Line::from(" zkv ").style(theme::title_style()),
)
.style(theme::bar());
frame.render_widget(brand, chunks[0]);
let (center_text, center_style) = match app.message.as_deref() {
Some(m) if !m.is_empty() => (m.to_string(), theme::accent2()),
_ => (
app.path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default(),
theme::muted(),
),
};
let center = ratatui::widgets::Paragraph::new(
ratatui::text::Line::from(center_text).style(center_style),
)
.style(theme::bar());
frame.render_widget(center, chunks[1]);
let unlocked = app.db.is_some();
let state_label = if unlocked { "unlocked" } else { "locked" };
let state_style = if unlocked { theme::accent() } else { theme::error() };
let right = ratatui::widgets::Paragraph::new(ratatui::text::Line::from(vec![
ratatui::text::Span::styled(
format!(
" {} items · {}c · {}t · ",
app.items.len(),
app.categories.len(),
app.tags.len()
),
theme::muted(),
),
ratatui::text::Span::styled("● ", state_style),
ratatui::text::Span::styled(state_label.to_string(), state_style),
]))
.style(theme::bar())
.alignment(ratatui::layout::Alignment::Right);
frame.render_widget(right, chunks[2]);
}
fn render_footer(frame: &mut Frame, area: Rect, _app: &App) {
let hints: &[(&str, &str)] = &[
("n", "new"),
("e", "edit"),
("x", "del"),
("/", "search"),
("y", "copy"),
("o", "otp"),
("l", "lock"),
("c", "cat"),
("t", "tag"),
("q", "quit"),
];
let mut spans: Vec<ratatui::text::Span<'_>> = vec![ratatui::text::Span::raw(" ")];
for (i, (k, label)) in hints.iter().enumerate() {
if i > 0 {
spans.push(ratatui::text::Span::raw(" "));
}
spans.push(ratatui::text::Span::styled(*k, theme::accent2()));
spans.push(ratatui::text::Span::styled(format!(":{label}"), theme::muted()));
}
frame.render_widget(
ratatui::widgets::Paragraph::new(ratatui::text::Line::from(spans)).style(theme::bar()),
area,
);
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let pop_h = area.height * percent_y / 100;
let pop_w = area.width * percent_x / 100;
let y = area.y + (area.height.saturating_sub(pop_h)) / 2;
let x = area.x + (area.width.saturating_sub(pop_w)) / 2;
Rect::new(x, y, pop_w, pop_h)
}
fn map_io(e: io::Error) -> crate::error::Error {
crate::error::Error::Other(format!("tui io: {e}"))
}
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
let mut out = io::stdout();
let _ = execute!(out, LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock().unwrap()
}
#[test]
fn lock_timeout_secs_reads_env() {
let _g = env_lock();
unsafe {
std::env::set_var("ZKV_LOCK_SECS", "0");
}
assert_eq!(lock_timeout_secs(), 0);
unsafe {
std::env::set_var("ZKV_LOCK_SECS", "60");
}
assert_eq!(lock_timeout_secs(), 60);
unsafe {
std::env::set_var("ZKV_LOCK_SECS", "not-a-number");
}
assert_eq!(lock_timeout_secs(), 300);
unsafe {
std::env::set_var("ZKV_LOCK_SECS", "-5");
}
assert_eq!(lock_timeout_secs(), 300);
unsafe {
std::env::remove_var("ZKV_LOCK_SECS");
}
assert_eq!(lock_timeout_secs(), 300);
}
}