Skip to main content

imp_tui/views/
startup.rs

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 = &sections[..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}