1use ratatui::buffer::Buffer;
2use ratatui::layout::{Constraint, Direction, Layout, Rect};
3use ratatui::style::{Modifier, Style};
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{Block, Borders, Paragraph, Widget, Wrap};
6
7use crate::theme::Theme;
8
9#[derive(Debug, Clone, Default)]
10pub struct StartupAction {
11 pub trigger: String,
12 pub label: String,
13 pub description: String,
14}
15
16#[derive(Debug, Clone, Default)]
17pub struct StartupSection {
18 pub title: String,
19 pub lines: Vec<String>,
20}
21
22#[derive(Debug, Clone, Default)]
23pub struct StartupPanelData {
24 pub actions: Vec<StartupAction>,
25 pub sections: Vec<StartupSection>,
26}
27
28pub struct StartupPanelView<'a> {
29 data: &'a StartupPanelData,
30 theme: &'a Theme,
31}
32
33impl<'a> StartupPanelView<'a> {
34 pub fn new(data: &'a StartupPanelData, theme: &'a Theme) -> Self {
35 Self { data, theme }
36 }
37}
38
39impl Widget for StartupPanelView<'_> {
40 fn render(self, area: Rect, buf: &mut Buffer) {
41 if area.width < 24 || area.height < 8 {
42 return;
43 }
44
45 let outer = Block::default()
46 .title(Line::from(Span::styled(" imp ", self.theme.accent_style())))
47 .borders(Borders::ALL)
48 .border_style(self.theme.border_style());
49 let inner = outer.inner(area);
50 outer.render(area, buf);
51
52 if inner.height < 12 {
53 let chunks = Layout::default()
54 .direction(Direction::Vertical)
55 .constraints([Constraint::Length(3), Constraint::Min(3)])
56 .split(inner);
57 render_actions(chunks[0], buf, self.theme, &self.data.actions);
58 render_sections(chunks[1], buf, self.theme, &self.data.sections);
59 return;
60 }
61
62 let actions_height = action_block_height(inner.width, self.data.actions.len());
63
64 let chunks = Layout::default()
65 .direction(Direction::Vertical)
66 .constraints([Constraint::Length(actions_height), Constraint::Min(6)])
67 .split(inner);
68
69 render_actions(chunks[0], buf, self.theme, &self.data.actions);
70 render_sections(chunks[1], buf, self.theme, &self.data.sections);
71 }
72}
73
74fn render_actions(area: Rect, buf: &mut Buffer, theme: &Theme, actions: &[StartupAction]) {
75 if area.height < 3 || area.width < 18 || actions.is_empty() {
76 return;
77 }
78
79 let block = Block::default()
80 .title(Line::from(Span::styled(
81 " common actions ",
82 theme.header_style(),
83 )))
84 .borders(Borders::ALL)
85 .border_style(theme.accent_style());
86 let inner = block.inner(area);
87 block.render(area, buf);
88
89 if inner.height == 0 || inner.width == 0 {
90 return;
91 }
92
93 if inner.width >= 96 && actions.len() >= 4 {
94 let columns = Layout::default()
95 .direction(Direction::Horizontal)
96 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
97 .split(inner);
98 let mid = actions.len().div_ceil(2);
99 render_action_lines(columns[0], buf, theme, &actions[..mid]);
100 render_action_lines(columns[1], buf, theme, &actions[mid..]);
101 return;
102 }
103
104 render_action_lines(inner, buf, theme, actions);
105}
106
107fn render_action_lines(area: Rect, buf: &mut Buffer, theme: &Theme, actions: &[StartupAction]) {
108 let lines = actions
109 .iter()
110 .map(|action| {
111 Line::from(vec![
112 Span::styled(
113 format!(" {:<11}", action.trigger),
114 theme.accent_style().add_modifier(Modifier::BOLD),
115 ),
116 Span::styled(
117 action.label.clone(),
118 Style::default().add_modifier(Modifier::BOLD),
119 ),
120 Span::styled(format!(" {}", action.description), theme.muted_style()),
121 ])
122 })
123 .collect::<Vec<_>>();
124
125 Paragraph::new(lines)
126 .wrap(Wrap { trim: false })
127 .render(area, buf);
128}
129
130fn render_sections(area: Rect, buf: &mut Buffer, theme: &Theme, sections: &[StartupSection]) {
131 if sections.is_empty() || area.height == 0 || area.width == 0 {
132 return;
133 }
134
135 let visible_count = visible_section_count(area.width, area.height, sections.len());
136 let visible_sections = §ions[..visible_count];
137
138 if area.width >= 96 {
139 let columns = Layout::default()
140 .direction(Direction::Horizontal)
141 .constraints([
142 Constraint::Percentage(25),
143 Constraint::Percentage(25),
144 Constraint::Percentage(25),
145 Constraint::Percentage(25),
146 ])
147 .split(area);
148 for (section, rect) in visible_sections.iter().zip(columns.iter().copied()) {
149 render_section(rect, buf, theme, section);
150 }
151 return;
152 }
153
154 match visible_sections.len() {
155 0 => {}
156 1 => render_section(area, buf, theme, &visible_sections[0]),
157 2 => {
158 let chunks = if area.width >= 90 {
159 Layout::default()
160 .direction(Direction::Horizontal)
161 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
162 .split(area)
163 } else {
164 Layout::default()
165 .direction(Direction::Vertical)
166 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
167 .split(area)
168 };
169 render_section(chunks[0], buf, theme, &visible_sections[0]);
170 render_section(chunks[1], buf, theme, &visible_sections[1]);
171 }
172 3 => {
173 if area.width >= 120 {
174 let chunks = Layout::default()
175 .direction(Direction::Horizontal)
176 .constraints([
177 Constraint::Percentage(33),
178 Constraint::Percentage(34),
179 Constraint::Percentage(33),
180 ])
181 .split(area);
182 for (section, rect) in visible_sections.iter().zip(chunks.iter().copied()) {
183 render_section(rect, buf, theme, section);
184 }
185 } else if area.width >= 78 && area.height >= 12 {
186 let rows = Layout::default()
187 .direction(Direction::Vertical)
188 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
189 .split(area);
190 let top = Layout::default()
191 .direction(Direction::Horizontal)
192 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
193 .split(rows[0]);
194 render_section(top[0], buf, theme, &visible_sections[0]);
195 render_section(top[1], buf, theme, &visible_sections[1]);
196 render_section(rows[1], buf, theme, &visible_sections[2]);
197 } else {
198 let chunks = Layout::default()
199 .direction(Direction::Vertical)
200 .constraints([
201 Constraint::Percentage(34),
202 Constraint::Percentage(33),
203 Constraint::Percentage(33),
204 ])
205 .split(area);
206 for (section, rect) in visible_sections.iter().zip(chunks.iter().copied()) {
207 render_section(rect, buf, theme, section);
208 }
209 }
210 }
211 _ => {
212 let constraints =
213 vec![
214 Constraint::Length((area.height / visible_sections.len() as u16).max(3));
215 visible_sections.len()
216 ];
217 let rows = Layout::default()
218 .direction(Direction::Vertical)
219 .constraints(constraints)
220 .split(area);
221 for (section, rect) in visible_sections.iter().zip(rows.iter().copied()) {
222 render_section(rect, buf, theme, section);
223 }
224 }
225 }
226}
227
228fn render_section(area: Rect, buf: &mut Buffer, theme: &Theme, section: &StartupSection) {
229 if area.height < 3 || area.width < 12 {
230 return;
231 }
232
233 let block = Block::default()
234 .title(Line::from(Span::styled(
235 format!(" {} ", section.title),
236 theme.header_style(),
237 )))
238 .borders(Borders::ALL)
239 .border_style(theme.border_style());
240 let inner = block.inner(area);
241 block.render(area, buf);
242
243 let lines = if section.lines.is_empty() {
244 vec![Line::from(Span::styled("none", theme.muted_style()))]
245 } else {
246 section
247 .lines
248 .iter()
249 .map(|line| render_section_line(line, theme))
250 .collect()
251 };
252
253 Paragraph::new(lines)
254 .wrap(Wrap { trim: false })
255 .render(inner, buf);
256}
257
258fn render_section_line(line: &str, theme: &Theme) -> Line<'static> {
259 if let Some(rest) = line.strip_prefix("• ") {
260 if let Some((label, value)) = rest.split_once(':') {
261 return Line::from(vec![
262 Span::styled("• ", theme.accent_style()),
263 Span::styled(format!("{label}:"), theme.muted_style()),
264 Span::raw(value.to_string()),
265 ]);
266 }
267
268 return Line::from(vec![
269 Span::styled("• ", theme.accent_style()),
270 Span::raw(rest.to_string()),
271 ]);
272 }
273
274 Line::from(Span::styled(line.to_string(), theme.muted_style()))
275}
276
277fn action_block_height(width: u16, action_count: usize) -> u16 {
278 if action_count == 0 {
279 return 0;
280 }
281
282 if width >= 96 && action_count >= 4 {
283 4
284 } else {
285 (action_count as u16 + 2).clamp(4, 8)
286 }
287}
288
289fn visible_section_count(width: u16, height: u16, total: usize) -> usize {
290 if total == 0 {
291 return 0;
292 }
293
294 if width < 48 || height < 10 {
295 total.min(1)
296 } else if width < 72 || height < 16 {
297 total.min(2)
298 } else if width < 110 || height < 22 {
299 total.min(3)
300 } else {
301 total.min(4)
302 }
303}
304
305pub fn summarize_lines(lines: Vec<String>, max_items: usize) -> Vec<String> {
306 if lines.len() <= max_items {
307 return lines;
308 }
309
310 let hidden = lines.len() - max_items;
311 let mut visible = lines.into_iter().take(max_items).collect::<Vec<_>>();
312 visible.push(format!("… +{hidden} more"));
313 visible
314}
315
316pub fn summarize_inline(items: Vec<String>, max_items: usize) -> String {
317 if items.is_empty() {
318 return "none".to_string();
319 }
320
321 if items.len() <= max_items {
322 return items.join(", ");
323 }
324
325 let hidden = items.len() - max_items;
326 let visible = items.into_iter().take(max_items).collect::<Vec<_>>();
327 format!("{} … +{hidden} more", visible.join(", "))
328}
329
330pub fn truncate_preview(text: &str, max_lines: usize, max_chars: usize) -> String {
331 if max_lines == 0 || max_chars == 0 || text.is_empty() {
332 return String::new();
333 }
334
335 let mut lines = Vec::new();
336 let mut used_chars = 0usize;
337 let mut truncated = false;
338
339 for line in text.lines() {
340 let next_len = line.chars().count() + usize::from(!lines.is_empty());
341 if lines.len() >= max_lines || used_chars + next_len > max_chars {
342 truncated = true;
343 break;
344 }
345 used_chars += next_len;
346 lines.push(line.to_string());
347 }
348
349 let mut preview = lines.join("\n");
350 if truncated {
351 if !preview.is_empty() {
352 preview.push_str("\n");
353 }
354 preview.push_str("[… truncated preview]");
355 }
356 preview
357}
358
359#[cfg(test)]
360mod tests {
361 use super::{summarize_inline, summarize_lines, truncate_preview, visible_section_count};
362
363 #[test]
364 fn summarize_lines_appends_hidden_count() {
365 let lines = vec![
366 "one".to_string(),
367 "two".to_string(),
368 "three".to_string(),
369 "four".to_string(),
370 ];
371
372 let summarized = summarize_lines(lines, 2);
373 assert_eq!(summarized, vec!["one", "two", "… +2 more"]);
374 }
375
376 #[test]
377 fn summarize_inline_compacts_items() {
378 let text = summarize_inline(
379 vec!["ask".into(), "bash".into(), "read".into(), "edit".into()],
380 2,
381 );
382 assert_eq!(text, "ask, bash … +2 more");
383 }
384
385 #[test]
386 fn truncate_preview_marks_truncation() {
387 let text = "a\nb\nc\nd";
388 let preview = truncate_preview(text, 2, 32);
389 assert_eq!(preview, "a\nb\n[… truncated preview]");
390 }
391
392 #[test]
393 fn narrow_layout_prioritizes_fewer_sections() {
394 assert_eq!(visible_section_count(44, 20, 4), 1);
395 assert_eq!(visible_section_count(68, 14, 4), 2);
396 assert_eq!(visible_section_count(100, 20, 4), 3);
397 assert_eq!(visible_section_count(120, 24, 4), 4);
398 }
399}