Skip to main content

slt/context/widgets_display/
rich_output.rs

1use super::*;
2
3impl Context {
4    /// Render 8x8 bitmap text as half-block pixels (4 terminal rows tall).
5    pub fn big_text(&mut self, s: impl Into<String>) -> Response {
6        let text = s.into();
7        let glyphs: Vec<[u8; 8]> = text.chars().map(glyph_8x8).collect();
8        let total_width = (glyphs.len() as u32).saturating_mul(8);
9        let on_color = self.theme.primary;
10
11        self.container().w(total_width).h(4).draw(move |buf, rect| {
12            if rect.width == 0 || rect.height == 0 {
13                return;
14            }
15
16            for (glyph_idx, glyph) in glyphs.iter().enumerate() {
17                let base_x = rect.x + (glyph_idx as u32) * 8;
18                if base_x >= rect.right() {
19                    break;
20                }
21
22                for pair in 0..4usize {
23                    let y = rect.y + pair as u32;
24                    if y >= rect.bottom() {
25                        continue;
26                    }
27
28                    let upper = glyph[pair * 2];
29                    let lower = glyph[pair * 2 + 1];
30
31                    for bit in 0..8u32 {
32                        let x = base_x + bit;
33                        if x >= rect.right() {
34                            break;
35                        }
36
37                        let mask = 1u8 << (bit as u8);
38                        let upper_on = (upper & mask) != 0;
39                        let lower_on = (lower & mask) != 0;
40                        let (ch, fg, bg) = match (upper_on, lower_on) {
41                            (true, true) => ('█', on_color, on_color),
42                            (true, false) => ('▀', on_color, Color::Reset),
43                            (false, true) => ('▄', on_color, Color::Reset),
44                            (false, false) => (' ', Color::Reset, Color::Reset),
45                        };
46                        buf.set_char(x, y, ch, Style::new().fg(fg).bg(bg));
47                    }
48                }
49            }
50        });
51
52        Response::none()
53    }
54
55    /// Render a half-block image in the terminal.
56    ///
57    /// Each terminal cell displays two vertical pixels using the `▀` character
58    /// with foreground (upper pixel) and background (lower pixel) colors.
59    ///
60    /// Create a [`HalfBlockImage`] from a file (requires `image` feature):
61    /// ```ignore
62    /// let img = image::open("photo.png").unwrap();
63    /// let half = HalfBlockImage::from_dynamic(&img, 40, 20);
64    /// ui.image(&half);
65    /// ```
66    ///
67    /// Or from raw RGB data (no feature needed):
68    /// ```no_run
69    /// # use slt::{Context, HalfBlockImage};
70    /// # slt::run(|ui: &mut Context| {
71    /// let rgb = vec![255u8; 30 * 20 * 3];
72    /// let half = HalfBlockImage::from_rgb(&rgb, 30, 10);
73    /// ui.image(&half);
74    /// # });
75    /// ```
76    pub fn image(&mut self, img: &HalfBlockImage) -> Response {
77        let pixels: Vec<(Color, Color)> = img.pixels.clone();
78        let (w, h) = (img.width, img.height);
79        self.container().w(w).h(h).draw(move |buf, rect| {
80            for row in 0..h {
81                for col in 0..w {
82                    if let Some(&(fg, bg)) = pixels.get((row * w + col) as usize) {
83                        buf.set_char(rect.x + col, rect.y + row, '▀', Style::new().fg(fg).bg(bg));
84                    }
85                }
86            }
87        });
88
89        Response::none()
90    }
91
92    /// Render a pixel-perfect image using the Kitty graphics protocol.
93    ///
94    /// The image data must be raw RGBA bytes (4 bytes per pixel).
95    /// The widget allocates `cols` x `rows` cells and renders the image
96    /// at full pixel resolution within that space.
97    ///
98    /// Requires a Kitty-compatible terminal (Kitty, Ghostty, WezTerm). On
99    /// unsupported terminals, falls back to half-block cell rendering. Set
100    /// `SLT_FORCE_KITTY=1` only when the terminal path is known to pass Kitty
101    /// graphics through.
102    ///
103    /// # Arguments
104    /// * `rgba` - Raw RGBA pixel data
105    /// * `pixel_width` - Image width in pixels
106    /// * `pixel_height` - Image height in pixels
107    /// * `cols` - Terminal cell columns to occupy
108    /// * `rows` - Terminal cell rows to occupy
109    pub fn kitty_image(
110        &mut self,
111        rgba: &[u8],
112        pixel_width: u32,
113        pixel_height: u32,
114        cols: u32,
115        rows: u32,
116    ) -> Response {
117        if !self.kitty_graphics_supported() {
118            return self.rgba_halfblock_fallback(
119                rgba,
120                pixel_width,
121                pixel_height,
122                cols,
123                rows,
124                "[kitty unsupported]",
125            );
126        }
127
128        let rgba_data = normalize_rgba(rgba, pixel_width, pixel_height);
129        let content_hash = crate::buffer::hash_rgba(&rgba_data);
130        let rgba_arc = std::sync::Arc::new(rgba_data);
131        let sw = pixel_width;
132        let sh = pixel_height;
133
134        self.container().w(cols).h(rows).draw(move |buf, rect| {
135            if rect.width == 0 || rect.height == 0 {
136                return;
137            }
138            buf.kitty_place(crate::buffer::KittyPlacement {
139                content_hash,
140                rgba: rgba_arc.clone(),
141                src_width: sw,
142                src_height: sh,
143                x: rect.x,
144                y: rect.y,
145                cols: rect.width,
146                rows: rect.height,
147                crop_y: 0,
148                crop_h: 0,
149            });
150        });
151        Response::none()
152    }
153
154    /// Render a pixel-perfect image that preserves aspect ratio.
155    ///
156    /// Sends the original RGBA data to the terminal and lets the Kitty
157    /// protocol handle scaling. The container width is `cols` cells;
158    /// height is calculated automatically from the image aspect ratio
159    /// using detected cell pixel dimensions (falls back to 8×16 if
160    /// detection fails).
161    ///
162    /// Requires a Kitty-compatible terminal (Kitty, Ghostty, WezTerm). On
163    /// unsupported terminals, falls back to half-block cell rendering without
164    /// probing cell pixel size.
165    pub fn kitty_image_fit(
166        &mut self,
167        rgba: &[u8],
168        src_width: u32,
169        src_height: u32,
170        cols: u32,
171    ) -> Response {
172        let supported = self.kitty_graphics_supported();
173        #[cfg(feature = "crossterm")]
174        let (cell_w, cell_h) = if supported {
175            crate::terminal::cell_pixel_size()
176        } else {
177            (8u32, 16u32)
178        };
179        #[cfg(not(feature = "crossterm"))]
180        let (cell_w, cell_h) = (8u32, 16u32);
181
182        let rows = image_fit_rows(src_width, src_height, cols, cell_w, cell_h);
183        if !supported {
184            return self.rgba_halfblock_fallback(
185                rgba,
186                src_width,
187                src_height,
188                cols,
189                rows,
190                "[kitty unsupported]",
191            );
192        }
193
194        let rgba_data = normalize_rgba(rgba, src_width, src_height);
195        let content_hash = crate::buffer::hash_rgba(&rgba_data);
196        let rgba_arc = std::sync::Arc::new(rgba_data);
197        let sw = src_width;
198        let sh = src_height;
199
200        self.container().w(cols).h(rows).draw(move |buf, rect| {
201            if rect.width == 0 || rect.height == 0 {
202                return;
203            }
204            buf.kitty_place(crate::buffer::KittyPlacement {
205                content_hash,
206                rgba: rgba_arc.clone(),
207                src_width: sw,
208                src_height: sh,
209                x: rect.x,
210                y: rect.y,
211                cols: rect.width,
212                rows: rect.height,
213                crop_y: 0,
214                crop_h: 0,
215            });
216        });
217        Response::none()
218    }
219
220    /// Render an image using the Sixel protocol.
221    ///
222    /// `rgba` is raw RGBA pixel data, `pixel_width`/`pixel_height` are pixel dimensions,
223    /// and `cols`/`rows` are the terminal cell size to reserve for the image.
224    ///
225    /// Requires the `crossterm` feature (enabled by default). Falls back to
226    /// `[sixel unsupported]` on terminals without Sixel support. Set the
227    /// `SLT_FORCE_SIXEL=1` environment variable to skip terminal detection.
228    ///
229    /// # Example
230    ///
231    /// ```no_run
232    /// # slt::run(|ui: &mut slt::Context| {
233    /// // 2x2 red square (RGBA: 4 pixels × 4 bytes)
234    /// let rgba = [255u8, 0, 0, 255].repeat(4);
235    /// ui.sixel_image(&rgba, 2, 2, 20, 2);
236    /// # });
237    /// ```
238    #[cfg(feature = "crossterm")]
239    #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
240    pub fn sixel_image(
241        &mut self,
242        rgba: &[u8],
243        pixel_width: u32,
244        pixel_height: u32,
245        cols: u32,
246        rows: u32,
247    ) -> Response {
248        // Issue #264: consult the negotiated capability snapshot first (the
249        // DA1 probe is authoritative when it answered). Fall back to the env
250        // allowlist (now including WezTerm/Ghostty) / `SLT_FORCE_SIXEL` when the
251        // probe returned unknown. App code never selects a protocol — the
252        // blitter ladder resolves it.
253        let sixel_supported = self.sixel_supported();
254        if !sixel_supported {
255            self.container().w(cols).h(rows).draw(|buf, rect| {
256                if rect.width == 0 || rect.height == 0 {
257                    return;
258                }
259                buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
260            });
261            return Response::none();
262        }
263
264        let rgba = normalize_rgba(rgba, pixel_width, pixel_height);
265        let content_hash = crate::buffer::hash_rgba(&rgba);
266        let encoded = crate::sixel::encode_sixel(&rgba, pixel_width, pixel_height, 256);
267
268        if encoded.is_empty() {
269            self.container().w(cols).h(rows).draw(|buf, rect| {
270                if rect.width == 0 || rect.height == 0 {
271                    return;
272                }
273                buf.set_string(rect.x, rect.y, "[sixel empty]", Style::new());
274            });
275            return Response::none();
276        }
277
278        // Issue #265: route through the sprixel damage matrix instead of a flat
279        // `raw_sequence`, so a text edit adjacent to the image no longer forces
280        // a full re-blit. The footprint is recorded as fully `Opaque`; the flush
281        // layer flips cells to `Annihilated` only where text overwrites ink.
282        self.container().w(cols).h(rows).draw(move |buf, rect| {
283            if rect.width == 0 || rect.height == 0 {
284                return;
285            }
286            let cells = (rect.width as usize).saturating_mul(rect.height as usize);
287            buf.sprixel_place(crate::buffer::SprixelPlacement {
288                content_hash,
289                seq: encoded,
290                x: rect.x,
291                y: rect.y,
292                cols: rect.width,
293                rows: rect.height,
294                cells: vec![crate::buffer::SprixelCell::Opaque; cells],
295            });
296        });
297        Response::none()
298    }
299
300    /// Render an image via iTerm2's OSC 1337 inline-image protocol.
301    ///
302    /// Unlike [`Context::kitty_image`] (raw RGBA) or [`Context::sixel_image`]
303    /// (raw RGBA, quantized), `data` is **encoded image-file bytes**
304    /// (PNG/JPEG/GIF): the terminal decodes and scales the file itself. This is
305    /// the pixel-accurate path on Tabby, older iTerm2 builds, and WezTerm's
306    /// iTerm2-compat mode (issue #265).
307    ///
308    /// `cols`/`rows` reserve the cell box for the image. On a terminal without
309    /// OSC 1337 support the area is reserved and `[iterm2 unsupported]` is
310    /// drawn, mirroring the Sixel fallback. Set `SLT_FORCE_ITERM=1` to skip
311    /// detection.
312    ///
313    /// # Example
314    ///
315    /// ```no_run
316    /// # slt::run(|ui: &mut slt::Context| {
317    /// // `png` holds encoded PNG bytes loaded from disk or memory.
318    /// let png = [0x89u8, b'P', b'N', b'G'];
319    /// ui.iterm_image(&png, 20, 4);
320    /// # });
321    /// ```
322    #[cfg(feature = "crossterm")]
323    #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
324    pub fn iterm_image(&mut self, data: &[u8], cols: u32, rows: u32) -> Response {
325        // Issue #264 ladder integration: consult the negotiated capability
326        // snapshot first, then the env allowlist / `SLT_FORCE_ITERM`. App code
327        // never selects a protocol directly.
328        let supported = self.iterm_supported();
329        if !supported {
330            return self.iterm_placeholder(cols, rows);
331        }
332
333        let content_hash = crate::buffer::hash_rgba(data);
334        let encoded = crate::iterm::encode_iterm_osc1337(data, cols, rows, false);
335        if encoded.is_empty() {
336            return self.iterm_placeholder(cols, rows);
337        }
338
339        self.container().w(cols).h(rows).draw(move |buf, rect| {
340            if rect.width == 0 || rect.height == 0 {
341                return;
342            }
343            let cells = (rect.width as usize).saturating_mul(rect.height as usize);
344            buf.sprixel_place(crate::buffer::SprixelPlacement {
345                content_hash,
346                seq: encoded,
347                x: rect.x,
348                y: rect.y,
349                cols: rect.width,
350                rows: rect.height,
351                cells: vec![crate::buffer::SprixelCell::Opaque; cells],
352            });
353        });
354        Response::none()
355    }
356
357    /// Render an iTerm2 OSC 1337 inline image preserving aspect ratio.
358    ///
359    /// `data` is **encoded image-file bytes** (PNG/JPEG/GIF). The container is
360    /// `cols` cells wide; height is reserved from the detected cell pixel
361    /// dimensions (falling back to 8×16) and the OSC 1337 `height=auto` /
362    /// `preserveAspectRatio=1` flags let the terminal scale to fit. Mirrors
363    /// [`Context::kitty_image_fit`] (issue #265).
364    ///
365    /// Falls back to `[iterm2 unsupported]` on terminals without OSC 1337.
366    ///
367    /// # Example
368    ///
369    /// ```no_run
370    /// # slt::run(|ui: &mut slt::Context| {
371    /// let png = [0x89u8, b'P', b'N', b'G'];
372    /// ui.iterm_image_fit(&png, 20);
373    /// # });
374    /// ```
375    #[cfg(feature = "crossterm")]
376    #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
377    pub fn iterm_image_fit(&mut self, data: &[u8], cols: u32) -> Response {
378        let supported = self.iterm_supported();
379
380        // Reserve rows from the cell aspect using a 1:1 source assumption — the
381        // terminal does the real aspect math from the decoded file; this only
382        // sizes the cell box so layout below the image is not clobbered. Use a
383        // square-ish default of `cols / 2` rows (cells are ~2:1 tall), min 1.
384        let (cell_w, cell_h) = if supported {
385            crate::terminal::cell_pixel_size()
386        } else {
387            (8u32, 16u32)
388        };
389        let rows = if cell_h == 0 {
390            cols.max(1)
391        } else {
392            ((cols as f64 * cell_w as f64) / cell_h as f64)
393                .ceil()
394                .max(1.0) as u32
395        };
396
397        if !supported {
398            return self.iterm_placeholder(cols, rows);
399        }
400
401        let content_hash = crate::buffer::hash_rgba(data);
402        // `rows == 0` signals `height=auto`; the reserved cell box is `rows`.
403        let encoded = crate::iterm::encode_iterm_osc1337(data, cols, 0, true);
404        if encoded.is_empty() {
405            return self.iterm_placeholder(cols, rows);
406        }
407
408        self.container().w(cols).h(rows).draw(move |buf, rect| {
409            if rect.width == 0 || rect.height == 0 {
410                return;
411            }
412            let cells = (rect.width as usize).saturating_mul(rect.height as usize);
413            buf.sprixel_place(crate::buffer::SprixelPlacement {
414                content_hash,
415                seq: encoded,
416                x: rect.x,
417                y: rect.y,
418                cols: rect.width,
419                rows: rect.height,
420                cells: vec![crate::buffer::SprixelCell::Opaque; cells],
421            });
422        });
423        Response::none()
424    }
425
426    #[cfg(feature = "crossterm")]
427    fn kitty_graphics_supported(&self) -> bool {
428        if !self.is_real_terminal {
429            return false;
430        }
431        if terminal_force_graphics("SLT_FORCE_KITTY") {
432            return true;
433        }
434        if terminal_graphics_blocked_by_multiplexer() {
435            return false;
436        }
437        self.capabilities.kitty_graphics || terminal_supports_kitty()
438    }
439
440    #[cfg(not(feature = "crossterm"))]
441    fn kitty_graphics_supported(&self) -> bool {
442        false
443    }
444
445    #[cfg(feature = "crossterm")]
446    fn sixel_supported(&self) -> bool {
447        if !self.is_real_terminal {
448            return false;
449        }
450        if terminal_force_graphics("SLT_FORCE_SIXEL") {
451            return true;
452        }
453        if terminal_graphics_blocked_by_multiplexer() {
454            return false;
455        }
456        self.capabilities.sixel || terminal_supports_sixel()
457    }
458
459    #[cfg(feature = "crossterm")]
460    fn iterm_supported(&self) -> bool {
461        if !self.is_real_terminal {
462            return false;
463        }
464        if terminal_force_graphics("SLT_FORCE_ITERM") {
465            return true;
466        }
467        if terminal_graphics_blocked_by_multiplexer() {
468            return false;
469        }
470        self.capabilities.iterm2 || terminal_supports_iterm()
471    }
472
473    fn rgba_halfblock_fallback(
474        &mut self,
475        rgba: &[u8],
476        pixel_width: u32,
477        pixel_height: u32,
478        cols: u32,
479        rows: u32,
480        placeholder: &'static str,
481    ) -> Response {
482        let rgba_data = normalize_rgba(rgba, pixel_width, pixel_height);
483        if rgba_data.is_empty() {
484            self.container().w(cols).h(rows).draw(move |buf, rect| {
485                if rect.width == 0 || rect.height == 0 {
486                    return;
487                }
488                buf.set_string(rect.x, rect.y, placeholder, Style::new());
489            });
490            return Response::none();
491        }
492
493        self.container().w(cols).h(rows).draw(move |buf, rect| {
494            if rect.width == 0 || rect.height == 0 {
495                return;
496            }
497
498            let dst_pixel_height = rect.height.saturating_mul(2).max(1);
499            for row in 0..rect.height {
500                for col in 0..rect.width {
501                    let upper = sample_rgba_color(
502                        &rgba_data,
503                        pixel_width,
504                        pixel_height,
505                        col,
506                        row * 2,
507                        rect.width,
508                        dst_pixel_height,
509                    );
510                    let lower = sample_rgba_color(
511                        &rgba_data,
512                        pixel_width,
513                        pixel_height,
514                        col,
515                        row.saturating_mul(2).saturating_add(1),
516                        rect.width,
517                        dst_pixel_height,
518                    );
519                    draw_halfblock_cell(buf, rect.x + col, rect.y + row, upper, lower);
520                }
521            }
522        });
523        Response::none()
524    }
525
526    /// Reserve a `cols`×`rows` container and draw the unsupported placeholder,
527    /// matching the Sixel fallback pattern.
528    #[cfg(feature = "crossterm")]
529    fn iterm_placeholder(&mut self, cols: u32, rows: u32) -> Response {
530        self.container().w(cols).h(rows).draw(|buf, rect| {
531            if rect.width == 0 || rect.height == 0 {
532                return;
533            }
534            buf.set_string(rect.x, rect.y, "[iterm2 unsupported]", Style::new());
535        });
536        Response::none()
537    }
538
539    /// Render an image via iTerm2's OSC 1337 inline-image protocol.
540    #[cfg(not(feature = "crossterm"))]
541    pub fn iterm_image(&mut self, _data: &[u8], cols: u32, rows: u32) -> Response {
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, "[iterm2 unsupported]", Style::new());
547        });
548        Response::none()
549    }
550
551    /// Render an iTerm2 OSC 1337 inline image preserving aspect ratio.
552    #[cfg(not(feature = "crossterm"))]
553    pub fn iterm_image_fit(&mut self, _data: &[u8], cols: u32) -> Response {
554        // Without `crossterm` there is no cell-pixel probe; reserve a
555        // square-ish box so layout below the image is stable.
556        let rows = (cols / 2).max(1);
557        self.container().w(cols).h(rows).draw(|buf, rect| {
558            if rect.width == 0 || rect.height == 0 {
559                return;
560            }
561            buf.set_string(rect.x, rect.y, "[iterm2 unsupported]", Style::new());
562        });
563        Response::none()
564    }
565
566    /// Render an image using the Sixel protocol.
567    #[cfg(not(feature = "crossterm"))]
568    pub fn sixel_image(
569        &mut self,
570        _rgba: &[u8],
571        _pixel_width: u32,
572        _pixel_height: u32,
573        cols: u32,
574        rows: u32,
575    ) -> Response {
576        self.container().w(cols).h(rows).draw(|buf, rect| {
577            if rect.width == 0 || rect.height == 0 {
578                return;
579            }
580            buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
581        });
582        Response::none()
583    }
584
585    /// Render streaming text with a typing cursor indicator.
586    ///
587    /// Displays the accumulated text content. While `streaming` is true,
588    /// shows a blinking cursor (`▌`) at the end.
589    ///
590    /// ```no_run
591    /// # use slt::widgets::StreamingTextState;
592    /// # slt::run(|ui: &mut slt::Context| {
593    /// let mut stream = StreamingTextState::new();
594    /// stream.start();
595    /// stream.push("Hello from ");
596    /// stream.push("the AI!");
597    /// ui.streaming_text(&mut stream);
598    /// # });
599    /// ```
600    pub fn streaming_text(&mut self, state: &mut StreamingTextState) -> Response {
601        if state.streaming {
602            state.cursor_tick = state.cursor_tick.wrapping_add(1);
603            state.cursor_visible = (state.cursor_tick / 8).is_multiple_of(2);
604        }
605
606        if state.content.is_empty() && state.streaming {
607            let cursor = if state.cursor_visible { "▌" } else { " " };
608            let primary = self.theme.primary;
609            self.text(cursor).fg(primary);
610            return Response::none();
611        }
612
613        if !state.content.is_empty() {
614            self.text(&state.content).wrap();
615            if state.streaming && state.cursor_visible {
616                let primary = self.theme.primary;
617                self.styled("▌", Style::new().fg(primary));
618            }
619        }
620
621        Response::none()
622    }
623
624    /// Render streaming markdown with a typing cursor indicator.
625    ///
626    /// Parses accumulated markdown content line-by-line while streaming.
627    /// Supports headings, lists, inline formatting, horizontal rules, and
628    /// fenced code blocks with open/close tracking across stream chunks.
629    ///
630    /// ```no_run
631    /// # use slt::widgets::StreamingMarkdownState;
632    /// # slt::run(|ui: &mut slt::Context| {
633    /// let mut stream = StreamingMarkdownState::new();
634    /// stream.start();
635    /// stream.push("# Hello\n");
636    /// stream.push("- **streaming** markdown\n");
637    /// stream.push("```rust\nlet x = 1;\n");
638    /// ui.streaming_markdown(&mut stream);
639    /// # });
640    /// ```
641    pub fn streaming_markdown(
642        &mut self,
643        state: &mut crate::widgets::StreamingMarkdownState,
644    ) -> Response {
645        if state.streaming {
646            state.cursor_tick = state.cursor_tick.wrapping_add(1);
647            state.cursor_visible = (state.cursor_tick / 8).is_multiple_of(2);
648        }
649
650        if state.content.is_empty() && state.streaming {
651            let cursor = if state.cursor_visible { "▌" } else { " " };
652            let primary = self.theme.primary;
653            self.text(cursor).fg(primary);
654            return Response::none();
655        }
656
657        let show_cursor = state.streaming && state.cursor_visible;
658        let trailing_newline = state.content.ends_with('\n');
659        let lines: Vec<&str> = state.content.lines().collect();
660        let last_line_index = lines.len().saturating_sub(1);
661
662        self.commands
663            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
664                direction: Direction::Column,
665                gap: 0,
666                align: Align::Start,
667                align_self: None,
668                justify: Justify::Start,
669                border: None,
670                border_sides: BorderSides::all(),
671                border_style: Style::new().fg(self.theme.border),
672                bg_color: None,
673                padding: Padding::default(),
674                margin: Margin::default(),
675                constraints: Constraints::default(),
676                title: None,
677                grow: 0,
678                group_name: None,
679            })));
680        self.skip_interaction_slot();
681
682        let text_style = Style::new().fg(self.theme.text);
683        let bold_style = Style::new().fg(self.theme.text).bold();
684        let code_style = Style::new().fg(self.theme.accent);
685        let border_style = Style::new().fg(self.theme.border).dim();
686
687        let mut in_code_block = false;
688        let mut code_block_lang = String::new();
689
690        for (idx, line) in lines.iter().enumerate() {
691            let line = *line;
692            let trimmed = line.trim();
693            let append_cursor = show_cursor && !trailing_newline && idx == last_line_index;
694            let cursor = if append_cursor { "▌" } else { "" };
695
696            if in_code_block {
697                if trimmed.starts_with("```") {
698                    in_code_block = false;
699                    code_block_lang.clear();
700                    let mut line = String::from("  └────");
701                    line.push_str(cursor);
702                    self.styled(line, border_style);
703                } else {
704                    self.line(|ui| {
705                        ui.text("  ");
706                        render_highlighted_line(ui, line);
707                        if !cursor.is_empty() {
708                            ui.styled(cursor, Style::new().fg(ui.theme.primary));
709                        }
710                    });
711                }
712                continue;
713            }
714
715            if trimmed.is_empty() {
716                if append_cursor {
717                    self.styled("▌", Style::new().fg(self.theme.primary));
718                } else {
719                    self.text(" ");
720                }
721                continue;
722            }
723
724            if trimmed == "---" || trimmed == "***" || trimmed == "___" {
725                let mut line = "─".repeat(40);
726                line.push_str(cursor);
727                self.styled(line, border_style);
728                continue;
729            }
730
731            if let Some(heading) = trimmed.strip_prefix("### ") {
732                let mut line = String::with_capacity(heading.len() + cursor.len());
733                line.push_str(heading);
734                line.push_str(cursor);
735                self.styled(line, Style::new().bold().fg(self.theme.accent));
736                continue;
737            }
738
739            if let Some(heading) = trimmed.strip_prefix("## ") {
740                let mut line = String::with_capacity(heading.len() + cursor.len());
741                line.push_str(heading);
742                line.push_str(cursor);
743                self.styled(line, Style::new().bold().fg(self.theme.secondary));
744                continue;
745            }
746
747            if let Some(heading) = trimmed.strip_prefix("# ") {
748                let mut line = String::with_capacity(heading.len() + cursor.len());
749                line.push_str(heading);
750                line.push_str(cursor);
751                self.styled(line, Style::new().bold().fg(self.theme.primary));
752                continue;
753            }
754
755            if let Some(code) = trimmed.strip_prefix("```") {
756                in_code_block = true;
757                code_block_lang = code.trim().to_string();
758                let label = if code_block_lang.is_empty() {
759                    "code".to_string()
760                } else {
761                    let mut label = String::from("code:");
762                    label.push_str(&code_block_lang);
763                    label
764                };
765                let mut line = String::with_capacity(5 + label.len() + cursor.len());
766                line.push_str("  ┌─");
767                line.push_str(&label);
768                line.push('─');
769                line.push_str(cursor);
770                self.styled(line, border_style);
771                continue;
772            }
773
774            if let Some(item) = trimmed
775                .strip_prefix("- ")
776                .or_else(|| trimmed.strip_prefix("* "))
777            {
778                let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
779                if segs.len() <= 1 {
780                    let mut line = String::with_capacity(4 + item.len() + cursor.len());
781                    line.push_str("  • ");
782                    line.push_str(item);
783                    line.push_str(cursor);
784                    self.styled(line, text_style);
785                } else {
786                    self.line(|ui| {
787                        ui.styled("  • ", text_style);
788                        for (s, st) in segs {
789                            ui.styled(s, st);
790                        }
791                        if append_cursor {
792                            ui.styled("▌", Style::new().fg(ui.theme.primary));
793                        }
794                    });
795                }
796                continue;
797            }
798
799            if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
800                let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
801                if parts.len() == 2 {
802                    let segs =
803                        Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
804                    if segs.len() <= 1 {
805                        let mut line = String::with_capacity(
806                            4 + parts[0].len() + parts[1].len() + cursor.len(),
807                        );
808                        line.push_str("  ");
809                        line.push_str(parts[0]);
810                        line.push_str(". ");
811                        line.push_str(parts[1]);
812                        line.push_str(cursor);
813                        self.styled(line, text_style);
814                    } else {
815                        self.line(|ui| {
816                            let mut prefix = String::with_capacity(4 + parts[0].len());
817                            prefix.push_str("  ");
818                            prefix.push_str(parts[0]);
819                            prefix.push_str(". ");
820                            ui.styled(prefix, text_style);
821                            for (s, st) in segs {
822                                ui.styled(s, st);
823                            }
824                            if append_cursor {
825                                ui.styled("▌", Style::new().fg(ui.theme.primary));
826                            }
827                        });
828                    }
829                } else {
830                    let mut line = String::with_capacity(trimmed.len() + cursor.len());
831                    line.push_str(trimmed);
832                    line.push_str(cursor);
833                    self.styled(line, text_style);
834                }
835                continue;
836            }
837
838            let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
839            if segs.len() <= 1 {
840                let mut line = String::with_capacity(trimmed.len() + cursor.len());
841                line.push_str(trimmed);
842                line.push_str(cursor);
843                self.styled(line, text_style);
844            } else {
845                self.line(|ui| {
846                    for (s, st) in segs {
847                        ui.styled(s, st);
848                    }
849                    if append_cursor {
850                        ui.styled("▌", Style::new().fg(ui.theme.primary));
851                    }
852                });
853            }
854        }
855
856        if show_cursor && trailing_newline {
857            if in_code_block {
858                self.styled("  ▌", code_style);
859            } else {
860                self.styled("▌", Style::new().fg(self.theme.primary));
861            }
862        }
863
864        if state.in_code_block != in_code_block {
865            state.in_code_block = in_code_block;
866        }
867        if state.code_block_lang != code_block_lang {
868            state.code_block_lang = code_block_lang;
869        }
870
871        self.commands.push(Command::EndContainer);
872        self.rollback.last_text_idx = None;
873        Response::none()
874    }
875
876    /// Render a tool approval widget with approve/reject buttons.
877    ///
878    /// Shows the tool name, description, and two action buttons.
879    /// Returns the updated [`ApprovalAction`] each frame.
880    ///
881    /// ```no_run
882    /// # use slt::widgets::{ApprovalAction, ToolApprovalState};
883    /// # slt::run(|ui: &mut slt::Context| {
884    /// let mut tool = ToolApprovalState::new("read_file", "Read contents of config.toml");
885    /// ui.tool_approval(&mut tool);
886    /// if tool.action == ApprovalAction::Approved {
887    /// }
888    /// # });
889    /// ```
890    pub fn tool_approval(&mut self, state: &mut ToolApprovalState) -> Response {
891        let old_action = state.action;
892        let theme = self.theme;
893        let _ = self.bordered(Border::Rounded).col(|ui| {
894            let _ = ui.row(|ui| {
895                ui.text("⚡").fg(theme.warning);
896                ui.text(&state.tool_name).bold().fg(theme.primary);
897            });
898            ui.text(&state.description).dim();
899
900            if state.action == ApprovalAction::Pending {
901                let _ = ui.row(|ui| {
902                    if ui.button("✓ Approve").clicked {
903                        state.action = ApprovalAction::Approved;
904                    }
905                    if ui.button("✗ Reject").clicked {
906                        state.action = ApprovalAction::Rejected;
907                    }
908                });
909            } else {
910                let (label, color) = match state.action {
911                    ApprovalAction::Approved => ("✓ Approved", theme.success),
912                    ApprovalAction::Rejected => ("✗ Rejected", theme.error),
913                    ApprovalAction::Pending => unreachable!(),
914                };
915                ui.text(label).fg(color).bold();
916            }
917        });
918
919        Response {
920            changed: state.action != old_action,
921            ..Response::none()
922        }
923    }
924
925    /// Render a context bar showing active context items with token counts.
926    ///
927    /// Displays a horizontal bar of context sources (files, URLs, etc.)
928    /// with their token counts, useful for AI chat interfaces.
929    ///
930    /// ```no_run
931    /// # use slt::widgets::ContextItem;
932    /// # slt::run(|ui: &mut slt::Context| {
933    /// let items = vec![ContextItem::new("main.rs", 1200), ContextItem::new("lib.rs", 800)];
934    /// ui.context_bar(&items);
935    /// # });
936    /// ```
937    pub fn context_bar(&mut self, items: &[ContextItem]) -> Response {
938        if items.is_empty() {
939            return Response::none();
940        }
941
942        let theme = self.theme;
943        let total: usize = items.iter().map(|item| item.tokens).sum();
944
945        let _ = self.container().row(|ui| {
946            ui.text("📎").dim();
947            for item in items {
948                let token_count = format_token_count(item.tokens);
949                let mut line = String::with_capacity(item.label.len() + token_count.len() + 3);
950                line.push_str(&item.label);
951                line.push_str(" (");
952                line.push_str(&token_count);
953                line.push(')');
954                ui.text(line).fg(theme.secondary);
955            }
956            ui.spacer();
957            let total_text = format_token_count(total);
958            let mut line = String::with_capacity(2 + total_text.len());
959            line.push_str("Σ ");
960            line.push_str(&total_text);
961            ui.text(line).dim();
962        });
963
964        Response::none()
965    }
966}