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