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    ///
6    /// Argument order is `(message, level)` — message first, then the
7    /// [`AlertLevel`](crate::widgets::AlertLevel). This is the executable
8    /// proof that [API_DESIGN.md](https://github.com/subinium/superlighttui/blob/main/docs/API_DESIGN.md)
9    /// Rule 3 matches the shipped signature.
10    ///
11    /// # Example
12    ///
13    /// ```no_run
14    /// # use slt::AlertLevel;
15    /// # slt::run(|ui: &mut slt::Context| {
16    /// ui.alert("Disk full", AlertLevel::Error);
17    /// ui.alert("Saved", AlertLevel::Success);
18    /// # });
19    /// ```
20    pub fn alert(&mut self, message: &str, level: crate::widgets::AlertLevel) -> Response {
21        use crate::widgets::AlertLevel;
22
23        let theme = self.theme;
24        let (icon, color) = match level {
25            AlertLevel::Info => ("ℹ", theme.accent),
26            AlertLevel::Success => ("✓", theme.success),
27            AlertLevel::Warning => ("⚠", theme.warning),
28            AlertLevel::Error => ("✕", theme.error),
29        };
30
31        let focused = self.register_focusable();
32        let key_dismiss = if focused {
33            let consumed: Vec<usize> = self
34                .available_key_presses()
35                .filter_map(|(i, key)| {
36                    if matches!(key.code, KeyCode::Enter | KeyCode::Char('x')) {
37                        Some(i)
38                    } else {
39                        None
40                    }
41                })
42                .collect();
43            let dismissed = !consumed.is_empty();
44            self.consume_indices(consumed);
45            dismissed
46        } else {
47            false
48        };
49
50        let mut response = self.container().col(|ui| {
51            ui.line(|ui| {
52                let mut icon_text = String::with_capacity(icon.len() + 2);
53                icon_text.push(' ');
54                icon_text.push_str(icon);
55                icon_text.push(' ');
56                ui.text(icon_text).fg(color).bold();
57                ui.text(message).grow(1);
58                ui.text(" [×] ").dim();
59            });
60        });
61        response.focused = focused;
62        if key_dismiss {
63            response.clicked = true;
64        }
65
66        response
67    }
68
69    /// Yes/No confirmation dialog. Returns Response with .clicked=true when answered.
70    ///
71    /// `result` is set to true for Yes, false for No.
72    ///
73    /// # Examples
74    /// ```
75    /// # use slt::*;
76    /// # TestBackend::new(80, 24).render(|ui| {
77    /// let mut answer = false;
78    /// let r = ui.confirm("Delete this file?", &mut answer);
79    /// if r.clicked && answer { /* user confirmed */ }
80    /// # });
81    /// ```
82    pub fn confirm(&mut self, question: &str, result: &mut bool) -> Response {
83        let focused = self.register_focusable();
84        let mut is_yes = *result;
85        let mut clicked = false;
86
87        // 1) Keyboard hit-test runs first so it can mutate `is_yes`.
88        if focused {
89            let mut consumed_indices = Vec::new();
90            for (i, key) in self.available_key_presses() {
91                match key.code {
92                    KeyCode::Char('y') => {
93                        is_yes = true;
94                        *result = true;
95                        clicked = true;
96                        consumed_indices.push(i);
97                    }
98                    KeyCode::Char('n') => {
99                        is_yes = false;
100                        *result = false;
101                        clicked = true;
102                        consumed_indices.push(i);
103                    }
104                    KeyCode::Tab | KeyCode::BackTab | KeyCode::Left | KeyCode::Right => {
105                        is_yes = !is_yes;
106                        *result = is_yes;
107                        consumed_indices.push(i);
108                    }
109                    KeyCode::Enter => {
110                        *result = is_yes;
111                        clicked = true;
112                        consumed_indices.push(i);
113                    }
114                    _ => {}
115                }
116            }
117            self.consume_indices(consumed_indices);
118        }
119
120        // 2) Mouse hit-test runs *before* style computation and rendering so
121        // the visual feedback for `[Yes]` / `[No]` reflects the click in the
122        // same frame the click happened. Predict the row's interaction id
123        // (the next slot the row will allocate) and look up the previous
124        // frame's rect from `prev_hit_map`. On the first frame the row has
125        // no entry yet, so we fall back to assuming the row starts at (0,0)
126        // — same behaviour as the prior implementation.
127        let q_width = UnicodeWidthStr::width(question) as u32;
128        if !clicked && let Some((mx, my)) = self.click_pos {
129            let next_id = self.rollback.interaction_count;
130            let prev_rect = self.prev_hit_map.get(next_id).copied();
131            let row_x = prev_rect.map(|r| r.x).unwrap_or(0);
132            let in_row_y = match prev_rect {
133                Some(r) if r.height > 0 => my >= r.y && my < r.bottom(),
134                _ => true,
135            };
136            if in_row_y {
137                let yes_start = row_x + q_width + 1;
138                let yes_end = yes_start + 5;
139                let no_start = yes_end + 1;
140                let no_end = no_start + 4; // "[No]" = 4 display columns
141                if mx >= yes_start && mx < yes_end {
142                    is_yes = true;
143                    *result = true;
144                    clicked = true;
145                } else if mx >= no_start && mx < no_end {
146                    is_yes = false;
147                    *result = false;
148                    clicked = true;
149                }
150            }
151        }
152
153        // 3) Style computation reads the now-mutated `is_yes`.
154        let yes_style = if is_yes {
155            if focused {
156                Style::new().fg(self.theme.bg).bg(self.theme.success).bold()
157            } else {
158                Style::new().fg(self.theme.success).bold()
159            }
160        } else {
161            Style::new().fg(self.theme.text_dim)
162        };
163        let no_style = if !is_yes {
164            if focused {
165                Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
166            } else {
167                Style::new().fg(self.theme.error).bold()
168            }
169        } else {
170            Style::new().fg(self.theme.text_dim)
171        };
172
173        // 4) Render with the post-hit-test styles.
174        let mut response = self.row(|ui| {
175            ui.text(question);
176            ui.text(" ");
177            ui.styled("[Yes]", yes_style);
178            ui.text(" ");
179            ui.styled("[No]", no_style);
180        });
181
182        response.focused = focused;
183        response.clicked = clicked;
184        response.changed = clicked;
185        response
186    }
187
188    /// Begin building a breadcrumb navigation bar with the default separator
189    /// (` › `).
190    ///
191    /// Returns a [`Breadcrumb`] builder that auto-renders on `Drop`. Chain
192    /// `.separator(s)` for a custom separator and `.color(c)` for a custom
193    /// link color. Call `.show()` to render and obtain a
194    /// [`BreadcrumbResponse`] carrying `clicked_segment` and `Deref<Response>`.
195    ///
196    /// # Example
197    ///
198    /// ```no_run
199    /// # slt::run(|ui: &mut slt::Context| {
200    /// // simple
201    /// ui.breadcrumb(&["Home", "Settings", "Profile"]);
202    ///
203    /// // with custom separator + color, capturing the response
204    /// let r = ui
205    ///     .breadcrumb(&["Home", "src", "lib.rs"])
206    ///     .separator(" > ")
207    ///     .show();
208    /// if let Some(i) = r.clicked_segment {
209    ///     // navigate to segment `i`
210    /// }
211    /// # });
212    /// ```
213    pub fn breadcrumb<'a>(&'a mut self, segments: &'a [&'a str]) -> Breadcrumb<'a> {
214        Breadcrumb::new(self, segments)
215    }
216
217    /// Collapsible section that toggles on click, Enter, or Space.
218    ///
219    /// # Example
220    ///
221    /// ```no_run
222    /// # let mut open = true;
223    /// # slt::run(|ui: &mut slt::Context| {
224    /// ui.accordion("Advanced", &mut open, |ui| {
225    ///     ui.text("Expert settings");
226    /// });
227    /// # });
228    /// ```
229    pub fn accordion(
230        &mut self,
231        title: &str,
232        open: &mut bool,
233        f: impl FnOnce(&mut Context),
234    ) -> Response {
235        let theme = self.theme;
236        let focused = self.register_focusable();
237        let old_open = *open;
238        let toggled_from_key = self.consume_activation_keys(focused);
239        if toggled_from_key {
240            *open = !*open;
241        }
242
243        let icon = if *open { "▾" } else { "▸" };
244        let title_color = if focused { theme.primary } else { theme.text };
245
246        let mut response = self.container().col(|ui| {
247            ui.line(|ui| {
248                ui.text(icon).fg(title_color);
249                let mut title_text = String::with_capacity(1 + title.len());
250                title_text.push(' ');
251                title_text.push_str(title);
252                ui.text(title_text).bold().fg(title_color);
253            });
254        });
255
256        if response.clicked {
257            *open = !*open;
258        }
259
260        if *open {
261            let indent = self.theme.spacing.sm();
262            let _ = self.container().pl(indent).col(f);
263        }
264
265        response.focused = focused;
266        response.changed = *open != old_open;
267        response
268    }
269
270    /// Render a key-value definition list with aligned columns.
271    ///
272    /// Keys are right-padded to the widest key so the value column lines up.
273    ///
274    /// # Example
275    ///
276    /// ```no_run
277    /// # slt::run(|ui: &mut slt::Context| {
278    /// ui.definition_list(&[
279    ///     ("Name", "SuperLightTUI"),
280    ///     ("Version", "0.21.1"),
281    ///     ("License", "MIT"),
282    /// ]);
283    /// # });
284    /// ```
285    pub fn definition_list(&mut self, items: &[(&str, &str)]) -> Response {
286        let max_key_width = items
287            .iter()
288            .map(|(k, _)| UnicodeWidthStr::width(*k))
289            .max()
290            .unwrap_or(0);
291
292        let _ = self.col(|ui| {
293            for (key, value) in items {
294                ui.line(|ui| {
295                    let key_display_w = UnicodeWidthStr::width(*key);
296                    let pad = max_key_width.saturating_sub(key_display_w);
297                    let mut padded = String::with_capacity(key.len() + pad);
298                    padded.extend(std::iter::repeat_n(' ', pad));
299                    padded.push_str(key);
300                    ui.text(padded).dim();
301                    ui.text("  ");
302                    ui.text(*value);
303                });
304            }
305        });
306
307        Response::none()
308    }
309
310    /// Render a horizontal divider with a centered text label.
311    ///
312    /// The label is padded with one space on each side and centered between
313    /// two `─` separator runs spanning the available width.
314    ///
315    /// # Example
316    ///
317    /// ```no_run
318    /// # slt::run(|ui: &mut slt::Context| {
319    /// ui.divider_text("Settings");
320    /// # });
321    /// ```
322    pub fn divider_text(&mut self, label: &str) -> Response {
323        let w = self.width();
324        let label_len = UnicodeWidthStr::width(label) as u32;
325        // Reserve `label_len + 2` for the label and its single-space padding on
326        // each side, then split the remaining width evenly. On odd widths the
327        // right separator is one cell longer (no asymmetry that's visible).
328        let total_separator = w.saturating_sub(label_len + 2);
329        let left_len = total_separator / 2;
330        let right_len = total_separator - left_len;
331        let left: String = "─".repeat(left_len as usize);
332        let right: String = "─".repeat(right_len as usize);
333        let theme = self.theme;
334        self.line(|ui| {
335            ui.text(&left).fg(theme.border);
336            let mut label_text = String::with_capacity(label.len() + 2);
337            label_text.push(' ');
338            label_text.push_str(label);
339            label_text.push(' ');
340            ui.text(label_text).fg(theme.text);
341            ui.text(&right).fg(theme.border);
342        });
343
344        Response::none()
345    }
346
347    /// Render a badge with the theme's primary color.
348    ///
349    /// Returns a [`Response`] carrying real `hovered` / `right_clicked` state
350    /// for the badge's rect, so callers can attach `.on_hover(...)` tooltips.
351    /// Prior to v0.21.0 this always returned [`Response::none()`]; statement-form
352    /// callers (`ui.badge("NEW");`) compile unchanged.
353    ///
354    /// # Example
355    ///
356    /// ```no_run
357    /// # slt::run(|ui: &mut slt::Context| {
358    /// let r = ui.badge("NEW");
359    /// if r.hovered { /* attach a tooltip */ }
360    /// # });
361    /// ```
362    pub fn badge(&mut self, label: &str) -> Response {
363        let theme = self.theme;
364        self.badge_colored(label, theme.primary)
365    }
366
367    /// Render a badge with a custom background color.
368    ///
369    /// Foreground is auto-selected for contrast via [`Color::contrast_fg`].
370    ///
371    /// Returns a [`Response`] carrying real `hovered` / `right_clicked` state
372    /// for the badge's rect, so callers can attach `.on_hover(...)` tooltips.
373    /// Prior to v0.21.0 this always returned [`Response::none()`]; statement-form
374    /// callers compile unchanged.
375    ///
376    /// # Example
377    ///
378    /// ```no_run
379    /// # use slt::Color;
380    /// # slt::run(|ui: &mut slt::Context| {
381    /// let r = ui.badge_colored("ALPHA", Color::Magenta);
382    /// if r.hovered { /* attach a tooltip */ }
383    /// # });
384    /// ```
385    pub fn badge_colored(&mut self, label: &str, color: Color) -> Response {
386        let fg = Color::contrast_fg(color);
387        let mut label_text = String::with_capacity(label.len() + 2);
388        label_text.push(' ');
389        label_text.push_str(label);
390        label_text.push(' ');
391        // Reserve the interaction slot *before* the text so the marker
392        // attaches to the badge's rect (same pattern as `spinner` / `gauge`).
393        let response = self.interaction();
394        self.text(label_text).fg(fg).bg(color);
395
396        response
397    }
398
399    /// Render a keyboard shortcut hint with reversed styling.
400    ///
401    /// Returns a [`Response`] carrying real `hovered` / `right_clicked` state
402    /// for the hint's rect, so callers can attach `.on_hover(...)` tooltips.
403    /// Prior to v0.21.0 this always returned [`Response::none()`]; statement-form
404    /// callers compile unchanged.
405    ///
406    /// # Example
407    ///
408    /// ```no_run
409    /// # slt::run(|ui: &mut slt::Context| {
410    /// ui.line(|ui| {
411    ///     ui.text("Quit: ");
412    ///     let r = ui.key_hint("Ctrl+Q");
413    ///     if r.hovered { /* attach a tooltip */ }
414    /// });
415    /// # });
416    /// ```
417    pub fn key_hint(&mut self, key: &str) -> Response {
418        let theme = self.theme;
419        let mut key_text = String::with_capacity(key.len() + 2);
420        key_text.push(' ');
421        key_text.push_str(key);
422        key_text.push(' ');
423        // Reserve the interaction slot *before* the text so the marker
424        // attaches to the hint's rect.
425        let response = self.interaction();
426        self.text(key_text).reversed().fg(theme.text_dim);
427
428        response
429    }
430
431    /// Render a label-value stat pair.
432    ///
433    /// Renders as a column: a dim label above a bold value. Pair multiple
434    /// stats in a [`row`](Self::row) for a compact dashboard strip.
435    ///
436    /// Returns a [`Response`] carrying real `hovered` / `clicked` /
437    /// `right_clicked` state for the stat's column rect, so callers can attach
438    /// `.on_hover(...)` tooltips. Prior to v0.21.0 this always returned
439    /// [`Response::none()`]; statement-form callers compile unchanged.
440    ///
441    /// # Example
442    ///
443    /// ```no_run
444    /// # slt::run(|ui: &mut slt::Context| {
445    /// ui.row(|ui| {
446    ///     let r = ui.stat("Users", "1.2k");
447    ///     if r.hovered { /* attach a tooltip */ }
448    ///     ui.stat("Revenue", "$8,420");
449    /// });
450    /// # });
451    /// ```
452    pub fn stat(&mut self, label: &str, value: &str) -> Response {
453        self.col(|ui| {
454            ui.text(label).dim();
455            ui.text(value).bold();
456        })
457    }
458
459    /// Render a stat pair with a custom value color.
460    ///
461    /// Returns a [`Response`] carrying real `hovered` / `clicked` /
462    /// `right_clicked` state for the stat's column rect, so callers can attach
463    /// `.on_hover(...)` tooltips. Prior to v0.21.0 this always returned
464    /// [`Response::none()`]; statement-form callers compile unchanged.
465    ///
466    /// # Example
467    ///
468    /// ```no_run
469    /// # use slt::Color;
470    /// # slt::run(|ui: &mut slt::Context| {
471    /// let r = ui.stat_colored("Errors", "0", Color::Green);
472    /// if r.hovered { /* attach a tooltip */ }
473    /// # });
474    /// ```
475    pub fn stat_colored(&mut self, label: &str, value: &str, color: Color) -> Response {
476        self.col(|ui| {
477            ui.text(label).dim();
478            ui.text(value).bold().fg(color);
479        })
480    }
481
482    /// Render a stat pair with an up/down trend arrow.
483    ///
484    /// The arrow color follows the theme: `success` for [`Trend::Up`],
485    /// `error` for [`Trend::Down`].
486    ///
487    /// Returns a [`Response`] carrying real `hovered` / `clicked` /
488    /// `right_clicked` state for the stat's column rect, so callers can attach
489    /// `.on_hover(...)` tooltips. Prior to v0.21.0 this always returned
490    /// [`Response::none()`]; statement-form callers compile unchanged.
491    ///
492    /// [`Trend::Up`]: crate::widgets::Trend::Up
493    /// [`Trend::Down`]: crate::widgets::Trend::Down
494    ///
495    /// # Example
496    ///
497    /// ```no_run
498    /// # use slt::widgets::Trend;
499    /// # slt::run(|ui: &mut slt::Context| {
500    /// let r = ui.stat_trend("MRR", "$24.5k", Trend::Up);
501    /// if r.hovered { /* attach a tooltip */ }
502    /// ui.stat_trend("Churn", "1.8%", Trend::Down);
503    /// # });
504    /// ```
505    pub fn stat_trend(
506        &mut self,
507        label: &str,
508        value: &str,
509        trend: crate::widgets::Trend,
510    ) -> Response {
511        let theme = self.theme;
512        let (arrow, color) = match trend {
513            crate::widgets::Trend::Up => ("↑", theme.success),
514            crate::widgets::Trend::Down => ("↓", theme.error),
515        };
516        self.col(|ui| {
517            ui.text(label).dim();
518            ui.line(|ui| {
519                ui.text(value).bold();
520                let mut arrow_text = String::with_capacity(1 + arrow.len());
521                arrow_text.push(' ');
522                arrow_text.push_str(arrow);
523                ui.text(arrow_text).fg(color);
524            });
525        })
526    }
527
528    /// Render a centered empty-state placeholder.
529    ///
530    /// Title is rendered prominently; description is dimmed below. Both are
531    /// centered horizontally and vertically inside the available space.
532    ///
533    /// Returns a [`Response`] carrying real `hovered` / `clicked` /
534    /// `right_clicked` state for the placeholder rect, so callers can attach
535    /// `.on_hover(...)` tooltips. Prior to v0.21.0 this always returned
536    /// [`Response::none()`]; statement-form callers compile unchanged.
537    ///
538    /// # Example
539    ///
540    /// ```no_run
541    /// # let items: Vec<&str> = vec![];
542    /// # slt::run(|ui: &mut slt::Context| {
543    /// if items.is_empty() {
544    ///     ui.empty_state("No items yet", "Press 'a' to add one");
545    /// }
546    /// # });
547    /// ```
548    pub fn empty_state(&mut self, title: &str, description: &str) -> Response {
549        self.container().center().col(|ui| {
550            ui.text(title).align(Align::Center);
551            ui.text(description).dim().align(Align::Center);
552        })
553    }
554
555    /// Render a centered empty-state placeholder with an action button.
556    ///
557    /// Returns a [`Response`] whose `clicked` field is `true` on the frame
558    /// the action button is activated. As of v0.21.0 the response also carries
559    /// real `hovered` / `right_clicked` state (and the laid-out `rect`) for the
560    /// placeholder area, so callers can attach `.on_hover(...)` tooltips. The
561    /// `clicked` / `changed` fields still track the action button specifically,
562    /// not the whole placeholder.
563    ///
564    /// # Example
565    ///
566    /// ```no_run
567    /// # let items: Vec<&str> = vec![];
568    /// # slt::run(|ui: &mut slt::Context| {
569    /// if items.is_empty() {
570    ///     let r = ui.empty_state_action("No items yet", "Get started", "Add first item");
571    ///     if r.clicked {
572    ///         // open create flow
573    ///     }
574    /// }
575    /// # });
576    /// ```
577    pub fn empty_state_action(
578        &mut self,
579        title: &str,
580        description: &str,
581        action_label: &str,
582    ) -> Response {
583        let mut clicked = false;
584        // The container response carries hover / right-click / rect for the
585        // whole placeholder area; `clicked` still tracks the action button.
586        let mut response = self.container().center().col(|ui| {
587            ui.text(title).align(Align::Center);
588            ui.text(description).dim().align(Align::Center);
589            if ui.button(action_label).clicked {
590                clicked = true;
591            }
592        });
593
594        response.clicked = clicked;
595        response.changed = clicked;
596        response
597    }
598
599    /// Begin building a syntax-highlighted code block.
600    ///
601    /// Chain `.lang(...)` for language-aware highlighting and `.numbered()`
602    /// for a line-number gutter. The returned [`CodeBlock`] auto-renders when
603    /// dropped, so a bare `ui.code_block(code);` produces a default block.
604    /// Call `.show()` (instead of dropping) to capture the [`Response`].
605    ///
606    /// This is the consuming-builder shape shared with [`Context::gauge`] /
607    /// [`Context::breadcrumb`] — see [API_DESIGN.md](https://github.com/subinium/superlighttui/blob/main/docs/API_DESIGN.md) Rule 1.
608    ///
609    /// # Example
610    ///
611    /// ```no_run
612    /// # slt::run(|ui: &mut slt::Context| {
613    /// ui.code_block("let x = 1;");
614    /// let r = ui.code_block("fn main() {}").lang("rust").numbered().show();
615    /// if r.hovered { /* attach tooltip */ }
616    /// # });
617    /// ```
618    pub fn code_block<'a>(&'a mut self, code: &'a str) -> CodeBlock<'a> {
619        CodeBlock::new(self, code)
620    }
621
622    /// Render a code block with language-aware syntax highlighting.
623    ///
624    /// # Example
625    ///
626    /// ```no_run
627    /// # slt::run(|ui: &mut slt::Context| {
628    /// ui.code_block("fn main() {}").lang("rust");
629    /// # });
630    /// ```
631    #[deprecated(since = "0.21.0", note = "use `code_block(code).lang(lang)`")]
632    pub fn code_block_lang(&mut self, code: &str, lang: &str) -> Response {
633        render_code_block(self, code, lang, false)
634    }
635
636    /// Render a code block with line numbers and keyword highlighting.
637    ///
638    /// # Example
639    ///
640    /// ```no_run
641    /// # slt::run(|ui: &mut slt::Context| {
642    /// ui.code_block("let first = 1;\nlet second = 2;").numbered();
643    /// # });
644    /// ```
645    #[deprecated(since = "0.21.0", note = "use `code_block(code).numbered()`")]
646    pub fn code_block_numbered(&mut self, code: &str) -> Response {
647        render_code_block(self, code, "", true)
648    }
649
650    /// Render a code block with line numbers and language-aware highlighting.
651    ///
652    /// # Example
653    ///
654    /// ```no_run
655    /// # slt::run(|ui: &mut slt::Context| {
656    /// ui.code_block("fn main() {}").lang("rust").numbered();
657    /// # });
658    /// ```
659    #[deprecated(
660        since = "0.21.0",
661        note = "use `code_block(code).lang(lang).numbered()`"
662    )]
663    pub fn code_block_numbered_lang(&mut self, code: &str, lang: &str) -> Response {
664        render_code_block(self, code, lang, true)
665    }
666}
667
668/// Syntax-highlighted code block builder. Auto-renders on `Drop`.
669///
670/// Constructed via [`Context::code_block`]. Chain `.lang(...)` for
671/// language-aware highlighting and `.numbered()` for a line-number gutter.
672/// Drop the value to render without capturing a response, or call
673/// [`Self::show`] to render and obtain a [`Response`].
674///
675/// Consuming-builder shape, mirroring [`Gauge`](super::Gauge) /
676/// [`Breadcrumb`]: `Drop` is intentional so `ui.code_block(code);` is the
677/// idiomatic form when the response isn't needed (egui's `ui.add(...)` idiom).
678pub struct CodeBlock<'a> {
679    ctx: Option<&'a mut Context>,
680    code: &'a str,
681    lang: &'a str,
682    numbered: bool,
683}
684
685impl<'a> CodeBlock<'a> {
686    fn new(ctx: &'a mut Context, code: &'a str) -> Self {
687        Self {
688            ctx: Some(ctx),
689            code,
690            lang: "",
691            numbered: false,
692        }
693    }
694
695    /// Set the language for syntax highlighting (e.g. `"rust"`). Empty string
696    /// (the default) falls back to keyword-based highlighting.
697    pub fn lang(mut self, lang: &'a str) -> Self {
698        self.lang = lang;
699        self
700    }
701
702    /// Enable the line-number gutter.
703    pub fn numbered(mut self) -> Self {
704        self.numbered = true;
705        self
706    }
707
708    /// Render now and return the [`Response`].
709    pub fn show(mut self) -> Response {
710        // SAFETY: ctx is Some until Drop runs; show consumes self before Drop.
711        let ctx = self.ctx.take().expect("CodeBlock::show called twice");
712        render_code_block(ctx, self.code, self.lang, self.numbered)
713    }
714}
715
716impl Drop for CodeBlock<'_> {
717    fn drop(&mut self) {
718        if let Some(ctx) = self.ctx.take() {
719            let _ = render_code_block(ctx, self.code, self.lang, self.numbered);
720        }
721    }
722}
723
724/// Internal code-block rendering shared by the [`CodeBlock`] builder and the
725/// deprecated `code_block_*` aliases. Folds the language-aware and
726/// line-numbered paths on the `numbered` flag — no behavior change versus the
727/// previous separate `code_block_lang` / `code_block_numbered_lang` bodies.
728fn render_code_block(ctx: &mut Context, code: &str, lang: &str, numbered: bool) -> Response {
729    if code.is_empty() {
730        return Response::none();
731    }
732
733    let theme = ctx.theme;
734    let pad = theme.spacing.xs();
735    let highlighted = crate::syntax::highlight_code_cached(code, lang, &theme);
736
737    if numbered {
738        let lines: Vec<&str> = code.lines().collect();
739        let gutter_w = (lines.len().max(1).ilog10() + 1) as usize;
740        ctx.bordered(Border::Rounded)
741            .bg(theme.surface)
742            .p(pad)
743            .col(|ui| {
744                if let Some(ref hl_lines) = highlighted {
745                    for (i, segs) in hl_lines.iter().enumerate() {
746                        ui.line(|ui| {
747                            ui.text(format!("{:>gutter_w$} │ ", i + 1))
748                                .fg(theme.text_dim);
749                            for (text, style) in segs {
750                                ui.styled(text, *style);
751                            }
752                        });
753                    }
754                } else {
755                    for (i, line) in lines.iter().enumerate() {
756                        ui.line(|ui| {
757                            ui.text(format!("{:>gutter_w$} │ ", i + 1))
758                                .fg(theme.text_dim);
759                            render_highlighted_line(ui, line);
760                        });
761                    }
762                }
763            })
764    } else {
765        ctx.bordered(Border::Rounded)
766            .bg(theme.surface)
767            .p(pad)
768            .col(|ui| {
769                if let Some(ref lines) = highlighted {
770                    render_tree_sitter_lines(ui, lines);
771                } else {
772                    for line in code.lines() {
773                        ui.line(|ui| render_highlighted_line(ui, line));
774                    }
775                }
776            })
777    }
778}
779
780/// Breadcrumb navigation bar builder. Auto-renders on `Drop`.
781///
782/// Constructed via [`Context::breadcrumb`]. Chain `.separator(s)` to override
783/// the default ` › ` separator and `.color(c)` to override the link color.
784/// Drop the value to render without capturing a response, or call
785/// [`Self::show`] to render and obtain a [`BreadcrumbResponse`].
786///
787/// `Drop` is intentional: `ui.breadcrumb(&["Home", "src"]).separator(" > ");`
788/// is the idiomatic form when the response isn't needed.
789pub struct Breadcrumb<'a> {
790    ctx: Option<&'a mut Context>,
791    segments: &'a [&'a str],
792    separator: &'a str,
793    color: Option<Color>,
794}
795
796impl<'a> Breadcrumb<'a> {
797    pub(super) fn new(ctx: &'a mut Context, segments: &'a [&'a str]) -> Self {
798        Self {
799            ctx: Some(ctx),
800            segments,
801            separator: " › ",
802            color: None,
803        }
804    }
805
806    /// Set the separator string between segments (default: ` › `).
807    pub fn separator(mut self, sep: &'a str) -> Self {
808        self.separator = sep;
809        self
810    }
811
812    /// Override the link (clickable segment) color. Defaults to `theme.primary`.
813    pub fn color(mut self, color: Color) -> Self {
814        self.color = Some(color);
815        self
816    }
817
818    /// Render now and return the [`BreadcrumbResponse`].
819    pub fn show(mut self) -> BreadcrumbResponse {
820        let Some(ctx) = self.ctx.take() else {
821            // `show` consumes the builder, so safe code cannot reach this
822            // branch. Stay defensive if the internal invariant changes.
823            return BreadcrumbResponse::default();
824        };
825        render_breadcrumb(ctx, self.segments, self.separator, self.color)
826    }
827}
828
829impl Drop for Breadcrumb<'_> {
830    fn drop(&mut self) {
831        if let Some(ctx) = self.ctx.take() {
832            let _ = render_breadcrumb(ctx, self.segments, self.separator, self.color);
833        }
834    }
835}
836
837fn render_breadcrumb(
838    ctx: &mut Context,
839    segments: &[&str],
840    separator: &str,
841    color_override: Option<Color>,
842) -> BreadcrumbResponse {
843    let theme = ctx.theme;
844    let last_idx = segments.len().saturating_sub(1);
845    let mut clicked_segment: Option<usize> = None;
846    let link_color = color_override.unwrap_or(theme.primary);
847
848    let response = ctx.row(|ui| {
849        for (i, segment) in segments.iter().enumerate() {
850            let is_last = i == last_idx;
851            if is_last {
852                ui.text(*segment).bold();
853            } else {
854                let focused = ui.register_focusable();
855                let resp = ui.interaction();
856                let activated = resp.clicked || ui.consume_activation_keys(focused);
857                let color = if resp.hovered || focused {
858                    theme.accent
859                } else {
860                    link_color
861                };
862                ui.text(*segment).fg(color).underline();
863                if activated {
864                    clicked_segment = Some(i);
865                }
866                ui.text(separator).dim();
867            }
868        }
869    });
870
871    BreadcrumbResponse {
872        response,
873        clicked_segment,
874    }
875}
876
877#[cfg(test)]
878mod code_block_tests {
879    use crate::test_utils::TestBackend;
880    use crate::widgets::AlertLevel;
881    use crate::{Rect, Response};
882
883    #[test]
884    fn code_block_builder_renders_lang_and_gutter() {
885        let mut tb = TestBackend::new(40, 8);
886        tb.render(|ui| {
887            let _ = ui.code_block("let x = 1;").lang("rust").numbered().show();
888        });
889        tb.assert_contains("let");
890        // Line-number gutter from the numbered path (`status.rs` render).
891        tb.assert_contains("1 │");
892    }
893
894    #[test]
895    fn code_block_default_drop_renders() {
896        // Bare drop-render (no chain) must produce the same content as `.show()`.
897        let mut tb_drop = TestBackend::new(40, 8);
898        tb_drop.render(|ui| {
899            ui.code_block("a\nb");
900        });
901        let mut tb_show = TestBackend::new(40, 8);
902        tb_show.render(|ui| {
903            let _ = ui.code_block("a\nb").show();
904        });
905        assert_eq!(tb_drop.to_string(), tb_show.to_string());
906    }
907
908    #[test]
909    fn code_block_show_returns_outer_warm_frame_response() {
910        let mut backend = TestBackend::new(30, 8);
911        let mut response = Response::none();
912        backend.render(|ui| {
913            response = ui.code_block("let x = 1;").lang("rust").show();
914        });
915        assert_eq!(response.rect, Rect::default());
916
917        backend.render(|ui| {
918            response = ui.code_block("let x = 1;").lang("rust").show();
919        });
920        assert!(response.rect.width > 0);
921        assert!(response.rect.height > 0);
922    }
923
924    #[test]
925    fn empty_code_block_returns_none_without_rendering() {
926        let mut backend = TestBackend::new(30, 8);
927        let mut response = Response::none();
928        backend.render(|ui| {
929            response = ui.code_block("").show();
930        });
931        assert_eq!(response.rect, Rect::default());
932        assert_eq!(backend.to_string().trim(), "");
933    }
934
935    #[test]
936    fn code_block_deprecated_alias_byte_identical() {
937        let code = "fn main() {}\nlet y = 2;";
938        let mut tb_builder = TestBackend::new(40, 8);
939        tb_builder.render(|ui| {
940            let _ = ui.code_block(code).lang("rust").numbered().show();
941        });
942        let mut tb_alias = TestBackend::new(40, 8);
943        tb_alias.render(|ui| {
944            #[allow(deprecated)]
945            let _ = ui.code_block_numbered_lang(code, "rust");
946        });
947        assert_eq!(
948            tb_builder.to_string(),
949            tb_alias.to_string(),
950            "deprecated alias must be behavior-preserving"
951        );
952    }
953
954    #[test]
955    fn alert_message_first_then_level() {
956        // Regression guard for the API_DESIGN.md arg-order drift: `(message,
957        // level)` is the shipped order. Compiles == doc order matches code.
958        let mut tb = TestBackend::new(40, 5);
959        tb.render(|ui| {
960            let _ = ui.alert("Disk full", AlertLevel::Error);
961        });
962        tb.assert_contains("Disk full");
963    }
964}