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