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}