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