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).
99    /// On unsupported terminals, the area will be blank.
100    ///
101    /// # Arguments
102    /// * `rgba` - Raw RGBA pixel data
103    /// * `pixel_width` - Image width in pixels
104    /// * `pixel_height` - Image height in pixels
105    /// * `cols` - Terminal cell columns to occupy
106    /// * `rows` - Terminal cell rows to occupy
107    pub fn kitty_image(
108        &mut self,
109        rgba: &[u8],
110        pixel_width: u32,
111        pixel_height: u32,
112        cols: u32,
113        rows: u32,
114    ) -> Response {
115        let rgba_data = normalize_rgba(rgba, pixel_width, pixel_height);
116        let content_hash = crate::buffer::hash_rgba(&rgba_data);
117        let rgba_arc = std::sync::Arc::new(rgba_data);
118        let sw = pixel_width;
119        let sh = pixel_height;
120
121        self.container().w(cols).h(rows).draw(move |buf, rect| {
122            if rect.width == 0 || rect.height == 0 {
123                return;
124            }
125            buf.kitty_place(crate::buffer::KittyPlacement {
126                content_hash,
127                rgba: rgba_arc.clone(),
128                src_width: sw,
129                src_height: sh,
130                x: rect.x,
131                y: rect.y,
132                cols: rect.width,
133                rows: rect.height,
134                crop_y: 0,
135                crop_h: 0,
136            });
137        });
138        Response::none()
139    }
140
141    /// Render a pixel-perfect image that preserves aspect ratio.
142    ///
143    /// Sends the original RGBA data to the terminal and lets the Kitty
144    /// protocol handle scaling. The container width is `cols` cells;
145    /// height is calculated automatically from the image aspect ratio
146    /// using detected cell pixel dimensions (falls back to 8×16 if
147    /// detection fails).
148    ///
149    /// Requires a Kitty-compatible terminal (Kitty, Ghostty, WezTerm).
150    pub fn kitty_image_fit(
151        &mut self,
152        rgba: &[u8],
153        src_width: u32,
154        src_height: u32,
155        cols: u32,
156    ) -> Response {
157        #[cfg(feature = "crossterm")]
158        let (cell_w, cell_h) = crate::terminal::cell_pixel_size();
159        #[cfg(not(feature = "crossterm"))]
160        let (cell_w, cell_h) = (8u32, 16u32);
161
162        let rows = if src_width == 0 {
163            1
164        } else {
165            ((cols as f64 * src_height as f64 * cell_w as f64) / (src_width as f64 * cell_h as f64))
166                .ceil()
167                .max(1.0) as u32
168        };
169        let rgba_data = normalize_rgba(rgba, src_width, src_height);
170        let content_hash = crate::buffer::hash_rgba(&rgba_data);
171        let rgba_arc = std::sync::Arc::new(rgba_data);
172        let sw = src_width;
173        let sh = src_height;
174
175        self.container().w(cols).h(rows).draw(move |buf, rect| {
176            if rect.width == 0 || rect.height == 0 {
177                return;
178            }
179            buf.kitty_place(crate::buffer::KittyPlacement {
180                content_hash,
181                rgba: rgba_arc.clone(),
182                src_width: sw,
183                src_height: sh,
184                x: rect.x,
185                y: rect.y,
186                cols: rect.width,
187                rows: rect.height,
188                crop_y: 0,
189                crop_h: 0,
190            });
191        });
192        Response::none()
193    }
194
195    /// Render an image using the Sixel protocol.
196    ///
197    /// `rgba` is raw RGBA pixel data, `pixel_width`/`pixel_height` are pixel dimensions,
198    /// and `cols`/`rows` are the terminal cell size to reserve for the image.
199    ///
200    /// Requires the `crossterm` feature (enabled by default). Falls back to
201    /// `[sixel unsupported]` on terminals without Sixel support. Set the
202    /// `SLT_FORCE_SIXEL=1` environment variable to skip terminal detection.
203    ///
204    /// # Example
205    ///
206    /// ```no_run
207    /// # slt::run(|ui: &mut slt::Context| {
208    /// // 2x2 red square (RGBA: 4 pixels × 4 bytes)
209    /// let rgba = [255u8, 0, 0, 255].repeat(4);
210    /// ui.sixel_image(&rgba, 2, 2, 20, 2);
211    /// # });
212    /// ```
213    #[cfg(feature = "crossterm")]
214    pub fn sixel_image(
215        &mut self,
216        rgba: &[u8],
217        pixel_width: u32,
218        pixel_height: u32,
219        cols: u32,
220        rows: u32,
221    ) -> Response {
222        let sixel_supported = self.is_real_terminal && terminal_supports_sixel();
223        if !sixel_supported {
224            self.container().w(cols).h(rows).draw(|buf, rect| {
225                if rect.width == 0 || rect.height == 0 {
226                    return;
227                }
228                buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
229            });
230            return Response::none();
231        }
232
233        let rgba = normalize_rgba(rgba, pixel_width, pixel_height);
234        let encoded = crate::sixel::encode_sixel(&rgba, pixel_width, pixel_height, 256);
235
236        if encoded.is_empty() {
237            self.container().w(cols).h(rows).draw(|buf, rect| {
238                if rect.width == 0 || rect.height == 0 {
239                    return;
240                }
241                buf.set_string(rect.x, rect.y, "[sixel empty]", Style::new());
242            });
243            return Response::none();
244        }
245
246        self.container().w(cols).h(rows).draw(move |buf, rect| {
247            if rect.width == 0 || rect.height == 0 {
248                return;
249            }
250            buf.raw_sequence(rect.x, rect.y, encoded);
251        });
252        Response::none()
253    }
254
255    /// Render an image using the Sixel protocol.
256    #[cfg(not(feature = "crossterm"))]
257    pub fn sixel_image(
258        &mut self,
259        _rgba: &[u8],
260        _pixel_width: u32,
261        _pixel_height: u32,
262        cols: u32,
263        rows: u32,
264    ) -> Response {
265        self.container().w(cols).h(rows).draw(|buf, rect| {
266            if rect.width == 0 || rect.height == 0 {
267                return;
268            }
269            buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
270        });
271        Response::none()
272    }
273
274    /// Render streaming text with a typing cursor indicator.
275    ///
276    /// Displays the accumulated text content. While `streaming` is true,
277    /// shows a blinking cursor (`▌`) at the end.
278    ///
279    /// ```no_run
280    /// # use slt::widgets::StreamingTextState;
281    /// # slt::run(|ui: &mut slt::Context| {
282    /// let mut stream = StreamingTextState::new();
283    /// stream.start();
284    /// stream.push("Hello from ");
285    /// stream.push("the AI!");
286    /// ui.streaming_text(&mut stream);
287    /// # });
288    /// ```
289    pub fn streaming_text(&mut self, state: &mut StreamingTextState) -> Response {
290        if state.streaming {
291            state.cursor_tick = state.cursor_tick.wrapping_add(1);
292            state.cursor_visible = (state.cursor_tick / 8) % 2 == 0;
293        }
294
295        if state.content.is_empty() && state.streaming {
296            let cursor = if state.cursor_visible { "▌" } else { " " };
297            let primary = self.theme.primary;
298            self.text(cursor).fg(primary);
299            return Response::none();
300        }
301
302        if !state.content.is_empty() {
303            self.text(&state.content).wrap();
304            if state.streaming && state.cursor_visible {
305                let primary = self.theme.primary;
306                self.styled("▌", Style::new().fg(primary));
307            }
308        }
309
310        Response::none()
311    }
312
313    /// Render streaming markdown with a typing cursor indicator.
314    ///
315    /// Parses accumulated markdown content line-by-line while streaming.
316    /// Supports headings, lists, inline formatting, horizontal rules, and
317    /// fenced code blocks with open/close tracking across stream chunks.
318    ///
319    /// ```no_run
320    /// # use slt::widgets::StreamingMarkdownState;
321    /// # slt::run(|ui: &mut slt::Context| {
322    /// let mut stream = StreamingMarkdownState::new();
323    /// stream.start();
324    /// stream.push("# Hello\n");
325    /// stream.push("- **streaming** markdown\n");
326    /// stream.push("```rust\nlet x = 1;\n");
327    /// ui.streaming_markdown(&mut stream);
328    /// # });
329    /// ```
330    pub fn streaming_markdown(
331        &mut self,
332        state: &mut crate::widgets::StreamingMarkdownState,
333    ) -> Response {
334        if state.streaming {
335            state.cursor_tick = state.cursor_tick.wrapping_add(1);
336            state.cursor_visible = (state.cursor_tick / 8) % 2 == 0;
337        }
338
339        if state.content.is_empty() && state.streaming {
340            let cursor = if state.cursor_visible { "▌" } else { " " };
341            let primary = self.theme.primary;
342            self.text(cursor).fg(primary);
343            return Response::none();
344        }
345
346        let show_cursor = state.streaming && state.cursor_visible;
347        let trailing_newline = state.content.ends_with('\n');
348        let lines: Vec<&str> = state.content.lines().collect();
349        let last_line_index = lines.len().saturating_sub(1);
350
351        self.commands
352            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
353                direction: Direction::Column,
354                gap: 0,
355                align: Align::Start,
356                align_self: None,
357                justify: Justify::Start,
358                border: None,
359                border_sides: BorderSides::all(),
360                border_style: Style::new().fg(self.theme.border),
361                bg_color: None,
362                padding: Padding::default(),
363                margin: Margin::default(),
364                constraints: Constraints::default(),
365                title: None,
366                grow: 0,
367                group_name: None,
368            })));
369        self.skip_interaction_slot();
370
371        let text_style = Style::new().fg(self.theme.text);
372        let bold_style = Style::new().fg(self.theme.text).bold();
373        let code_style = Style::new().fg(self.theme.accent);
374        let border_style = Style::new().fg(self.theme.border).dim();
375
376        let mut in_code_block = false;
377        let mut code_block_lang = String::new();
378
379        for (idx, line) in lines.iter().enumerate() {
380            let line = *line;
381            let trimmed = line.trim();
382            let append_cursor = show_cursor && !trailing_newline && idx == last_line_index;
383            let cursor = if append_cursor { "▌" } else { "" };
384
385            if in_code_block {
386                if trimmed.starts_with("```") {
387                    in_code_block = false;
388                    code_block_lang.clear();
389                    let mut line = String::from("  └────");
390                    line.push_str(cursor);
391                    self.styled(line, border_style);
392                } else {
393                    self.line(|ui| {
394                        ui.text("  ");
395                        render_highlighted_line(ui, line);
396                        if !cursor.is_empty() {
397                            ui.styled(cursor, Style::new().fg(ui.theme.primary));
398                        }
399                    });
400                }
401                continue;
402            }
403
404            if trimmed.is_empty() {
405                if append_cursor {
406                    self.styled("▌", Style::new().fg(self.theme.primary));
407                } else {
408                    self.text(" ");
409                }
410                continue;
411            }
412
413            if trimmed == "---" || trimmed == "***" || trimmed == "___" {
414                let mut line = "─".repeat(40);
415                line.push_str(cursor);
416                self.styled(line, border_style);
417                continue;
418            }
419
420            if let Some(heading) = trimmed.strip_prefix("### ") {
421                let mut line = String::with_capacity(heading.len() + cursor.len());
422                line.push_str(heading);
423                line.push_str(cursor);
424                self.styled(line, Style::new().bold().fg(self.theme.accent));
425                continue;
426            }
427
428            if let Some(heading) = trimmed.strip_prefix("## ") {
429                let mut line = String::with_capacity(heading.len() + cursor.len());
430                line.push_str(heading);
431                line.push_str(cursor);
432                self.styled(line, Style::new().bold().fg(self.theme.secondary));
433                continue;
434            }
435
436            if let Some(heading) = trimmed.strip_prefix("# ") {
437                let mut line = String::with_capacity(heading.len() + cursor.len());
438                line.push_str(heading);
439                line.push_str(cursor);
440                self.styled(line, Style::new().bold().fg(self.theme.primary));
441                continue;
442            }
443
444            if let Some(code) = trimmed.strip_prefix("```") {
445                in_code_block = true;
446                code_block_lang = code.trim().to_string();
447                let label = if code_block_lang.is_empty() {
448                    "code".to_string()
449                } else {
450                    let mut label = String::from("code:");
451                    label.push_str(&code_block_lang);
452                    label
453                };
454                let mut line = String::with_capacity(5 + label.len() + cursor.len());
455                line.push_str("  ┌─");
456                line.push_str(&label);
457                line.push('─');
458                line.push_str(cursor);
459                self.styled(line, border_style);
460                continue;
461            }
462
463            if let Some(item) = trimmed
464                .strip_prefix("- ")
465                .or_else(|| trimmed.strip_prefix("* "))
466            {
467                let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
468                if segs.len() <= 1 {
469                    let mut line = String::with_capacity(4 + item.len() + cursor.len());
470                    line.push_str("  • ");
471                    line.push_str(item);
472                    line.push_str(cursor);
473                    self.styled(line, text_style);
474                } else {
475                    self.line(|ui| {
476                        ui.styled("  • ", text_style);
477                        for (s, st) in segs {
478                            ui.styled(s, st);
479                        }
480                        if append_cursor {
481                            ui.styled("▌", Style::new().fg(ui.theme.primary));
482                        }
483                    });
484                }
485                continue;
486            }
487
488            if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
489                let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
490                if parts.len() == 2 {
491                    let segs =
492                        Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
493                    if segs.len() <= 1 {
494                        let mut line = String::with_capacity(
495                            4 + parts[0].len() + parts[1].len() + cursor.len(),
496                        );
497                        line.push_str("  ");
498                        line.push_str(parts[0]);
499                        line.push_str(". ");
500                        line.push_str(parts[1]);
501                        line.push_str(cursor);
502                        self.styled(line, text_style);
503                    } else {
504                        self.line(|ui| {
505                            let mut prefix = String::with_capacity(4 + parts[0].len());
506                            prefix.push_str("  ");
507                            prefix.push_str(parts[0]);
508                            prefix.push_str(". ");
509                            ui.styled(prefix, text_style);
510                            for (s, st) in segs {
511                                ui.styled(s, st);
512                            }
513                            if append_cursor {
514                                ui.styled("▌", Style::new().fg(ui.theme.primary));
515                            }
516                        });
517                    }
518                } else {
519                    let mut line = String::with_capacity(trimmed.len() + cursor.len());
520                    line.push_str(trimmed);
521                    line.push_str(cursor);
522                    self.styled(line, text_style);
523                }
524                continue;
525            }
526
527            let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
528            if segs.len() <= 1 {
529                let mut line = String::with_capacity(trimmed.len() + cursor.len());
530                line.push_str(trimmed);
531                line.push_str(cursor);
532                self.styled(line, text_style);
533            } else {
534                self.line(|ui| {
535                    for (s, st) in segs {
536                        ui.styled(s, st);
537                    }
538                    if append_cursor {
539                        ui.styled("▌", Style::new().fg(ui.theme.primary));
540                    }
541                });
542            }
543        }
544
545        if show_cursor && trailing_newline {
546            if in_code_block {
547                self.styled("  ▌", code_style);
548            } else {
549                self.styled("▌", Style::new().fg(self.theme.primary));
550            }
551        }
552
553        if state.in_code_block != in_code_block {
554            state.in_code_block = in_code_block;
555        }
556        if state.code_block_lang != code_block_lang {
557            state.code_block_lang = code_block_lang;
558        }
559
560        self.commands.push(Command::EndContainer);
561        self.rollback.last_text_idx = None;
562        Response::none()
563    }
564
565    /// Render a tool approval widget with approve/reject buttons.
566    ///
567    /// Shows the tool name, description, and two action buttons.
568    /// Returns the updated [`ApprovalAction`] each frame.
569    ///
570    /// ```no_run
571    /// # use slt::widgets::{ApprovalAction, ToolApprovalState};
572    /// # slt::run(|ui: &mut slt::Context| {
573    /// let mut tool = ToolApprovalState::new("read_file", "Read contents of config.toml");
574    /// ui.tool_approval(&mut tool);
575    /// if tool.action == ApprovalAction::Approved {
576    /// }
577    /// # });
578    /// ```
579    pub fn tool_approval(&mut self, state: &mut ToolApprovalState) -> Response {
580        let old_action = state.action;
581        let theme = self.theme;
582        let _ = self.bordered(Border::Rounded).col(|ui| {
583            let _ = ui.row(|ui| {
584                ui.text("⚡").fg(theme.warning);
585                ui.text(&state.tool_name).bold().fg(theme.primary);
586            });
587            ui.text(&state.description).dim();
588
589            if state.action == ApprovalAction::Pending {
590                let _ = ui.row(|ui| {
591                    if ui.button("✓ Approve").clicked {
592                        state.action = ApprovalAction::Approved;
593                    }
594                    if ui.button("✗ Reject").clicked {
595                        state.action = ApprovalAction::Rejected;
596                    }
597                });
598            } else {
599                let (label, color) = match state.action {
600                    ApprovalAction::Approved => ("✓ Approved", theme.success),
601                    ApprovalAction::Rejected => ("✗ Rejected", theme.error),
602                    ApprovalAction::Pending => unreachable!(),
603                };
604                ui.text(label).fg(color).bold();
605            }
606        });
607
608        Response {
609            changed: state.action != old_action,
610            ..Response::none()
611        }
612    }
613
614    /// Render a context bar showing active context items with token counts.
615    ///
616    /// Displays a horizontal bar of context sources (files, URLs, etc.)
617    /// with their token counts, useful for AI chat interfaces.
618    ///
619    /// ```no_run
620    /// # use slt::widgets::ContextItem;
621    /// # slt::run(|ui: &mut slt::Context| {
622    /// let items = vec![ContextItem::new("main.rs", 1200), ContextItem::new("lib.rs", 800)];
623    /// ui.context_bar(&items);
624    /// # });
625    /// ```
626    pub fn context_bar(&mut self, items: &[ContextItem]) -> Response {
627        if items.is_empty() {
628            return Response::none();
629        }
630
631        let theme = self.theme;
632        let total: usize = items.iter().map(|item| item.tokens).sum();
633
634        let _ = self.container().row(|ui| {
635            ui.text("📎").dim();
636            for item in items {
637                let token_count = format_token_count(item.tokens);
638                let mut line = String::with_capacity(item.label.len() + token_count.len() + 3);
639                line.push_str(&item.label);
640                line.push_str(" (");
641                line.push_str(&token_count);
642                line.push(')');
643                ui.text(line).fg(theme.secondary);
644            }
645            ui.spacer();
646            let total_text = format_token_count(total);
647            let mut line = String::with_capacity(2 + total_text.len());
648            line.push_str("Σ ");
649            line.push_str(&total_text);
650            ui.text(line).dim();
651        });
652
653        Response::none()
654    }
655}