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