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