Lines
96.26 %
Functions
82.98 %
Branches
100 %
//! Ratatui rendering. The draw layer is a pure function of
//! [`crate::app::App`]: take a `Frame` and an `App`, render the current
//! visual state. No I/O of its own; I/O is the transport's job.
mod form;
use crate::app::App;
use crate::modal::Modal;
use crate::view::{DrawCtx, Tab};
use crate::widgets::{EditMode, VimMode};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Span, Text};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Tabs};
pub fn draw(frame: &mut Frame, app: &App) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(1),
])
.split(area);
draw_tabs(frame, chunks[0], app);
draw_body(frame, chunks[1], app);
draw_status(frame, chunks[2], app);
if let Some(modal) = app.overlays.top() {
draw_modal(frame, area, modal);
}
fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) {
use ratatui::style::Modifier;
use ratatui::text::Line;
let titles: Vec<Line> = Tab::ALL.iter().map(|t| Line::from(t.label())).collect();
let selected = app.active_tab.index();
let tabs = Tabs::new(titles)
.select(selected)
.block(Block::default().borders(Borders::ALL).title("nomisync-tui"))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
frame.render_widget(tabs, area);
fn draw_body(frame: &mut Frame, area: Rect, app: &App) {
let ctx = DrawCtx {
edit_mode: app.edit_mode,
is_active: app.console_focused,
};
app.active_view().draw(frame, area, &ctx);
fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
let edit_indicator = match app.cmdline.editor.mode() {
EditMode::Emacs => "emacs",
EditMode::Vim => match app.cmdline.editor.vim_mode() {
VimMode::Normal => "vim:normal",
VimMode::Insert => "vim:insert",
},
let content = if app.cmdline.active {
let buf = app.cmdline.editor.buffer();
if app.cmdline.completions.is_empty() {
format!(":{} (cursor={})", buf, app.cmdline.editor.cursor())
} else {
format!(":{} [{}]", buf, app.cmdline.completions.join(" "))
} else if app.status.is_empty() {
format!("[{edit_indicator}] Tab/BTab tabs : cmdline ? help C-v edit-mode q quit")
format!("[{edit_indicator}] {}", app.status)
let line = Span::styled(content, Style::default().fg(Color::Gray));
let para = Paragraph::new(line).block(Block::default().borders(Borders::ALL));
frame.render_widget(para, area);
fn draw_modal(frame: &mut Frame, full: Rect, modal: &Modal) {
// Help is a near-full-size modal so its ~18 lines never clip on 80x24.
let (pct_x, pct_y) = match modal {
Modal::Help => (95, 95),
_ => (60, 30),
let area = centered_rect(pct_x, pct_y, full);
frame.render_widget(Clear, area);
let (title, body) = modal_content(modal);
let widget = Paragraph::new(body).block(Block::default().title(title).borders(Borders::ALL));
frame.render_widget(widget, area);
fn modal_content(modal: &Modal) -> (&'static str, Text<'static>) {
match modal {
Modal::Help => (
"Help",
Text::from(
concat!(
"Tabs: 1-6 jump Tab/BTab next/prev q quit C-v emacs/vim ? help\n",
"\n",
"Palette (:): Tab complete Enter run Esc cancel (twice in vim)\n",
" account create|list|balance|tag transaction create|list|tag\n",
" commodity create|list config get|set reports ... version\n",
"Lists: Up/Down or j/k select r refresh\n",
" Accounts: e tag\n",
" Transactions: d delete (confirm) e edit (tag: :transaction tag)\n",
" Commodities: view only\n",
" Config: Up/Down select e/Enter edit r refresh\n",
"Forms: Tab/BTab fields Up/Down Select Enter submit Esc cancel\n",
" Tx splits: +/C-n add row -/C-d remove row\n",
"Console: i/Enter focus Esc blur C-c interrupt\n",
" Up/Down history Tab/BTab cycle panes\n",
)
.to_string(),
),
Modal::Confirm { prompt, .. } => (
"Confirm",
Text::from(format!(
"{prompt}\n\n y / Enter — confirm\n n / q / Esc — cancel"
)),
Modal::Form(form) => form::form_modal_content(form),
#[must_use]
pub fn centered_rect(pct_x: u16, pct_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
Constraint::Percentage((100 - pct_y) / 2),
Constraint::Percentage(pct_y),
.split(r);
Layout::default()
.direction(Direction::Horizontal)
Constraint::Percentage((100 - pct_x) / 2),
Constraint::Percentage(pct_x),
.split(popup_layout[1])[1]
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{Intent, apply};
use crate::widgets::EditMode;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use sqlx::types::Uuid;
#[test]
fn centered_rect_clamps_to_parent() {
let parent = Rect::new(0, 0, 100, 100);
let r = centered_rect(60, 30, parent);
assert!(r.x + r.width <= parent.x + parent.width);
assert!(r.y + r.height <= parent.y + parent.height);
fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect()
fn render_console(app: &App) -> String {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("test terminal");
.draw(|frame| draw(frame, app))
.expect("draw must not panic");
buffer_text(&terminal)
fn console_app() -> App {
let mut app = App::new(Uuid::nil(), EditMode::Emacs);
app.active_tab = Tab::Console;
app
fn transaction_modal_renders_split_row_details() {
use crate::form::Form;
use crate::widgets::splits::{COL_FROM, COL_VALUE};
use crate::widgets::{FocusedSubWidget, SelectOption, Widget};
let mut form = Form::transaction_create(EditMode::Emacs);
if let Widget::Splits(ref mut sw) = form.fields[2].widget {
sw.set_account_options(vec![
SelectOption {
id: "acc-0".to_string(),
label: "Cash".to_string(),
id: "acc-1".to_string(),
label: "Food".to_string(),
]);
sw.col_focus = COL_VALUE;
if let Some(FocusedSubWidget::Amount(aw)) = sw.focused_subwidget_mut() {
aw.insert_char('5');
aw.insert_char('0');
// Move focus off the value cell so the amount renders unbracketed.
sw.col_focus = COL_FROM;
app.overlays.push(Modal::Form(form));
let backend = TestBackend::new(120, 40);
.draw(|frame| draw(frame, &app))
let text = buffer_text(&terminal);
assert!(
text.contains("[Cash]"),
"focused from-account label must render bracketed"
);
text.contains("val=50"),
"typed amount must render: not found"
fn console_tab_renders_scrollback_and_prompt() {
let mut app = console_app();
app.console.push_scrollback("(:id 0 :value 42)");
app.console.input.insert_char('(');
let text = render_console(&app);
assert!(text.contains("(:id 0 :value 42)"), "scrollback missing");
assert!(text.contains("nms>"), "prompt missing");
fn console_prompt_shows_continuation_marker_when_pending() {
for c in "(list".chars() {
app.console.input.insert_char(c);
app.console.take_complete_form();
assert!(!app.console.pending.is_empty(), "form must be pending");
assert!(text.contains("...>"), "continuation marker missing");
assert!(!text.contains("nms>"), "primary prompt should be hidden");
fn console_hint_switches_with_focus() {
let unfocused = render_console(&app);
assert!(unfocused.contains("i/Enter focus"), "blurred hint missing");
app.console_focused = true;
let focused = render_console(&app);
assert!(focused.contains("Esc blur"), "focused hint missing");
assert!(focused.contains("C-c interrupt"), "interrupt hint missing");
fn console_prompt_style_bold_only_when_focused() {
let app = console_app();
let prompt_cell = prompt_marker_cell(&terminal);
!prompt_cell.modifier.contains(Modifier::BOLD),
"blurred prompt must not be bold"
let mut focused = console_app();
focused.console_focused = true;
.draw(|frame| draw(frame, &focused))
prompt_cell.modifier.contains(Modifier::BOLD),
"focused prompt must be bold"
fn prompt_marker_cell(terminal: &Terminal<TestBackend>) -> ratatui::buffer::Cell {
let buffer = terminal.backend().buffer();
for y in 0..buffer.area.height {
if buffer[(1, y)].symbol() == "n" && buffer[(2, y)].symbol() == "m" {
return buffer[(1, y)].clone();
panic!("nms> prompt marker not found in rendered buffer");
fn focused_field_marker_pos(terminal: &Terminal<TestBackend>) -> (u16, u16) {
for x in 0..buffer.area.width {
if buffer[(x, y)].symbol() == "â–¸" {
return (x, y);
panic!("focused field marker 'â–¸' not found in rendered buffer");
fn form_focused_field_reversed_unfocused_not() {
use crate::widgets::Editor;
let form = Form::config_set(Editor::new(EditMode::Emacs), Editor::new(EditMode::Emacs));
assert_eq!(form.focus, 0, "focus must start on first field");
let (marker_x, marker_y) = focused_field_marker_pos(&terminal);
buffer[(marker_x, marker_y)]
.modifier
.contains(Modifier::REVERSED),
"focused field must carry REVERSED modifier"
!buffer[(marker_x, marker_y + 1)]
"unfocused field must not carry REVERSED modifier"
fn scrollback_shows_only_the_tail_when_longer_than_viewport() {
for i in 0..100 {
app.console.push_scrollback(format!("line-{i:03}"));
text.contains(&format!("line-{:03}", 99)),
"newest line must be visible"
!text.contains("line-000"),
"oldest line must be scrolled out of the viewport"
fn make_app() -> App {
App::new(Uuid::nil(), EditMode::Emacs)
fn render_app(app: &App) -> String {
fn help_modal_contains_command_palette_key() {
let mut app = make_app();
app.overlays.push(Modal::Help);
let text = render_app(&app);
assert!(text.contains(':'), "help must mention the ':' key");
fn help_modal_contains_list_view_keys() {
assert!(text.contains('d'), "help must mention 'd' for delete");
assert!(text.contains('e'), "help must mention 'e' for edit/tag");
fn help_modal_contains_tab_key_description() {
text.contains("Tab"),
"help must mention Tab for completion/navigation"
fn help_modal_does_not_clip_last_line_on_80x24() {
let text: String = terminal
.collect();
text.contains("cycle panes"),
"last help line must be visible on an 80x24 terminal (not clipped)"
fn cmdline_active_with_completions_shows_them_in_status() {
apply(&mut app, Intent::OpenCommandLine);
for c in "account ".chars() {
apply(&mut app, Intent::InsertChar(c));
apply(&mut app, Intent::CompleteCommandLine);
text.contains("create"),
"completion candidates must appear in the status area"
fn cmdline_active_without_completions_shows_cursor() {
for c in "ver".chars() {
text.contains("cursor"),
"without completions the cursor position must be shown"