//! Rendering of the application state into a Ratatui frame. //! //! The render function is pure with respect to runtime: given an //! `App` and a `Theme`, the same frame is produced regardless of //! when or where it is called. That property is what makes Tier 2 //! (TestBackend) and Tier 3 (synthetic event) tests in ADR-0008 //! straightforward. use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap}; use crate::app::{App, EffectiveMode, OutputKind, OutputLine}; use crate::mode::Mode; use crate::theme::Theme; /// Render the entire application frame. /// /// Takes `&mut App` because the renderer reports the current /// output-panel row count back to the App for scroll-cap /// computation — without that feedback, scrolling past the top /// of the buffer would slide the visible window off and /// "eat" lines from the bottom on subsequent renders. pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { let area = frame.area(); paint_background(theme, frame, area); // Reserve two rows at the bottom for status: // - top row: "Project: " (P-NAME-3, ADR-0015 §2). // - bottom row: mode-aware keyboard shortcuts. let outer = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(8), Constraint::Length(1), Constraint::Length(1), ]) .split(area); let columns = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Length(28), Constraint::Min(20)]) .split(outer[0]); render_items_panel(app, theme, frame, columns[0]); render_right_column(app, theme, frame, columns[1]); render_project_label(app, theme, frame, outer[1]); render_status_bar(app, theme, frame, outer[2]); // Modal dialogs (rebuild confirm, save-as prompt, load // picker, …) are drawn last so they overlay the rest of // the frame. if let Some(modal) = app.modal.as_ref() { render_modal(modal, theme, frame, area); } } fn render_modal(modal: &crate::app::Modal, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { use crate::app::Modal; match modal { Modal::RebuildConfirm(m) => render_rebuild_confirm(&m.summary, theme, frame, area), Modal::PathEntry(m) => render_path_entry(m, theme, frame, area), Modal::LoadPicker(m) => render_load_picker(m, theme, frame, area), } } fn render_path_entry( m: &crate::app::PathEntryModal, theme: &Theme, frame: &mut Frame<'_>, area: Rect, ) { let dialog_w = area.width.clamp(20, 70); let inner_w = dialog_w.saturating_sub(4) as usize; let prompt_lines = wrap_lines(&m.prompt, inner_w); // Title + blank + prompt + blank + input box (1 row + borders) + blank + key hints. let dialog_h = (prompt_lines.len() as u16).saturating_add(8).min(area.height); let x = area.x + (area.width.saturating_sub(dialog_w)) / 2; let y = area.y + (area.height.saturating_sub(dialog_h)) / 2; let dialog_area = Rect { x, y, width: dialog_w, height: dialog_h, }; frame.render_widget(ratatui::widgets::Clear, dialog_area); let title_style = Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.fg)) .title(Line::from(vec![Span::styled( format!(" {} ", m.title), title_style, )])) .style(Style::default().bg(theme.bg).fg(theme.fg)); let mut text_lines: Vec> = Vec::new(); text_lines.push(Line::from("")); for line in prompt_lines { text_lines.push(Line::from(line)); } text_lines.push(Line::from("")); let cursor_marker = "█"; let display_input = if m.cursor == m.input.len() { format!("{}{cursor_marker}", m.input) } else { format!( "{}{cursor_marker}{}", &m.input[..m.cursor], &m.input[m.cursor..] ) }; text_lines.push(Line::from(format!("> {display_input}"))); text_lines.push(Line::from("")); text_lines.push(Line::from(vec![ Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!(" {} ", crate::t!("shortcut.confirm"))), Span::styled("Esc", Style::default().fg(theme.muted)), Span::styled( format!(" {}", crate::t!("shortcut.cancel")), Style::default().fg(theme.muted), ), ])); let paragraph = Paragraph::new(text_lines) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, dialog_area); } fn render_load_picker( m: &crate::app::LoadPickerModal, theme: &Theme, frame: &mut Frame<'_>, area: Rect, ) { use crate::app::LoadPickerSubMode; let dialog_w = area.width.clamp(20, 70); let dialog_h = area.height.clamp(10, 20); let x = area.x + (area.width.saturating_sub(dialog_w)) / 2; let y = area.y + (area.height.saturating_sub(dialog_h)) / 2; let dialog_area = Rect { x, y, width: dialog_w, height: dialog_h, }; frame.render_widget(ratatui::widgets::Clear, dialog_area); let title_style = Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.fg)) .title(Line::from(vec![Span::styled( format!(" {} ", crate::t!("modal.load_picker_title")), title_style, )])) .style(Style::default().bg(theme.bg).fg(theme.fg)); let mut text_lines: Vec> = Vec::new(); text_lines.push(Line::from("")); match &m.sub_mode { LoadPickerSubMode::List => { if m.entries.is_empty() { text_lines.push(Line::from(crate::t!("modal.load_picker_empty"))); } else { for (i, entry) in m.entries.iter().enumerate() { let marker = if i == m.selected { "›" } else { " " }; let temp_tag = if entry.is_temp { "[TEMP] " } else { "" }; let style = if i == m.selected { Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.fg) }; let line = format!( " {marker} {temp_tag}{name} {modified}", name = entry.display_name, modified = entry.modified, ); text_lines.push(Line::from(Span::styled(line, style))); } } text_lines.push(Line::from("")); text_lines.push(Line::from(vec![ Span::styled("↑↓", Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!(" {} ", crate::t!("shortcut.select"))), Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!(" {} ", crate::t!("shortcut.load"))), Span::styled("b", Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!(" {} ", crate::t!("shortcut.browse_path"))), Span::styled("Esc", Style::default().fg(theme.muted)), Span::styled( format!(" {}", crate::t!("shortcut.cancel")), Style::default().fg(theme.muted), ), ])); } LoadPickerSubMode::PathEntry { input, cursor } => { text_lines.push(Line::from(crate::t!("modal.load_picker_path_prompt"))); text_lines.push(Line::from("")); let cursor_marker = "█"; let display_input = if *cursor == input.len() { format!("{input}{cursor_marker}") } else { format!( "{}{cursor_marker}{}", &input[..*cursor], &input[*cursor..] ) }; text_lines.push(Line::from(format!("> {display_input}"))); text_lines.push(Line::from("")); text_lines.push(Line::from(vec![ Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!(" {} ", crate::t!("shortcut.load"))), Span::styled("Esc", Style::default().fg(theme.muted)), Span::styled( format!(" {}", crate::t!("shortcut.back_to_list")), Style::default().fg(theme.muted), ), ])); } } let paragraph = Paragraph::new(text_lines) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, dialog_area); } /// Centred dialog with a one-paragraph body and a [Y]es/[N]o /// hint at the bottom. Sized at min(60 cols, area.width-4) /// wide and tall enough to fit the wrapped body plus 4 rows /// of chrome. fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let dialog_w = area.width.min(60).saturating_sub(0); let dialog_w = dialog_w.max(20); let inner_w = dialog_w.saturating_sub(4) as usize; let body_lines: Vec = wrap_lines(summary, inner_w); let body_height = body_lines.len() as u16; // Title row + blank + body + blank + prompt + blank + keys + borders (2). let dialog_h = body_height.saturating_add(7).min(area.height); let x = area.x + (area.width.saturating_sub(dialog_w)) / 2; let y = area.y + (area.height.saturating_sub(dialog_h)) / 2; let dialog_area = Rect { x, y, width: dialog_w, height: dialog_h, }; // Solid background panel so we cover whatever was beneath. let bg = ratatui::widgets::Clear; frame.render_widget(bg, dialog_area); let title_style = Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.fg)) .title(Line::from(vec![Span::styled( format!(" {} ", crate::t!("modal.rebuild_confirm_title")), title_style, )])) .style(Style::default().bg(theme.bg).fg(theme.fg)); let mut text_lines: Vec> = Vec::new(); text_lines.push(Line::from("")); for line in body_lines { text_lines.push(Line::from(line)); } text_lines.push(Line::from("")); text_lines.push(Line::from(crate::t!("modal.rebuild_confirm_prompt"))); text_lines.push(Line::from("")); text_lines.push(Line::from(vec![ Span::styled( "[Y]", Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD), ), Span::raw(format!(" {} ", crate::t!("shortcut.yes"))), Span::styled( "[N]", Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD), ), Span::raw(format!(" {} ", crate::t!("shortcut.no"))), Span::styled("Esc", Style::default().fg(theme.muted)), Span::styled( format!(" {}", crate::t!("shortcut.cancel")), Style::default().fg(theme.muted), ), ])); let paragraph = Paragraph::new(text_lines) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, dialog_area); } /// Greedy word-wrap to `width` columns. Sufficient for the /// short prose modals carry; we don't try to be Unicode-aware /// (display-width-wise) since the strings we generate are /// ASCII-friendly. fn wrap_lines(s: &str, width: usize) -> Vec { if width == 0 { return vec![s.to_string()]; } let mut lines: Vec = Vec::new(); let mut current = String::new(); for word in s.split_whitespace() { if !current.is_empty() && current.len() + 1 + word.len() <= width { current.push(' '); } else if !current.is_empty() { lines.push(std::mem::take(&mut current)); } current.push_str(word); } if !current.is_empty() { lines.push(current); } if lines.is_empty() { lines.push(String::new()); } lines } fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let label_style = Style::default().fg(theme.muted); let value_style = Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD); let bar_style = Style::default().bg(theme.bg).fg(theme.muted); let no_project = crate::t!("status.no_project"); let display = app.project_name.as_deref().unwrap_or(no_project.as_str()); let mut spans: Vec> = vec![Span::styled( crate::t!("status.project_label"), label_style, )]; if app.project_is_temp { spans.push(Span::styled( "[TEMP] ", Style::default() .fg(theme.muted) .add_modifier(Modifier::BOLD), )); } spans.push(Span::styled(display.to_string(), value_style)); let line = Line::from(spans); let paragraph = Paragraph::new(line).style(bar_style); frame.render_widget(paragraph, area); } fn render_right_column(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(5), // Output panel Constraint::Length(3), // Input panel Constraint::Length(3), // Hint panel ]) .split(area); render_output_panel(app, theme, frame, rows[0]); render_input_panel(app, theme, frame, rows[1]); render_hint_panel(app, theme, frame, rows[2]); } fn paint_background(theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default().style(Style::default().bg(theme.bg).fg(theme.fg)); frame.render_widget(block, area); } fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.border)) .title(Span::styled( format!(" {} ", crate::t!("panel.tables_title")), Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD), )) .style(Style::default().bg(theme.bg).fg(theme.fg)); if app.tables.is_empty() { let placeholder = Paragraph::new(Line::from(Span::styled( crate::t!("panel.tables_empty"), Style::default() .fg(theme.muted) .add_modifier(Modifier::ITALIC), ))) .block(block); frame.render_widget(placeholder, area); return; } let highlight = app .current_table .as_ref() .map(|t| t.name.as_str()) .unwrap_or_default(); let lines: Vec> = app .tables .iter() .map(|name| { let style = if name == highlight { Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.fg) }; Line::from(Span::styled(name.as_str(), style)) }) .collect(); let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, area); } fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.border)) .title(Span::styled( " Output ", Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD), )) .style(Style::default().bg(theme.bg).fg(theme.fg)); let inner = area.inner(Margin { horizontal: 1, vertical: 1, }); // Render every output line into a wrapped Paragraph and let // ratatui handle the wrapping; we then use the wrapped row // count to cap scroll correctly. Bottom-anchoring (most // recent visible by default) is achieved by computing the // scroll offset relative to the bottom of the wrapped view. let visible = inner.height as usize; // Compute the total wrapped row count first, working from // OutputLines directly (so the borrow ends before the // mutable `note_output_viewport` call below). let total_wrapped = approximate_wrapped_rows_from_output(&app.output, inner.width); app.note_output_viewport(visible, total_wrapped); let lines: Vec> = app .output .iter() .map(|line| render_output_line(line, theme)) .collect(); let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); let max_scroll = total_wrapped.saturating_sub(visible); let effective_scroll = app.output_scroll.min(max_scroll); // Paragraph::scroll((y, _)) sets the topmost visible row. // We want bottom-anchored: y = max_scroll - effective_scroll // so scroll==0 shows the bottom and PageUp moves y down to // reveal older content. let scroll_y = max_scroll.saturating_sub(effective_scroll); let scroll_y_u16 = u16::try_from(scroll_y).unwrap_or(u16::MAX); frame.render_widget(block, area); frame.render_widget(paragraph.scroll((scroll_y_u16, 0)), inner); } /// Approximate the number of display rows the output buffer /// will occupy after width-wrapping. Computed directly from /// `OutputLine`s (rather than the rendered `Line` objects) so /// the calculation can run before we hand `&mut App` to the /// scroll-cap update. Ratatui's exact `line_count` is gated /// behind an unstable feature; this character-based /// approximation is close enough for scroll capping — off-by-one /// at boundaries is acceptable. fn approximate_wrapped_rows_from_output( output: &std::collections::VecDeque, width: u16, ) -> usize { if width == 0 { return output.len(); } let w = usize::from(width); output .iter() .map(|line| { // Tag width matches `render_output_line` exactly so // the row count is right when the panel is too narrow // for the natural line. let tag_len = match line.kind { OutputKind::Echo => match line.mode_at_submission { Mode::Simple => "[simple] ".len(), Mode::Advanced => "[advanced] ".len(), }, OutputKind::System => "[system] ".len(), OutputKind::Error => "[error] ".len(), }; let total = tag_len + line.text.chars().count(); if total == 0 { 1 } else { total.div_ceil(w) } }) .sum() } fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> { let tag_style = match line.mode_at_submission { Mode::Simple => Style::default().fg(theme.mode_simple), Mode::Advanced => Style::default().fg(theme.mode_advanced), }; let body_style = match line.kind { OutputKind::Echo => Style::default().fg(theme.fg), OutputKind::System => Style::default().fg(theme.system), OutputKind::Error => Style::default().fg(theme.error), }; let tag = match line.kind { OutputKind::Echo => format!("[{}] ", line.mode_at_submission.label().to_lowercase()), OutputKind::System => "[system] ".to_string(), OutputKind::Error => "[error] ".to_string(), }; Line::from(vec![ Span::styled(tag, tag_style), Span::styled(line.text.as_str(), body_style), ]) } fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let effective = app.effective_mode(); let (border_color, mode_color, label) = match effective { EffectiveMode::Simple => (theme.border, theme.mode_simple, "SIMPLE"), EffectiveMode::AdvancedPersistent => { (theme.border_advanced, theme.mode_advanced, "ADVANCED") } // Mixed-case label distinguishes the one-shot (`:`-triggered) // state from a persistent advanced mode at a glance. EffectiveMode::AdvancedOneShot => { (theme.border_advanced, theme.mode_advanced, "Advanced:") } }; let title = Line::from(vec![ Span::raw(" "), Span::styled( label, Style::default() .fg(mode_color) .add_modifier(Modifier::BOLD), ), Span::raw(" "), ]); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(border_color)) .title(title) .style(Style::default().bg(theme.bg).fg(theme.fg)); // Cursor block: render the character at the cursor position // inverted so the cursor is visible without enabling a real // terminal cursor. When the cursor is at end-of-input we // append an inverted space. let cursor = app.input_cursor.min(app.input.len()); let before = &app.input[..cursor]; let (under, after) = if cursor < app.input.len() { // Find the end of the character under the cursor. let mut end = cursor + 1; while end < app.input.len() && !app.input.is_char_boundary(end) { end += 1; } (&app.input[cursor..end], &app.input[end..]) } else { (" ", "") }; let spans = vec![ Span::styled(before, Style::default().fg(theme.fg)), Span::styled( under, Style::default().fg(theme.fg).add_modifier(Modifier::REVERSED), ), Span::styled(after, Style::default().fg(theme.fg)), ]; let paragraph = Paragraph::new(Line::from(spans)).block(block); frame.render_widget(paragraph, area); } fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.border)) .title(Span::styled( " Hint ", Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD), )) .style(Style::default().bg(theme.bg).fg(theme.fg)); let empty_hint = crate::t!("panel.hint_empty"); let body = app.hint.as_deref().unwrap_or(empty_hint.as_str()); let paragraph = Paragraph::new(Line::from(Span::styled( body.to_string(), Style::default().fg(theme.muted), ))) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); } fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let key_style = Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD); let sep_style = Style::default().fg(theme.muted); let label_style = Style::default().fg(theme.muted); let bar_style = Style::default().bg(theme.bg).fg(theme.muted); let separator = Span::styled(" · ", sep_style); let mut spans: Vec> = Vec::new(); let push_shortcut = |spans: &mut Vec>, key: &'static str, label: &str| { if !spans.is_empty() { spans.push(separator.clone()); } spans.push(Span::styled(key, key_style)); spans.push(Span::raw(" ")); spans.push(Span::styled(label.to_string(), label_style)); }; let submit = crate::t!("shortcut.submit"); push_shortcut(&mut spans, "Enter", &submit); let switch = crate::t!("shortcut.switch"); let advanced_once = crate::t!("shortcut.advanced_once"); let cancel_one_shot = crate::t!("shortcut.cancel_one_shot"); let quit = crate::t!("shortcut.quit"); match app.effective_mode() { EffectiveMode::Simple => { push_shortcut(&mut spans, ":", &advanced_once); push_shortcut(&mut spans, "mode advanced", &switch); } EffectiveMode::AdvancedPersistent => { push_shortcut(&mut spans, "mode simple", &switch); } EffectiveMode::AdvancedOneShot => { push_shortcut(&mut spans, "Backspace", &cancel_one_shot); } } push_shortcut(&mut spans, "Ctrl-C", &quit); let paragraph = Paragraph::new(Line::from(spans)).style(bar_style); frame.render_widget(paragraph, area); } #[cfg(test)] mod tests { use super::*; use crate::app::App; use ratatui::Terminal; use ratatui::backend::TestBackend; fn render_to_string(app: &mut App, theme: &Theme, width: u16, height: u16) -> String { // Snapshot tests need realistic state, not the boot // fallback "(no project)" — every real session has a // project. Set a representative name unless the test // already set one. if app.project_name.is_none() { app.project_name = Some("Term Planner".to_string()); } let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).expect("create terminal"); terminal .draw(|f| render(app, theme, f)) .expect("draw frame"); let buffer = terminal.backend().buffer().clone(); let mut out = String::new(); for y in 0..buffer.area.height { for x in 0..buffer.area.width { out.push_str(buffer[(x, y)].symbol()); } out.push('\n'); } out } #[test] fn dark_theme_default_view_snapshot() { let mut app = App::new(); let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("default_simple_dark", snapshot); } #[test] fn light_theme_default_view_snapshot() { let mut app = App::new(); let theme = Theme::light(); let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("default_simple_light", snapshot); } #[test] fn advanced_mode_default_view_snapshot() { let mut app = App::new(); app.mode = Mode::Advanced; let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("default_advanced_dark", snapshot); } #[test] fn one_shot_advanced_prompt_snapshot() { // Typing `:sel` in simple mode should flip the input panel // label to `Advanced:` while the persistent mode stays simple. // The visible input includes the auto-inserted space after `:`. let mut app = App::new(); app.input.push_str(": sel"); let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("one_shot_advanced_dark", snapshot); } #[test] fn rebuild_confirm_modal_snapshot() { use crate::app::{Modal, RebuildConfirmModal}; let mut app = App::new(); app.modal = Some(Modal::RebuildConfirm(RebuildConfirmModal { summary: "3 tables and 47 rows will be reconstructed; \ the existing playground.db will be replaced" .to_string(), })); let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("rebuild_confirm_modal_dark", snapshot); } #[test] fn populated_with_table_snapshot() { // Items panel lists tables; output panel shows the // structure of the current table. use crate::app::{OutputKind, OutputLine}; use crate::db::{ColumnDescription, TableDescription}; let mut app = App::new(); app.tables = vec!["Customers".to_string(), "Orders".to_string()]; use crate::dsl::Type; let desc = TableDescription { name: "Customers".to_string(), columns: vec![ ColumnDescription { name: "id".to_string(), user_type: Some(Type::Serial), sqlite_type: "INTEGER".to_string(), notnull: false, primary_key: true, }, ColumnDescription { name: "Name".to_string(), user_type: Some(Type::Text), sqlite_type: "TEXT".to_string(), notnull: false, primary_key: false, }, ], outbound_relationships: Vec::new(), inbound_relationships: Vec::new(), }; app.current_table = Some(desc); // Mirror what the App writes when a DSL command succeeds. app.output.push_back(OutputLine { text: "[ok] create table Customers".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, }); app.output.push_back(OutputLine { text: " Customers".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, }); app.output.push_back(OutputLine { text: " id serial [PK]".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, }); app.output.push_back(OutputLine { text: " Name text".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, }); let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("populated_with_table_dark", snapshot); } }