pub mod detail;
pub mod input;
pub mod list;
pub mod theme;
use std::io::{self, Stdout};
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<()> {
loop {
if terminal.draw(|f| draw(f, app)).is_err() {
break;
}
let Ok(true) = event::poll(std::time::Duration::from_millis(250)) else {
continue;
};
let ev = match event::read() {
Ok(ev) => ev,
Err(_) => break,
};
if let Event::Key(key) = ev {
let action = app.handle_key(key).map_err(|e| e)?;
if matches!(action, Action::Quit) || app.quit {
break;
}
}
}
Ok(())
}
fn draw(frame: &mut Frame, app: &App) {
match &app.mode {
Mode::PromptPassphrase(kind) => {
draw_passphrase(frame, app, *kind);
return;
}
_ => {}
}
let whole = frame.area();
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(whole);
let msg_area = vert[0];
let body_area = vert[1];
let msg_text = top_message(app);
let msg_line = ratatui::widgets::Paragraph::new(msg_text)
.style(theme::accent2());
frame.render_widget(msg_line, msg_area);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(24),
Constraint::Percentage(40),
Constraint::Min(20),
])
.split(body_area);
list::render_sidebar(frame, cols[0], app);
list::render_list(frame, cols[1], app);
detail::render_detail(frame, cols[2], app);
match app.mode {
Mode::ConfirmDelete => draw_confirm_delete(frame, app),
Mode::CategoryMgr => draw_simple_modal(frame, "Category Manager", "Esc to return"),
Mode::TagMgr => draw_simple_modal(frame, "Tag Manager", "Esc to return"),
_ => {}
}
}
fn draw_passphrase(frame: &mut Frame, app: &App, kind: PassKind) {
let area = centered_rect(60, 20, 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 block = theme::block(title);
let inner = block.inner(area);
frame.render_widget(block, area);
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_simple_modal(frame: &mut Frame, title: &str, body: &str) {
let area = centered_rect(50, 25, frame.area());
input::render_modal(frame, area, title, &[body]);
}
fn top_message(app: &App) -> String {
let mode_tag = match &app.mode {
Mode::Normal => "NORMAL",
Mode::Search => "SEARCH",
Mode::NewItem(t) => match t {
crate::model::ItemType::Password => "NEW·PASSWORD",
crate::model::ItemType::Note => "NEW·NOTE",
crate::model::ItemType::Card => "NEW·CARD",
},
Mode::EditItem => "EDIT",
Mode::ConfirmDelete => "CONFIRM-DELETE",
Mode::CategoryMgr => "CATEGORY-MGR",
Mode::TagMgr => "TAG-MGR",
Mode::PromptPassphrase(_) => "LOCKED",
};
let msg = app.message.as_deref().unwrap_or("");
format!(" [{mode_tag}] {msg} · n:new e:edit x:del /:search y:copy l:lock q:quit")
}
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();
}
}