Skip to main content

slt/context/
widgets_display.rs

1use super::*;
2
3impl Context {
4    // ── text ──────────────────────────────────────────────────────────
5
6    /// Render a text element. Returns `&mut Self` for style chaining.
7    ///
8    /// # Example
9    ///
10    /// ```no_run
11    /// # slt::run(|ui: &mut slt::Context| {
12    /// use slt::Color;
13    /// ui.text("hello").bold().fg(Color::Cyan);
14    /// # });
15    /// ```
16    pub fn text(&mut self, s: impl Into<String>) -> &mut Self {
17        let content = s.into();
18        self.commands.push(Command::Text {
19            content,
20            style: Style::new(),
21            grow: 0,
22            align: Align::Start,
23            wrap: false,
24            margin: Margin::default(),
25            constraints: Constraints::default(),
26        });
27        self.last_text_idx = Some(self.commands.len() - 1);
28        self
29    }
30
31    /// Render a clickable hyperlink.
32    ///
33    /// The link is interactive: clicking it (or pressing Enter/Space when
34    /// focused) opens the URL in the system browser. OSC 8 is also emitted
35    /// for terminals that support native hyperlinks.
36    pub fn link(&mut self, text: impl Into<String>, url: impl Into<String>) -> &mut Self {
37        let url_str = url.into();
38        let focused = self.register_focusable();
39        let interaction_id = self.interaction_count;
40        self.interaction_count += 1;
41        let response = self.response_for(interaction_id);
42
43        let mut activated = response.clicked;
44        if focused {
45            for (i, event) in self.events.iter().enumerate() {
46                if let Event::Key(key) = event {
47                    if key.kind != KeyEventKind::Press {
48                        continue;
49                    }
50                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
51                        activated = true;
52                        self.consumed[i] = true;
53                    }
54                }
55            }
56        }
57
58        if activated {
59            let _ = open_url(&url_str);
60        }
61
62        let style = if focused {
63            Style::new()
64                .fg(self.theme.primary)
65                .bg(self.theme.surface_hover)
66                .underline()
67                .bold()
68        } else if response.hovered {
69            Style::new()
70                .fg(self.theme.accent)
71                .bg(self.theme.surface_hover)
72                .underline()
73        } else {
74            Style::new().fg(self.theme.primary).underline()
75        };
76
77        self.commands.push(Command::Link {
78            text: text.into(),
79            url: url_str,
80            style,
81            margin: Margin::default(),
82            constraints: Constraints::default(),
83        });
84        self.last_text_idx = Some(self.commands.len() - 1);
85        self
86    }
87
88    /// Render a text element with word-boundary wrapping.
89    ///
90    /// Long lines are broken at word boundaries to fit the container width.
91    /// Style chaining works the same as [`Context::text`].
92    pub fn text_wrap(&mut self, s: impl Into<String>) -> &mut Self {
93        let content = s.into();
94        self.commands.push(Command::Text {
95            content,
96            style: Style::new(),
97            grow: 0,
98            align: Align::Start,
99            wrap: true,
100            margin: Margin::default(),
101            constraints: Constraints::default(),
102        });
103        self.last_text_idx = Some(self.commands.len() - 1);
104        self
105    }
106
107    // ── style chain (applies to last text) ───────────────────────────
108
109    /// Apply bold to the last rendered text element.
110    pub fn bold(&mut self) -> &mut Self {
111        self.modify_last_style(|s| s.modifiers |= Modifiers::BOLD);
112        self
113    }
114
115    /// Apply dim styling to the last rendered text element.
116    ///
117    /// Also sets the foreground color to the theme's `text_dim` color if no
118    /// explicit foreground has been set.
119    pub fn dim(&mut self) -> &mut Self {
120        let text_dim = self.theme.text_dim;
121        self.modify_last_style(|s| {
122            s.modifiers |= Modifiers::DIM;
123            if s.fg.is_none() {
124                s.fg = Some(text_dim);
125            }
126        });
127        self
128    }
129
130    /// Apply italic to the last rendered text element.
131    pub fn italic(&mut self) -> &mut Self {
132        self.modify_last_style(|s| s.modifiers |= Modifiers::ITALIC);
133        self
134    }
135
136    /// Apply underline to the last rendered text element.
137    pub fn underline(&mut self) -> &mut Self {
138        self.modify_last_style(|s| s.modifiers |= Modifiers::UNDERLINE);
139        self
140    }
141
142    /// Apply reverse-video to the last rendered text element.
143    pub fn reversed(&mut self) -> &mut Self {
144        self.modify_last_style(|s| s.modifiers |= Modifiers::REVERSED);
145        self
146    }
147
148    /// Apply strikethrough to the last rendered text element.
149    pub fn strikethrough(&mut self) -> &mut Self {
150        self.modify_last_style(|s| s.modifiers |= Modifiers::STRIKETHROUGH);
151        self
152    }
153
154    /// Set the foreground color of the last rendered text element.
155    pub fn fg(&mut self, color: Color) -> &mut Self {
156        self.modify_last_style(|s| s.fg = Some(color));
157        self
158    }
159
160    /// Set the background color of the last rendered text element.
161    pub fn bg(&mut self, color: Color) -> &mut Self {
162        self.modify_last_style(|s| s.bg = Some(color));
163        self
164    }
165
166    pub fn group_hover_fg(&mut self, color: Color) -> &mut Self {
167        let apply_group_style = self
168            .group_stack
169            .last()
170            .map(|name| self.is_group_hovered(name) || self.is_group_focused(name))
171            .unwrap_or(false);
172        if apply_group_style {
173            self.modify_last_style(|s| s.fg = Some(color));
174        }
175        self
176    }
177
178    pub fn group_hover_bg(&mut self, color: Color) -> &mut Self {
179        let apply_group_style = self
180            .group_stack
181            .last()
182            .map(|name| self.is_group_hovered(name) || self.is_group_focused(name))
183            .unwrap_or(false);
184        if apply_group_style {
185            self.modify_last_style(|s| s.bg = Some(color));
186        }
187        self
188    }
189
190    /// Render a text element with an explicit [`Style`] applied immediately.
191    ///
192    /// Equivalent to calling `text(s)` followed by style-chain methods, but
193    /// more concise when you already have a `Style` value.
194    pub fn styled(&mut self, s: impl Into<String>, style: Style) -> &mut Self {
195        self.commands.push(Command::Text {
196            content: s.into(),
197            style,
198            grow: 0,
199            align: Align::Start,
200            wrap: false,
201            margin: Margin::default(),
202            constraints: Constraints::default(),
203        });
204        self.last_text_idx = Some(self.commands.len() - 1);
205        self
206    }
207
208    /// Render a half-block image in the terminal.
209    ///
210    /// Each terminal cell displays two vertical pixels using the `▀` character
211    /// with foreground (upper pixel) and background (lower pixel) colors.
212    ///
213    /// Create a [`HalfBlockImage`] from a file (requires `image` feature):
214    /// ```ignore
215    /// let img = image::open("photo.png").unwrap();
216    /// let half = HalfBlockImage::from_dynamic(&img, 40, 20);
217    /// ui.image(&half);
218    /// ```
219    ///
220    /// Or from raw RGB data (no feature needed):
221    /// ```no_run
222    /// # use slt::{Context, HalfBlockImage};
223    /// # slt::run(|ui: &mut Context| {
224    /// let rgb = vec![255u8; 30 * 20 * 3];
225    /// let half = HalfBlockImage::from_rgb(&rgb, 30, 10);
226    /// ui.image(&half);
227    /// # });
228    /// ```
229    pub fn image(&mut self, img: &HalfBlockImage) {
230        let width = img.width;
231        let height = img.height;
232
233        self.container().w(width).h(height).gap(0).col(|ui| {
234            for row in 0..height {
235                ui.container().gap(0).row(|ui| {
236                    for col in 0..width {
237                        let idx = (row * width + col) as usize;
238                        if let Some(&(upper, lower)) = img.pixels.get(idx) {
239                            ui.styled("▀", Style::new().fg(upper).bg(lower));
240                        }
241                    }
242                });
243            }
244        });
245    }
246
247    /// Render streaming text with a typing cursor indicator.
248    ///
249    /// Displays the accumulated text content. While `streaming` is true,
250    /// shows a blinking cursor (`▌`) at the end.
251    ///
252    /// ```no_run
253    /// # use slt::widgets::StreamingTextState;
254    /// # slt::run(|ui: &mut slt::Context| {
255    /// let mut stream = StreamingTextState::new();
256    /// stream.start();
257    /// stream.push("Hello from ");
258    /// stream.push("the AI!");
259    /// ui.streaming_text(&mut stream);
260    /// # });
261    /// ```
262    pub fn streaming_text(&mut self, state: &mut StreamingTextState) {
263        if state.streaming {
264            state.cursor_tick = state.cursor_tick.wrapping_add(1);
265            state.cursor_visible = (state.cursor_tick / 8) % 2 == 0;
266        }
267
268        if state.content.is_empty() && state.streaming {
269            let cursor = if state.cursor_visible { "▌" } else { " " };
270            let primary = self.theme.primary;
271            self.text(cursor).fg(primary);
272            return;
273        }
274
275        if !state.content.is_empty() {
276            if state.streaming && state.cursor_visible {
277                self.text_wrap(format!("{}▌", state.content));
278            } else {
279                self.text_wrap(&state.content);
280            }
281        }
282    }
283
284    /// Render a tool approval widget with approve/reject buttons.
285    ///
286    /// Shows the tool name, description, and two action buttons.
287    /// Returns the updated [`ApprovalAction`] each frame.
288    ///
289    /// ```no_run
290    /// # use slt::widgets::{ApprovalAction, ToolApprovalState};
291    /// # slt::run(|ui: &mut slt::Context| {
292    /// let mut tool = ToolApprovalState::new("read_file", "Read contents of config.toml");
293    /// ui.tool_approval(&mut tool);
294    /// if tool.action == ApprovalAction::Approved {
295    /// }
296    /// # });
297    /// ```
298    pub fn tool_approval(&mut self, state: &mut ToolApprovalState) {
299        let theme = self.theme;
300        self.bordered(Border::Rounded).col(|ui| {
301            ui.row(|ui| {
302                ui.text("⚡").fg(theme.warning);
303                ui.text(&state.tool_name).bold().fg(theme.primary);
304            });
305            ui.text(&state.description).dim();
306
307            if state.action == ApprovalAction::Pending {
308                ui.row(|ui| {
309                    if ui.button("✓ Approve") {
310                        state.action = ApprovalAction::Approved;
311                    }
312                    if ui.button("✗ Reject") {
313                        state.action = ApprovalAction::Rejected;
314                    }
315                });
316            } else {
317                let (label, color) = match state.action {
318                    ApprovalAction::Approved => ("✓ Approved", theme.success),
319                    ApprovalAction::Rejected => ("✗ Rejected", theme.error),
320                    ApprovalAction::Pending => unreachable!(),
321                };
322                ui.text(label).fg(color).bold();
323            }
324        });
325    }
326
327    /// Render a context bar showing active context items with token counts.
328    ///
329    /// Displays a horizontal bar of context sources (files, URLs, etc.)
330    /// with their token counts, useful for AI chat interfaces.
331    ///
332    /// ```no_run
333    /// # use slt::widgets::ContextItem;
334    /// # slt::run(|ui: &mut slt::Context| {
335    /// let items = vec![ContextItem::new("main.rs", 1200), ContextItem::new("lib.rs", 800)];
336    /// ui.context_bar(&items);
337    /// # });
338    /// ```
339    pub fn context_bar(&mut self, items: &[ContextItem]) {
340        if items.is_empty() {
341            return;
342        }
343
344        let theme = self.theme;
345        let total: usize = items.iter().map(|item| item.tokens).sum();
346
347        self.container().row(|ui| {
348            ui.text("📎").dim();
349            for item in items {
350                ui.text(format!(
351                    "{} ({})",
352                    item.label,
353                    format_token_count(item.tokens)
354                ))
355                .fg(theme.secondary);
356            }
357            ui.spacer();
358            ui.text(format!("Σ {}", format_token_count(total))).dim();
359        });
360    }
361
362    pub fn alert(&mut self, message: &str, level: crate::widgets::AlertLevel) -> bool {
363        use crate::widgets::AlertLevel;
364
365        let theme = self.theme;
366        let (icon, color) = match level {
367            AlertLevel::Info => ("ℹ", theme.accent),
368            AlertLevel::Success => ("✓", theme.success),
369            AlertLevel::Warning => ("⚠", theme.warning),
370            AlertLevel::Error => ("✕", theme.error),
371        };
372
373        let focused = self.register_focusable();
374        let key_dismiss = focused && (self.key_code(KeyCode::Enter) || self.key('x'));
375
376        let resp = self.container().col(|ui| {
377            ui.line(|ui| {
378                ui.text(format!(" {icon} ")).fg(color).bold();
379                ui.text(message).grow(1);
380                ui.text(" [×] ").dim();
381            });
382        });
383
384        key_dismiss || resp.clicked
385    }
386
387    pub fn breadcrumb(&mut self, segments: &[&str]) -> Option<usize> {
388        self.breadcrumb_with(segments, " › ")
389    }
390
391    pub fn breadcrumb_with(&mut self, segments: &[&str], separator: &str) -> Option<usize> {
392        let theme = self.theme;
393        let last_idx = segments.len().saturating_sub(1);
394        let mut clicked_idx: Option<usize> = None;
395
396        self.row(|ui| {
397            for (i, segment) in segments.iter().enumerate() {
398                let is_last = i == last_idx;
399                if is_last {
400                    ui.text(*segment).bold();
401                } else {
402                    let focused = ui.register_focusable();
403                    let pressed = focused && (ui.key_code(KeyCode::Enter) || ui.key(' '));
404                    let resp = ui.interaction();
405                    let color = if resp.hovered || focused {
406                        theme.accent
407                    } else {
408                        theme.primary
409                    };
410                    ui.text(*segment).fg(color).underline();
411                    if resp.clicked || pressed {
412                        clicked_idx = Some(i);
413                    }
414                    ui.text(separator).dim();
415                }
416            }
417        });
418
419        clicked_idx
420    }
421
422    pub fn accordion(&mut self, title: &str, open: &mut bool, f: impl FnOnce(&mut Context)) {
423        let theme = self.theme;
424        let focused = self.register_focusable();
425
426        if focused && self.key_code(KeyCode::Enter) {
427            *open = !*open;
428        }
429
430        let icon = if *open { "▾" } else { "▸" };
431        let title_color = if focused { theme.primary } else { theme.text };
432
433        let resp = self.container().col(|ui| {
434            ui.line(|ui| {
435                ui.text(icon).fg(title_color);
436                ui.text(format!(" {title}")).bold().fg(title_color);
437            });
438        });
439
440        if resp.clicked {
441            *open = !*open;
442        }
443
444        if *open {
445            self.container().pl(2).col(f);
446        }
447    }
448
449    pub fn definition_list(&mut self, items: &[(&str, &str)]) {
450        let max_key_width = items
451            .iter()
452            .map(|(k, _)| unicode_width::UnicodeWidthStr::width(*k))
453            .max()
454            .unwrap_or(0);
455
456        self.col(|ui| {
457            for (key, value) in items {
458                ui.line(|ui| {
459                    let padded = format!("{:>width$}", key, width = max_key_width);
460                    ui.text(padded).dim();
461                    ui.text("  ");
462                    ui.text(*value);
463                });
464            }
465        });
466    }
467
468    pub fn divider_text(&mut self, label: &str) {
469        let w = self.width();
470        let label_len = unicode_width::UnicodeWidthStr::width(label) as u32;
471        let pad = 1u32;
472        let left_len = 4u32;
473        let right_len = w.saturating_sub(left_len + pad + label_len + pad);
474        let left: String = "─".repeat(left_len as usize);
475        let right: String = "─".repeat(right_len as usize);
476        let theme = self.theme;
477        self.line(|ui| {
478            ui.text(&left).fg(theme.border);
479            ui.text(format!(" {} ", label)).fg(theme.text);
480            ui.text(&right).fg(theme.border);
481        });
482    }
483
484    pub fn badge(&mut self, label: &str) {
485        let theme = self.theme;
486        self.badge_colored(label, theme.primary);
487    }
488
489    pub fn badge_colored(&mut self, label: &str, color: Color) {
490        let fg = Color::contrast_fg(color);
491        self.text(format!(" {} ", label)).fg(fg).bg(color);
492    }
493
494    pub fn key_hint(&mut self, key: &str) {
495        let theme = self.theme;
496        self.text(format!(" {} ", key))
497            .reversed()
498            .fg(theme.text_dim);
499    }
500
501    pub fn stat(&mut self, label: &str, value: &str) {
502        self.col(|ui| {
503            ui.text(label).dim();
504            ui.text(value).bold();
505        });
506    }
507
508    pub fn stat_colored(&mut self, label: &str, value: &str, color: Color) {
509        self.col(|ui| {
510            ui.text(label).dim();
511            ui.text(value).bold().fg(color);
512        });
513    }
514
515    pub fn stat_trend(&mut self, label: &str, value: &str, trend: crate::widgets::Trend) {
516        let theme = self.theme;
517        let (arrow, color) = match trend {
518            crate::widgets::Trend::Up => ("↑", theme.success),
519            crate::widgets::Trend::Down => ("↓", theme.error),
520        };
521        self.col(|ui| {
522            ui.text(label).dim();
523            ui.line(|ui| {
524                ui.text(value).bold();
525                ui.text(format!(" {arrow}")).fg(color);
526            });
527        });
528    }
529
530    pub fn empty_state(&mut self, title: &str, description: &str) {
531        self.container().center().col(|ui| {
532            ui.text(title).align(Align::Center);
533            ui.text(description).dim().align(Align::Center);
534        });
535    }
536
537    pub fn empty_state_action(
538        &mut self,
539        title: &str,
540        description: &str,
541        action_label: &str,
542    ) -> bool {
543        let mut clicked = false;
544        self.container().center().col(|ui| {
545            ui.text(title).align(Align::Center);
546            ui.text(description).dim().align(Align::Center);
547            if ui.button(action_label) {
548                clicked = true;
549            }
550        });
551        clicked
552    }
553
554    pub fn code_block(&mut self, code: &str) {
555        let theme = self.theme;
556        self.bordered(Border::Rounded)
557            .bg(theme.surface)
558            .pad(1)
559            .col(|ui| {
560                for line in code.lines() {
561                    render_highlighted_line(ui, line);
562                }
563            });
564    }
565
566    pub fn code_block_numbered(&mut self, code: &str) {
567        let lines: Vec<&str> = code.lines().collect();
568        let gutter_w = format!("{}", lines.len()).len();
569        let theme = self.theme;
570        self.bordered(Border::Rounded)
571            .bg(theme.surface)
572            .pad(1)
573            .col(|ui| {
574                for (i, line) in lines.iter().enumerate() {
575                    ui.line(|ui| {
576                        ui.text(format!("{:>gutter_w$} │ ", i + 1))
577                            .fg(theme.text_dim);
578                        render_highlighted_line(ui, line);
579                    });
580                }
581            });
582    }
583
584    /// Enable word-boundary wrapping on the last rendered text element.
585    pub fn wrap(&mut self) -> &mut Self {
586        if let Some(idx) = self.last_text_idx {
587            if let Command::Text { wrap, .. } = &mut self.commands[idx] {
588                *wrap = true;
589            }
590        }
591        self
592    }
593
594    fn modify_last_style(&mut self, f: impl FnOnce(&mut Style)) {
595        if let Some(idx) = self.last_text_idx {
596            match &mut self.commands[idx] {
597                Command::Text { style, .. } | Command::Link { style, .. } => f(style),
598                _ => {}
599            }
600        }
601    }
602
603    // ── containers ───────────────────────────────────────────────────
604
605    /// Create a vertical (column) container.
606    ///
607    /// Children are stacked top-to-bottom. Returns a [`Response`] with
608    /// click/hover state for the container area.
609    ///
610    /// # Example
611    ///
612    /// ```no_run
613    /// # slt::run(|ui: &mut slt::Context| {
614    /// ui.col(|ui| {
615    ///     ui.text("line one");
616    ///     ui.text("line two");
617    /// });
618    /// # });
619    /// ```
620    pub fn col(&mut self, f: impl FnOnce(&mut Context)) -> Response {
621        self.push_container(Direction::Column, 0, f)
622    }
623
624    /// Create a vertical (column) container with a gap between children.
625    ///
626    /// `gap` is the number of blank rows inserted between each child.
627    pub fn col_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
628        self.push_container(Direction::Column, gap, f)
629    }
630
631    /// Create a horizontal (row) container.
632    ///
633    /// Children are placed left-to-right. Returns a [`Response`] with
634    /// click/hover state for the container area.
635    ///
636    /// # Example
637    ///
638    /// ```no_run
639    /// # slt::run(|ui: &mut slt::Context| {
640    /// ui.row(|ui| {
641    ///     ui.text("left");
642    ///     ui.spacer();
643    ///     ui.text("right");
644    /// });
645    /// # });
646    /// ```
647    pub fn row(&mut self, f: impl FnOnce(&mut Context)) -> Response {
648        self.push_container(Direction::Row, 0, f)
649    }
650
651    /// Create a horizontal (row) container with a gap between children.
652    ///
653    /// `gap` is the number of blank columns inserted between each child.
654    pub fn row_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
655        self.push_container(Direction::Row, gap, f)
656    }
657
658    /// Render inline text with mixed styles on a single line.
659    ///
660    /// Unlike [`row`](Context::row), `line()` is designed for rich text —
661    /// children are rendered as continuous inline text without gaps.
662    ///
663    /// # Example
664    ///
665    /// ```no_run
666    /// # use slt::Color;
667    /// # slt::run(|ui: &mut slt::Context| {
668    /// ui.line(|ui| {
669    ///     ui.text("Status: ");
670    ///     ui.text("Online").bold().fg(Color::Green);
671    /// });
672    /// # });
673    /// ```
674    pub fn line(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
675        let _ = self.push_container(Direction::Row, 0, f);
676        self
677    }
678
679    /// Render inline text with mixed styles, wrapping at word boundaries.
680    ///
681    /// Like [`line`](Context::line), but when the combined text exceeds
682    /// the container width it wraps across multiple lines while
683    /// preserving per-segment styles.
684    ///
685    /// # Example
686    ///
687    /// ```no_run
688    /// # use slt::{Color, Style};
689    /// # slt::run(|ui: &mut slt::Context| {
690    /// ui.line_wrap(|ui| {
691    ///     ui.text("This is a long ");
692    ///     ui.text("important").bold().fg(Color::Red);
693    ///     ui.text(" message that wraps across lines");
694    /// });
695    /// # });
696    /// ```
697    pub fn line_wrap(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
698        let start = self.commands.len();
699        f(self);
700        let mut segments: Vec<(String, Style)> = Vec::new();
701        for cmd in self.commands.drain(start..) {
702            if let Command::Text { content, style, .. } = cmd {
703                segments.push((content, style));
704            }
705        }
706        self.commands.push(Command::RichText {
707            segments,
708            wrap: true,
709            align: Align::Start,
710            margin: Margin::default(),
711            constraints: Constraints::default(),
712        });
713        self.last_text_idx = None;
714        self
715    }
716
717    /// Render content in a modal overlay with dimmed background.
718    ///
719    /// ```ignore
720    /// ui.modal(|ui| {
721    ///     ui.text("Are you sure?");
722    ///     if ui.button("OK") { show = false; }
723    /// });
724    /// ```
725    pub fn modal(&mut self, f: impl FnOnce(&mut Context)) {
726        self.commands.push(Command::BeginOverlay { modal: true });
727        self.overlay_depth += 1;
728        self.modal_active = true;
729        f(self);
730        self.overlay_depth = self.overlay_depth.saturating_sub(1);
731        self.commands.push(Command::EndOverlay);
732        self.last_text_idx = None;
733    }
734
735    /// Render floating content without dimming the background.
736    pub fn overlay(&mut self, f: impl FnOnce(&mut Context)) {
737        self.commands.push(Command::BeginOverlay { modal: false });
738        self.overlay_depth += 1;
739        f(self);
740        self.overlay_depth = self.overlay_depth.saturating_sub(1);
741        self.commands.push(Command::EndOverlay);
742        self.last_text_idx = None;
743    }
744
745    /// Create a named group container for shared hover/focus styling.
746    ///
747    /// ```ignore
748    /// ui.group("card").border(Border::Rounded)
749    ///     .group_hover_bg(Color::Indexed(238))
750    ///     .col(|ui| { ui.text("Hover anywhere"); });
751    /// ```
752    pub fn group(&mut self, name: &str) -> ContainerBuilder<'_> {
753        self.group_count = self.group_count.saturating_add(1);
754        self.group_stack.push(name.to_string());
755        self.container().group_name(name.to_string())
756    }
757
758    /// Create a container with a fluent builder.
759    ///
760    /// Use this for borders, padding, grow, constraints, and titles. Chain
761    /// configuration methods on the returned [`ContainerBuilder`], then call
762    /// `.col()` or `.row()` to finalize.
763    ///
764    /// # Example
765    ///
766    /// ```no_run
767    /// # slt::run(|ui: &mut slt::Context| {
768    /// use slt::Border;
769    /// ui.container()
770    ///     .border(Border::Rounded)
771    ///     .pad(1)
772    ///     .title("My Panel")
773    ///     .col(|ui| {
774    ///         ui.text("content");
775    ///     });
776    /// # });
777    /// ```
778    pub fn container(&mut self) -> ContainerBuilder<'_> {
779        let border = self.theme.border;
780        ContainerBuilder {
781            ctx: self,
782            gap: 0,
783            align: Align::Start,
784            justify: Justify::Start,
785            border: None,
786            border_sides: BorderSides::all(),
787            border_style: Style::new().fg(border),
788            bg: None,
789            dark_bg: None,
790            dark_border_style: None,
791            group_hover_bg: None,
792            group_hover_border_style: None,
793            group_name: None,
794            padding: Padding::default(),
795            margin: Margin::default(),
796            constraints: Constraints::default(),
797            title: None,
798            grow: 0,
799            scroll_offset: None,
800        }
801    }
802
803    /// Create a scrollable container. Handles wheel scroll and drag-to-scroll automatically.
804    ///
805    /// Pass a [`ScrollState`] to persist scroll position across frames. The state
806    /// is updated in-place with the current scroll offset and bounds.
807    ///
808    /// # Example
809    ///
810    /// ```no_run
811    /// # use slt::widgets::ScrollState;
812    /// # slt::run(|ui: &mut slt::Context| {
813    /// let mut scroll = ScrollState::new();
814    /// ui.scrollable(&mut scroll).col(|ui| {
815    ///     for i in 0..100 {
816    ///         ui.text(format!("Line {i}"));
817    ///     }
818    /// });
819    /// # });
820    /// ```
821    pub fn scrollable(&mut self, state: &mut ScrollState) -> ContainerBuilder<'_> {
822        let index = self.scroll_count;
823        self.scroll_count += 1;
824        if let Some(&(ch, vh)) = self.prev_scroll_infos.get(index) {
825            state.set_bounds(ch, vh);
826            let max = ch.saturating_sub(vh) as usize;
827            state.offset = state.offset.min(max);
828        }
829
830        let next_id = self.interaction_count;
831        if let Some(rect) = self.prev_hit_map.get(next_id).copied() {
832            let inner_rects: Vec<Rect> = self
833                .prev_scroll_rects
834                .iter()
835                .enumerate()
836                .filter(|&(j, sr)| {
837                    j != index
838                        && sr.width > 0
839                        && sr.height > 0
840                        && sr.x >= rect.x
841                        && sr.right() <= rect.right()
842                        && sr.y >= rect.y
843                        && sr.bottom() <= rect.bottom()
844                })
845                .map(|(_, sr)| *sr)
846                .collect();
847            self.auto_scroll_nested(&rect, state, &inner_rects);
848        }
849
850        self.container().scroll_offset(state.offset as u32)
851    }
852
853    /// Render a scrollbar track for a [`ScrollState`].
854    ///
855    /// Displays a track (`│`) with a proportional thumb (`█`). The thumb size
856    /// and position are calculated from the scroll state's content height,
857    /// viewport height, and current offset.
858    ///
859    /// Typically placed beside a `scrollable()` container in a `row()`:
860    /// ```no_run
861    /// # use slt::widgets::ScrollState;
862    /// # slt::run(|ui: &mut slt::Context| {
863    /// let mut scroll = ScrollState::new();
864    /// ui.row(|ui| {
865    ///     ui.scrollable(&mut scroll).grow(1).col(|ui| {
866    ///         for i in 0..100 { ui.text(format!("Line {i}")); }
867    ///     });
868    ///     ui.scrollbar(&scroll);
869    /// });
870    /// # });
871    /// ```
872    pub fn scrollbar(&mut self, state: &ScrollState) {
873        let vh = state.viewport_height();
874        let ch = state.content_height();
875        if vh == 0 || ch <= vh {
876            return;
877        }
878
879        let track_height = vh;
880        let thumb_height = ((vh as f64 * vh as f64 / ch as f64).ceil() as u32).max(1);
881        let max_offset = ch.saturating_sub(vh);
882        let thumb_pos = if max_offset == 0 {
883            0
884        } else {
885            ((state.offset as f64 / max_offset as f64) * (track_height - thumb_height) as f64)
886                .round() as u32
887        };
888
889        let theme = self.theme;
890        let track_char = '│';
891        let thumb_char = '█';
892
893        self.container().w(1).h(track_height).col(|ui| {
894            for i in 0..track_height {
895                if i >= thumb_pos && i < thumb_pos + thumb_height {
896                    ui.styled(thumb_char.to_string(), Style::new().fg(theme.primary));
897                } else {
898                    ui.styled(
899                        track_char.to_string(),
900                        Style::new().fg(theme.text_dim).dim(),
901                    );
902                }
903            }
904        });
905    }
906
907    fn auto_scroll_nested(
908        &mut self,
909        rect: &Rect,
910        state: &mut ScrollState,
911        inner_scroll_rects: &[Rect],
912    ) {
913        let mut to_consume: Vec<usize> = Vec::new();
914
915        for (i, event) in self.events.iter().enumerate() {
916            if self.consumed[i] {
917                continue;
918            }
919            if let Event::Mouse(mouse) = event {
920                let in_bounds = mouse.x >= rect.x
921                    && mouse.x < rect.right()
922                    && mouse.y >= rect.y
923                    && mouse.y < rect.bottom();
924                if !in_bounds {
925                    continue;
926                }
927                let in_inner = inner_scroll_rects.iter().any(|sr| {
928                    mouse.x >= sr.x
929                        && mouse.x < sr.right()
930                        && mouse.y >= sr.y
931                        && mouse.y < sr.bottom()
932                });
933                if in_inner {
934                    continue;
935                }
936                match mouse.kind {
937                    MouseKind::ScrollUp => {
938                        state.scroll_up(1);
939                        to_consume.push(i);
940                    }
941                    MouseKind::ScrollDown => {
942                        state.scroll_down(1);
943                        to_consume.push(i);
944                    }
945                    MouseKind::Drag(MouseButton::Left) => {}
946                    _ => {}
947                }
948            }
949        }
950
951        for i in to_consume {
952            self.consumed[i] = true;
953        }
954    }
955
956    /// Shortcut for `container().border(border)`.
957    ///
958    /// Returns a [`ContainerBuilder`] pre-configured with the given border style.
959    pub fn bordered(&mut self, border: Border) -> ContainerBuilder<'_> {
960        self.container()
961            .border(border)
962            .border_sides(BorderSides::all())
963    }
964
965    fn push_container(
966        &mut self,
967        direction: Direction,
968        gap: u32,
969        f: impl FnOnce(&mut Context),
970    ) -> Response {
971        let interaction_id = self.interaction_count;
972        self.interaction_count += 1;
973        let border = self.theme.border;
974
975        self.commands.push(Command::BeginContainer {
976            direction,
977            gap,
978            align: Align::Start,
979            justify: Justify::Start,
980            border: None,
981            border_sides: BorderSides::all(),
982            border_style: Style::new().fg(border),
983            bg_color: None,
984            padding: Padding::default(),
985            margin: Margin::default(),
986            constraints: Constraints::default(),
987            title: None,
988            grow: 0,
989            group_name: None,
990        });
991        f(self);
992        self.commands.push(Command::EndContainer);
993        self.last_text_idx = None;
994
995        self.response_for(interaction_id)
996    }
997
998    pub(super) fn response_for(&self, interaction_id: usize) -> Response {
999        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1000            return Response::default();
1001        }
1002        if let Some(rect) = self.prev_hit_map.get(interaction_id) {
1003            let clicked = self
1004                .click_pos
1005                .map(|(mx, my)| {
1006                    mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1007                })
1008                .unwrap_or(false);
1009            let hovered = self
1010                .mouse_pos
1011                .map(|(mx, my)| {
1012                    mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1013                })
1014                .unwrap_or(false);
1015            Response { clicked, hovered }
1016        } else {
1017            Response::default()
1018        }
1019    }
1020
1021    /// Returns true if the named group is currently hovered by the mouse.
1022    pub fn is_group_hovered(&self, name: &str) -> bool {
1023        if let Some(pos) = self.mouse_pos {
1024            self.prev_group_rects.iter().any(|(n, rect)| {
1025                n == name
1026                    && pos.0 >= rect.x
1027                    && pos.0 < rect.x + rect.width
1028                    && pos.1 >= rect.y
1029                    && pos.1 < rect.y + rect.height
1030            })
1031        } else {
1032            false
1033        }
1034    }
1035
1036    /// Returns true if the named group contains the currently focused widget.
1037    pub fn is_group_focused(&self, name: &str) -> bool {
1038        if self.prev_focus_count == 0 {
1039            return false;
1040        }
1041        let focused_index = self.focus_index % self.prev_focus_count;
1042        self.prev_focus_groups
1043            .get(focused_index)
1044            .and_then(|group| group.as_deref())
1045            .map(|group| group == name)
1046            .unwrap_or(false)
1047    }
1048
1049    /// Set the flex-grow factor of the last rendered text element.
1050    ///
1051    /// A value of `1` causes the element to expand and fill remaining space
1052    /// along the main axis.
1053    pub fn grow(&mut self, value: u16) -> &mut Self {
1054        if let Some(idx) = self.last_text_idx {
1055            if let Command::Text { grow, .. } = &mut self.commands[idx] {
1056                *grow = value;
1057            }
1058        }
1059        self
1060    }
1061
1062    /// Set the text alignment of the last rendered text element.
1063    pub fn align(&mut self, align: Align) -> &mut Self {
1064        if let Some(idx) = self.last_text_idx {
1065            if let Command::Text {
1066                align: text_align, ..
1067            } = &mut self.commands[idx]
1068            {
1069                *text_align = align;
1070            }
1071        }
1072        self
1073    }
1074
1075    /// Render an invisible spacer that expands to fill available space.
1076    ///
1077    /// Useful for pushing siblings to opposite ends of a row or column.
1078    pub fn spacer(&mut self) -> &mut Self {
1079        self.commands.push(Command::Spacer { grow: 1 });
1080        self.last_text_idx = None;
1081        self
1082    }
1083
1084    /// Render a form that groups input fields vertically.
1085    ///
1086    /// Use [`Context::form_field`] inside the closure to render each field.
1087    pub fn form(
1088        &mut self,
1089        state: &mut FormState,
1090        f: impl FnOnce(&mut Context, &mut FormState),
1091    ) -> &mut Self {
1092        self.col(|ui| {
1093            f(ui, state);
1094        });
1095        self
1096    }
1097
1098    /// Render a single form field with label and input.
1099    ///
1100    /// Shows a validation error below the input when present.
1101    pub fn form_field(&mut self, field: &mut FormField) -> &mut Self {
1102        self.col(|ui| {
1103            ui.styled(field.label.clone(), Style::new().bold().fg(ui.theme.text));
1104            ui.text_input(&mut field.input);
1105            if let Some(error) = field.error.as_deref() {
1106                ui.styled(error.to_string(), Style::new().dim().fg(ui.theme.error));
1107            }
1108        });
1109        self
1110    }
1111
1112    /// Render a submit button.
1113    ///
1114    /// Returns `true` when the button is clicked or activated.
1115    pub fn form_submit(&mut self, label: impl Into<String>) -> bool {
1116        self.button(label)
1117    }
1118}
1119
1120const KEYWORDS: &[&str] = &[
1121    "fn",
1122    "let",
1123    "mut",
1124    "pub",
1125    "use",
1126    "impl",
1127    "struct",
1128    "enum",
1129    "trait",
1130    "type",
1131    "const",
1132    "static",
1133    "if",
1134    "else",
1135    "match",
1136    "for",
1137    "while",
1138    "loop",
1139    "return",
1140    "break",
1141    "continue",
1142    "where",
1143    "self",
1144    "super",
1145    "crate",
1146    "mod",
1147    "async",
1148    "await",
1149    "move",
1150    "ref",
1151    "in",
1152    "as",
1153    "true",
1154    "false",
1155    "Some",
1156    "None",
1157    "Ok",
1158    "Err",
1159    "Self",
1160    "def",
1161    "class",
1162    "import",
1163    "from",
1164    "pass",
1165    "lambda",
1166    "yield",
1167    "with",
1168    "try",
1169    "except",
1170    "raise",
1171    "finally",
1172    "elif",
1173    "del",
1174    "global",
1175    "nonlocal",
1176    "assert",
1177    "is",
1178    "not",
1179    "and",
1180    "or",
1181    "function",
1182    "var",
1183    "const",
1184    "export",
1185    "default",
1186    "switch",
1187    "case",
1188    "throw",
1189    "catch",
1190    "typeof",
1191    "instanceof",
1192    "new",
1193    "delete",
1194    "void",
1195    "this",
1196    "null",
1197    "undefined",
1198    "func",
1199    "package",
1200    "defer",
1201    "go",
1202    "chan",
1203    "select",
1204    "range",
1205    "map",
1206    "interface",
1207    "fallthrough",
1208    "nil",
1209];
1210
1211fn render_highlighted_line(ui: &mut Context, line: &str) {
1212    let theme = ui.theme;
1213    let is_light = matches!(
1214        theme.bg,
1215        Color::Reset | Color::White | Color::Rgb(255, 255, 255)
1216    );
1217    let keyword_color = if is_light {
1218        Color::Rgb(166, 38, 164)
1219    } else {
1220        Color::Rgb(198, 120, 221)
1221    };
1222    let string_color = if is_light {
1223        Color::Rgb(80, 161, 79)
1224    } else {
1225        Color::Rgb(152, 195, 121)
1226    };
1227    let comment_color = theme.text_dim;
1228    let number_color = if is_light {
1229        Color::Rgb(152, 104, 1)
1230    } else {
1231        Color::Rgb(209, 154, 102)
1232    };
1233    let fn_color = if is_light {
1234        Color::Rgb(64, 120, 242)
1235    } else {
1236        Color::Rgb(97, 175, 239)
1237    };
1238    let macro_color = if is_light {
1239        Color::Rgb(1, 132, 188)
1240    } else {
1241        Color::Rgb(86, 182, 194)
1242    };
1243
1244    let trimmed = line.trim_start();
1245    let indent = &line[..line.len() - trimmed.len()];
1246    if !indent.is_empty() {
1247        ui.text(indent);
1248    }
1249
1250    if trimmed.starts_with("//") {
1251        ui.text(trimmed).fg(comment_color).italic();
1252        return;
1253    }
1254
1255    let mut pos = 0;
1256
1257    while pos < trimmed.len() {
1258        let ch = trimmed.as_bytes()[pos];
1259
1260        if ch == b'"' {
1261            if let Some(end) = trimmed[pos + 1..].find('"') {
1262                let s = &trimmed[pos..pos + end + 2];
1263                ui.text(s).fg(string_color);
1264                pos += end + 2;
1265                continue;
1266            }
1267        }
1268
1269        if ch.is_ascii_digit() && (pos == 0 || !trimmed.as_bytes()[pos - 1].is_ascii_alphanumeric())
1270        {
1271            let end = trimmed[pos..]
1272                .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '_')
1273                .map_or(trimmed.len(), |e| pos + e);
1274            ui.text(&trimmed[pos..end]).fg(number_color);
1275            pos = end;
1276            continue;
1277        }
1278
1279        if ch.is_ascii_alphabetic() || ch == b'_' {
1280            let end = trimmed[pos..]
1281                .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
1282                .map_or(trimmed.len(), |e| pos + e);
1283            let word = &trimmed[pos..end];
1284
1285            if end < trimmed.len() && trimmed.as_bytes()[end] == b'!' {
1286                ui.text(&trimmed[pos..end + 1]).fg(macro_color);
1287                pos = end + 1;
1288            } else if end < trimmed.len()
1289                && trimmed.as_bytes()[end] == b'('
1290                && !KEYWORDS.contains(&word)
1291            {
1292                ui.text(word).fg(fn_color);
1293                pos = end;
1294            } else if KEYWORDS.contains(&word) {
1295                ui.text(word).fg(keyword_color);
1296                pos = end;
1297            } else {
1298                ui.text(word);
1299                pos = end;
1300            }
1301            continue;
1302        }
1303
1304        let end = trimmed[pos..]
1305            .find(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '"')
1306            .map_or(trimmed.len(), |e| pos + e);
1307        ui.text(&trimmed[pos..end]);
1308        pos = end;
1309    }
1310}