1pub mod cards;
9pub mod chat;
10pub mod overlay;
11pub mod rail;
12pub mod status_bar;
13pub mod stream;
14
15use ratatui::layout::{Constraint, Direction, Layout, Rect};
16use ratatui::style::Style;
17use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
18use ratatui::Frame;
19
20use crate::tui::app::DashboardApp;
21use crate::tui::rail::Tone;
22
23pub const MIN_WIDTH: u16 = 40;
27pub const MIN_HEIGHT: u16 = 24;
28
29pub const TWO_COLUMN_WIDTH: u16 = 80;
31
32const STACKED_CANVAS_PERCENT: u16 = 55;
35const STACKED_STREAM_MIN: u16 = 6;
36
37const LEFT_MIN: u16 = 38;
40const LEFT_MAX: u16 = 76;
41const LEFT_PERCENT: u16 = 48;
42const RIGHT_MIN: u16 = 36;
43
44pub fn draw(frame: &mut Frame, app: &mut DashboardApp) {
45 app.hits.clear();
46 let area = frame.area();
47
48 if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
49 let notice = Paragraph::new(format!(
50 "Terminal too small: {}x{} (need {}x{}). Resize the window.",
51 area.width, area.height, MIN_WIDTH, MIN_HEIGHT
52 ))
53 .style(app.styles.warning);
54 frame.render_widget(notice, area);
55 return;
56 }
57
58 let rows = Layout::default()
59 .direction(Direction::Vertical)
60 .constraints([Constraint::Min(1), Constraint::Length(1)])
61 .split(area);
62 let body = rows[0];
63 let status = rows[1];
64
65 let (left, right) = if body.width >= TWO_COLUMN_WIDTH {
66 let left_width = ((body.width as u32 * LEFT_PERCENT as u32) / 100) as u16;
67 let left_width = left_width
68 .clamp(LEFT_MIN, LEFT_MAX)
69 .min(body.width.saturating_sub(RIGHT_MIN));
70 let columns = Layout::default()
71 .direction(Direction::Horizontal)
72 .constraints([Constraint::Length(left_width), Constraint::Min(RIGHT_MIN)])
73 .split(body);
74 (columns[0], columns[1])
75 } else {
76 let canvas_height = ((body.height as u32 * STACKED_CANVAS_PERCENT as u32) / 100) as u16;
77 let canvas_height = canvas_height.min(
78 body.height
79 .saturating_sub(STACKED_STREAM_MIN + chat::input_height(app)),
80 );
81 let stack = Layout::default()
82 .direction(Direction::Vertical)
83 .constraints([Constraint::Length(canvas_height), Constraint::Min(1)])
84 .split(body);
85 (stack[0], stack[1])
86 };
87
88 cards::draw(frame, app, left);
89
90 let input_height = chat::input_height(app);
91 let right_rows = Layout::default()
92 .direction(Direction::Vertical)
93 .constraints([Constraint::Min(4), Constraint::Length(input_height)])
94 .split(right);
95 stream::draw(frame, app, right_rows[0]);
96 chat::draw_input(frame, app, right_rows[1]);
97
98 status_bar::draw(frame, app, status);
99
100 if app.modal().is_some() {
101 overlay::draw(frame, app, area);
102 }
103}
104
105pub fn pane_block<'a>(app: &DashboardApp, focused: bool) -> Block<'a> {
107 Block::default()
108 .borders(Borders::ALL)
109 .border_type(BorderType::Rounded)
110 .border_style(if focused {
111 app.styles.accent
112 } else {
113 app.styles.separator
114 })
115}
116
117pub fn tone_style(app: &DashboardApp, tone: Tone) -> Style {
119 match tone {
120 Tone::Normal => app.styles.normal,
121 Tone::Dim => app.styles.dimmed,
122 Tone::Good => app.styles.success,
123 Tone::Warn => app.styles.warning,
124 Tone::Bad => app.styles.error,
125 Tone::Accent => app.styles.info,
126 Tone::Server => app.styles.server,
127 Tone::Client => app.styles.client,
128 Tone::Title => app.styles.title,
129 Tone::Reasoning => app.styles.reasoning,
130 }
131}
132
133pub fn centered(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
135 centered_capped(area, percent_x, percent_y, u16::MAX, u16::MAX)
136}
137
138pub fn centered_capped(
140 area: Rect,
141 percent_x: u16,
142 percent_y: u16,
143 max_cols: u16,
144 max_rows: u16,
145) -> Rect {
146 let width = (area.width * percent_x / 100)
147 .min(max_cols)
148 .max(24)
149 .min(area.width);
150 let height = (area.height * percent_y / 100)
151 .min(max_rows)
152 .max(5)
153 .min(area.height);
154 Rect {
155 x: area.x + (area.width.saturating_sub(width)) / 2,
156 y: area.y + (area.height.saturating_sub(height)) / 2,
157 width,
158 height,
159 }
160}