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        // 1) Keyboard hit-test runs first so it can mutate `is_yes`.
73        if focused {
74            let mut consumed_indices = Vec::new();
75            for (i, key) in self.available_key_presses() {
76                match key.code {
77                    KeyCode::Char('y') => {
78                        is_yes = true;
79                        *result = true;
80                        clicked = true;
81                        consumed_indices.push(i);
82                    }
83                    KeyCode::Char('n') => {
84                        is_yes = false;
85                        *result = false;
86                        clicked = true;
87                        consumed_indices.push(i);
88                    }
89                    KeyCode::Tab | KeyCode::BackTab | KeyCode::Left | KeyCode::Right => {
90                        is_yes = !is_yes;
91                        *result = is_yes;
92                        consumed_indices.push(i);
93                    }
94                    KeyCode::Enter => {
95                        *result = is_yes;
96                        clicked = true;
97                        consumed_indices.push(i);
98                    }
99                    _ => {}
100                }
101            }
102            self.consume_indices(consumed_indices);
103        }
104
105        // 2) Mouse hit-test runs *before* style computation and rendering so
106        // the visual feedback for `[Yes]` / `[No]` reflects the click in the
107        // same frame the click happened. Predict the row's interaction id
108        // (the next slot the row will allocate) and look up the previous
109        // frame's rect from `prev_hit_map`. On the first frame the row has
110        // no entry yet, so we fall back to assuming the row starts at (0,0)
111        // — same behaviour as the prior implementation.
112        let q_width = UnicodeWidthStr::width(question) as u32;
113        if !clicked {
114            if let Some((mx, my)) = self.click_pos {
115                let next_id = self.rollback.interaction_count;
116                let prev_rect = self.prev_hit_map.get(next_id).copied();
117                let row_x = prev_rect.map(|r| r.x).unwrap_or(0);
118                let in_row_y = match prev_rect {
119                    Some(r) if r.height > 0 => my >= r.y && my < r.bottom(),
120                    _ => true,
121                };
122                if in_row_y {
123                    let yes_start = row_x + q_width + 1;
124                    let yes_end = yes_start + 5;
125                    let no_start = yes_end + 1;
126                    let no_end = no_start + 4; // "[No]" = 4 display columns
127                    if mx >= yes_start && mx < yes_end {
128                        is_yes = true;
129                        *result = true;
130                        clicked = true;
131                    } else if mx >= no_start && mx < no_end {
132                        is_yes = false;
133                        *result = false;
134                        clicked = true;
135                    }
136                }
137            }
138        }
139
140        // 3) Style computation reads the now-mutated `is_yes`.
141        let yes_style = if is_yes {
142            if focused {
143                Style::new().fg(self.theme.bg).bg(self.theme.success).bold()
144            } else {
145                Style::new().fg(self.theme.success).bold()
146            }
147        } else {
148            Style::new().fg(self.theme.text_dim)
149        };
150        let no_style = if !is_yes {
151            if focused {
152                Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
153            } else {
154                Style::new().fg(self.theme.error).bold()
155            }
156        } else {
157            Style::new().fg(self.theme.text_dim)
158        };
159
160        // 4) Render with the post-hit-test styles.
161        let mut response = self.row(|ui| {
162            ui.text(question);
163            ui.text(" ");
164            ui.styled("[Yes]", yes_style);
165            ui.text(" ");
166            ui.styled("[No]", no_style);
167        });
168
169        response.focused = focused;
170        response.clicked = clicked;
171        response.changed = clicked;
172        response
173    }
174
175    /// Begin building a breadcrumb navigation bar with the default separator
176    /// (` › `).
177    ///
178    /// Returns a [`Breadcrumb`] builder that auto-renders on `Drop`. Chain
179    /// `.separator(s)` for a custom separator and `.color(c)` for a custom
180    /// link color. Call `.show()` to render and obtain a
181    /// [`BreadcrumbResponse`] carrying `clicked_segment` and `Deref<Response>`.
182    ///
183    /// # Example
184    ///
185    /// ```no_run
186    /// # slt::run(|ui: &mut slt::Context| {
187    /// // simple
188    /// ui.breadcrumb(&["Home", "Settings", "Profile"]);
189    ///
190    /// // with custom separator + color, capturing the response
191    /// let r = ui
192    ///     .breadcrumb(&["Home", "src", "lib.rs"])
193    ///     .separator(" > ")
194    ///     .show();
195    /// if let Some(i) = r.clicked_segment {
196    ///     // navigate to segment `i`
197    /// }
198    /// # });
199    /// ```
200    pub fn breadcrumb<'a>(&'a mut self, segments: &'a [&'a str]) -> Breadcrumb<'a> {
201        Breadcrumb::new(self, segments)
202    }
203
204    /// Collapsible section that toggles on click, Enter, or Space.
205    pub fn accordion(
206        &mut self,
207        title: &str,
208        open: &mut bool,
209        f: impl FnOnce(&mut Context),
210    ) -> Response {
211        let theme = self.theme;
212        let focused = self.register_focusable();
213        let old_open = *open;
214        let toggled_from_key = self.consume_activation_keys(focused);
215        if toggled_from_key {
216            *open = !*open;
217        }
218
219        let icon = if *open { "▾" } else { "▸" };
220        let title_color = if focused { theme.primary } else { theme.text };
221
222        let mut response = self.container().col(|ui| {
223            ui.line(|ui| {
224                ui.text(icon).fg(title_color);
225                let mut title_text = String::with_capacity(1 + title.len());
226                title_text.push(' ');
227                title_text.push_str(title);
228                ui.text(title_text).bold().fg(title_color);
229            });
230        });
231
232        if response.clicked {
233            *open = !*open;
234        }
235
236        if *open {
237            let indent = self.theme.spacing.sm();
238            let _ = self.container().pl(indent).col(f);
239        }
240
241        response.focused = focused;
242        response.changed = *open != old_open;
243        response
244    }
245
246    /// Render a key-value definition list with aligned columns.
247    pub fn definition_list(&mut self, items: &[(&str, &str)]) -> Response {
248        let max_key_width = items
249            .iter()
250            .map(|(k, _)| UnicodeWidthStr::width(*k))
251            .max()
252            .unwrap_or(0);
253
254        let _ = self.col(|ui| {
255            for (key, value) in items {
256                ui.line(|ui| {
257                    let key_display_w = UnicodeWidthStr::width(*key);
258                    let pad = max_key_width.saturating_sub(key_display_w);
259                    let mut padded = String::with_capacity(key.len() + pad);
260                    padded.extend(std::iter::repeat(' ').take(pad));
261                    padded.push_str(key);
262                    ui.text(padded).dim();
263                    ui.text("  ");
264                    ui.text(*value);
265                });
266            }
267        });
268
269        Response::none()
270    }
271
272    /// Render a horizontal divider with a centered text label.
273    pub fn divider_text(&mut self, label: &str) -> Response {
274        let w = self.width();
275        let label_len = UnicodeWidthStr::width(label) as u32;
276        // Reserve `label_len + 2` for the label and its single-space padding on
277        // each side, then split the remaining width evenly. On odd widths the
278        // right separator is one cell longer (no asymmetry that's visible).
279        let total_separator = w.saturating_sub(label_len + 2);
280        let left_len = total_separator / 2;
281        let right_len = total_separator - left_len;
282        let left: String = "─".repeat(left_len as usize);
283        let right: String = "─".repeat(right_len as usize);
284        let theme = self.theme;
285        self.line(|ui| {
286            ui.text(&left).fg(theme.border);
287            let mut label_text = String::with_capacity(label.len() + 2);
288            label_text.push(' ');
289            label_text.push_str(label);
290            label_text.push(' ');
291            ui.text(label_text).fg(theme.text);
292            ui.text(&right).fg(theme.border);
293        });
294
295        Response::none()
296    }
297
298    /// Render a badge with the theme's primary color.
299    ///
300    /// # Example
301    ///
302    /// ```no_run
303    /// # slt::run(|ui: &mut slt::Context| {
304    /// ui.badge("NEW");
305    /// # });
306    /// ```
307    pub fn badge(&mut self, label: &str) -> Response {
308        let theme = self.theme;
309        self.badge_colored(label, theme.primary)
310    }
311
312    /// Render a badge with a custom background color.
313    ///
314    /// Foreground is auto-selected for contrast via [`Color::contrast_fg`].
315    ///
316    /// # Example
317    ///
318    /// ```no_run
319    /// # use slt::Color;
320    /// # slt::run(|ui: &mut slt::Context| {
321    /// ui.badge_colored("ALPHA", Color::Magenta);
322    /// # });
323    /// ```
324    pub fn badge_colored(&mut self, label: &str, color: Color) -> Response {
325        let fg = Color::contrast_fg(color);
326        let mut label_text = String::with_capacity(label.len() + 2);
327        label_text.push(' ');
328        label_text.push_str(label);
329        label_text.push(' ');
330        self.text(label_text).fg(fg).bg(color);
331
332        Response::none()
333    }
334
335    /// Render a keyboard shortcut hint with reversed styling.
336    ///
337    /// # Example
338    ///
339    /// ```no_run
340    /// # slt::run(|ui: &mut slt::Context| {
341    /// ui.line(|ui| {
342    ///     ui.text("Quit: ");
343    ///     ui.key_hint("Ctrl+Q");
344    /// });
345    /// # });
346    /// ```
347    pub fn key_hint(&mut self, key: &str) -> Response {
348        let theme = self.theme;
349        let mut key_text = String::with_capacity(key.len() + 2);
350        key_text.push(' ');
351        key_text.push_str(key);
352        key_text.push(' ');
353        self.text(key_text).reversed().fg(theme.text_dim);
354
355        Response::none()
356    }
357
358    /// Render a label-value stat pair.
359    ///
360    /// Renders as a column: a dim label above a bold value. Pair multiple
361    /// stats in a [`row`](Self::row) for a compact dashboard strip.
362    ///
363    /// # Example
364    ///
365    /// ```no_run
366    /// # slt::run(|ui: &mut slt::Context| {
367    /// ui.row(|ui| {
368    ///     ui.stat("Users", "1.2k");
369    ///     ui.stat("Revenue", "$8,420");
370    /// });
371    /// # });
372    /// ```
373    pub fn stat(&mut self, label: &str, value: &str) -> Response {
374        let _ = self.col(|ui| {
375            ui.text(label).dim();
376            ui.text(value).bold();
377        });
378
379        Response::none()
380    }
381
382    /// Render a stat pair with a custom value color.
383    ///
384    /// # Example
385    ///
386    /// ```no_run
387    /// # use slt::Color;
388    /// # slt::run(|ui: &mut slt::Context| {
389    /// ui.stat_colored("Errors", "0", Color::Green);
390    /// # });
391    /// ```
392    pub fn stat_colored(&mut self, label: &str, value: &str, color: Color) -> Response {
393        let _ = self.col(|ui| {
394            ui.text(label).dim();
395            ui.text(value).bold().fg(color);
396        });
397
398        Response::none()
399    }
400
401    /// Render a stat pair with an up/down trend arrow.
402    ///
403    /// The arrow color follows the theme: `success` for [`Trend::Up`],
404    /// `error` for [`Trend::Down`].
405    ///
406    /// [`Trend::Up`]: crate::widgets::Trend::Up
407    /// [`Trend::Down`]: crate::widgets::Trend::Down
408    ///
409    /// # Example
410    ///
411    /// ```no_run
412    /// # use slt::widgets::Trend;
413    /// # slt::run(|ui: &mut slt::Context| {
414    /// ui.stat_trend("MRR", "$24.5k", Trend::Up);
415    /// ui.stat_trend("Churn", "1.8%", Trend::Down);
416    /// # });
417    /// ```
418    pub fn stat_trend(
419        &mut self,
420        label: &str,
421        value: &str,
422        trend: crate::widgets::Trend,
423    ) -> Response {
424        let theme = self.theme;
425        let (arrow, color) = match trend {
426            crate::widgets::Trend::Up => ("↑", theme.success),
427            crate::widgets::Trend::Down => ("↓", theme.error),
428        };
429        let _ = self.col(|ui| {
430            ui.text(label).dim();
431            ui.line(|ui| {
432                ui.text(value).bold();
433                let mut arrow_text = String::with_capacity(1 + arrow.len());
434                arrow_text.push(' ');
435                arrow_text.push_str(arrow);
436                ui.text(arrow_text).fg(color);
437            });
438        });
439
440        Response::none()
441    }
442
443    /// Render a centered empty-state placeholder.
444    ///
445    /// Title is rendered prominently; description is dimmed below. Both are
446    /// centered horizontally and vertically inside the available space.
447    ///
448    /// # Example
449    ///
450    /// ```no_run
451    /// # let items: Vec<&str> = vec![];
452    /// # slt::run(|ui: &mut slt::Context| {
453    /// if items.is_empty() {
454    ///     ui.empty_state("No items yet", "Press 'a' to add one");
455    /// }
456    /// # });
457    /// ```
458    pub fn empty_state(&mut self, title: &str, description: &str) -> Response {
459        let _ = self.container().center().col(|ui| {
460            ui.text(title).align(Align::Center);
461            ui.text(description).dim().align(Align::Center);
462        });
463
464        Response::none()
465    }
466
467    /// Render a centered empty-state placeholder with an action button.
468    ///
469    /// Returns a [`Response`] whose `clicked` field is `true` on the frame
470    /// the action button is activated.
471    ///
472    /// # Example
473    ///
474    /// ```no_run
475    /// # let items: Vec<&str> = vec![];
476    /// # slt::run(|ui: &mut slt::Context| {
477    /// if items.is_empty() {
478    ///     let r = ui.empty_state_action("No items yet", "Get started", "Add first item");
479    ///     if r.clicked {
480    ///         // open create flow
481    ///     }
482    /// }
483    /// # });
484    /// ```
485    pub fn empty_state_action(
486        &mut self,
487        title: &str,
488        description: &str,
489        action_label: &str,
490    ) -> Response {
491        let mut clicked = false;
492        let _ = self.container().center().col(|ui| {
493            ui.text(title).align(Align::Center);
494            ui.text(description).dim().align(Align::Center);
495            if ui.button(action_label).clicked {
496                clicked = true;
497            }
498        });
499
500        Response {
501            clicked,
502            changed: clicked,
503            ..Response::none()
504        }
505    }
506
507    /// Render a code block with keyword-based syntax highlighting.
508    pub fn code_block(&mut self, code: &str) -> Response {
509        self.code_block_lang(code, "")
510    }
511
512    /// Render a code block with language-aware syntax highlighting.
513    pub fn code_block_lang(&mut self, code: &str, lang: &str) -> Response {
514        let theme = self.theme;
515        let pad = theme.spacing.xs();
516        let highlighted: Option<Vec<Vec<(String, Style)>>> =
517            crate::syntax::highlight_code(code, lang, &theme);
518        let _ = self
519            .bordered(Border::Rounded)
520            .bg(theme.surface)
521            .p(pad)
522            .col(|ui| {
523                if let Some(ref lines) = highlighted {
524                    render_tree_sitter_lines(ui, lines);
525                } else {
526                    for line in code.lines() {
527                        ui.line(|ui| render_highlighted_line(ui, line));
528                    }
529                }
530            });
531
532        Response::none()
533    }
534
535    /// Render a code block with line numbers and keyword highlighting.
536    pub fn code_block_numbered(&mut self, code: &str) -> Response {
537        self.code_block_numbered_lang(code, "")
538    }
539
540    /// Render a code block with line numbers and language-aware highlighting.
541    pub fn code_block_numbered_lang(&mut self, code: &str, lang: &str) -> Response {
542        let lines: Vec<&str> = code.lines().collect();
543        let gutter_w = (lines.len().max(1).ilog10() + 1) as usize;
544        let theme = self.theme;
545        let pad = theme.spacing.xs();
546        let highlighted: Option<Vec<Vec<(String, Style)>>> =
547            crate::syntax::highlight_code(code, lang, &theme);
548        let _ = self
549            .bordered(Border::Rounded)
550            .bg(theme.surface)
551            .p(pad)
552            .col(|ui| {
553                if let Some(ref hl_lines) = highlighted {
554                    for (i, segs) in hl_lines.iter().enumerate() {
555                        ui.line(|ui| {
556                            ui.text(format!("{:>gutter_w$} │ ", i + 1))
557                                .fg(theme.text_dim);
558                            for (text, style) in segs {
559                                ui.styled(text, *style);
560                            }
561                        });
562                    }
563                } else {
564                    for (i, line) in lines.iter().enumerate() {
565                        ui.line(|ui| {
566                            ui.text(format!("{:>gutter_w$} │ ", i + 1))
567                                .fg(theme.text_dim);
568                            render_highlighted_line(ui, line);
569                        });
570                    }
571                }
572            });
573
574        Response::none()
575    }
576}
577
578/// Breadcrumb navigation bar builder. Auto-renders on `Drop`.
579///
580/// Constructed via [`Context::breadcrumb`]. Chain `.separator(s)` to override
581/// the default ` › ` separator and `.color(c)` to override the link color.
582/// Drop the value to render without capturing a response, or call
583/// [`Self::show`] to render and obtain a [`BreadcrumbResponse`].
584///
585/// `Drop` is intentional: `ui.breadcrumb(&["Home", "src"]).separator(" > ");`
586/// is the idiomatic form when the response isn't needed.
587pub struct Breadcrumb<'a> {
588    ctx: Option<&'a mut Context>,
589    segments: &'a [&'a str],
590    separator: &'a str,
591    color: Option<Color>,
592}
593
594impl<'a> Breadcrumb<'a> {
595    pub(super) fn new(ctx: &'a mut Context, segments: &'a [&'a str]) -> Self {
596        Self {
597            ctx: Some(ctx),
598            segments,
599            separator: " › ",
600            color: None,
601        }
602    }
603
604    /// Set the separator string between segments (default: ` › `).
605    pub fn separator(mut self, sep: &'a str) -> Self {
606        self.separator = sep;
607        self
608    }
609
610    /// Override the link (clickable segment) color. Defaults to `theme.primary`.
611    pub fn color(mut self, color: Color) -> Self {
612        self.color = Some(color);
613        self
614    }
615
616    /// Render now and return the [`BreadcrumbResponse`].
617    pub fn show(mut self) -> BreadcrumbResponse {
618        let ctx = self.ctx.take().expect("Breadcrumb::show called twice");
619        render_breadcrumb(ctx, self.segments, self.separator, self.color)
620    }
621}
622
623impl Drop for Breadcrumb<'_> {
624    fn drop(&mut self) {
625        if let Some(ctx) = self.ctx.take() {
626            let _ = render_breadcrumb(ctx, self.segments, self.separator, self.color);
627        }
628    }
629}
630
631fn render_breadcrumb(
632    ctx: &mut Context,
633    segments: &[&str],
634    separator: &str,
635    color_override: Option<Color>,
636) -> BreadcrumbResponse {
637    let theme = ctx.theme;
638    let last_idx = segments.len().saturating_sub(1);
639    let mut clicked_segment: Option<usize> = None;
640    let link_color = color_override.unwrap_or(theme.primary);
641
642    let response = ctx.row(|ui| {
643        for (i, segment) in segments.iter().enumerate() {
644            let is_last = i == last_idx;
645            if is_last {
646                ui.text(*segment).bold();
647            } else {
648                let focused = ui.register_focusable();
649                let resp = ui.interaction();
650                let activated = resp.clicked || ui.consume_activation_keys(focused);
651                let color = if resp.hovered || focused {
652                    theme.accent
653                } else {
654                    link_color
655                };
656                ui.text(*segment).fg(color).underline();
657                if activated {
658                    clicked_segment = Some(i);
659                }
660                ui.text(separator).dim();
661            }
662        }
663    });
664
665    BreadcrumbResponse {
666        response,
667        clicked_segment,
668    }
669}