1use ratatui::{
2 Frame,
3 style::{Color, Modifier, Style},
4 text::{Line, Span},
5 widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
6};
7
8use crate::tui::app::{ActivePanel, App, ModelsMode};
9use crate::tui::panel;
10
11mod hints;
12mod overlays;
13mod status;
14
15fn render_scrollbar(f: &mut Frame, area: ratatui::layout::Rect, total_items: usize, scroll_offset: usize) {
16 let scrollbar_area = ratatui::layout::Rect {
17 x: area.right().saturating_sub(1),
18 y: area.top(),
19 width: 1,
20 height: area.height,
21 };
22 let mut scrollbar_state = ScrollbarState::new(total_items).position(scroll_offset);
23 f.render_stateful_widget(
24 Scrollbar::new(ScrollbarOrientation::VerticalRight)
25 .begin_symbol(Some("↑"))
26 .end_symbol(Some("↓")),
27 scrollbar_area,
28 &mut scrollbar_state,
29 );
30}
31
32pub fn render(f: &mut Frame, app: &mut App) {
33 if overlays::render_overlays(f, app) {
34 return;
35 }
36
37 let is_search = matches!(app.models_mode, ModelsMode::Search { .. });
38 let active_model_visible = app.is_panel_visible(4) && !is_search;
39 let log_visible = app.is_panel_visible(5);
40
41 let chunks = if app.log.log_expanded {
42 ratatui::layout::Layout::default()
43 .direction(ratatui::layout::Direction::Vertical)
44 .margin(0)
45 .constraints([
46 ratatui::layout::Constraint::Length(1),
47 ratatui::layout::Constraint::Fill(1),
48 ])
49 .split(f.area())
50 } else {
51 let active_model_constraint = if active_model_visible {
52 ratatui::layout::Constraint::Length(6)
53 } else {
54 ratatui::layout::Constraint::Length(0)
55 };
56
57 let bottom_constraint = if log_visible && !active_model_visible {
58 ratatui::layout::Constraint::Fill(1)
59 } else {
60 let mut h = 0;
61 if log_visible {
62 h += 3;
63 }
64 if app.download.downloading {
65 h += 7;
66 }
67
68 if h > 0 {
69 ratatui::layout::Constraint::Min(h)
70 } else {
71 ratatui::layout::Constraint::Length(0)
72 }
73 };
74
75 ratatui::layout::Layout::default()
76 .direction(ratatui::layout::Direction::Vertical)
77 .margin(0)
78 .constraints([
79 ratatui::layout::Constraint::Length(1),
80 ratatui::layout::Constraint::Fill(3),
81 active_model_constraint,
82 bottom_constraint,
83 ])
84 .split(f.area())
85 };
86
87 let status = status::render_status_bar(app, chunks[0]);
88 f.render_widget(Paragraph::new(status), chunks[0]);
89
90 if app.log.log_expanded {
91 let log_area = chunks[1];
92 panel::log::render(f, log_area, app);
93 return;
94 }
95
96 let top_chunks = if !app.is_panel_visible(1)
97 && !app.is_panel_visible(3)
98 && !matches!(
99 app.ui.active_panel,
100 ActivePanel::Profiles | ActivePanel::SystemPromptPresets | ActivePanel::SearchReadme
101 ) {
102 ratatui::layout::Layout::default()
103 .direction(ratatui::layout::Direction::Horizontal)
104 .constraints([
105 ratatui::layout::Constraint::Percentage(100),
106 ratatui::layout::Constraint::Length(0),
107 ])
108 .split(chunks[1])
109 } else {
110 let left_pct = app.ui.left_pct.clamp(20, 80);
111 ratatui::layout::Layout::default()
112 .direction(ratatui::layout::Direction::Horizontal)
113 .constraints([
114 ratatui::layout::Constraint::Fill(left_pct),
115 ratatui::layout::Constraint::Fill(100 - left_pct),
116 ])
117 .split(chunks[1])
118 };
119
120 let info_visible = app.is_panel_visible(2);
121 let (left_chunks, info_lines) = if info_visible {
122 let lines = panel::tabbed::get_info_lines(app, top_chunks[0].width);
123 let info_height = (lines.len() as u16 + 2).max(3);
124 let chunks = ratatui::layout::Layout::default()
125 .direction(ratatui::layout::Direction::Vertical)
126 .constraints([
127 ratatui::layout::Constraint::Min(5),
128 ratatui::layout::Constraint::Length(info_height),
129 ])
130 .split(top_chunks[0]);
131 (chunks, Some(lines))
132 } else {
133 let chunks = ratatui::layout::Layout::default()
134 .direction(ratatui::layout::Direction::Vertical)
135 .constraints([ratatui::layout::Constraint::Fill(1)])
136 .split(top_chunks[0]);
137 (chunks, None)
138 };
139
140 panel::models::render(f, left_chunks[0], app);
141
142 if let Some(lines) = info_lines {
143 panel::tabbed::render_info_with_lines(f, left_chunks[1], lines);
144 }
145
146 match app.ui.active_panel {
147 ActivePanel::Profiles => {
148 let all_profiles = app.config.merged_profiles();
149 let (profile_lines, count) = panel::profiles::render_all(
150 &all_profiles,
151 app.settings_state.settings_selected_idx,
152 &app.settings,
153 );
154 if app.settings_state.settings_selected_idx >= count {
155 app.settings_state.settings_selected_idx = count.saturating_sub(1);
156 }
157
158 let area = top_chunks[1];
159 let available_height = area.height.saturating_sub(2);
160
161 let max_offset = profile_lines
162 .len()
163 .saturating_sub(available_height as usize) as u16;
164 if app.picker.profiles_scroll_offset > max_offset.into() {
165 app.picker.profiles_scroll_offset = max_offset.into();
166 }
167
168 let start_idx = app.picker.profiles_scroll_offset;
169 let visible_lines: Vec<Line> = profile_lines
170 .iter()
171 .skip(start_idx)
172 .take(available_height as usize)
173 .cloned()
174 .collect();
175
176 let block = Block::default()
177 .title(" Profiles ")
178 .borders(Borders::ALL)
179 .border_style(Style::default().fg(Color::Yellow));
180 let paragraph = Paragraph::new(visible_lines).block(block);
181 f.render_widget(paragraph, area);
182
183 if profile_lines.len() > available_height as usize {
184 render_scrollbar(f, area, profile_lines.len(), app.picker.profiles_scroll_offset);
185 }
186 }
187 ActivePanel::SystemPromptPresets => {
188 let presets = app.config.merged_presets();
189 let preset_lines = panel::system_prompt_presets::render_all(
190 &presets,
191 app.settings_state.settings_selected_idx,
192 app.edit.editing_preset.is_some(),
193 &app.settings_state.settings_edit_buffer,
194 app.edit.edit_cursor_pos,
195 );
196
197 let area = top_chunks[1];
198 let available_height = area.height.saturating_sub(2);
199
200 let max_offset = preset_lines.len().saturating_sub(available_height as usize) as u16;
201 if app.picker.system_prompt_presets_scroll_offset > max_offset.into() {
202 app.picker.system_prompt_presets_scroll_offset = max_offset.into();
203 }
204
205 let start_idx = app.picker.system_prompt_presets_scroll_offset;
206 let visible_lines: Vec<Line> = preset_lines
207 .iter()
208 .skip(start_idx)
209 .take(available_height as usize)
210 .cloned()
211 .collect();
212
213 let block = Block::default()
214 .title(" System Prompt Presets ")
215 .borders(Borders::ALL)
216 .border_style(Style::default().fg(Color::Yellow));
217 let paragraph = Paragraph::new(visible_lines).block(block);
218 f.render_widget(paragraph, area);
219
220 if preset_lines.len() > available_height as usize {
221 render_scrollbar(f, area, preset_lines.len(), app.picker.system_prompt_presets_scroll_offset);
222 }
223 }
224 _ => {
225 let show_readme = match &app.models_mode {
226 ModelsMode::Search { show_readme, .. } => *show_readme,
227 ModelsMode::Files { .. } => true,
228 _ => false,
229 };
230 if show_readme {
231 panel::readme::render(f, top_chunks[1], app);
232 } else {
233 let server_visible = app.is_panel_visible(1);
234 let llm_visible = app.is_panel_visible(3);
235 if server_visible && llm_visible {
236 panel::tabbed::render_settings_only(f, top_chunks[1], app);
237 } else if server_visible {
238 panel::tabbed::render_server_only(f, top_chunks[1], app);
239 } else if llm_visible {
240 panel::tabbed::render_llm_only(f, top_chunks[1], app);
241 }
242 }
243 }
244 }
245
246 if active_model_visible {
247 panel::active::render(f, chunks[2], app);
248 }
249
250 let bottom_area = chunks[3];
251 if log_visible && app.download.downloading {
252 let bottom_chunks = ratatui::layout::Layout::default()
253 .direction(ratatui::layout::Direction::Vertical)
254 .constraints([
255 ratatui::layout::Constraint::Fill(1),
256 ratatui::layout::Constraint::Length(7),
257 ])
258 .split(bottom_area);
259
260 let downloads_focused = app.ui.active_panel == ActivePanel::Downloads;
261
262 panel::log::render(f, bottom_chunks[0], app);
263
264 let total_speed: f64 = app
265 .download
266 .download_progress
267 .iter()
268 .map(|d| d.bytes_per_second)
269 .sum();
270 panel::models::render_download_panel(
271 f,
272 bottom_chunks[1],
273 &app.download.download_progress,
274 total_speed,
275 &mut app.download.download_scroll_state,
276 downloads_focused,
277 );
278 } else if log_visible {
279 panel::log::render(f, bottom_area, app);
280 } else if app.download.downloading {
281 let total_speed: f64 = app
282 .download
283 .download_progress
284 .iter()
285 .map(|d| d.bytes_per_second)
286 .sum();
287 let downloads_focused = app.ui.active_panel == ActivePanel::Downloads;
288 panel::models::render_download_panel(
289 f,
290 bottom_area,
291 &app.download.download_progress,
292 total_speed,
293 &mut app.download.download_scroll_state,
294 downloads_focused,
295 );
296 }
297}