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