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