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