Skip to main content

slt/context/
widgets_display.rs

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