Files
zesdex/apps/interfaces/tui/src/view/mod.rs
T

164 lines
5.3 KiB
Rust
Raw Normal View History

//! 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::default();
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::default();
chat_comp.draw(frame, chat_area, state);
}
let mut input_comp = input::InputComponent::default();
input_comp.draw(frame, input_area, state);
let mut status_comp = status::StatusBarComponent::default();
status_comp.draw(frame, status_area, state);
if let Some(sidebar_rect) = sidebar_area {
let mut sidebar_comp = sidebar::SidebarComponent::default();
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<T>(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),
))
}