Skip to main content

slt/context/
widgets_display.rs

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