Skip to main content

slt/context/widgets_display/
status.rs

1use super::*;
2
3impl Context {
4    /// Render an alert banner with icon and level-based coloring.
5    pub fn alert(&mut self, message: &str, level: crate::widgets::AlertLevel) -> Response {
6        use crate::widgets::AlertLevel;
7
8        let theme = self.theme;
9        let (icon, color) = match level {
10            AlertLevel::Info => ("ℹ", theme.accent),
11            AlertLevel::Success => ("✓", theme.success),
12            AlertLevel::Warning => ("⚠", theme.warning),
13            AlertLevel::Error => ("✕", theme.error),
14        };
15
16        let focused = self.register_focusable();
17        let key_dismiss = if focused {
18            let consumed: Vec<usize> = self
19                .available_key_presses()
20                .filter_map(|(i, key)| {
21                    if matches!(key.code, KeyCode::Enter | KeyCode::Char('x')) {
22                        Some(i)
23                    } else {
24                        None
25                    }
26                })
27                .collect();
28            let dismissed = !consumed.is_empty();
29            self.consume_indices(consumed);
30            dismissed
31        } else {
32            false
33        };
34
35        let mut response = self.container().col(|ui| {
36            ui.line(|ui| {
37                let mut icon_text = String::with_capacity(icon.len() + 2);
38                icon_text.push(' ');
39                icon_text.push_str(icon);
40                icon_text.push(' ');
41                ui.text(icon_text).fg(color).bold();
42                ui.text(message).grow(1);
43                ui.text(" [×] ").dim();
44            });
45        });
46        response.focused = focused;
47        if key_dismiss {
48            response.clicked = true;
49        }
50
51        response
52    }
53
54    /// Yes/No confirmation dialog. Returns Response with .clicked=true when answered.
55    ///
56    /// `result` is set to true for Yes, false for No.
57    ///
58    /// # Examples
59    /// ```
60    /// # use slt::*;
61    /// # TestBackend::new(80, 24).render(|ui| {
62    /// let mut answer = false;
63    /// let r = ui.confirm("Delete this file?", &mut answer);
64    /// if r.clicked && answer { /* user confirmed */ }
65    /// # });
66    /// ```
67    pub fn confirm(&mut self, question: &str, result: &mut bool) -> Response {
68        let focused = self.register_focusable();
69        let mut is_yes = *result;
70        let mut clicked = false;
71
72        if focused {
73            let mut consumed_indices = Vec::new();
74            for (i, key) in self.available_key_presses() {
75                match key.code {
76                    KeyCode::Char('y') => {
77                        is_yes = true;
78                        *result = true;
79                        clicked = true;
80                        consumed_indices.push(i);
81                    }
82                    KeyCode::Char('n') => {
83                        is_yes = false;
84                        *result = false;
85                        clicked = true;
86                        consumed_indices.push(i);
87                    }
88                    KeyCode::Tab | KeyCode::BackTab | KeyCode::Left | KeyCode::Right => {
89                        is_yes = !is_yes;
90                        *result = is_yes;
91                        consumed_indices.push(i);
92                    }
93                    KeyCode::Enter => {
94                        *result = is_yes;
95                        clicked = true;
96                        consumed_indices.push(i);
97                    }
98                    _ => {}
99                }
100            }
101            self.consume_indices(consumed_indices);
102        }
103
104        let yes_style = if is_yes {
105            if focused {
106                Style::new().fg(self.theme.bg).bg(self.theme.success).bold()
107            } else {
108                Style::new().fg(self.theme.success).bold()
109            }
110        } else {
111            Style::new().fg(self.theme.text_dim)
112        };
113        let no_style = if !is_yes {
114            if focused {
115                Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
116            } else {
117                Style::new().fg(self.theme.error).bold()
118            }
119        } else {
120            Style::new().fg(self.theme.text_dim)
121        };
122
123        let q_width = UnicodeWidthStr::width(question) as u32;
124        let mut response = self.row(|ui| {
125            ui.text(question);
126            ui.text(" ");
127            ui.styled("[Yes]", yes_style);
128            ui.text(" ");
129            ui.styled("[No]", no_style);
130        });
131
132        if !clicked {
133            if let Some((mx, my)) = self.click_pos {
134                // Hit-test against the row's recorded rect when available.
135                // On the first frame the row has no entry in `prev_hit_map`
136                // yet, so `response.rect` is the zero rect. In that case we
137                // fall back to assuming the row starts at (0, 0): callers
138                // testing confirm() on the first frame still get correct
139                // x-axis hit-testing because the column layout begins at
140                // x=0 by default.
141                let row_x = response.rect.x;
142                let in_row_y = response.rect.height == 0
143                    || (my >= response.rect.y && my < response.rect.bottom());
144                if in_row_y {
145                    let yes_start = row_x + q_width + 1;
146                    let yes_end = yes_start + 5;
147                    let no_start = yes_end + 1;
148                    let no_end = no_start + 4; // "[No]" = 4 display columns
149                    if mx >= yes_start && mx < yes_end {
150                        is_yes = true;
151                        *result = true;
152                        clicked = true;
153                    } else if mx >= no_start && mx < no_end {
154                        is_yes = false;
155                        *result = false;
156                        clicked = true;
157                    }
158                }
159            }
160        }
161
162        response.focused = focused;
163        response.clicked = clicked;
164        response.changed = clicked;
165        let _ = is_yes;
166        response
167    }
168
169    /// Render a breadcrumb navigation bar. Returns the clicked segment index.
170    pub fn breadcrumb(&mut self, segments: &[&str]) -> Option<usize> {
171        self.breadcrumb_with(segments, " › ")
172    }
173
174    /// Render a breadcrumb with a custom separator string.
175    pub fn breadcrumb_with(&mut self, segments: &[&str], separator: &str) -> Option<usize> {
176        let theme = self.theme;
177        let last_idx = segments.len().saturating_sub(1);
178        let mut clicked_idx: Option<usize> = None;
179
180        let _ = self.row(|ui| {
181            for (i, segment) in segments.iter().enumerate() {
182                let is_last = i == last_idx;
183                if is_last {
184                    ui.text(*segment).bold();
185                } else {
186                    let focused = ui.register_focusable();
187                    let resp = ui.interaction();
188                    let activated = resp.clicked || ui.consume_activation_keys(focused);
189                    let color = if resp.hovered || focused {
190                        theme.accent
191                    } else {
192                        theme.primary
193                    };
194                    ui.text(*segment).fg(color).underline();
195                    if activated {
196                        clicked_idx = Some(i);
197                    }
198                    ui.text(separator).dim();
199                }
200            }
201        });
202
203        clicked_idx
204    }
205
206    /// Collapsible section that toggles on click, Enter, or Space.
207    pub fn accordion(
208        &mut self,
209        title: &str,
210        open: &mut bool,
211        f: impl FnOnce(&mut Context),
212    ) -> Response {
213        let theme = self.theme;
214        let focused = self.register_focusable();
215        let old_open = *open;
216        let toggled_from_key = self.consume_activation_keys(focused);
217        if toggled_from_key {
218            *open = !*open;
219        }
220
221        let icon = if *open { "▾" } else { "▸" };
222        let title_color = if focused { theme.primary } else { theme.text };
223
224        let mut response = self.container().col(|ui| {
225            ui.line(|ui| {
226                ui.text(icon).fg(title_color);
227                let mut title_text = String::with_capacity(1 + title.len());
228                title_text.push(' ');
229                title_text.push_str(title);
230                ui.text(title_text).bold().fg(title_color);
231            });
232        });
233
234        if response.clicked {
235            *open = !*open;
236        }
237
238        if *open {
239            let _ = self.container().pl(2).col(f);
240        }
241
242        response.focused = focused;
243        response.changed = *open != old_open;
244        response
245    }
246
247    /// Render a key-value definition list with aligned columns.
248    pub fn definition_list(&mut self, items: &[(&str, &str)]) -> Response {
249        let max_key_width = items
250            .iter()
251            .map(|(k, _)| UnicodeWidthStr::width(*k))
252            .max()
253            .unwrap_or(0);
254
255        let _ = self.col(|ui| {
256            for (key, value) in items {
257                ui.line(|ui| {
258                    let key_display_w = UnicodeWidthStr::width(*key);
259                    let pad = max_key_width.saturating_sub(key_display_w);
260                    let mut padded = String::with_capacity(key.len() + pad);
261                    padded.extend(std::iter::repeat(' ').take(pad));
262                    padded.push_str(key);
263                    ui.text(padded).dim();
264                    ui.text("  ");
265                    ui.text(*value);
266                });
267            }
268        });
269
270        Response::none()
271    }
272
273    /// Render a horizontal divider with a centered text label.
274    pub fn divider_text(&mut self, label: &str) -> Response {
275        let w = self.width();
276        let label_len = UnicodeWidthStr::width(label) as u32;
277        // Reserve `label_len + 2` for the label and its single-space padding on
278        // each side, then split the remaining width evenly. On odd widths the
279        // right separator is one cell longer (no asymmetry that's visible).
280        let total_separator = w.saturating_sub(label_len + 2);
281        let left_len = total_separator / 2;
282        let right_len = total_separator - left_len;
283        let left: String = "─".repeat(left_len as usize);
284        let right: String = "─".repeat(right_len as usize);
285        let theme = self.theme;
286        self.line(|ui| {
287            ui.text(&left).fg(theme.border);
288            let mut label_text = String::with_capacity(label.len() + 2);
289            label_text.push(' ');
290            label_text.push_str(label);
291            label_text.push(' ');
292            ui.text(label_text).fg(theme.text);
293            ui.text(&right).fg(theme.border);
294        });
295
296        Response::none()
297    }
298
299    /// Render a badge with the theme's primary color.
300    pub fn badge(&mut self, label: &str) -> Response {
301        let theme = self.theme;
302        self.badge_colored(label, theme.primary)
303    }
304
305    /// Render a badge with a custom background color.
306    pub fn badge_colored(&mut self, label: &str, color: Color) -> Response {
307        let fg = Color::contrast_fg(color);
308        let mut label_text = String::with_capacity(label.len() + 2);
309        label_text.push(' ');
310        label_text.push_str(label);
311        label_text.push(' ');
312        self.text(label_text).fg(fg).bg(color);
313
314        Response::none()
315    }
316
317    /// Render a keyboard shortcut hint with reversed styling.
318    pub fn key_hint(&mut self, key: &str) -> Response {
319        let theme = self.theme;
320        let mut key_text = String::with_capacity(key.len() + 2);
321        key_text.push(' ');
322        key_text.push_str(key);
323        key_text.push(' ');
324        self.text(key_text).reversed().fg(theme.text_dim);
325
326        Response::none()
327    }
328
329    /// Render a label-value stat pair.
330    pub fn stat(&mut self, label: &str, value: &str) -> Response {
331        let _ = self.col(|ui| {
332            ui.text(label).dim();
333            ui.text(value).bold();
334        });
335
336        Response::none()
337    }
338
339    /// Render a stat pair with a custom value color.
340    pub fn stat_colored(&mut self, label: &str, value: &str, color: Color) -> Response {
341        let _ = self.col(|ui| {
342            ui.text(label).dim();
343            ui.text(value).bold().fg(color);
344        });
345
346        Response::none()
347    }
348
349    /// Render a stat pair with an up/down trend arrow.
350    pub fn stat_trend(
351        &mut self,
352        label: &str,
353        value: &str,
354        trend: crate::widgets::Trend,
355    ) -> Response {
356        let theme = self.theme;
357        let (arrow, color) = match trend {
358            crate::widgets::Trend::Up => ("↑", theme.success),
359            crate::widgets::Trend::Down => ("↓", theme.error),
360        };
361        let _ = self.col(|ui| {
362            ui.text(label).dim();
363            ui.line(|ui| {
364                ui.text(value).bold();
365                let mut arrow_text = String::with_capacity(1 + arrow.len());
366                arrow_text.push(' ');
367                arrow_text.push_str(arrow);
368                ui.text(arrow_text).fg(color);
369            });
370        });
371
372        Response::none()
373    }
374
375    /// Render a centered empty-state placeholder.
376    pub fn empty_state(&mut self, title: &str, description: &str) -> Response {
377        let _ = self.container().center().col(|ui| {
378            ui.text(title).align(Align::Center);
379            ui.text(description).dim().align(Align::Center);
380        });
381
382        Response::none()
383    }
384
385    /// Render a centered empty-state placeholder with an action button.
386    pub fn empty_state_action(
387        &mut self,
388        title: &str,
389        description: &str,
390        action_label: &str,
391    ) -> Response {
392        let mut clicked = false;
393        let _ = self.container().center().col(|ui| {
394            ui.text(title).align(Align::Center);
395            ui.text(description).dim().align(Align::Center);
396            if ui.button(action_label).clicked {
397                clicked = true;
398            }
399        });
400
401        Response {
402            clicked,
403            changed: clicked,
404            ..Response::none()
405        }
406    }
407
408    /// Render a code block with keyword-based syntax highlighting.
409    pub fn code_block(&mut self, code: &str) -> Response {
410        self.code_block_lang(code, "")
411    }
412
413    /// Render a code block with language-aware syntax highlighting.
414    pub fn code_block_lang(&mut self, code: &str, lang: &str) -> Response {
415        let theme = self.theme;
416        let highlighted: Option<Vec<Vec<(String, Style)>>> =
417            crate::syntax::highlight_code(code, lang, &theme);
418        let _ = self
419            .bordered(Border::Rounded)
420            .bg(theme.surface)
421            .pad(1)
422            .col(|ui| {
423                if let Some(ref lines) = highlighted {
424                    render_tree_sitter_lines(ui, lines);
425                } else {
426                    for line in code.lines() {
427                        render_highlighted_line(ui, line);
428                    }
429                }
430            });
431
432        Response::none()
433    }
434
435    /// Render a code block with line numbers and keyword highlighting.
436    pub fn code_block_numbered(&mut self, code: &str) -> Response {
437        self.code_block_numbered_lang(code, "")
438    }
439
440    /// Render a code block with line numbers and language-aware highlighting.
441    pub fn code_block_numbered_lang(&mut self, code: &str, lang: &str) -> Response {
442        let lines: Vec<&str> = code.lines().collect();
443        let gutter_w = (lines.len().max(1).ilog10() + 1) as usize;
444        let theme = self.theme;
445        let highlighted: Option<Vec<Vec<(String, Style)>>> =
446            crate::syntax::highlight_code(code, lang, &theme);
447        let _ = self
448            .bordered(Border::Rounded)
449            .bg(theme.surface)
450            .pad(1)
451            .col(|ui| {
452                if let Some(ref hl_lines) = highlighted {
453                    for (i, segs) in hl_lines.iter().enumerate() {
454                        ui.line(|ui| {
455                            ui.text(format!("{:>gutter_w$} │ ", i + 1))
456                                .fg(theme.text_dim);
457                            for (text, style) in segs {
458                                ui.styled(text, *style);
459                            }
460                        });
461                    }
462                } else {
463                    for (i, line) in lines.iter().enumerate() {
464                        ui.line(|ui| {
465                            ui.text(format!("{:>gutter_w$} │ ", i + 1))
466                                .fg(theme.text_dim);
467                            render_highlighted_line(ui, line);
468                        });
469                    }
470                }
471            });
472
473        Response::none()
474    }
475}