Files
IronStorage/apps/tui/src/ui.rs
2026-08-10 07:22:55 +00:00

680 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Responsive Ratatui rendering for the application shell.
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
};
use crate::{
action::available_actions,
app::{App, Mode, PaneFocus},
editor::EntryEditor,
viewer::EntryViewer,
};
const MINIMUM_WIDTH: u16 = 40;
const MINIMUM_HEIGHT: u16 = 8;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LayoutClass {
TooSmall,
Narrow,
Normal,
Wide,
}
pub fn layout_class(area: Rect) -> LayoutClass {
if area.width < MINIMUM_WIDTH || area.height < MINIMUM_HEIGHT {
LayoutClass::TooSmall
} else if area.width < 80 {
LayoutClass::Narrow
} else if area.width < 120 {
LayoutClass::Normal
} else {
LayoutClass::Wide
}
}
pub fn draw(frame: &mut Frame, app: &App) {
let area = frame.area();
if layout_class(area) == LayoutClass::TooSmall {
frame.render_widget(
Paragraph::new(format!(
"Terminal too small ({}×{}). Need at least {MINIMUM_WIDTH}×{MINIMUM_HEIGHT}.",
area.width, area.height
))
.block(Block::bordered().title(ironstorage::PRODUCT_NAME))
.wrap(Wrap { trim: true }),
area,
);
return;
}
let rows = Layout::vertical([
Constraint::Min(3),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(area);
render_content(frame, app, rows[0]);
frame.render_widget(status_line(app), rows[1]);
frame.render_widget(context_line(app), rows[2]);
frame.render_widget(prompt_line(app), rows[3]);
}
fn render_content(frame: &mut Frame, app: &App, area: Rect) {
if app.mode() == Mode::Locked {
frame.render_widget(
Paragraph::new("The password store is locked. Authentication is required.")
.block(Block::bordered().title("Locked"))
.wrap(Wrap { trim: true }),
area,
);
return;
}
if app.mode() == Mode::Help {
let available_rows = usize::from(area.height.saturating_sub(2)).max(1);
let lines = (0..available_rows).map(|row| {
let mut spans = Vec::new();
for index in (row..crate::action::ACTIONS.len()).step_by(available_rows) {
let spec = &crate::action::ACTIONS[index];
spans.push(Span::styled(
format!("{:>6}", spec.bindings[0].display),
Style::default().fg(Color::Cyan),
));
spans.push(Span::raw(format!(
" {:<16} :{:<16}",
spec.label, spec.command
)));
}
Line::from(spans)
});
frame.render_widget(
Paragraph::new(lines.collect::<Vec<_>>())
.block(Block::bordered().title("Contextual help"))
.wrap(Wrap { trim: false }),
area,
);
return;
}
let class = layout_class(frame.area());
let (direction, constraints) = match class {
LayoutClass::Narrow => (
Direction::Vertical,
[Constraint::Percentage(40), Constraint::Percentage(60)],
),
LayoutClass::Normal => (
Direction::Horizontal,
[Constraint::Percentage(35), Constraint::Percentage(65)],
),
LayoutClass::Wide => (
Direction::Horizontal,
[Constraint::Percentage(25), Constraint::Percentage(75)],
),
LayoutClass::TooSmall => return,
};
let panes = Layout::new(direction, constraints).split(area);
frame.render_widget(
Paragraph::new(sidebar_lines(app))
.block(pane_block("Passwords", app.focus() == PaneFocus::Sidebar))
.wrap(Wrap { trim: false }),
panes[0],
);
let main = match app.mode() {
Mode::Viewer => app.viewer().map_or_else(
|| Paragraph::new(main_text(app)),
|viewer| {
Paragraph::new(viewer_lines(viewer))
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
},
),
Mode::Editor => app.editor().map_or_else(
|| Paragraph::new(main_text(app)),
|editor| {
Paragraph::new(editor_lines(editor)).scroll((
u16::try_from(editor.focused_index().unwrap_or_default()).unwrap_or(u16::MAX),
0,
))
},
),
_ => Paragraph::new(main_text(app)),
};
frame.render_widget(
main.block(pane_block(
mode_title(app.mode()),
app.focus() == PaneFocus::Main,
))
.wrap(Wrap { trim: false }),
panes[1],
);
}
fn pane_block(title: &'static str, focused: bool) -> Block<'static> {
let style = if focused {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(style)
}
fn sidebar_lines(app: &App) -> Vec<Line<'static>> {
let selected = app.sidebar().selected();
let rows = app.sidebar().visible_window();
if rows.is_empty() {
if app.is_busy() {
return vec![Line::from("Loading password-store tree…")];
}
return vec![Line::from("No password entries.")];
}
rows.into_iter()
.map(|row| {
let marker = if row.id.is_directory() {
if row.expanded { "" } else { "" }
} else {
""
};
let mut indicators = String::new();
if row.indicators.is_locked() {
indicators.push_str(" L");
}
if row.indicators.is_changed() {
indicators.push_str(" *");
}
if row.indicators.has_conflict() {
indicators.push_str(" !");
}
let style = if selected == Some(&row.id) {
Style::default().bg(Color::Blue).fg(Color::White)
} else {
Style::default()
};
Line::styled(
format!(
"{}{} {}{}",
" ".repeat(row.depth),
marker,
row.name,
indicators
),
style,
)
})
.collect()
}
fn main_text(app: &App) -> String {
match app.mode() {
Mode::Browser => "Select an entry from the sidebar.".to_owned(),
Mode::Viewer => app.selected_entry().map_or_else(
|| "Structured entry viewer".to_owned(),
|path| format!("Opening {path}"),
),
Mode::Editor => "Saving structured entry…".to_owned(),
Mode::Dialog if app.discard_confirmation() => {
"Discard all unsaved edits? Press y to discard, n or Esc to keep editing.".to_owned()
}
Mode::Dialog => "Complete or cancel the active dialog.".to_owned(),
Mode::Command => "Enter a command on the bottom line.".to_owned(),
Mode::Help | Mode::Locked => String::new(),
}
}
fn viewer_lines(viewer: &EntryViewer) -> Vec<Line<'_>> {
let focused = viewer.focused_index();
if viewer.document().fields().is_empty() {
return vec![Line::from("This entry is empty.")];
}
viewer
.document()
.fields()
.iter()
.enumerate()
.map(|(index, field)| {
let metadata = field.metadata();
let label = metadata.name().map_or_else(
|| match metadata.kind() {
ironstorage::document::EntryFieldKind::Note => format!("note {}", index + 1),
ironstorage::document::EntryFieldKind::Blank => format!("blank {}", index + 1),
kind => format!("{kind:?}").to_ascii_lowercase(),
},
str::to_owned,
);
let value = if metadata.sensitivity()
== ironstorage::document::EntrySensitivity::Sensitive
&& !viewer.is_revealed(field.id())
{
Span::styled("••••••••", Style::default().fg(Color::DarkGray))
} else if field.value().is_empty() {
Span::styled("(empty)", Style::default().fg(Color::DarkGray))
} else {
match std::str::from_utf8(field.value()) {
Ok(value) => Span::raw(value),
Err(_) => Span::styled("[non-UTF-8 value]", Style::default().fg(Color::Yellow)),
}
};
let mut spans = vec![
Span::styled(
format!("{label}: "),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
value,
];
if let Some(otp) = metadata.otp() {
let timing = otp.period().map_or_else(
|| format!("counter {}", otp.counter().unwrap_or_default()),
|period| format!("period {period}s"),
);
spans.push(Span::styled(
format!(
" [{:?}, {}, {:?}, {}, {} digits, {timing}]",
otp.kind(),
otp.issuer().unwrap_or("no issuer"),
otp.algorithm(),
otp.account(),
otp.digits(),
),
Style::default().fg(Color::DarkGray),
));
}
let line = Line::from(spans);
if focused == Some(index) {
line.style(Style::default().bg(Color::Blue).fg(Color::White))
} else {
line
}
})
.collect()
}
fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
let focused = editor.focused_index();
if editor.document().fields().is_empty() {
return vec![Line::from(
"This entry is empty. Press a to add its first field.",
)];
}
editor
.document()
.fields()
.iter()
.enumerate()
.map(|(index, field)| {
let selected = focused == Some(index);
let contents = editor
.focused_contents(field.id())
.unwrap_or_else(|| field.contents().expose());
let label = field.metadata().name().map_or_else(
|| format!("{:?}", field.metadata().kind()),
|name| format!("{:?} ({name})", field.metadata().kind()),
);
let masked = field.metadata().sensitivity()
== ironstorage::document::EntrySensitivity::Sensitive
&& !editor.is_revealed(field.id());
let mut spans = vec![Span::styled(
format!("#{:02} {label}: ", index + 1),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)];
if masked {
spans.push(Span::styled(
"••••••••",
Style::default().fg(Color::DarkGray),
));
if selected && editor.is_input_active() {
spans.push(Span::styled(
" [hidden input]",
Style::default().fg(Color::Yellow),
));
}
} else if let Ok(value) = std::str::from_utf8(contents) {
if selected && editor.is_input_active() && value.is_char_boundary(editor.cursor()) {
let (before, after) = value.split_at(editor.cursor());
spans.push(Span::raw(before));
spans.push(Span::styled("", Style::default().fg(Color::Yellow)));
spans.push(Span::raw(after));
} else if value.is_empty() {
spans.push(Span::styled(
"(empty)",
Style::default().fg(Color::DarkGray),
));
} else {
spans.push(Span::raw(value));
}
} else {
spans.push(Span::styled(
"[non-UTF-8 field; editing will preserve bytes]",
Style::default().fg(Color::Yellow),
));
}
let line = Line::from(spans);
if selected {
line.style(Style::default().bg(Color::Blue).fg(Color::White))
} else {
line
}
})
.collect()
}
fn mode_title(mode: Mode) -> &'static str {
match mode {
Mode::Browser => "Browser",
Mode::Viewer => "Viewer",
Mode::Editor => "Editor",
Mode::Dialog => "Dialog",
Mode::Help => "Help",
Mode::Command => "Command",
Mode::Locked => "Locked",
}
}
fn status_line(app: &App) -> Paragraph<'_> {
let busy = if app.is_busy() { " [working]" } else { "" };
let warning = app
.remaining_lease()
.filter(|remaining| remaining.as_secs() <= 30)
.map_or_else(String::new, |remaining| {
format!(" [locks in {}s]", remaining.as_secs())
});
Paragraph::new(Line::from(vec![
Span::styled(
" status ",
Style::default().bg(Color::Blue).fg(Color::White),
),
Span::raw(format!(" {}{busy}{warning}", app.status())),
]))
}
fn context_line(app: &App) -> Paragraph<'static> {
let text = available_actions(app.mode())
.map(|spec| format!("{} {}", spec.bindings[0].display, spec.label))
.collect::<Vec<_>>()
.join(" ");
Paragraph::new(text)
}
fn prompt_line(app: &App) -> Paragraph<'static> {
match app.mode() {
Mode::Command => Paragraph::new(":").style(Style::default().fg(Color::Yellow)),
Mode::Dialog => Paragraph::new("dialog> ").style(Style::default().fg(Color::Yellow)),
Mode::Editor if app.editor().is_some_and(EntryEditor::is_input_active) => {
Paragraph::new("-- INSERT -- Esc stops input; Tab changes field; C-s saves")
.style(Style::default().fg(Color::Yellow))
}
_ if app.sidebar().is_editing_filter() => {
Paragraph::new(format!("/{}", app.sidebar().filter_query()))
.style(Style::default().fg(Color::Yellow))
}
_ => Paragraph::new(""),
}
}
#[cfg(test)]
mod tests {
use ratatui::{Terminal, backend::TestBackend};
use super::*;
use crate::app::Transition;
use crate::sidebar::TestTreeNode;
use crate::viewer::test_support::fixture_document;
fn render(width: u16, height: u16, app: &App) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal.draw(|frame| draw(frame, app)).expect("draw");
let buffer = terminal.backend().buffer();
(0..height)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn sizes_have_explicit_layout_classes() {
assert_eq!(layout_class(Rect::new(0, 0, 39, 20)), LayoutClass::TooSmall);
assert_eq!(layout_class(Rect::new(0, 0, 40, 7)), LayoutClass::TooSmall);
assert_eq!(layout_class(Rect::new(0, 0, 60, 20)), LayoutClass::Narrow);
assert_eq!(layout_class(Rect::new(0, 0, 100, 20)), LayoutClass::Normal);
assert_eq!(layout_class(Rect::new(0, 0, 140, 20)), LayoutClass::Wide);
}
#[test]
fn minimum_size_message_is_clear() {
let output = render(39, 8, &App::new());
assert!(output.contains("Terminal too small"));
assert!(output.contains("40×8"));
}
#[test]
fn normal_shell_contains_every_required_region() {
let output = render(100, 20, &App::new());
assert!(output.contains("Passwords"));
assert!(output.contains("Browser"));
assert!(output.contains("status"));
assert!(output.contains("? help"));
}
#[test]
fn narrow_and_wide_shells_render_without_losing_panes() {
for width in [60, 140] {
let output = render(width, 20, &App::new());
assert!(output.contains("Passwords"));
assert!(output.contains("Browser"));
}
}
#[test]
fn help_is_generated_from_the_action_registry() {
let mut app = App::new();
assert!(app.transition(Transition::OpenHelp));
let output = render(140, 35, &app);
for spec in crate::action::ACTIONS {
assert!(output.contains(spec.label));
assert!(output.contains(spec.command));
}
}
#[test]
fn hierarchy_selection_and_storage_indicators_have_stable_rendering() {
let mut app = App::new();
app.resize(100, 20);
app.sidebar_mut().replace_test_tree(vec![TestTreeNode {
path: "personal".to_owned(),
name: "personal".to_owned(),
directory: true,
indicators: ironstorage::read::TreeNodeIndicators::default(),
children: vec![TestTreeNode {
path: "personal/咖啡".to_owned(),
name: "咖啡".to_owned(),
directory: false,
indicators: ironstorage::read::TreeNodeIndicators::new(true, true, true),
children: vec![],
}],
}]);
app.sidebar_mut().move_child();
let output = render(100, 20, &app);
assert!(output.contains("▼ personal"));
assert!(output.contains('咖'));
assert!(output.contains("L * !"));
}
#[test]
fn scrolling_focus_filter_prompt_and_resize_are_rendered() {
let mut app = App::new();
app.resize(60, 10);
app.sidebar_mut().replace_test_tree(
(0..20)
.map(|index| TestTreeNode {
path: format!("entry-{index:02}"),
name: format!("entry-{index:02}"),
directory: false,
indicators: ironstorage::read::TreeNodeIndicators::default(),
children: vec![],
})
.collect(),
);
app.sidebar_mut().move_last();
app.sidebar_mut().begin_filter();
app.sidebar_mut().push_filter_character('咖');
let narrow = render(60, 10, &app);
assert!(narrow.contains("entry-19"));
assert!(narrow.contains("/咖"));
assert!(!narrow.contains("entry-00"));
app.dispatch(crate::action::Action::FocusNext);
let wide = render(140, 20, &app);
assert!(wide.contains("Browser"));
}
#[test]
fn lease_warning_is_concise_and_never_contains_entry_state() {
let mut app = App::new();
app.update_remaining_lease(Some(std::time::Duration::from_secs(30)));
let output = render(100, 20, &app);
assert!(output.contains("locks in 30s"));
}
#[test]
fn viewer_masks_storage_sensitive_fields_and_renders_dynamic_unicode_fields() {
let mut app = App::new();
app.open_test_document("unicode/咖啡", fixture_document("unicode/咖啡"));
for width in [60, 100, 140] {
let output = render(width, 20, &app);
assert!(output.contains("password: ••••••••"));
assert!(output.contains("login:"));
assert!(output.contains('用'));
assert!(output.contains("@example.test"));
assert!(output.contains("notes: ••••••••"));
assert!(!output.contains("pässwörd-猫"));
}
assert!(!format!("{app:?}").contains("pässwörd-猫"));
}
#[test]
fn reveal_is_explicit_and_focus_change_or_lock_removes_secret_from_rendering() {
let mut app = App::new();
app.open_test_document("email/personal", fixture_document("email/personal"));
app.dispatch(crate::action::Action::Reveal);
let revealed = render(100, 20, &app);
assert!(revealed.contains("correct horse fixture"));
app.dispatch(crate::action::Action::FocusNext);
let hidden = render(100, 20, &app);
assert!(!hidden.contains("correct horse fixture"));
app.dispatch(crate::action::Action::FocusPrevious);
app.dispatch(crate::action::Action::Reveal);
app.dispatch(crate::action::Action::Help);
app.dispatch(crate::action::Action::Cancel);
let after_overlay = render(100, 20, &app);
assert!(!after_overlay.contains("correct horse fixture"));
app.dispatch(crate::action::Action::Reveal);
app.forced_relock("test lock");
let locked = render(100, 20, &app);
assert!(!locked.contains("correct horse fixture"));
assert!(locked.contains("locked"));
}
#[test]
fn otp_metadata_is_visible_while_the_secret_uri_stays_masked() {
let mut app = App::new();
app.open_test_document("otp/totp", fixture_document("otp/totp"));
app.dispatch(crate::action::Action::FocusNext);
let output = render(120, 20, &app);
assert!(output.contains("Totp"));
assert!(output.contains("IronStorage"));
assert!(output.contains("alice@example.test"));
assert!(output.contains("period 30s"));
assert!(!output.contains("JBSWY3DPEHPK3PXP"));
}
#[test]
fn closing_a_document_preserves_the_sidebar_selection() {
let mut app = App::new();
app.sidebar_mut().replace_test_tree(vec![TestTreeNode {
path: "email/personal".to_owned(),
name: "personal".to_owned(),
directory: false,
indicators: ironstorage::read::TreeNodeIndicators::default(),
children: vec![],
}]);
let selected = app.sidebar().selected().cloned();
app.open_test_document("email/personal", fixture_document("email/personal"));
app.dispatch(crate::action::Action::CloseEntry);
assert_eq!(app.mode(), Mode::Browser);
assert_eq!(app.sidebar().selected(), selected.as_ref());
assert!(app.viewer().is_none());
}
#[test]
fn editor_keeps_sensitive_input_masked_until_explicit_reveal() {
let mut app = App::new();
app.open_test_document("email/personal", fixture_document("email/personal"));
app.dispatch(crate::action::Action::EditEntry);
app.dispatch(crate::action::Action::BeginInput);
app.handle_editor_input(crossterm::event::KeyCode::Char('x'));
for width in [60, 100, 140] {
let hidden = render(width, 20, &app);
assert!(hidden.contains("hidden input"));
assert!(!hidden.contains("correct horse fixturex"));
}
app.handle_editor_input(crossterm::event::KeyCode::Esc);
app.dispatch(crate::action::Action::Reveal);
let revealed = render(100, 20, &app);
assert!(revealed.contains("correct horse fixturex"));
assert!(!format!("{app:?}").contains("correct horse fixturex"));
}
#[test]
fn dirty_editor_requires_explicit_discard_and_relock_discards_from_dialog() {
let mut app = App::new();
app.open_test_document("email/personal", fixture_document("email/personal"));
app.dispatch(crate::action::Action::EditEntry);
app.dispatch(crate::action::Action::AddField);
app.dispatch(crate::action::Action::Cancel);
assert_eq!(app.mode(), Mode::Dialog);
assert!(app.discard_confirmation());
assert!(render(100, 20, &app).contains("Discard all unsaved edits"));
app.dispatch(crate::action::Action::KeepEditing);
assert_eq!(app.mode(), Mode::Editor);
app.dispatch(crate::action::Action::Cancel);
app.dispatch(crate::action::Action::ConfirmDiscard);
assert_eq!(app.mode(), Mode::Browser);
assert!(app.editor().is_none());
app.open_test_document("email/personal", fixture_document("email/personal"));
app.dispatch(crate::action::Action::EditEntry);
app.dispatch(crate::action::Action::AddField);
app.dispatch(crate::action::Action::Cancel);
app.forced_relock("test expiry");
assert_eq!(app.mode(), Mode::Locked);
assert!(app.editor().is_none());
assert!(app.status().contains("unsaved edits were discarded"));
}
}