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}