Skip to main content

slt/context/
widgets_display.rs

1use super::*;
2use crate::KeyMap;
3
4impl Context {
5    // ── text ──────────────────────────────────────────────────────────
6
7    /// Render a text element. Returns `&mut Self` for style chaining.
8    ///
9    /// # Example
10    ///
11    /// ```no_run
12    /// # slt::run(|ui: &mut slt::Context| {
13    /// use slt::Color;
14    /// ui.text("hello").bold().fg(Color::Cyan);
15    /// # });
16    /// ```
17    pub fn text(&mut self, s: impl Into<String>) -> &mut Self {
18        let content = s.into();
19        let default_fg = self
20            .text_color_stack
21            .iter()
22            .rev()
23            .find_map(|c| *c)
24            .unwrap_or(self.theme.text);
25        self.commands.push(Command::Text {
26            content,
27            cursor_offset: None,
28            style: Style::new().fg(default_fg),
29            grow: 0,
30            align: Align::Start,
31            wrap: false,
32            truncate: false,
33            margin: Margin::default(),
34            constraints: Constraints::default(),
35        });
36        self.last_text_idx = Some(self.commands.len() - 1);
37        self
38    }
39
40    /// Render a clickable hyperlink.
41    ///
42    /// The link is interactive: clicking it (or pressing Enter/Space when
43    /// focused) opens the URL in the system browser. OSC 8 is also emitted
44    /// for terminals that support native hyperlinks.
45    #[allow(clippy::print_stderr)]
46    pub fn link(&mut self, text: impl Into<String>, url: impl Into<String>) -> &mut Self {
47        let url_str = url.into();
48        let focused = self.register_focusable();
49        let interaction_id = self.next_interaction_id();
50        let response = self.response_for(interaction_id);
51
52        let mut activated = response.clicked;
53        if focused {
54            for (i, event) in self.events.iter().enumerate() {
55                if let Event::Key(key) = event {
56                    if key.kind != KeyEventKind::Press {
57                        continue;
58                    }
59                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
60                        activated = true;
61                        self.consumed[i] = true;
62                    }
63                }
64            }
65        }
66
67        if activated {
68            if let Err(e) = open_url(&url_str) {
69                eprintln!("[slt] failed to open URL: {e}");
70            }
71        }
72
73        let style = if focused {
74            Style::new()
75                .fg(self.theme.primary)
76                .bg(self.theme.surface_hover)
77                .underline()
78                .bold()
79        } else if response.hovered {
80            Style::new()
81                .fg(self.theme.accent)
82                .bg(self.theme.surface_hover)
83                .underline()
84        } else {
85            Style::new().fg(self.theme.primary).underline()
86        };
87
88        self.commands.push(Command::Link {
89            text: text.into(),
90            url: url_str,
91            style,
92            margin: Margin::default(),
93            constraints: Constraints::default(),
94        });
95        self.last_text_idx = Some(self.commands.len() - 1);
96        self
97    }
98
99    /// Render a text element with word-boundary wrapping.
100    ///
101    /// Long lines are broken at word boundaries to fit the container width.
102    /// Style chaining works the same as [`Context::text`].
103    ///
104    /// **Prefer** `ui.text("...").wrap()` — this method exists for convenience
105    /// but the chaining form is more consistent with the rest of the API.
106    #[deprecated(since = "0.15.4", note = "use ui.text(s).wrap() instead")]
107    pub fn text_wrap(&mut self, s: impl Into<String>) -> &mut Self {
108        let content = s.into();
109        let default_fg = self
110            .text_color_stack
111            .iter()
112            .rev()
113            .find_map(|c| *c)
114            .unwrap_or(self.theme.text);
115        self.commands.push(Command::Text {
116            content,
117            cursor_offset: None,
118            style: Style::new().fg(default_fg),
119            grow: 0,
120            align: Align::Start,
121            wrap: true,
122            truncate: false,
123            margin: Margin::default(),
124            constraints: Constraints::default(),
125        });
126        self.last_text_idx = Some(self.commands.len() - 1);
127        self
128    }
129
130    /// Render an elapsed time display.
131    ///
132    /// Formats as `HH:MM:SS.CC` when hours are non-zero, otherwise `MM:SS.CC`.
133    pub fn timer_display(&mut self, elapsed: std::time::Duration) -> &mut Self {
134        let total_centis = elapsed.as_millis() / 10;
135        let centis = total_centis % 100;
136        let total_seconds = total_centis / 100;
137        let seconds = total_seconds % 60;
138        let minutes = (total_seconds / 60) % 60;
139        let hours = total_seconds / 3600;
140
141        let content = if hours > 0 {
142            format!("{hours:02}:{minutes:02}:{seconds:02}.{centis:02}")
143        } else {
144            format!("{minutes:02}:{seconds:02}.{centis:02}")
145        };
146
147        self.commands.push(Command::Text {
148            content,
149            cursor_offset: None,
150            style: Style::new().fg(self.theme.text),
151            grow: 0,
152            align: Align::Start,
153            wrap: false,
154            truncate: false,
155            margin: Margin::default(),
156            constraints: Constraints::default(),
157        });
158        self.last_text_idx = Some(self.commands.len() - 1);
159        self
160    }
161
162    /// Render help bar from a KeyMap. Shows visible bindings as key-description pairs.
163    pub fn help_from_keymap(&mut self, keymap: &KeyMap) -> Response {
164        let pairs: Vec<(&str, &str)> = keymap
165            .visible_bindings()
166            .map(|binding| (binding.display.as_str(), binding.description.as_str()))
167            .collect();
168        self.help(&pairs)
169    }
170
171    // ── style chain (applies to last text) ───────────────────────────
172
173    /// Apply bold to the last rendered text element.
174    pub fn bold(&mut self) -> &mut Self {
175        self.modify_last_style(|s| s.modifiers |= Modifiers::BOLD);
176        self
177    }
178
179    /// Apply dim styling to the last rendered text element.
180    ///
181    /// Also sets the foreground color to the theme's `text_dim` color if no
182    /// explicit foreground has been set.
183    pub fn dim(&mut self) -> &mut Self {
184        let text_dim = self.theme.text_dim;
185        self.modify_last_style(|s| {
186            s.modifiers |= Modifiers::DIM;
187            if s.fg.is_none() {
188                s.fg = Some(text_dim);
189            }
190        });
191        self
192    }
193
194    /// Apply italic to the last rendered text element.
195    pub fn italic(&mut self) -> &mut Self {
196        self.modify_last_style(|s| s.modifiers |= Modifiers::ITALIC);
197        self
198    }
199
200    /// Apply underline to the last rendered text element.
201    pub fn underline(&mut self) -> &mut Self {
202        self.modify_last_style(|s| s.modifiers |= Modifiers::UNDERLINE);
203        self
204    }
205
206    /// Apply reverse-video to the last rendered text element.
207    pub fn reversed(&mut self) -> &mut Self {
208        self.modify_last_style(|s| s.modifiers |= Modifiers::REVERSED);
209        self
210    }
211
212    /// Apply strikethrough to the last rendered text element.
213    pub fn strikethrough(&mut self) -> &mut Self {
214        self.modify_last_style(|s| s.modifiers |= Modifiers::STRIKETHROUGH);
215        self
216    }
217
218    /// Set the foreground color of the last rendered text element.
219    pub fn fg(&mut self, color: Color) -> &mut Self {
220        self.modify_last_style(|s| s.fg = Some(color));
221        self
222    }
223
224    /// Set the background color of the last rendered text element.
225    pub fn bg(&mut self, color: Color) -> &mut Self {
226        self.modify_last_style(|s| s.bg = Some(color));
227        self
228    }
229
230    /// Apply a per-character foreground gradient to the last rendered text.
231    pub fn gradient(&mut self, from: Color, to: Color) -> &mut Self {
232        if let Some(idx) = self.last_text_idx {
233            let replacement = match &self.commands[idx] {
234                Command::Text {
235                    content,
236                    style,
237                    wrap,
238                    align,
239                    margin,
240                    constraints,
241                    ..
242                } => {
243                    let chars: Vec<char> = content.chars().collect();
244                    let len = chars.len();
245                    let denom = len.saturating_sub(1).max(1) as f32;
246                    let segments = chars
247                        .into_iter()
248                        .enumerate()
249                        .map(|(i, ch)| {
250                            let mut seg_style = *style;
251                            seg_style.fg = Some(from.blend(to, i as f32 / denom));
252                            (ch.to_string(), seg_style)
253                        })
254                        .collect();
255
256                    Some(Command::RichText {
257                        segments,
258                        wrap: *wrap,
259                        align: *align,
260                        margin: *margin,
261                        constraints: *constraints,
262                    })
263                }
264                _ => None,
265            };
266
267            if let Some(command) = replacement {
268                self.commands[idx] = command;
269            }
270        }
271
272        self
273    }
274
275    /// Set foreground color when the current group is hovered or focused.
276    pub fn group_hover_fg(&mut self, color: Color) -> &mut Self {
277        let apply_group_style = self
278            .group_stack
279            .last()
280            .map(|name| self.is_group_hovered(name) || self.is_group_focused(name))
281            .unwrap_or(false);
282        if apply_group_style {
283            self.modify_last_style(|s| s.fg = Some(color));
284        }
285        self
286    }
287
288    /// Set background color when the current group is hovered or focused.
289    pub fn group_hover_bg(&mut self, color: Color) -> &mut Self {
290        let apply_group_style = self
291            .group_stack
292            .last()
293            .map(|name| self.is_group_hovered(name) || self.is_group_focused(name))
294            .unwrap_or(false);
295        if apply_group_style {
296            self.modify_last_style(|s| s.bg = Some(color));
297        }
298        self
299    }
300
301    /// Render a text element with an explicit [`Style`] applied immediately.
302    ///
303    /// Equivalent to calling `text(s)` followed by style-chain methods, but
304    /// more concise when you already have a `Style` value.
305    pub fn styled(&mut self, s: impl Into<String>, style: Style) -> &mut Self {
306        self.styled_with_cursor(s, style, None)
307    }
308
309    pub(crate) fn styled_with_cursor(
310        &mut self,
311        s: impl Into<String>,
312        style: Style,
313        cursor_offset: Option<usize>,
314    ) -> &mut Self {
315        self.commands.push(Command::Text {
316            content: s.into(),
317            cursor_offset,
318            style,
319            grow: 0,
320            align: Align::Start,
321            wrap: false,
322            truncate: false,
323            margin: Margin::default(),
324            constraints: Constraints::default(),
325        });
326        self.last_text_idx = Some(self.commands.len() - 1);
327        self
328    }
329
330    /// Render 8x8 bitmap text as half-block pixels (4 terminal rows tall).
331    pub fn big_text(&mut self, s: impl Into<String>) -> Response {
332        let text = s.into();
333        let glyphs: Vec<[u8; 8]> = text.chars().map(glyph_8x8).collect();
334        let total_width = (glyphs.len() as u32).saturating_mul(8);
335        let on_color = self.theme.primary;
336
337        self.container().w(total_width).h(4).draw(move |buf, rect| {
338            if rect.width == 0 || rect.height == 0 {
339                return;
340            }
341
342            for (glyph_idx, glyph) in glyphs.iter().enumerate() {
343                let base_x = rect.x + (glyph_idx as u32) * 8;
344                if base_x >= rect.right() {
345                    break;
346                }
347
348                for pair in 0..4usize {
349                    let y = rect.y + pair as u32;
350                    if y >= rect.bottom() {
351                        continue;
352                    }
353
354                    let upper = glyph[pair * 2];
355                    let lower = glyph[pair * 2 + 1];
356
357                    for bit in 0..8u32 {
358                        let x = base_x + bit;
359                        if x >= rect.right() {
360                            break;
361                        }
362
363                        let mask = 1u8 << (bit as u8);
364                        let upper_on = (upper & mask) != 0;
365                        let lower_on = (lower & mask) != 0;
366                        let (ch, fg, bg) = match (upper_on, lower_on) {
367                            (true, true) => ('█', on_color, on_color),
368                            (true, false) => ('▀', on_color, Color::Reset),
369                            (false, true) => ('▄', on_color, Color::Reset),
370                            (false, false) => (' ', Color::Reset, Color::Reset),
371                        };
372                        buf.set_char(x, y, ch, Style::new().fg(fg).bg(bg));
373                    }
374                }
375            }
376        });
377
378        Response::none()
379    }
380
381    /// Render a half-block image in the terminal.
382    ///
383    /// Each terminal cell displays two vertical pixels using the `▀` character
384    /// with foreground (upper pixel) and background (lower pixel) colors.
385    ///
386    /// Create a [`HalfBlockImage`] from a file (requires `image` feature):
387    /// ```ignore
388    /// let img = image::open("photo.png").unwrap();
389    /// let half = HalfBlockImage::from_dynamic(&img, 40, 20);
390    /// ui.image(&half);
391    /// ```
392    ///
393    /// Or from raw RGB data (no feature needed):
394    /// ```no_run
395    /// # use slt::{Context, HalfBlockImage};
396    /// # slt::run(|ui: &mut Context| {
397    /// let rgb = vec![255u8; 30 * 20 * 3];
398    /// let half = HalfBlockImage::from_rgb(&rgb, 30, 10);
399    /// ui.image(&half);
400    /// # });
401    /// ```
402    pub fn image(&mut self, img: &HalfBlockImage) -> Response {
403        let width = img.width;
404        let height = img.height;
405
406        let _ = self.container().w(width).h(height).gap(0).col(|ui| {
407            for row in 0..height {
408                let _ = ui.container().gap(0).row(|ui| {
409                    for col in 0..width {
410                        let idx = (row * width + col) as usize;
411                        if let Some(&(upper, lower)) = img.pixels.get(idx) {
412                            ui.styled("▀", Style::new().fg(upper).bg(lower));
413                        }
414                    }
415                });
416            }
417        });
418
419        Response::none()
420    }
421
422    /// Render a pixel-perfect image using the Kitty graphics protocol.
423    ///
424    /// The image data must be raw RGBA bytes (4 bytes per pixel).
425    /// The widget allocates `cols` x `rows` cells and renders the image
426    /// at full pixel resolution within that space.
427    ///
428    /// Requires a Kitty-compatible terminal (Kitty, Ghostty, WezTerm).
429    /// On unsupported terminals, the area will be blank.
430    ///
431    /// # Arguments
432    /// * `rgba` - Raw RGBA pixel data
433    /// * `pixel_width` - Image width in pixels
434    /// * `pixel_height` - Image height in pixels
435    /// * `cols` - Terminal cell columns to occupy
436    /// * `rows` - Terminal cell rows to occupy
437    pub fn kitty_image(
438        &mut self,
439        rgba: &[u8],
440        pixel_width: u32,
441        pixel_height: u32,
442        cols: u32,
443        rows: u32,
444    ) -> Response {
445        let rgba_data = normalize_rgba(rgba, pixel_width, pixel_height);
446        let content_hash = crate::buffer::hash_rgba(&rgba_data);
447        let rgba_arc = std::sync::Arc::new(rgba_data);
448        let sw = pixel_width;
449        let sh = pixel_height;
450
451        self.container().w(cols).h(rows).draw(move |buf, rect| {
452            if rect.width == 0 || rect.height == 0 {
453                return;
454            }
455            buf.kitty_place(crate::buffer::KittyPlacement {
456                content_hash,
457                rgba: rgba_arc.clone(),
458                src_width: sw,
459                src_height: sh,
460                x: rect.x,
461                y: rect.y,
462                cols: rect.width,
463                rows: rect.height,
464                crop_y: 0,
465                crop_h: 0,
466            });
467        });
468        Response::none()
469    }
470
471    /// Render a pixel-perfect image that preserves aspect ratio.
472    ///
473    /// Sends the original RGBA data to the terminal and lets the Kitty
474    /// protocol handle scaling. The container width is `cols` cells;
475    /// height is calculated automatically from the image aspect ratio
476    /// using detected cell pixel dimensions (falls back to 8×16 if
477    /// detection fails).
478    ///
479    /// Requires a Kitty-compatible terminal (Kitty, Ghostty, WezTerm).
480    pub fn kitty_image_fit(
481        &mut self,
482        rgba: &[u8],
483        src_width: u32,
484        src_height: u32,
485        cols: u32,
486    ) -> Response {
487        #[cfg(feature = "crossterm")]
488        let (cell_w, cell_h) = crate::terminal::cell_pixel_size();
489        #[cfg(not(feature = "crossterm"))]
490        let (cell_w, cell_h) = (8u32, 16u32);
491
492        let rows = if src_width == 0 {
493            1
494        } else {
495            ((cols as f64 * src_height as f64 * cell_w as f64) / (src_width as f64 * cell_h as f64))
496                .ceil()
497                .max(1.0) as u32
498        };
499        let rgba_data = normalize_rgba(rgba, src_width, src_height);
500        let content_hash = crate::buffer::hash_rgba(&rgba_data);
501        let rgba_arc = std::sync::Arc::new(rgba_data);
502        let sw = src_width;
503        let sh = src_height;
504
505        self.container().w(cols).h(rows).draw(move |buf, rect| {
506            if rect.width == 0 || rect.height == 0 {
507                return;
508            }
509            buf.kitty_place(crate::buffer::KittyPlacement {
510                content_hash,
511                rgba: rgba_arc.clone(),
512                src_width: sw,
513                src_height: sh,
514                x: rect.x,
515                y: rect.y,
516                cols: rect.width,
517                rows: rect.height,
518                crop_y: 0,
519                crop_h: 0,
520            });
521        });
522        Response::none()
523    }
524
525    /// Render an image using the Sixel protocol.
526    ///
527    /// `rgba` is raw RGBA pixel data, `pixel_w`/`pixel_h` are pixel dimensions,
528    /// and `cols`/`rows` are the terminal cell size to reserve for the image.
529    ///
530    /// Requires the `crossterm` feature (enabled by default). Falls back to
531    /// `[sixel unsupported]` on terminals without Sixel support. Set the
532    /// `SLT_FORCE_SIXEL=1` environment variable to skip terminal detection.
533    ///
534    /// # Example
535    ///
536    /// ```no_run
537    /// # slt::run(|ui: &mut slt::Context| {
538    /// // 2x2 red square (RGBA: 4 pixels × 4 bytes)
539    /// let rgba = [255u8, 0, 0, 255].repeat(4);
540    /// ui.sixel_image(&rgba, 2, 2, 20, 2);
541    /// # });
542    /// ```
543    #[cfg(feature = "crossterm")]
544    pub fn sixel_image(
545        &mut self,
546        rgba: &[u8],
547        pixel_w: u32,
548        pixel_h: u32,
549        cols: u32,
550        rows: u32,
551    ) -> Response {
552        let sixel_supported = self.is_real_terminal && terminal_supports_sixel();
553        if !sixel_supported {
554            self.container().w(cols).h(rows).draw(|buf, rect| {
555                if rect.width == 0 || rect.height == 0 {
556                    return;
557                }
558                buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
559            });
560            return Response::none();
561        }
562
563        let rgba = normalize_rgba(rgba, pixel_w, pixel_h);
564        let encoded = crate::sixel::encode_sixel(&rgba, pixel_w, pixel_h, 256);
565
566        if encoded.is_empty() {
567            self.container().w(cols).h(rows).draw(|buf, rect| {
568                if rect.width == 0 || rect.height == 0 {
569                    return;
570                }
571                buf.set_string(rect.x, rect.y, "[sixel empty]", Style::new());
572            });
573            return Response::none();
574        }
575
576        self.container().w(cols).h(rows).draw(move |buf, rect| {
577            if rect.width == 0 || rect.height == 0 {
578                return;
579            }
580            buf.raw_sequence(rect.x, rect.y, encoded);
581        });
582        Response::none()
583    }
584
585    /// Render an image using the Sixel protocol.
586    #[cfg(not(feature = "crossterm"))]
587    pub fn sixel_image(
588        &mut self,
589        _rgba: &[u8],
590        _pixel_w: u32,
591        _pixel_h: u32,
592        cols: u32,
593        rows: u32,
594    ) -> Response {
595        self.container().w(cols).h(rows).draw(|buf, rect| {
596            if rect.width == 0 || rect.height == 0 {
597                return;
598            }
599            buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
600        });
601        Response::none()
602    }
603
604    /// Render streaming text with a typing cursor indicator.
605    ///
606    /// Displays the accumulated text content. While `streaming` is true,
607    /// shows a blinking cursor (`▌`) at the end.
608    ///
609    /// ```no_run
610    /// # use slt::widgets::StreamingTextState;
611    /// # slt::run(|ui: &mut slt::Context| {
612    /// let mut stream = StreamingTextState::new();
613    /// stream.start();
614    /// stream.push("Hello from ");
615    /// stream.push("the AI!");
616    /// ui.streaming_text(&mut stream);
617    /// # });
618    /// ```
619    pub fn streaming_text(&mut self, state: &mut StreamingTextState) -> Response {
620        if state.streaming {
621            state.cursor_tick = state.cursor_tick.wrapping_add(1);
622            state.cursor_visible = (state.cursor_tick / 8) % 2 == 0;
623        }
624
625        if state.content.is_empty() && state.streaming {
626            let cursor = if state.cursor_visible { "▌" } else { " " };
627            let primary = self.theme.primary;
628            self.text(cursor).fg(primary);
629            return Response::none();
630        }
631
632        if !state.content.is_empty() {
633            if state.streaming && state.cursor_visible {
634                self.text(format!("{}▌", state.content)).wrap();
635            } else {
636                self.text(&state.content).wrap();
637            }
638        }
639
640        Response::none()
641    }
642
643    /// Render streaming markdown with a typing cursor indicator.
644    ///
645    /// Parses accumulated markdown content line-by-line while streaming.
646    /// Supports headings, lists, inline formatting, horizontal rules, and
647    /// fenced code blocks with open/close tracking across stream chunks.
648    ///
649    /// ```no_run
650    /// # use slt::widgets::StreamingMarkdownState;
651    /// # slt::run(|ui: &mut slt::Context| {
652    /// let mut stream = StreamingMarkdownState::new();
653    /// stream.start();
654    /// stream.push("# Hello\n");
655    /// stream.push("- **streaming** markdown\n");
656    /// stream.push("```rust\nlet x = 1;\n");
657    /// ui.streaming_markdown(&mut stream);
658    /// # });
659    /// ```
660    pub fn streaming_markdown(
661        &mut self,
662        state: &mut crate::widgets::StreamingMarkdownState,
663    ) -> Response {
664        if state.streaming {
665            state.cursor_tick = state.cursor_tick.wrapping_add(1);
666            state.cursor_visible = (state.cursor_tick / 8) % 2 == 0;
667        }
668
669        if state.content.is_empty() && state.streaming {
670            let cursor = if state.cursor_visible { "▌" } else { " " };
671            let primary = self.theme.primary;
672            self.text(cursor).fg(primary);
673            return Response::none();
674        }
675
676        let show_cursor = state.streaming && state.cursor_visible;
677        let trailing_newline = state.content.ends_with('\n');
678        let lines: Vec<&str> = state.content.lines().collect();
679        let last_line_index = lines.len().saturating_sub(1);
680
681        self.commands.push(Command::BeginContainer {
682            direction: Direction::Column,
683            gap: 0,
684            align: Align::Start,
685            align_self: None,
686            justify: Justify::Start,
687            border: None,
688            border_sides: BorderSides::all(),
689            border_style: Style::new().fg(self.theme.border),
690            bg_color: None,
691            padding: Padding::default(),
692            margin: Margin::default(),
693            constraints: Constraints::default(),
694            title: None,
695            grow: 0,
696            group_name: None,
697        });
698        self.interaction_count += 1;
699
700        let text_style = Style::new().fg(self.theme.text);
701        let bold_style = Style::new().fg(self.theme.text).bold();
702        let code_style = Style::new().fg(self.theme.accent);
703        let border_style = Style::new().fg(self.theme.border).dim();
704
705        let mut in_code_block = false;
706        let mut code_block_lang = String::new();
707
708        for (idx, line) in lines.iter().enumerate() {
709            let line = *line;
710            let trimmed = line.trim();
711            let append_cursor = show_cursor && !trailing_newline && idx == last_line_index;
712            let cursor = if append_cursor { "▌" } else { "" };
713
714            if in_code_block {
715                if trimmed.starts_with("```") {
716                    in_code_block = false;
717                    code_block_lang.clear();
718                    let mut line = String::from("  └────");
719                    line.push_str(cursor);
720                    self.styled(line, border_style);
721                } else {
722                    self.line(|ui| {
723                        ui.text("  ");
724                        render_highlighted_line(ui, line);
725                        if !cursor.is_empty() {
726                            ui.styled(cursor, Style::new().fg(ui.theme.primary));
727                        }
728                    });
729                }
730                continue;
731            }
732
733            if trimmed.is_empty() {
734                if append_cursor {
735                    self.styled("▌", Style::new().fg(self.theme.primary));
736                } else {
737                    self.text(" ");
738                }
739                continue;
740            }
741
742            if trimmed == "---" || trimmed == "***" || trimmed == "___" {
743                let mut line = "─".repeat(40);
744                line.push_str(cursor);
745                self.styled(line, border_style);
746                continue;
747            }
748
749            if let Some(heading) = trimmed.strip_prefix("### ") {
750                let mut line = String::with_capacity(heading.len() + cursor.len());
751                line.push_str(heading);
752                line.push_str(cursor);
753                self.styled(line, Style::new().bold().fg(self.theme.accent));
754                continue;
755            }
756
757            if let Some(heading) = trimmed.strip_prefix("## ") {
758                let mut line = String::with_capacity(heading.len() + cursor.len());
759                line.push_str(heading);
760                line.push_str(cursor);
761                self.styled(line, Style::new().bold().fg(self.theme.secondary));
762                continue;
763            }
764
765            if let Some(heading) = trimmed.strip_prefix("# ") {
766                let mut line = String::with_capacity(heading.len() + cursor.len());
767                line.push_str(heading);
768                line.push_str(cursor);
769                self.styled(line, Style::new().bold().fg(self.theme.primary));
770                continue;
771            }
772
773            if let Some(code) = trimmed.strip_prefix("```") {
774                in_code_block = true;
775                code_block_lang = code.trim().to_string();
776                let label = if code_block_lang.is_empty() {
777                    "code".to_string()
778                } else {
779                    let mut label = String::from("code:");
780                    label.push_str(&code_block_lang);
781                    label
782                };
783                let mut line = String::with_capacity(5 + label.len() + cursor.len());
784                line.push_str("  ┌─");
785                line.push_str(&label);
786                line.push('─');
787                line.push_str(cursor);
788                self.styled(line, border_style);
789                continue;
790            }
791
792            if let Some(item) = trimmed
793                .strip_prefix("- ")
794                .or_else(|| trimmed.strip_prefix("* "))
795            {
796                let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
797                if segs.len() <= 1 {
798                    let mut line = String::with_capacity(4 + item.len() + cursor.len());
799                    line.push_str("  • ");
800                    line.push_str(item);
801                    line.push_str(cursor);
802                    self.styled(line, text_style);
803                } else {
804                    self.line(|ui| {
805                        ui.styled("  • ", text_style);
806                        for (s, st) in segs {
807                            ui.styled(s, st);
808                        }
809                        if append_cursor {
810                            ui.styled("▌", Style::new().fg(ui.theme.primary));
811                        }
812                    });
813                }
814                continue;
815            }
816
817            if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
818                let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
819                if parts.len() == 2 {
820                    let segs =
821                        Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
822                    if segs.len() <= 1 {
823                        let mut line = String::with_capacity(
824                            4 + parts[0].len() + parts[1].len() + cursor.len(),
825                        );
826                        line.push_str("  ");
827                        line.push_str(parts[0]);
828                        line.push_str(". ");
829                        line.push_str(parts[1]);
830                        line.push_str(cursor);
831                        self.styled(line, text_style);
832                    } else {
833                        self.line(|ui| {
834                            let mut prefix = String::with_capacity(4 + parts[0].len());
835                            prefix.push_str("  ");
836                            prefix.push_str(parts[0]);
837                            prefix.push_str(". ");
838                            ui.styled(prefix, text_style);
839                            for (s, st) in segs {
840                                ui.styled(s, st);
841                            }
842                            if append_cursor {
843                                ui.styled("▌", Style::new().fg(ui.theme.primary));
844                            }
845                        });
846                    }
847                } else {
848                    let mut line = String::with_capacity(trimmed.len() + cursor.len());
849                    line.push_str(trimmed);
850                    line.push_str(cursor);
851                    self.styled(line, text_style);
852                }
853                continue;
854            }
855
856            let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
857            if segs.len() <= 1 {
858                let mut line = String::with_capacity(trimmed.len() + cursor.len());
859                line.push_str(trimmed);
860                line.push_str(cursor);
861                self.styled(line, text_style);
862            } else {
863                self.line(|ui| {
864                    for (s, st) in segs {
865                        ui.styled(s, st);
866                    }
867                    if append_cursor {
868                        ui.styled("▌", Style::new().fg(ui.theme.primary));
869                    }
870                });
871            }
872        }
873
874        if show_cursor && trailing_newline {
875            if in_code_block {
876                self.styled("  ▌", code_style);
877            } else {
878                self.styled("▌", Style::new().fg(self.theme.primary));
879            }
880        }
881
882        state.in_code_block = in_code_block;
883        state.code_block_lang = code_block_lang;
884
885        self.commands.push(Command::EndContainer);
886        self.last_text_idx = None;
887        Response::none()
888    }
889
890    /// Render a tool approval widget with approve/reject buttons.
891    ///
892    /// Shows the tool name, description, and two action buttons.
893    /// Returns the updated [`ApprovalAction`] each frame.
894    ///
895    /// ```no_run
896    /// # use slt::widgets::{ApprovalAction, ToolApprovalState};
897    /// # slt::run(|ui: &mut slt::Context| {
898    /// let mut tool = ToolApprovalState::new("read_file", "Read contents of config.toml");
899    /// ui.tool_approval(&mut tool);
900    /// if tool.action == ApprovalAction::Approved {
901    /// }
902    /// # });
903    /// ```
904    pub fn tool_approval(&mut self, state: &mut ToolApprovalState) -> Response {
905        let old_action = state.action;
906        let theme = self.theme;
907        let _ = self.bordered(Border::Rounded).col(|ui| {
908            let _ = ui.row(|ui| {
909                ui.text("⚡").fg(theme.warning);
910                ui.text(&state.tool_name).bold().fg(theme.primary);
911            });
912            ui.text(&state.description).dim();
913
914            if state.action == ApprovalAction::Pending {
915                let _ = ui.row(|ui| {
916                    if ui.button("✓ Approve").clicked {
917                        state.action = ApprovalAction::Approved;
918                    }
919                    if ui.button("✗ Reject").clicked {
920                        state.action = ApprovalAction::Rejected;
921                    }
922                });
923            } else {
924                let (label, color) = match state.action {
925                    ApprovalAction::Approved => ("✓ Approved", theme.success),
926                    ApprovalAction::Rejected => ("✗ Rejected", theme.error),
927                    ApprovalAction::Pending => unreachable!(),
928                };
929                ui.text(label).fg(color).bold();
930            }
931        });
932
933        Response {
934            changed: state.action != old_action,
935            ..Response::none()
936        }
937    }
938
939    /// Render a context bar showing active context items with token counts.
940    ///
941    /// Displays a horizontal bar of context sources (files, URLs, etc.)
942    /// with their token counts, useful for AI chat interfaces.
943    ///
944    /// ```no_run
945    /// # use slt::widgets::ContextItem;
946    /// # slt::run(|ui: &mut slt::Context| {
947    /// let items = vec![ContextItem::new("main.rs", 1200), ContextItem::new("lib.rs", 800)];
948    /// ui.context_bar(&items);
949    /// # });
950    /// ```
951    pub fn context_bar(&mut self, items: &[ContextItem]) -> Response {
952        if items.is_empty() {
953            return Response::none();
954        }
955
956        let theme = self.theme;
957        let total: usize = items.iter().map(|item| item.tokens).sum();
958
959        let _ = self.container().row(|ui| {
960            ui.text("📎").dim();
961            for item in items {
962                let token_count = format_token_count(item.tokens);
963                let mut line = String::with_capacity(item.label.len() + token_count.len() + 3);
964                line.push_str(&item.label);
965                line.push_str(" (");
966                line.push_str(&token_count);
967                line.push(')');
968                ui.text(line).fg(theme.secondary);
969            }
970            ui.spacer();
971            let total_text = format_token_count(total);
972            let mut line = String::with_capacity(2 + total_text.len());
973            line.push_str("Σ ");
974            line.push_str(&total_text);
975            ui.text(line).dim();
976        });
977
978        Response::none()
979    }
980
981    /// Render an alert banner with icon and level-based coloring.
982    pub fn alert(&mut self, message: &str, level: crate::widgets::AlertLevel) -> Response {
983        use crate::widgets::AlertLevel;
984
985        let theme = self.theme;
986        let (icon, color) = match level {
987            AlertLevel::Info => ("ℹ", theme.accent),
988            AlertLevel::Success => ("✓", theme.success),
989            AlertLevel::Warning => ("⚠", theme.warning),
990            AlertLevel::Error => ("✕", theme.error),
991        };
992
993        let focused = self.register_focusable();
994        let key_dismiss = focused && (self.key_code(KeyCode::Enter) || self.key('x'));
995
996        let mut response = self.container().col(|ui| {
997            ui.line(|ui| {
998                let mut icon_text = String::with_capacity(icon.len() + 2);
999                icon_text.push(' ');
1000                icon_text.push_str(icon);
1001                icon_text.push(' ');
1002                ui.text(icon_text).fg(color).bold();
1003                ui.text(message).grow(1);
1004                ui.text(" [×] ").dim();
1005            });
1006        });
1007        response.focused = focused;
1008        if key_dismiss {
1009            response.clicked = true;
1010        }
1011
1012        response
1013    }
1014
1015    /// Yes/No confirmation dialog. Returns Response with .clicked=true when answered.
1016    ///
1017    /// `result` is set to true for Yes, false for No.
1018    ///
1019    /// # Examples
1020    /// ```
1021    /// # use slt::*;
1022    /// # TestBackend::new(80, 24).render(|ui| {
1023    /// let mut answer = false;
1024    /// let r = ui.confirm("Delete this file?", &mut answer);
1025    /// if r.clicked && answer { /* user confirmed */ }
1026    /// # });
1027    /// ```
1028    pub fn confirm(&mut self, question: &str, result: &mut bool) -> Response {
1029        let focused = self.register_focusable();
1030        let mut is_yes = *result;
1031        let mut clicked = false;
1032
1033        if focused {
1034            let mut consumed_indices = Vec::new();
1035            for (i, event) in self.events.iter().enumerate() {
1036                if let Event::Key(key) = event {
1037                    if key.kind != KeyEventKind::Press {
1038                        continue;
1039                    }
1040
1041                    match key.code {
1042                        KeyCode::Char('y') => {
1043                            is_yes = true;
1044                            *result = true;
1045                            clicked = true;
1046                            consumed_indices.push(i);
1047                        }
1048                        KeyCode::Char('n') => {
1049                            is_yes = false;
1050                            *result = false;
1051                            clicked = true;
1052                            consumed_indices.push(i);
1053                        }
1054                        KeyCode::Tab | KeyCode::BackTab | KeyCode::Left | KeyCode::Right => {
1055                            is_yes = !is_yes;
1056                            *result = is_yes;
1057                            consumed_indices.push(i);
1058                        }
1059                        KeyCode::Enter => {
1060                            *result = is_yes;
1061                            clicked = true;
1062                            consumed_indices.push(i);
1063                        }
1064                        _ => {}
1065                    }
1066                }
1067            }
1068
1069            for idx in consumed_indices {
1070                self.consumed[idx] = true;
1071            }
1072        }
1073
1074        let yes_style = if is_yes {
1075            if focused {
1076                Style::new().fg(self.theme.bg).bg(self.theme.success).bold()
1077            } else {
1078                Style::new().fg(self.theme.success).bold()
1079            }
1080        } else {
1081            Style::new().fg(self.theme.text_dim)
1082        };
1083        let no_style = if !is_yes {
1084            if focused {
1085                Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
1086            } else {
1087                Style::new().fg(self.theme.error).bold()
1088            }
1089        } else {
1090            Style::new().fg(self.theme.text_dim)
1091        };
1092
1093        let q_width = UnicodeWidthStr::width(question) as u32;
1094        let mut response = self.row(|ui| {
1095            ui.text(question);
1096            ui.text(" ");
1097            ui.styled("[Yes]", yes_style);
1098            ui.text(" ");
1099            ui.styled("[No]", no_style);
1100        });
1101
1102        if !clicked && response.clicked {
1103            if let Some((mx, _)) = self.click_pos {
1104                let yes_start = response.rect.x + q_width + 1;
1105                let yes_end = yes_start + 5;
1106                let no_start = yes_end + 1;
1107                if mx >= yes_start && mx < yes_end {
1108                    is_yes = true;
1109                    *result = true;
1110                    clicked = true;
1111                } else if mx >= no_start {
1112                    is_yes = false;
1113                    *result = false;
1114                    clicked = true;
1115                }
1116            }
1117        }
1118
1119        response.focused = focused;
1120        response.clicked = clicked;
1121        response.changed = clicked;
1122        let _ = is_yes;
1123        response
1124    }
1125
1126    /// Render a breadcrumb navigation bar. Returns the clicked segment index.
1127    pub fn breadcrumb(&mut self, segments: &[&str]) -> Option<usize> {
1128        self.breadcrumb_with(segments, " › ")
1129    }
1130
1131    /// Render a breadcrumb with a custom separator string.
1132    pub fn breadcrumb_with(&mut self, segments: &[&str], separator: &str) -> Option<usize> {
1133        let theme = self.theme;
1134        let last_idx = segments.len().saturating_sub(1);
1135        let mut clicked_idx: Option<usize> = None;
1136
1137        let _ = self.row(|ui| {
1138            for (i, segment) in segments.iter().enumerate() {
1139                let is_last = i == last_idx;
1140                if is_last {
1141                    ui.text(*segment).bold();
1142                } else {
1143                    let focused = ui.register_focusable();
1144                    let pressed = focused && (ui.key_code(KeyCode::Enter) || ui.key(' '));
1145                    let resp = ui.interaction();
1146                    let color = if resp.hovered || focused {
1147                        theme.accent
1148                    } else {
1149                        theme.primary
1150                    };
1151                    ui.text(*segment).fg(color).underline();
1152                    if resp.clicked || pressed {
1153                        clicked_idx = Some(i);
1154                    }
1155                    ui.text(separator).dim();
1156                }
1157            }
1158        });
1159
1160        clicked_idx
1161    }
1162
1163    /// Collapsible section that toggles on click or Enter.
1164    pub fn accordion(
1165        &mut self,
1166        title: &str,
1167        open: &mut bool,
1168        f: impl FnOnce(&mut Context),
1169    ) -> Response {
1170        let theme = self.theme;
1171        let focused = self.register_focusable();
1172        let old_open = *open;
1173
1174        if focused && self.key_code(KeyCode::Enter) {
1175            *open = !*open;
1176        }
1177
1178        let icon = if *open { "▾" } else { "▸" };
1179        let title_color = if focused { theme.primary } else { theme.text };
1180
1181        let mut response = self.container().col(|ui| {
1182            ui.line(|ui| {
1183                ui.text(icon).fg(title_color);
1184                let mut title_text = String::with_capacity(1 + title.len());
1185                title_text.push(' ');
1186                title_text.push_str(title);
1187                ui.text(title_text).bold().fg(title_color);
1188            });
1189        });
1190
1191        if response.clicked {
1192            *open = !*open;
1193        }
1194
1195        if *open {
1196            let _ = self.container().pl(2).col(f);
1197        }
1198
1199        response.focused = focused;
1200        response.changed = *open != old_open;
1201        response
1202    }
1203
1204    /// Render a key-value definition list with aligned columns.
1205    pub fn definition_list(&mut self, items: &[(&str, &str)]) -> Response {
1206        let max_key_width = items
1207            .iter()
1208            .map(|(k, _)| unicode_width::UnicodeWidthStr::width(*k))
1209            .max()
1210            .unwrap_or(0);
1211
1212        let _ = self.col(|ui| {
1213            for (key, value) in items {
1214                ui.line(|ui| {
1215                    let padded = format!("{:>width$}", key, width = max_key_width);
1216                    ui.text(padded).dim();
1217                    ui.text("  ");
1218                    ui.text(*value);
1219                });
1220            }
1221        });
1222
1223        Response::none()
1224    }
1225
1226    /// Render a horizontal divider with a centered text label.
1227    pub fn divider_text(&mut self, label: &str) -> Response {
1228        let w = self.width();
1229        let label_len = unicode_width::UnicodeWidthStr::width(label) as u32;
1230        let pad = 1u32;
1231        let left_len = 4u32;
1232        let right_len = w.saturating_sub(left_len + pad + label_len + pad);
1233        let left: String = "─".repeat(left_len as usize);
1234        let right: String = "─".repeat(right_len as usize);
1235        let theme = self.theme;
1236        self.line(|ui| {
1237            ui.text(&left).fg(theme.border);
1238            let mut label_text = String::with_capacity(label.len() + 2);
1239            label_text.push(' ');
1240            label_text.push_str(label);
1241            label_text.push(' ');
1242            ui.text(label_text).fg(theme.text);
1243            ui.text(&right).fg(theme.border);
1244        });
1245
1246        Response::none()
1247    }
1248
1249    /// Render a badge with the theme's primary color.
1250    pub fn badge(&mut self, label: &str) -> Response {
1251        let theme = self.theme;
1252        self.badge_colored(label, theme.primary)
1253    }
1254
1255    /// Render a badge with a custom background color.
1256    pub fn badge_colored(&mut self, label: &str, color: Color) -> Response {
1257        let fg = Color::contrast_fg(color);
1258        let mut label_text = String::with_capacity(label.len() + 2);
1259        label_text.push(' ');
1260        label_text.push_str(label);
1261        label_text.push(' ');
1262        self.text(label_text).fg(fg).bg(color);
1263
1264        Response::none()
1265    }
1266
1267    /// Render a keyboard shortcut hint with reversed styling.
1268    pub fn key_hint(&mut self, key: &str) -> Response {
1269        let theme = self.theme;
1270        let mut key_text = String::with_capacity(key.len() + 2);
1271        key_text.push(' ');
1272        key_text.push_str(key);
1273        key_text.push(' ');
1274        self.text(key_text).reversed().fg(theme.text_dim);
1275
1276        Response::none()
1277    }
1278
1279    /// Render a label-value stat pair.
1280    pub fn stat(&mut self, label: &str, value: &str) -> Response {
1281        let _ = self.col(|ui| {
1282            ui.text(label).dim();
1283            ui.text(value).bold();
1284        });
1285
1286        Response::none()
1287    }
1288
1289    /// Render a stat pair with a custom value color.
1290    pub fn stat_colored(&mut self, label: &str, value: &str, color: Color) -> Response {
1291        let _ = self.col(|ui| {
1292            ui.text(label).dim();
1293            ui.text(value).bold().fg(color);
1294        });
1295
1296        Response::none()
1297    }
1298
1299    /// Render a stat pair with an up/down trend arrow.
1300    pub fn stat_trend(
1301        &mut self,
1302        label: &str,
1303        value: &str,
1304        trend: crate::widgets::Trend,
1305    ) -> Response {
1306        let theme = self.theme;
1307        let (arrow, color) = match trend {
1308            crate::widgets::Trend::Up => ("↑", theme.success),
1309            crate::widgets::Trend::Down => ("↓", theme.error),
1310        };
1311        let _ = self.col(|ui| {
1312            ui.text(label).dim();
1313            ui.line(|ui| {
1314                ui.text(value).bold();
1315                let mut arrow_text = String::with_capacity(1 + arrow.len());
1316                arrow_text.push(' ');
1317                arrow_text.push_str(arrow);
1318                ui.text(arrow_text).fg(color);
1319            });
1320        });
1321
1322        Response::none()
1323    }
1324
1325    /// Render a centered empty-state placeholder.
1326    pub fn empty_state(&mut self, title: &str, description: &str) -> Response {
1327        let _ = self.container().center().col(|ui| {
1328            ui.text(title).align(Align::Center);
1329            ui.text(description).dim().align(Align::Center);
1330        });
1331
1332        Response::none()
1333    }
1334
1335    /// Render a centered empty-state placeholder with an action button.
1336    pub fn empty_state_action(
1337        &mut self,
1338        title: &str,
1339        description: &str,
1340        action_label: &str,
1341    ) -> Response {
1342        let mut clicked = false;
1343        let _ = self.container().center().col(|ui| {
1344            ui.text(title).align(Align::Center);
1345            ui.text(description).dim().align(Align::Center);
1346            if ui.button(action_label).clicked {
1347                clicked = true;
1348            }
1349        });
1350
1351        Response {
1352            clicked,
1353            changed: clicked,
1354            ..Response::none()
1355        }
1356    }
1357
1358    /// Render a code block with keyword-based syntax highlighting.
1359    pub fn code_block(&mut self, code: &str) -> Response {
1360        self.code_block_lang(code, "")
1361    }
1362
1363    /// Render a code block with language-aware syntax highlighting.
1364    pub fn code_block_lang(&mut self, code: &str, lang: &str) -> Response {
1365        let theme = self.theme;
1366        let highlighted: Option<Vec<Vec<(String, Style)>>> =
1367            crate::syntax::highlight_code(code, lang, &theme);
1368        let _ = self
1369            .bordered(Border::Rounded)
1370            .bg(theme.surface)
1371            .pad(1)
1372            .col(|ui| {
1373                if let Some(ref lines) = highlighted {
1374                    render_tree_sitter_lines(ui, lines);
1375                } else {
1376                    for line in code.lines() {
1377                        render_highlighted_line(ui, line);
1378                    }
1379                }
1380            });
1381
1382        Response::none()
1383    }
1384
1385    /// Render a code block with line numbers and keyword highlighting.
1386    pub fn code_block_numbered(&mut self, code: &str) -> Response {
1387        self.code_block_numbered_lang(code, "")
1388    }
1389
1390    /// Render a code block with line numbers and language-aware highlighting.
1391    pub fn code_block_numbered_lang(&mut self, code: &str, lang: &str) -> Response {
1392        let lines: Vec<&str> = code.lines().collect();
1393        let gutter_w = format!("{}", lines.len()).len();
1394        let theme = self.theme;
1395        let highlighted: Option<Vec<Vec<(String, Style)>>> =
1396            crate::syntax::highlight_code(code, lang, &theme);
1397        let _ = self
1398            .bordered(Border::Rounded)
1399            .bg(theme.surface)
1400            .pad(1)
1401            .col(|ui| {
1402                if let Some(ref hl_lines) = highlighted {
1403                    for (i, segs) in hl_lines.iter().enumerate() {
1404                        ui.line(|ui| {
1405                            ui.text(format!("{:>gutter_w$} │ ", i + 1))
1406                                .fg(theme.text_dim);
1407                            for (text, style) in segs {
1408                                ui.styled(text, *style);
1409                            }
1410                        });
1411                    }
1412                } else {
1413                    for (i, line) in lines.iter().enumerate() {
1414                        ui.line(|ui| {
1415                            ui.text(format!("{:>gutter_w$} │ ", i + 1))
1416                                .fg(theme.text_dim);
1417                            render_highlighted_line(ui, line);
1418                        });
1419                    }
1420                }
1421            });
1422
1423        Response::none()
1424    }
1425
1426    /// Enable word-boundary wrapping on the last rendered text element.
1427    pub fn wrap(&mut self) -> &mut Self {
1428        if let Some(idx) = self.last_text_idx {
1429            if let Command::Text { wrap, .. } = &mut self.commands[idx] {
1430                *wrap = true;
1431            }
1432        }
1433        self
1434    }
1435
1436    /// Truncate the last rendered text with `…` when it exceeds its allocated width.
1437    /// Use with `.w()` to set a fixed width, or let the parent container constrain it.
1438    pub fn truncate(&mut self) -> &mut Self {
1439        if let Some(idx) = self.last_text_idx {
1440            if let Command::Text { truncate, .. } = &mut self.commands[idx] {
1441                *truncate = true;
1442            }
1443        }
1444        self
1445    }
1446
1447    fn modify_last_style(&mut self, f: impl FnOnce(&mut Style)) {
1448        if let Some(idx) = self.last_text_idx {
1449            match &mut self.commands[idx] {
1450                Command::Text { style, .. } | Command::Link { style, .. } => f(style),
1451                _ => {}
1452            }
1453        }
1454    }
1455
1456    fn modify_last_constraints(&mut self, f: impl FnOnce(&mut Constraints)) {
1457        if let Some(idx) = self.last_text_idx {
1458            match &mut self.commands[idx] {
1459                Command::Text { constraints, .. } | Command::Link { constraints, .. } => {
1460                    f(constraints)
1461                }
1462                _ => {}
1463            }
1464        }
1465    }
1466
1467    fn modify_last_margin(&mut self, f: impl FnOnce(&mut Margin)) {
1468        if let Some(idx) = self.last_text_idx {
1469            match &mut self.commands[idx] {
1470                Command::Text { margin, .. } | Command::Link { margin, .. } => f(margin),
1471                _ => {}
1472            }
1473        }
1474    }
1475
1476    // ── containers ───────────────────────────────────────────────────
1477
1478    /// Conditionally render content when the named screen is active.
1479    pub fn screen(&mut self, name: &str, screens: &ScreenState, f: impl FnOnce(&mut Context)) {
1480        if screens.current() == name {
1481            f(self);
1482        }
1483    }
1484
1485    /// Create a vertical (column) container.
1486    ///
1487    /// Children are stacked top-to-bottom. Returns a [`Response`] with
1488    /// click/hover state for the container area.
1489    ///
1490    /// # Example
1491    ///
1492    /// ```no_run
1493    /// # slt::run(|ui: &mut slt::Context| {
1494    /// ui.col(|ui| {
1495    ///     ui.text("line one");
1496    ///     ui.text("line two");
1497    /// });
1498    /// # });
1499    /// ```
1500    pub fn col(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1501        self.push_container(Direction::Column, 0, f)
1502    }
1503
1504    /// Create a vertical (column) container with a gap between children.
1505    ///
1506    /// `gap` is the number of blank rows inserted between each child.
1507    pub fn col_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
1508        self.push_container(Direction::Column, gap, f)
1509    }
1510
1511    /// Create a horizontal (row) container.
1512    ///
1513    /// Children are placed left-to-right. Returns a [`Response`] with
1514    /// click/hover state for the container area.
1515    ///
1516    /// # Example
1517    ///
1518    /// ```no_run
1519    /// # slt::run(|ui: &mut slt::Context| {
1520    /// ui.row(|ui| {
1521    ///     ui.text("left");
1522    ///     ui.spacer();
1523    ///     ui.text("right");
1524    /// });
1525    /// # });
1526    /// ```
1527    pub fn row(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1528        self.push_container(Direction::Row, 0, f)
1529    }
1530
1531    /// Create a horizontal (row) container with a gap between children.
1532    ///
1533    /// `gap` is the number of blank columns inserted between each child.
1534    pub fn row_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
1535        self.push_container(Direction::Row, gap, f)
1536    }
1537
1538    /// Render inline text with mixed styles on a single line.
1539    ///
1540    /// Unlike [`row`](Context::row), `line()` is designed for rich text —
1541    /// children are rendered as continuous inline text without gaps.
1542    ///
1543    /// # Example
1544    ///
1545    /// ```no_run
1546    /// # use slt::Color;
1547    /// # slt::run(|ui: &mut slt::Context| {
1548    /// ui.line(|ui| {
1549    ///     ui.text("Status: ");
1550    ///     ui.text("Online").bold().fg(Color::Green);
1551    /// });
1552    /// # });
1553    /// ```
1554    pub fn line(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
1555        let _ = self.push_container(Direction::Row, 0, f);
1556        self
1557    }
1558
1559    /// Render inline text with mixed styles, wrapping at word boundaries.
1560    ///
1561    /// Like [`line`](Context::line), but when the combined text exceeds
1562    /// the container width it wraps across multiple lines while
1563    /// preserving per-segment styles.
1564    ///
1565    /// # Example
1566    ///
1567    /// ```no_run
1568    /// # use slt::{Color, Style};
1569    /// # slt::run(|ui: &mut slt::Context| {
1570    /// ui.line_wrap(|ui| {
1571    ///     ui.text("This is a long ");
1572    ///     ui.text("important").bold().fg(Color::Red);
1573    ///     ui.text(" message that wraps across lines");
1574    /// });
1575    /// # });
1576    /// ```
1577    pub fn line_wrap(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
1578        let start = self.commands.len();
1579        f(self);
1580        let has_link = self.commands[start..]
1581            .iter()
1582            .any(|cmd| matches!(cmd, Command::Link { .. }));
1583
1584        if has_link {
1585            self.commands.insert(
1586                start,
1587                Command::BeginContainer {
1588                    direction: Direction::Row,
1589                    gap: 0,
1590                    align: Align::Start,
1591                    align_self: None,
1592                    justify: Justify::Start,
1593                    border: None,
1594                    border_sides: BorderSides::all(),
1595                    border_style: Style::new(),
1596                    bg_color: None,
1597                    padding: Padding::default(),
1598                    margin: Margin::default(),
1599                    constraints: Constraints::default(),
1600                    title: None,
1601                    grow: 0,
1602                    group_name: None,
1603                },
1604            );
1605            self.commands.push(Command::EndContainer);
1606            self.last_text_idx = None;
1607            return self;
1608        }
1609
1610        let mut segments: Vec<(String, Style)> = Vec::new();
1611        for cmd in self.commands.drain(start..) {
1612            match cmd {
1613                Command::Text { content, style, .. } => {
1614                    segments.push((content, style));
1615                }
1616                Command::Link { text, style, .. } => {
1617                    // Preserve link text with underline styling (URL lost in RichText,
1618                    // but text is visible and wraps correctly)
1619                    segments.push((text, style));
1620                }
1621                _ => {}
1622            }
1623        }
1624        self.commands.push(Command::RichText {
1625            segments,
1626            wrap: true,
1627            align: Align::Start,
1628            margin: Margin::default(),
1629            constraints: Constraints::default(),
1630        });
1631        self.last_text_idx = None;
1632        self
1633    }
1634
1635    /// Render content in a modal overlay with dimmed background.
1636    ///
1637    /// ```ignore
1638    /// ui.modal(|ui| {
1639    ///     ui.text("Are you sure?");
1640    ///     if ui.button("OK") { show = false; }
1641    /// });
1642    /// ```
1643    pub fn modal(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1644        let interaction_id = self.next_interaction_id();
1645        self.commands.push(Command::BeginOverlay { modal: true });
1646        self.overlay_depth += 1;
1647        self.modal_active = true;
1648        self.modal_focus_start = self.focus_count;
1649        f(self);
1650        self.modal_focus_count = self.focus_count.saturating_sub(self.modal_focus_start);
1651        self.overlay_depth = self.overlay_depth.saturating_sub(1);
1652        self.commands.push(Command::EndOverlay);
1653        self.last_text_idx = None;
1654        self.response_for(interaction_id)
1655    }
1656
1657    /// Render floating content without dimming the background.
1658    pub fn overlay(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1659        let interaction_id = self.next_interaction_id();
1660        self.commands.push(Command::BeginOverlay { modal: false });
1661        self.overlay_depth += 1;
1662        f(self);
1663        self.overlay_depth = self.overlay_depth.saturating_sub(1);
1664        self.commands.push(Command::EndOverlay);
1665        self.last_text_idx = None;
1666        self.response_for(interaction_id)
1667    }
1668
1669    /// Render a hover tooltip for the previously rendered interactive widget.
1670    ///
1671    /// Call this right after a widget or container response:
1672    /// ```ignore
1673    /// if ui.button("Save").clicked { save(); }
1674    /// ui.tooltip("Save the current document to disk");
1675    /// ```
1676    pub fn tooltip(&mut self, text: impl Into<String>) {
1677        let tooltip_text = text.into();
1678        if tooltip_text.is_empty() {
1679            return;
1680        }
1681        let last_interaction_id = self.interaction_count.saturating_sub(1);
1682        let last_response = self.response_for(last_interaction_id);
1683        if !last_response.hovered || last_response.rect.width == 0 || last_response.rect.height == 0
1684        {
1685            return;
1686        }
1687        let lines = wrap_tooltip_text(&tooltip_text, 38);
1688        self.pending_tooltips.push(PendingTooltip {
1689            anchor_rect: last_response.rect,
1690            lines,
1691        });
1692    }
1693
1694    pub(crate) fn emit_pending_tooltips(&mut self) {
1695        let tooltips = std::mem::take(&mut self.pending_tooltips);
1696        if tooltips.is_empty() {
1697            return;
1698        }
1699        let area_w = self.area_width;
1700        let area_h = self.area_height;
1701        let surface = self.theme.surface;
1702        let border_color = self.theme.border;
1703        let text_color = self.theme.surface_text;
1704
1705        for tooltip in tooltips {
1706            let content_w = tooltip
1707                .lines
1708                .iter()
1709                .map(|l| UnicodeWidthStr::width(l.as_str()) as u32)
1710                .max()
1711                .unwrap_or(0);
1712            let box_w = content_w.saturating_add(4).min(area_w);
1713            let box_h = (tooltip.lines.len() as u32).saturating_add(4).min(area_h);
1714
1715            let tooltip_x = tooltip.anchor_rect.x.min(area_w.saturating_sub(box_w));
1716            let below_y = tooltip.anchor_rect.bottom();
1717            let tooltip_y = if below_y.saturating_add(box_h) <= area_h {
1718                below_y
1719            } else {
1720                tooltip.anchor_rect.y.saturating_sub(box_h)
1721            };
1722
1723            let lines = tooltip.lines;
1724            let _ = self.overlay(|ui| {
1725                let _ = ui.container().w(area_w).h(area_h).col(|ui| {
1726                    let _ = ui
1727                        .container()
1728                        .ml(tooltip_x)
1729                        .mt(tooltip_y)
1730                        .max_w(box_w)
1731                        .border(Border::Rounded)
1732                        .border_fg(border_color)
1733                        .bg(surface)
1734                        .p(1)
1735                        .col(|ui| {
1736                            for line in &lines {
1737                                ui.text(line.as_str()).fg(text_color);
1738                            }
1739                        });
1740                });
1741            });
1742        }
1743    }
1744
1745    /// Create a named group container for shared hover/focus styling.
1746    ///
1747    /// ```ignore
1748    /// ui.group("card").border(Border::Rounded)
1749    ///     .group_hover_bg(Color::Indexed(238))
1750    ///     .col(|ui| { ui.text("Hover anywhere"); });
1751    /// ```
1752    pub fn group(&mut self, name: &str) -> ContainerBuilder<'_> {
1753        self.group_count = self.group_count.saturating_add(1);
1754        self.group_stack.push(name.to_string());
1755        self.container().group_name(name.to_string())
1756    }
1757
1758    /// Create a container with a fluent builder.
1759    ///
1760    /// Use this for borders, padding, grow, constraints, and titles. Chain
1761    /// configuration methods on the returned [`ContainerBuilder`], then call
1762    /// `.col()` or `.row()` to finalize.
1763    ///
1764    /// # Example
1765    ///
1766    /// ```no_run
1767    /// # slt::run(|ui: &mut slt::Context| {
1768    /// use slt::Border;
1769    /// ui.container()
1770    ///     .border(Border::Rounded)
1771    ///     .pad(1)
1772    ///     .title("My Panel")
1773    ///     .col(|ui| {
1774    ///         ui.text("content");
1775    ///     });
1776    /// # });
1777    /// ```
1778    pub fn container(&mut self) -> ContainerBuilder<'_> {
1779        let border = self.theme.border;
1780        ContainerBuilder {
1781            ctx: self,
1782            gap: 0,
1783            row_gap: None,
1784            col_gap: None,
1785            align: Align::Start,
1786            align_self_value: None,
1787            justify: Justify::Start,
1788            border: None,
1789            border_sides: BorderSides::all(),
1790            border_style: Style::new().fg(border),
1791            bg: None,
1792            text_color: None,
1793            dark_bg: None,
1794            dark_border_style: None,
1795            group_hover_bg: None,
1796            group_hover_border_style: None,
1797            group_name: None,
1798            padding: Padding::default(),
1799            margin: Margin::default(),
1800            constraints: Constraints::default(),
1801            title: None,
1802            grow: 0,
1803            scroll_offset: None,
1804        }
1805    }
1806
1807    /// Create a scrollable container. Handles wheel scroll and drag-to-scroll automatically.
1808    ///
1809    /// Pass a [`ScrollState`] to persist scroll position across frames. The state
1810    /// is updated in-place with the current scroll offset and bounds.
1811    ///
1812    /// # Example
1813    ///
1814    /// ```no_run
1815    /// # use slt::widgets::ScrollState;
1816    /// # slt::run(|ui: &mut slt::Context| {
1817    /// let mut scroll = ScrollState::new();
1818    /// ui.scrollable(&mut scroll).col(|ui| {
1819    ///     for i in 0..100 {
1820    ///         ui.text(format!("Line {i}"));
1821    ///     }
1822    /// });
1823    /// # });
1824    /// ```
1825    pub fn scrollable(&mut self, state: &mut ScrollState) -> ContainerBuilder<'_> {
1826        let index = self.scroll_count;
1827        self.scroll_count += 1;
1828        if let Some(&(ch, vh)) = self.prev_scroll_infos.get(index) {
1829            state.set_bounds(ch, vh);
1830            let max = ch.saturating_sub(vh) as usize;
1831            state.offset = state.offset.min(max);
1832        }
1833
1834        let next_id = self.interaction_count;
1835        if let Some(rect) = self.prev_hit_map.get(next_id).copied() {
1836            let inner_rects: Vec<Rect> = self
1837                .prev_scroll_rects
1838                .iter()
1839                .enumerate()
1840                .filter(|&(j, sr)| {
1841                    j != index
1842                        && sr.width > 0
1843                        && sr.height > 0
1844                        && sr.x >= rect.x
1845                        && sr.right() <= rect.right()
1846                        && sr.y >= rect.y
1847                        && sr.bottom() <= rect.bottom()
1848                })
1849                .map(|(_, sr)| *sr)
1850                .collect();
1851            self.auto_scroll_nested(&rect, state, &inner_rects);
1852        }
1853
1854        self.container().scroll_offset(state.offset as u32)
1855    }
1856
1857    /// Render a scrollbar track for a [`ScrollState`].
1858    ///
1859    /// Displays a track (`│`) with a proportional thumb (`█`). The thumb size
1860    /// and position are calculated from the scroll state's content height,
1861    /// viewport height, and current offset.
1862    ///
1863    /// Typically placed beside a `scrollable()` container in a `row()`:
1864    /// ```no_run
1865    /// # use slt::widgets::ScrollState;
1866    /// # slt::run(|ui: &mut slt::Context| {
1867    /// let mut scroll = ScrollState::new();
1868    /// ui.row(|ui| {
1869    ///     ui.scrollable(&mut scroll).grow(1).col(|ui| {
1870    ///         for i in 0..100 { ui.text(format!("Line {i}")); }
1871    ///     });
1872    ///     ui.scrollbar(&scroll);
1873    /// });
1874    /// # });
1875    /// ```
1876    pub fn scrollbar(&mut self, state: &ScrollState) {
1877        let vh = state.viewport_height();
1878        let ch = state.content_height();
1879        if vh == 0 || ch <= vh {
1880            return;
1881        }
1882
1883        let track_height = vh;
1884        let thumb_height = ((vh as f64 * vh as f64 / ch as f64).ceil() as u32).max(1);
1885        let max_offset = ch.saturating_sub(vh);
1886        let thumb_pos = if max_offset == 0 {
1887            0
1888        } else {
1889            ((state.offset as f64 / max_offset as f64) * (track_height - thumb_height) as f64)
1890                .round() as u32
1891        };
1892
1893        let theme = self.theme;
1894        let track_char = '│';
1895        let thumb_char = '█';
1896
1897        let _ = self.container().w(1).h(track_height).col(|ui| {
1898            for i in 0..track_height {
1899                if i >= thumb_pos && i < thumb_pos + thumb_height {
1900                    ui.styled(thumb_char.to_string(), Style::new().fg(theme.primary));
1901                } else {
1902                    ui.styled(
1903                        track_char.to_string(),
1904                        Style::new().fg(theme.text_dim).dim(),
1905                    );
1906                }
1907            }
1908        });
1909    }
1910
1911    fn auto_scroll_nested(
1912        &mut self,
1913        rect: &Rect,
1914        state: &mut ScrollState,
1915        inner_scroll_rects: &[Rect],
1916    ) {
1917        let mut to_consume: Vec<usize> = Vec::new();
1918
1919        for (i, event) in self.events.iter().enumerate() {
1920            if self.consumed[i] {
1921                continue;
1922            }
1923            if let Event::Mouse(mouse) = event {
1924                let in_bounds = mouse.x >= rect.x
1925                    && mouse.x < rect.right()
1926                    && mouse.y >= rect.y
1927                    && mouse.y < rect.bottom();
1928                if !in_bounds {
1929                    continue;
1930                }
1931                let in_inner = inner_scroll_rects.iter().any(|sr| {
1932                    mouse.x >= sr.x
1933                        && mouse.x < sr.right()
1934                        && mouse.y >= sr.y
1935                        && mouse.y < sr.bottom()
1936                });
1937                if in_inner {
1938                    continue;
1939                }
1940                let delta = self.scroll_lines_per_event as usize;
1941                match mouse.kind {
1942                    MouseKind::ScrollUp => {
1943                        state.scroll_up(delta);
1944                        to_consume.push(i);
1945                    }
1946                    MouseKind::ScrollDown => {
1947                        state.scroll_down(delta);
1948                        to_consume.push(i);
1949                    }
1950                    MouseKind::Drag(MouseButton::Left) => {}
1951                    _ => {}
1952                }
1953            }
1954        }
1955
1956        for i in to_consume {
1957            self.consumed[i] = true;
1958        }
1959    }
1960
1961    /// Shortcut for `container().border(border)`.
1962    ///
1963    /// Returns a [`ContainerBuilder`] pre-configured with the given border style.
1964    pub fn bordered(&mut self, border: Border) -> ContainerBuilder<'_> {
1965        self.container()
1966            .border(border)
1967            .border_sides(BorderSides::all())
1968    }
1969
1970    fn push_container(
1971        &mut self,
1972        direction: Direction,
1973        gap: u32,
1974        f: impl FnOnce(&mut Context),
1975    ) -> Response {
1976        let interaction_id = self.next_interaction_id();
1977        let border = self.theme.border;
1978
1979        self.commands.push(Command::BeginContainer {
1980            direction,
1981            gap,
1982            align: Align::Start,
1983            align_self: None,
1984            justify: Justify::Start,
1985            border: None,
1986            border_sides: BorderSides::all(),
1987            border_style: Style::new().fg(border),
1988            bg_color: None,
1989            padding: Padding::default(),
1990            margin: Margin::default(),
1991            constraints: Constraints::default(),
1992            title: None,
1993            grow: 0,
1994            group_name: None,
1995        });
1996        self.text_color_stack.push(None);
1997        f(self);
1998        self.text_color_stack.pop();
1999        self.commands.push(Command::EndContainer);
2000        self.last_text_idx = None;
2001
2002        self.response_for(interaction_id)
2003    }
2004
2005    pub(super) fn response_for(&self, interaction_id: usize) -> Response {
2006        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2007            return Response::none();
2008        }
2009        if let Some(rect) = self.prev_hit_map.get(interaction_id) {
2010            let clicked = self
2011                .click_pos
2012                .map(|(mx, my)| {
2013                    mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
2014                })
2015                .unwrap_or(false);
2016            let hovered = self
2017                .mouse_pos
2018                .map(|(mx, my)| {
2019                    mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
2020                })
2021                .unwrap_or(false);
2022            Response {
2023                clicked,
2024                hovered,
2025                changed: false,
2026                focused: false,
2027                rect: *rect,
2028            }
2029        } else {
2030            Response::none()
2031        }
2032    }
2033
2034    /// Returns true if the named group is currently hovered by the mouse.
2035    pub fn is_group_hovered(&self, name: &str) -> bool {
2036        if let Some(pos) = self.mouse_pos {
2037            self.prev_group_rects.iter().any(|(n, rect)| {
2038                n == name
2039                    && pos.0 >= rect.x
2040                    && pos.0 < rect.x + rect.width
2041                    && pos.1 >= rect.y
2042                    && pos.1 < rect.y + rect.height
2043            })
2044        } else {
2045            false
2046        }
2047    }
2048
2049    /// Returns true if the named group contains the currently focused widget.
2050    pub fn is_group_focused(&self, name: &str) -> bool {
2051        if self.prev_focus_count == 0 {
2052            return false;
2053        }
2054        let focused_index = self.focus_index % self.prev_focus_count;
2055        self.prev_focus_groups
2056            .get(focused_index)
2057            .and_then(|group| group.as_deref())
2058            .map(|group| group == name)
2059            .unwrap_or(false)
2060    }
2061
2062    /// Set the flex-grow factor of the last rendered text element.
2063    ///
2064    /// A value of `1` causes the element to expand and fill remaining space
2065    /// along the main axis.
2066    pub fn grow(&mut self, value: u16) -> &mut Self {
2067        if let Some(idx) = self.last_text_idx {
2068            if let Command::Text { grow, .. } = &mut self.commands[idx] {
2069                *grow = value;
2070            }
2071        }
2072        self
2073    }
2074
2075    /// Set the text alignment of the last rendered text element.
2076    pub fn align(&mut self, align: Align) -> &mut Self {
2077        if let Some(idx) = self.last_text_idx {
2078            if let Command::Text {
2079                align: text_align, ..
2080            } = &mut self.commands[idx]
2081            {
2082                *text_align = align;
2083            }
2084        }
2085        self
2086    }
2087
2088    /// Center-align the last rendered text element horizontally.
2089    /// Shorthand for `.align(Align::Center)`. Requires the text to have
2090    /// a width constraint (via `.w()` or parent container) to be visible.
2091    pub fn text_center(&mut self) -> &mut Self {
2092        self.align(Align::Center)
2093    }
2094
2095    /// Right-align the last rendered text element horizontally.
2096    /// Shorthand for `.align(Align::End)`.
2097    pub fn text_right(&mut self) -> &mut Self {
2098        self.align(Align::End)
2099    }
2100
2101    // ── size constraints on last text/link ──────────────────────────
2102
2103    /// Set a fixed width on the last rendered text or link element.
2104    ///
2105    /// Sets both `min_width` and `max_width` to `value`, making the element
2106    /// occupy exactly that many columns (padded with spaces or truncated).
2107    pub fn w(&mut self, value: u32) -> &mut Self {
2108        self.modify_last_constraints(|c| {
2109            c.min_width = Some(value);
2110            c.max_width = Some(value);
2111        });
2112        self
2113    }
2114
2115    /// Set a fixed height on the last rendered text or link element.
2116    ///
2117    /// Sets both `min_height` and `max_height` to `value`.
2118    pub fn h(&mut self, value: u32) -> &mut Self {
2119        self.modify_last_constraints(|c| {
2120            c.min_height = Some(value);
2121            c.max_height = Some(value);
2122        });
2123        self
2124    }
2125
2126    /// Set the minimum width on the last rendered text or link element.
2127    pub fn min_w(&mut self, value: u32) -> &mut Self {
2128        self.modify_last_constraints(|c| c.min_width = Some(value));
2129        self
2130    }
2131
2132    /// Set the maximum width on the last rendered text or link element.
2133    pub fn max_w(&mut self, value: u32) -> &mut Self {
2134        self.modify_last_constraints(|c| c.max_width = Some(value));
2135        self
2136    }
2137
2138    /// Set the minimum height on the last rendered text or link element.
2139    pub fn min_h(&mut self, value: u32) -> &mut Self {
2140        self.modify_last_constraints(|c| c.min_height = Some(value));
2141        self
2142    }
2143
2144    /// Set the maximum height on the last rendered text or link element.
2145    pub fn max_h(&mut self, value: u32) -> &mut Self {
2146        self.modify_last_constraints(|c| c.max_height = Some(value));
2147        self
2148    }
2149
2150    // ── margin on last text/link ────────────────────────────────────
2151
2152    /// Set uniform margin on all sides of the last rendered text or link element.
2153    pub fn m(&mut self, value: u32) -> &mut Self {
2154        self.modify_last_margin(|m| *m = Margin::all(value));
2155        self
2156    }
2157
2158    /// Set horizontal margin (left + right) on the last rendered text or link.
2159    pub fn mx(&mut self, value: u32) -> &mut Self {
2160        self.modify_last_margin(|m| {
2161            m.left = value;
2162            m.right = value;
2163        });
2164        self
2165    }
2166
2167    /// Set vertical margin (top + bottom) on the last rendered text or link.
2168    pub fn my(&mut self, value: u32) -> &mut Self {
2169        self.modify_last_margin(|m| {
2170            m.top = value;
2171            m.bottom = value;
2172        });
2173        self
2174    }
2175
2176    /// Set top margin on the last rendered text or link element.
2177    pub fn mt(&mut self, value: u32) -> &mut Self {
2178        self.modify_last_margin(|m| m.top = value);
2179        self
2180    }
2181
2182    /// Set right margin on the last rendered text or link element.
2183    pub fn mr(&mut self, value: u32) -> &mut Self {
2184        self.modify_last_margin(|m| m.right = value);
2185        self
2186    }
2187
2188    /// Set bottom margin on the last rendered text or link element.
2189    pub fn mb(&mut self, value: u32) -> &mut Self {
2190        self.modify_last_margin(|m| m.bottom = value);
2191        self
2192    }
2193
2194    /// Set left margin on the last rendered text or link element.
2195    pub fn ml(&mut self, value: u32) -> &mut Self {
2196        self.modify_last_margin(|m| m.left = value);
2197        self
2198    }
2199
2200    /// Render an invisible spacer that expands to fill available space.
2201    ///
2202    /// Useful for pushing siblings to opposite ends of a row or column.
2203    pub fn spacer(&mut self) -> &mut Self {
2204        self.commands.push(Command::Spacer { grow: 1 });
2205        self.last_text_idx = None;
2206        self
2207    }
2208
2209    /// Render a form that groups input fields vertically.
2210    ///
2211    /// Use [`Context::form_field`] inside the closure to render each field.
2212    pub fn form(
2213        &mut self,
2214        state: &mut FormState,
2215        f: impl FnOnce(&mut Context, &mut FormState),
2216    ) -> &mut Self {
2217        let _ = self.col(|ui| {
2218            f(ui, state);
2219        });
2220        self
2221    }
2222
2223    /// Render a single form field with label and input.
2224    ///
2225    /// Shows a validation error below the input when present.
2226    pub fn form_field(&mut self, field: &mut FormField) -> &mut Self {
2227        let _ = self.col(|ui| {
2228            ui.styled(field.label.clone(), Style::new().bold().fg(ui.theme.text));
2229            let _ = ui.text_input(&mut field.input);
2230            if let Some(error) = field.error.as_deref() {
2231                ui.styled(error.to_string(), Style::new().dim().fg(ui.theme.error));
2232            }
2233        });
2234        self
2235    }
2236
2237    /// Render a submit button.
2238    ///
2239    /// Returns `true` when the button is clicked or activated.
2240    pub fn form_submit(&mut self, label: impl Into<String>) -> Response {
2241        self.button(label)
2242    }
2243}
2244
2245#[cfg(test)]
2246mod line_wrap_tests {
2247    use super::*;
2248    use crate::event::Event;
2249    use crate::FrameState;
2250
2251    #[test]
2252    fn line_wrap_without_links_compacts_to_rich_text() {
2253        let mut frame = FrameState::default();
2254        let mut ctx = Context::new(Vec::<Event>::new(), 80, 24, &mut frame, Theme::dark());
2255
2256        ctx.line_wrap(|ui| {
2257            ui.text("hello ");
2258            ui.text("world").bold();
2259        });
2260
2261        assert!(matches!(
2262            ctx.commands.as_slice(),
2263            [Command::RichText { .. }]
2264        ));
2265    }
2266
2267    #[test]
2268    fn line_wrap_with_links_keeps_interactive_commands() {
2269        let mut frame = FrameState::default();
2270        let mut ctx = Context::new(Vec::<Event>::new(), 80, 24, &mut frame, Theme::dark());
2271
2272        ctx.line_wrap(|ui| {
2273            ui.text("Visit ");
2274            ui.link("Docs", "https://docs.rs");
2275        });
2276
2277        assert!(matches!(
2278            ctx.commands.first(),
2279            Some(Command::BeginContainer { .. })
2280        ));
2281        assert!(ctx
2282            .commands
2283            .iter()
2284            .any(|cmd| matches!(cmd, Command::Link { text, .. } if text == "Docs")));
2285        assert!(matches!(ctx.commands.last(), Some(Command::EndContainer)));
2286    }
2287}
2288
2289fn wrap_tooltip_text(text: &str, max_width: usize) -> Vec<String> {
2290    let max_width = max_width.max(1);
2291    let mut lines = Vec::new();
2292
2293    for paragraph in text.lines() {
2294        if paragraph.trim().is_empty() {
2295            lines.push(String::new());
2296            continue;
2297        }
2298
2299        let mut current = String::new();
2300        let mut current_width = 0usize;
2301
2302        for word in paragraph.split_whitespace() {
2303            for chunk in split_word_for_width(word, max_width) {
2304                let chunk_width = UnicodeWidthStr::width(chunk.as_str());
2305
2306                if current.is_empty() {
2307                    current = chunk;
2308                    current_width = chunk_width;
2309                    continue;
2310                }
2311
2312                if current_width + 1 + chunk_width <= max_width {
2313                    current.push(' ');
2314                    current.push_str(&chunk);
2315                    current_width += 1 + chunk_width;
2316                } else {
2317                    lines.push(std::mem::take(&mut current));
2318                    current = chunk;
2319                    current_width = chunk_width;
2320                }
2321            }
2322        }
2323
2324        if !current.is_empty() {
2325            lines.push(current);
2326        }
2327    }
2328
2329    if lines.is_empty() {
2330        lines.push(String::new());
2331    }
2332
2333    lines
2334}
2335
2336fn split_word_for_width(word: &str, max_width: usize) -> Vec<String> {
2337    let mut chunks = Vec::new();
2338    let mut current = String::new();
2339    let mut current_width = 0usize;
2340
2341    for ch in word.chars() {
2342        let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
2343        if !current.is_empty() && current_width + ch_width > max_width {
2344            chunks.push(std::mem::take(&mut current));
2345            current_width = 0;
2346        }
2347        current.push(ch);
2348        current_width += ch_width;
2349
2350        if current_width >= max_width {
2351            chunks.push(std::mem::take(&mut current));
2352            current_width = 0;
2353        }
2354    }
2355
2356    if !current.is_empty() {
2357        chunks.push(current);
2358    }
2359
2360    if chunks.is_empty() {
2361        chunks.push(String::new());
2362    }
2363
2364    chunks
2365}
2366
2367fn glyph_8x8(ch: char) -> [u8; 8] {
2368    if ch.is_ascii() {
2369        let code = ch as u8;
2370        if (32..=126).contains(&code) {
2371            return FONT_8X8_PRINTABLE[(code - 32) as usize];
2372        }
2373    }
2374
2375    FONT_8X8_PRINTABLE[(b'?' - 32) as usize]
2376}
2377
2378const FONT_8X8_PRINTABLE: [[u8; 8]; 95] = [
2379    [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
2380    [0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00],
2381    [0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
2382    [0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00],
2383    [0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00],
2384    [0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00],
2385    [0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00],
2386    [0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00],
2387    [0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00],
2388    [0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00],
2389    [0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00],
2390    [0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00],
2391    [0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06],
2392    [0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00],
2393    [0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00],
2394    [0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00],
2395    [0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00],
2396    [0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00],
2397    [0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00],
2398    [0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00],
2399    [0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00],
2400    [0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00],
2401    [0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00],
2402    [0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00],
2403    [0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00],
2404    [0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00],
2405    [0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00],
2406    [0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06],
2407    [0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00],
2408    [0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00],
2409    [0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00],
2410    [0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00],
2411    [0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00],
2412    [0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00],
2413    [0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00],
2414    [0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00],
2415    [0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00],
2416    [0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00],
2417    [0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00],
2418    [0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00],
2419    [0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00],
2420    [0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00],
2421    [0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00],
2422    [0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00],
2423    [0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00],
2424    [0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00],
2425    [0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00],
2426    [0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00],
2427    [0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00],
2428    [0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00],
2429    [0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00],
2430    [0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00],
2431    [0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00],
2432    [0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00],
2433    [0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00],
2434    [0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00],
2435    [0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00],
2436    [0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00],
2437    [0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00],
2438    [0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00],
2439    [0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00],
2440    [0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00],
2441    [0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00],
2442    [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF],
2443    [0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00],
2444    [0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00],
2445    [0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00],
2446    [0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00],
2447    [0x38, 0x30, 0x30, 0x3E, 0x33, 0x33, 0x6E, 0x00],
2448    [0x00, 0x00, 0x1E, 0x33, 0x3F, 0x03, 0x1E, 0x00],
2449    [0x1C, 0x36, 0x06, 0x0F, 0x06, 0x06, 0x0F, 0x00],
2450    [0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F],
2451    [0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00],
2452    [0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00],
2453    [0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E],
2454    [0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00],
2455    [0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00],
2456    [0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00],
2457    [0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00],
2458    [0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00],
2459    [0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F],
2460    [0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78],
2461    [0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00],
2462    [0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00],
2463    [0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00],
2464    [0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00],
2465    [0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00],
2466    [0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00],
2467    [0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00],
2468    [0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F],
2469    [0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00],
2470    [0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00],
2471    [0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00],
2472    [0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00],
2473    [0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
2474];
2475
2476const KEYWORDS: &[&str] = &[
2477    "fn",
2478    "let",
2479    "mut",
2480    "pub",
2481    "use",
2482    "impl",
2483    "struct",
2484    "enum",
2485    "trait",
2486    "type",
2487    "const",
2488    "static",
2489    "if",
2490    "else",
2491    "match",
2492    "for",
2493    "while",
2494    "loop",
2495    "return",
2496    "break",
2497    "continue",
2498    "where",
2499    "self",
2500    "super",
2501    "crate",
2502    "mod",
2503    "async",
2504    "await",
2505    "move",
2506    "ref",
2507    "in",
2508    "as",
2509    "true",
2510    "false",
2511    "Some",
2512    "None",
2513    "Ok",
2514    "Err",
2515    "Self",
2516    "def",
2517    "class",
2518    "import",
2519    "from",
2520    "pass",
2521    "lambda",
2522    "yield",
2523    "with",
2524    "try",
2525    "except",
2526    "raise",
2527    "finally",
2528    "elif",
2529    "del",
2530    "global",
2531    "nonlocal",
2532    "assert",
2533    "is",
2534    "not",
2535    "and",
2536    "or",
2537    "function",
2538    "var",
2539    "const",
2540    "export",
2541    "default",
2542    "switch",
2543    "case",
2544    "throw",
2545    "catch",
2546    "typeof",
2547    "instanceof",
2548    "new",
2549    "delete",
2550    "void",
2551    "this",
2552    "null",
2553    "undefined",
2554    "func",
2555    "package",
2556    "defer",
2557    "go",
2558    "chan",
2559    "select",
2560    "range",
2561    "map",
2562    "interface",
2563    "fallthrough",
2564    "nil",
2565];
2566
2567fn render_tree_sitter_lines(ui: &mut Context, lines: &[Vec<(String, crate::style::Style)>]) {
2568    for segs in lines {
2569        if segs.is_empty() {
2570            ui.text(" ");
2571        } else {
2572            ui.line(|ui| {
2573                for (text, style) in segs {
2574                    ui.styled(text, *style);
2575                }
2576            });
2577        }
2578    }
2579}
2580
2581fn render_highlighted_line(ui: &mut Context, line: &str) {
2582    let theme = ui.theme;
2583    let is_light = matches!(
2584        theme.bg,
2585        Color::Reset | Color::White | Color::Rgb(255, 255, 255)
2586    );
2587    let keyword_color = if is_light {
2588        Color::Rgb(166, 38, 164)
2589    } else {
2590        Color::Rgb(198, 120, 221)
2591    };
2592    let string_color = if is_light {
2593        Color::Rgb(80, 161, 79)
2594    } else {
2595        Color::Rgb(152, 195, 121)
2596    };
2597    let comment_color = theme.text_dim;
2598    let number_color = if is_light {
2599        Color::Rgb(152, 104, 1)
2600    } else {
2601        Color::Rgb(209, 154, 102)
2602    };
2603    let fn_color = if is_light {
2604        Color::Rgb(64, 120, 242)
2605    } else {
2606        Color::Rgb(97, 175, 239)
2607    };
2608    let macro_color = if is_light {
2609        Color::Rgb(1, 132, 188)
2610    } else {
2611        Color::Rgb(86, 182, 194)
2612    };
2613
2614    let trimmed = line.trim_start();
2615    let indent = &line[..line.len() - trimmed.len()];
2616    if !indent.is_empty() {
2617        ui.text(indent);
2618    }
2619
2620    if trimmed.starts_with("//") {
2621        ui.text(trimmed).fg(comment_color).italic();
2622        return;
2623    }
2624
2625    let mut pos = 0;
2626
2627    while pos < trimmed.len() {
2628        let ch = trimmed.as_bytes()[pos];
2629
2630        if ch == b'"' {
2631            if let Some(end) = trimmed[pos + 1..].find('"') {
2632                let s = &trimmed[pos..pos + end + 2];
2633                ui.text(s).fg(string_color);
2634                pos += end + 2;
2635                continue;
2636            }
2637        }
2638
2639        if ch.is_ascii_digit() && (pos == 0 || !trimmed.as_bytes()[pos - 1].is_ascii_alphanumeric())
2640        {
2641            let end = trimmed[pos..]
2642                .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '_')
2643                .map_or(trimmed.len(), |e| pos + e);
2644            ui.text(&trimmed[pos..end]).fg(number_color);
2645            pos = end;
2646            continue;
2647        }
2648
2649        if ch.is_ascii_alphabetic() || ch == b'_' {
2650            let end = trimmed[pos..]
2651                .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
2652                .map_or(trimmed.len(), |e| pos + e);
2653            let word = &trimmed[pos..end];
2654
2655            if end < trimmed.len() && trimmed.as_bytes()[end] == b'!' {
2656                ui.text(&trimmed[pos..end + 1]).fg(macro_color);
2657                pos = end + 1;
2658            } else if end < trimmed.len()
2659                && trimmed.as_bytes()[end] == b'('
2660                && !KEYWORDS.contains(&word)
2661            {
2662                ui.text(word).fg(fn_color);
2663                pos = end;
2664            } else if KEYWORDS.contains(&word) {
2665                ui.text(word).fg(keyword_color);
2666                pos = end;
2667            } else {
2668                ui.text(word);
2669                pos = end;
2670            }
2671            continue;
2672        }
2673
2674        let end = trimmed[pos..]
2675            .find(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '"')
2676            .map_or(trimmed.len(), |e| pos + e);
2677        ui.text(&trimmed[pos..end]);
2678        pos = end;
2679    }
2680}
2681
2682fn normalize_rgba(data: &[u8], width: u32, height: u32) -> Vec<u8> {
2683    let expected = (width as usize) * (height as usize) * 4;
2684    if data.len() >= expected {
2685        return data[..expected].to_vec();
2686    }
2687    let mut buf = Vec::with_capacity(expected);
2688    buf.extend_from_slice(data);
2689    buf.resize(expected, 0);
2690    buf
2691}
2692
2693#[cfg(feature = "crossterm")]
2694fn terminal_supports_sixel() -> bool {
2695    let force = std::env::var("SLT_FORCE_SIXEL")
2696        .ok()
2697        .map(|v| v.to_ascii_lowercase())
2698        .unwrap_or_default();
2699    if matches!(force.as_str(), "1" | "true" | "yes" | "on") {
2700        return true;
2701    }
2702
2703    let term = std::env::var("TERM")
2704        .ok()
2705        .map(|v| v.to_ascii_lowercase())
2706        .unwrap_or_default();
2707    let term_program = std::env::var("TERM_PROGRAM")
2708        .ok()
2709        .map(|v| v.to_ascii_lowercase())
2710        .unwrap_or_default();
2711
2712    term.contains("sixel")
2713        || term.contains("mlterm")
2714        || term.contains("xterm")
2715        || term.contains("foot")
2716        || term_program.contains("foot")
2717}
2718
2719#[cfg(test)]
2720mod tests {
2721    use super::*;
2722    use crate::TestBackend;
2723    use std::time::Duration;
2724
2725    #[test]
2726    fn gradient_text_renders_content() {
2727        let mut backend = TestBackend::new(20, 4);
2728        backend.render(|ui| {
2729            ui.text("ABCD").gradient(Color::Red, Color::Blue);
2730        });
2731
2732        backend.assert_contains("ABCD");
2733    }
2734
2735    #[test]
2736    fn big_text_renders_half_block_grid() {
2737        let mut backend = TestBackend::new(16, 4);
2738        backend.render(|ui| {
2739            let _ = ui.big_text("A");
2740        });
2741
2742        let output = backend.to_string();
2743        // Should contain half-block characters (▀, ▄, or █)
2744        assert!(
2745            output.contains('▀') || output.contains('▄') || output.contains('█'),
2746            "output should contain half-block glyphs: {output:?}"
2747        );
2748    }
2749
2750    #[test]
2751    fn timer_display_formats_minutes_seconds_centis() {
2752        let mut backend = TestBackend::new(20, 4);
2753        backend.render(|ui| {
2754            ui.timer_display(Duration::from_secs(83) + Duration::from_millis(450));
2755        });
2756
2757        backend.assert_contains("01:23.45");
2758    }
2759}