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