//! Top-level TUI render pipeline: layouts the terminal into chat / input //! / status regions, dispatches overlay rendering with glassmorphism-style //! centered panels, and floats toast notifications over the top-right corner. pub mod chat; pub mod input; pub mod markdown; pub mod overlays; pub mod sidebar; pub mod status; pub mod theme; pub mod workflow; use crate::components::Component; use crate::state::AppStateRest; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; use ratatui::Frame; use theme::Theme; use zesdex_infrastructure::ToastKind; const SIDEBAR_MIN_WIDTH: u16 = 90; /// Pre-render hook: update mutable caches (display lines, token count) /// before the immutable `draw` pass. Called once per frame when dirty. /// /// This separates cache mutation from rendering so `draw` can take /// `&AppStateRest` (required by `terminal.draw` closure constraints). pub fn pre_render(state: &mut AppStateRest) { let mut chat_comp = chat::ChatComponent; chat_comp.pre_render(state); } /// Top-level render entry point called once per TUI frame. pub fn draw(frame: &mut Frame, state: &AppStateRest) { let area = frame.area(); let show_sidebar = area.width > SIDEBAR_MIN_WIDTH; let (main_area, sidebar_area) = if show_sidebar { let has_workflow = !state.workflow_engine.agents.is_empty(); let sidebar_width = if has_workflow { 48 } else { 30 }; let h_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Min(40), Constraint::Length(sidebar_width)]) .split(area); (h_chunks[0], Some(h_chunks[1])) } else { (area, None) }; let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(3), Constraint::Length(3), Constraint::Length(1), ]) .split(main_area); let chat_area = chunks[0]; let input_area = chunks[1]; let status_area = chunks[2]; if state.misc.overlay.is_active() { let overlay = state.misc.overlay; overlays::render_overlay(frame, chat_area, overlay, state); } else { let mut chat_comp = chat::ChatComponent; chat_comp.draw(frame, chat_area, state); } let mut input_comp = input::InputComponent; input_comp.draw(frame, input_area, state); let mut status_comp = status::StatusBarComponent; status_comp.draw(frame, status_area, state); if let Some(sidebar_rect) = sidebar_area { let mut sidebar_comp = sidebar::SidebarComponent; sidebar_comp.draw(frame, sidebar_rect, state); } render_toasts(frame, state); } fn render_toasts(frame: &mut Frame, state: &AppStateRest) { let now_ms = chrono::Utc::now().timestamp_millis(); let active: Vec<&zesdex_infrastructure::Toast> = state .misc .toasts .iter() .filter(|t| !t.expired(now_ms)) .collect(); if active.is_empty() { return; } let area = frame.area(); let toast_w: u16 = 48; let x = area.width.saturating_sub(toast_w).saturating_sub(2); let mut y: u16 = 1; for toast in active.iter().rev().take(4) { let line_count = toast.message.lines().count().max(1) as u16; let h = line_count + 2; let toast_area = Rect { x, y, width: toast_w, height: h, }; if toast_area.bottom() > area.height { break; } // Shadow/Dimming effect background frame.render_widget(Clear, toast_area); let (border_color, icon) = match toast.kind { ToastKind::Success => (Theme::SUCCESS, " ✓ "), ToastKind::Warning => (Theme::WARNING, " ⚠ "), ToastKind::Error => (Theme::ERROR, " ✗ "), ToastKind::Info => (Theme::INFO, " ℹ "), ToastKind::Lesson => (Theme::ACCENT_PURPLE, " 📘 "), }; let block = Block::default() .borders(Borders::ALL) .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(border_color)) .title(Span::styled(icon, Style::default().fg(border_color))) .style(Style::default().bg(Theme::SURFACE_ELEVATED)); let paragraph = Paragraph::new(toast.message.as_str()) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, toast_area); y = y.saturating_add(h).saturating_add(1); } } /// Split `items` into the slice that fits within `max_visible` entries and /// the count of items hidden beyond that limit. pub(crate) fn split_for_display(items: &[T], max_visible: usize) -> (&[T], usize) { if items.len() <= max_visible { (items, 0) } else { (&items[..max_visible], items.len() - max_visible) } } /// Build the dim trailing hint line a sidebar widget shows when its /// content is truncated. pub(crate) fn overflow_hint_line(hidden: usize, command: &str) -> Line<'static> { Line::from(Span::styled( format!(" +{hidden} more — {command}"), Style::default() .fg(Theme::TEXT_DIM) .add_modifier(Modifier::ITALIC), )) }