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