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