//! ratatui rendering — top bar, chat, roster, input. use crate::app::{App, ChatLine, Role}; use crate::theme::Theme; use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Clear, List, ListItem, Paragraph, Wrap}; use ratatui::Frame; pub fn draw(f: &mut Frame, app: &App, theme: &Theme) { // Paint the whole frame in the theme background first so every panel (and the // gaps between them) sits on the same surface. With bg = Reset this is a no-op // and we ride the terminal's own colour. f.render_widget( Block::default().style(Style::default().bg(theme.bg)), f.area(), ); let rows = Layout::vertical([ Constraint::Length(1), Constraint::Min(1), Constraint::Length(app.layout.input_height()), ]) .split(f.area()); draw_top(f, rows[0], app, theme); // Divide the body into chat / roster / sandbox-terminal rects. `body_areas` // is the single source of truth for that geometry, shared with `pane_at` // (mouse hit-testing) so what you click is exactly what's painted. let areas = body_areas(rows[1], app); if let Some(chat_area) = areas.chat { draw_chat(f, chat_area, app, theme); } if let Some(roster_area) = areas.roster { draw_roster(f, roster_area, app, theme); } if let Some(area) = areas.sbx { draw_sandbox(f, area, app, theme); } draw_input(f, rows[2], app, theme); if app.show_help { draw_help(f, f.area(), app, theme); } if app.vbox_picker.is_some() { draw_vbox_picker(f, f.area(), app, theme); } if let Some(msg) = &app.error { draw_error(f, f.area(), theme, msg); } if let Some(len) = app.sudo_prompt_len() { draw_sudo_prompt(f, f.area(), theme, len); } } /// The body's pane rectangles. Any field is `None` when that pane is hidden /// (terminal absent, fullscreen zoom, or roster width 0). struct BodyAreas { chat: Option, roster: Option, sbx: Option, } /// Carve the body region (`rows[1]`) into chat / roster / sandbox rectangles, /// honouring the sandbox presence, `Zoom`, and roster width. This is the single /// source of truth used by both `draw` (painting) and `pane_at` (hit-testing). fn body_areas(body: Rect, app: &App) -> BodyAreas { use crate::app::Pane; let mut out = BodyAreas { chat: None, roster: None, sbx: None, }; // The layout tree is the single source of truth: it honours zoom, sandbox // presence and roster width, returning one rect per visible pane. for (pane, rect) in app.layout.regions(body, app.sandbox.is_some()) { match pane { Pane::Chat => out.chat = Some(rect), Pane::Roster => out.roster = Some(rect), Pane::Terminal => out.sbx = Some(rect), // The input bar isn't a body region (it's the frame's bottom row); // `regions` never yields it, so this arm is just for exhaustiveness. Pane::Input => {} } } out } /// Hit-test a screen cell against the laid-out panes, for click-to-select in /// interactive layout editing. Recomputes the exact rects `draw` used (via the /// shared `body_areas`) so a click lands on the pane the user actually sees. pub fn pane_at(w: u16, h: u16, app: &App, col: u16, row: u16) -> Option { use crate::app::Pane; let area = Rect { x: 0, y: 0, width: w, height: h, }; // Mirror draw()'s top-bar / body / input split; the body panes and the input // bar are selectable (the input bar grows its height when focused). let rows = Layout::vertical([ Constraint::Length(1), Constraint::Min(1), Constraint::Length(app.layout.input_height()), ]) .split(area); let areas = body_areas(rows[1], app); let p = Position { x: col, y: row }; if rows[2].contains(p) { Some(Pane::Input) } else if areas.sbx.is_some_and(|r| r.contains(p)) { Some(Pane::Terminal) } else if areas.roster.is_some_and(|r| r.contains(p)) { Some(Pane::Roster) } else if areas.chat.is_some_and(|r| r.contains(p)) { Some(Pane::Chat) } else { None } } /// Border decoration for a pane: when it's the one selected for interactive /// resize (F5 / click), give it a bold accent border and an "✎" title marker so /// it's obvious which pane the arrows will move; otherwise the plain `base`. fn edit_decor(app: &App, pane: crate::app::Pane, theme: &Theme, base: Color) -> (Style, &'static str) { if app.focused_pane == Some(pane) { ( Style::default() .fg(theme.accent) .add_modifier(Modifier::BOLD), "✎ ", ) } else { (Style::default().fg(base), "") } } /// Transient error popup, anchored top-right over the clergy so it never bleeds /// onto the input box. Cleared by the next keypress (see the run loop). fn draw_error(f: &mut Frame, area: Rect, theme: &Theme, msg: &str) { let text = format!("⚠ {msg}"); let w = area.width.saturating_sub(2).clamp(16, 48); let inner = w.saturating_sub(2).max(1); let rows = (text.chars().count() as u16).div_ceil(inner) + 2; let h = rows.min(area.height.saturating_sub(2)).max(3); let x = area.x + area.width.saturating_sub(w + 1); // hug the right edge let y = area.y + 1; // just under the top bar, over the clergy let rect = Rect { x, y, width: w, height: h, }; f.render_widget(Clear, rect); let popup = Paragraph::new(text) .style(Style::default().fg(theme.title).bg(theme.bg)) .block( Block::bordered() .border_style( Style::default() .fg(theme.accent) .add_modifier(Modifier::BOLD), ) .title(Span::styled( format!(" {} error · any key ", theme.sigil), Style::default() .fg(theme.accent) .add_modifier(Modifier::BOLD), )), ) .wrap(Wrap { trim: false }); f.render_widget(popup, rect); } /// Masked sudo-password modal (Option C). Renders one bullet per typed char — /// never the password itself — anchored just above the input box. The buffer it /// reflects lives in `app.sudo_prompt` and is never sent to chat or the PTY. fn draw_sudo_prompt(f: &mut Frame, area: Rect, theme: &Theme, len: usize) { let dots: String = "•".repeat(len); let body = format!("password: {dots}"); let w = area.width.saturating_sub(4).clamp(28, 56); let h = 3; // one input line + its borders let x = area.x + (area.width.saturating_sub(w)) / 2; // Hover just above the input row (bottom of the screen) so it reads as a prompt. let y = area.y + area.height.saturating_sub(h + 2); let rect = Rect { x, y, width: w, height: h, }; f.render_widget(Clear, rect); let popup = Paragraph::new(body) .style(Style::default().fg(theme.title).bg(theme.bg)) .block( Block::bordered() .border_style( Style::default() .fg(theme.accent) .add_modifier(Modifier::BOLD), ) .title(Span::styled( " 🔒 sudo · Enter launch · Esc cancel ", Style::default() .fg(theme.accent) .add_modifier(Modifier::BOLD), )), ); f.render_widget(popup, rect); } fn centered(percent_x: u16, percent_y: u16, area: Rect) -> Rect { let vy = (100u16.saturating_sub(percent_y)) / 2; let vx = (100u16.saturating_sub(percent_x)) / 2; let col = Layout::vertical([ Constraint::Percentage(vy), Constraint::Percentage(percent_y), Constraint::Percentage(vy), ]) .split(area)[1]; Layout::horizontal([ Constraint::Percentage(vx), Constraint::Percentage(percent_x), Constraint::Percentage(vx), ]) .split(col)[1] } // Popup geometry — shared by the renderer and the scroll-clamp helper so the two // always agree on how many rows are visible. const HELP_PCT_X: u16 = 78; const HELP_PCT_Y: u16 = 90; fn help_popup(area: Rect) -> Rect { centered(HELP_PCT_X, HELP_PCT_Y, area) } /// Largest vertical scroll offset for the help overlay given the full terminal /// area: total wrapped content rows minus the visible viewport (0 if it all fits). pub fn help_max_scroll(width: u16, height: u16, app: &App, theme: &Theme) -> u16 { let w = help_popup(Rect::new(0, 0, width, height)); let inner_w = w.width.saturating_sub(2); let inner_h = w.height.saturating_sub(2); if inner_w == 0 { return 0; } let total = Paragraph::new(help_render_lines(app, theme)) .wrap(Wrap { trim: false }) .line_count(inner_w) as u16; total.saturating_sub(inner_h) } /// One named, collapsible group of help entries. struct HelpCluster { title: &'static str, items: Vec<(String, String)>, } /// The help content, grouped into topical clusters. Order here is the order the /// up/down highlight walks through. fn help_clusters(theme: &Theme) -> Vec { let kv = |k: &str, v: &str| (k.to_string(), v.to_string()); // List whatever vestments are actually installed, so new themes show up here // automatically (church · neon · crypt · blush · matrix · wraith · …). let theme_help = format!( "vestments: {} · random · save [name]", Theme::available().join(" · ") ); vec![ HelpCluster { title: "VIRTUAL MACHINES", items: vec![ // ── launch: /sbx