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