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