Build the Mutt-style TUI shell and action model
This commit is contained in:
271
apps/tui/src/ui.rs
Normal file
271
apps/tui/src/ui.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
//! 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},
|
||||
};
|
||||
|
||||
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 lines = crate::action::ACTIONS.iter().map(|spec| {
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{:>7}", spec.binding.display),
|
||||
Style::default().fg(Color::Cyan),
|
||||
),
|
||||
Span::raw(format!(" {:<18} :{}", spec.label, spec.command)),
|
||||
])
|
||||
});
|
||||
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_text(app))
|
||||
.block(pane_block("Passwords", app.focus() == PaneFocus::Sidebar))
|
||||
.wrap(Wrap { trim: true }),
|
||||
panes[0],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(main_text(app))
|
||||
.block(pane_block(
|
||||
mode_title(app.mode()),
|
||||
app.focus() == PaneFocus::Main,
|
||||
))
|
||||
.wrap(Wrap { trim: true }),
|
||||
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_text(app: &App) -> String {
|
||||
if app.is_busy() {
|
||||
"Loading password-store tree…".to_owned()
|
||||
} else if let Some(startup) = app.startup() {
|
||||
format!("Vault\n{}", startup.vault)
|
||||
} else {
|
||||
"No password store is open.".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn main_text(app: &App) -> &'static str {
|
||||
match app.mode() {
|
||||
Mode::Browser => "Select an entry from the sidebar.",
|
||||
Mode::Viewer => "Structured entry viewer",
|
||||
Mode::Editor => "Structured entry editor",
|
||||
Mode::Dialog => "Complete or cancel the active dialog.",
|
||||
Mode::Command => "Enter a command on the bottom line.",
|
||||
Mode::Help | Mode::Locked => "",
|
||||
}
|
||||
}
|
||||
|
||||
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 { "" };
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
" status ",
|
||||
Style::default().bg(Color::Blue).fg(Color::White),
|
||||
),
|
||||
Span::raw(format!(" {}{busy}", app.status())),
|
||||
]))
|
||||
}
|
||||
|
||||
fn context_line(app: &App) -> Paragraph<'static> {
|
||||
let text = available_actions(app.mode())
|
||||
.map(|spec| format!("{} {}", spec.binding.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)),
|
||||
_ => Paragraph::new(""),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
|
||||
use super::*;
|
||||
use crate::app::Transition;
|
||||
|
||||
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(100, 20, &app);
|
||||
for spec in crate::action::ACTIONS {
|
||||
assert!(output.contains(spec.label));
|
||||
assert!(output.contains(spec.command));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user