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.line(|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().fg(theme.text);
401                } else {
402                    if ui.button_with(*segment, ButtonVariant::Default) {
403                        clicked_idx = Some(i);
404                    }
405                    ui.text(separator).dim();
406                }
407            }
408        });
409
410        clicked_idx
411    }
412
413    pub fn accordion(&mut self, title: &str, open: &mut bool, f: impl FnOnce(&mut Context)) {
414        let theme = self.theme;
415        let focused = self.register_focusable();
416
417        if focused && self.key_code(KeyCode::Enter) {
418            *open = !*open;
419        }
420
421        let icon = if *open { "▾" } else { "▸" };
422        let title_color = if focused { theme.primary } else { theme.text };
423
424        let resp = self.container().col(|ui| {
425            ui.line(|ui| {
426                ui.text(icon).fg(title_color);
427                ui.text(format!(" {title}")).bold().fg(title_color);
428            });
429        });
430
431        if resp.clicked {
432            *open = !*open;
433        }
434
435        if *open {
436            self.container().pl(2).col(f);
437        }
438    }
439
440    pub fn definition_list(&mut self, items: &[(&str, &str)]) {
441        let max_key_width = items
442            .iter()
443            .map(|(k, _)| unicode_width::UnicodeWidthStr::width(*k))
444            .max()
445            .unwrap_or(0);
446
447        self.col(|ui| {
448            for (key, value) in items {
449                ui.line(|ui| {
450                    let padded = format!("{:>width$}", key, width = max_key_width);
451                    ui.text(padded).dim();
452                    ui.text("  ");
453                    ui.text(*value);
454                });
455            }
456        });
457    }
458
459    pub fn divider_text(&mut self, label: &str) {
460        let w = self.width();
461        let label_len = unicode_width::UnicodeWidthStr::width(label) as u32;
462        let pad = 1u32;
463        let left_len = 4u32;
464        let right_len = w.saturating_sub(left_len + pad + label_len + pad);
465        let left: String = "─".repeat(left_len as usize);
466        let right: String = "─".repeat(right_len as usize);
467        let theme = self.theme;
468        self.line(|ui| {
469            ui.text(&left).fg(theme.border);
470            ui.text(format!(" {} ", label)).fg(theme.text);
471            ui.text(&right).fg(theme.border);
472        });
473    }
474
475    pub fn badge(&mut self, label: &str) {
476        let theme = self.theme;
477        self.badge_colored(label, theme.primary);
478    }
479
480    pub fn badge_colored(&mut self, label: &str, color: Color) {
481        let fg = Color::contrast_fg(color);
482        self.text(format!(" {} ", label)).fg(fg).bg(color);
483    }
484
485    pub fn key_hint(&mut self, key: &str) {
486        let theme = self.theme;
487        self.text(format!(" {} ", key))
488            .reversed()
489            .fg(theme.text_dim);
490    }
491
492    pub fn stat(&mut self, label: &str, value: &str) {
493        self.col(|ui| {
494            ui.text(label).dim();
495            ui.text(value).bold();
496        });
497    }
498
499    pub fn stat_colored(&mut self, label: &str, value: &str, color: Color) {
500        self.col(|ui| {
501            ui.text(label).dim();
502            ui.text(value).bold().fg(color);
503        });
504    }
505
506    pub fn stat_trend(&mut self, label: &str, value: &str, trend: crate::widgets::Trend) {
507        let theme = self.theme;
508        let (arrow, color) = match trend {
509            crate::widgets::Trend::Up => ("↑", theme.success),
510            crate::widgets::Trend::Down => ("↓", theme.error),
511        };
512        self.col(|ui| {
513            ui.text(label).dim();
514            ui.line(|ui| {
515                ui.text(value).bold();
516                ui.text(format!(" {arrow}")).fg(color);
517            });
518        });
519    }
520
521    pub fn empty_state(&mut self, title: &str, description: &str) {
522        self.container().center().col(|ui| {
523            ui.text(title).align(Align::Center);
524            ui.text(description).dim().align(Align::Center);
525        });
526    }
527
528    pub fn empty_state_action(
529        &mut self,
530        title: &str,
531        description: &str,
532        action_label: &str,
533    ) -> bool {
534        let mut clicked = false;
535        self.container().center().col(|ui| {
536            ui.text(title).align(Align::Center);
537            ui.text(description).dim().align(Align::Center);
538            if ui.button(action_label) {
539                clicked = true;
540            }
541        });
542        clicked
543    }
544
545    pub fn code_block(&mut self, code: &str) {
546        let theme = self.theme;
547        self.bordered(Border::Rounded)
548            .bg(theme.surface)
549            .pad(1)
550            .col(|ui| {
551                for line in code.lines() {
552                    render_highlighted_line(ui, line);
553                }
554            });
555    }
556
557    pub fn code_block_numbered(&mut self, code: &str) {
558        let lines: Vec<&str> = code.lines().collect();
559        let gutter_w = format!("{}", lines.len()).len();
560        let theme = self.theme;
561        self.bordered(Border::Rounded)
562            .bg(theme.surface)
563            .pad(1)
564            .col(|ui| {
565                for (i, line) in lines.iter().enumerate() {
566                    ui.line(|ui| {
567                        ui.text(format!("{:>gutter_w$} │ ", i + 1))
568                            .fg(theme.text_dim);
569                        render_highlighted_line(ui, line);
570                    });
571                }
572            });
573    }
574
575    /// Enable word-boundary wrapping on the last rendered text element.
576    pub fn wrap(&mut self) -> &mut Self {
577        if let Some(idx) = self.last_text_idx {
578            if let Command::Text { wrap, .. } = &mut self.commands[idx] {
579                *wrap = true;
580            }
581        }
582        self
583    }
584
585    fn modify_last_style(&mut self, f: impl FnOnce(&mut Style)) {
586        if let Some(idx) = self.last_text_idx {
587            match &mut self.commands[idx] {
588                Command::Text { style, .. } | Command::Link { style, .. } => f(style),
589                _ => {}
590            }
591        }
592    }
593
594    // ── containers ───────────────────────────────────────────────────
595
596    /// Create a vertical (column) container.
597    ///
598    /// Children are stacked top-to-bottom. Returns a [`Response`] with
599    /// click/hover state for the container area.
600    ///
601    /// # Example
602    ///
603    /// ```no_run
604    /// # slt::run(|ui: &mut slt::Context| {
605    /// ui.col(|ui| {
606    ///     ui.text("line one");
607    ///     ui.text("line two");
608    /// });
609    /// # });
610    /// ```
611    pub fn col(&mut self, f: impl FnOnce(&mut Context)) -> Response {
612        self.push_container(Direction::Column, 0, f)
613    }
614
615    /// Create a vertical (column) container with a gap between children.
616    ///
617    /// `gap` is the number of blank rows inserted between each child.
618    pub fn col_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
619        self.push_container(Direction::Column, gap, f)
620    }
621
622    /// Create a horizontal (row) container.
623    ///
624    /// Children are placed left-to-right. Returns a [`Response`] with
625    /// click/hover state for the container area.
626    ///
627    /// # Example
628    ///
629    /// ```no_run
630    /// # slt::run(|ui: &mut slt::Context| {
631    /// ui.row(|ui| {
632    ///     ui.text("left");
633    ///     ui.spacer();
634    ///     ui.text("right");
635    /// });
636    /// # });
637    /// ```
638    pub fn row(&mut self, f: impl FnOnce(&mut Context)) -> Response {
639        self.push_container(Direction::Row, 0, f)
640    }
641
642    /// Create a horizontal (row) container with a gap between children.
643    ///
644    /// `gap` is the number of blank columns inserted between each child.
645    pub fn row_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
646        self.push_container(Direction::Row, gap, f)
647    }
648
649    /// Render inline text with mixed styles on a single line.
650    ///
651    /// Unlike [`row`](Context::row), `line()` is designed for rich text —
652    /// children are rendered as continuous inline text without gaps.
653    ///
654    /// # Example
655    ///
656    /// ```no_run
657    /// # use slt::Color;
658    /// # slt::run(|ui: &mut slt::Context| {
659    /// ui.line(|ui| {
660    ///     ui.text("Status: ");
661    ///     ui.text("Online").bold().fg(Color::Green);
662    /// });
663    /// # });
664    /// ```
665    pub fn line(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
666        let _ = self.push_container(Direction::Row, 0, f);
667        self
668    }
669
670    /// Render inline text with mixed styles, wrapping at word boundaries.
671    ///
672    /// Like [`line`](Context::line), but when the combined text exceeds
673    /// the container width it wraps across multiple lines while
674    /// preserving per-segment styles.
675    ///
676    /// # Example
677    ///
678    /// ```no_run
679    /// # use slt::{Color, Style};
680    /// # slt::run(|ui: &mut slt::Context| {
681    /// ui.line_wrap(|ui| {
682    ///     ui.text("This is a long ");
683    ///     ui.text("important").bold().fg(Color::Red);
684    ///     ui.text(" message that wraps across lines");
685    /// });
686    /// # });
687    /// ```
688    pub fn line_wrap(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
689        let start = self.commands.len();
690        f(self);
691        let mut segments: Vec<(String, Style)> = Vec::new();
692        for cmd in self.commands.drain(start..) {
693            if let Command::Text { content, style, .. } = cmd {
694                segments.push((content, style));
695            }
696        }
697        self.commands.push(Command::RichText {
698            segments,
699            wrap: true,
700            align: Align::Start,
701            margin: Margin::default(),
702            constraints: Constraints::default(),
703        });
704        self.last_text_idx = None;
705        self
706    }
707
708    /// Render content in a modal overlay with dimmed background.
709    ///
710    /// ```ignore
711    /// ui.modal(|ui| {
712    ///     ui.text("Are you sure?");
713    ///     if ui.button("OK") { show = false; }
714    /// });
715    /// ```
716    pub fn modal(&mut self, f: impl FnOnce(&mut Context)) {
717        self.commands.push(Command::BeginOverlay { modal: true });
718        self.overlay_depth += 1;
719        self.modal_active = true;
720        f(self);
721        self.overlay_depth = self.overlay_depth.saturating_sub(1);
722        self.commands.push(Command::EndOverlay);
723        self.last_text_idx = None;
724    }
725
726    /// Render floating content without dimming the background.
727    pub fn overlay(&mut self, f: impl FnOnce(&mut Context)) {
728        self.commands.push(Command::BeginOverlay { modal: false });
729        self.overlay_depth += 1;
730        f(self);
731        self.overlay_depth = self.overlay_depth.saturating_sub(1);
732        self.commands.push(Command::EndOverlay);
733        self.last_text_idx = None;
734    }
735
736    /// Create a named group container for shared hover/focus styling.
737    ///
738    /// ```ignore
739    /// ui.group("card").border(Border::Rounded)
740    ///     .group_hover_bg(Color::Indexed(238))
741    ///     .col(|ui| { ui.text("Hover anywhere"); });
742    /// ```
743    pub fn group(&mut self, name: &str) -> ContainerBuilder<'_> {
744        self.group_count = self.group_count.saturating_add(1);
745        self.group_stack.push(name.to_string());
746        self.container().group_name(name.to_string())
747    }
748
749    /// Create a container with a fluent builder.
750    ///
751    /// Use this for borders, padding, grow, constraints, and titles. Chain
752    /// configuration methods on the returned [`ContainerBuilder`], then call
753    /// `.col()` or `.row()` to finalize.
754    ///
755    /// # Example
756    ///
757    /// ```no_run
758    /// # slt::run(|ui: &mut slt::Context| {
759    /// use slt::Border;
760    /// ui.container()
761    ///     .border(Border::Rounded)
762    ///     .pad(1)
763    ///     .title("My Panel")
764    ///     .col(|ui| {
765    ///         ui.text("content");
766    ///     });
767    /// # });
768    /// ```
769    pub fn container(&mut self) -> ContainerBuilder<'_> {
770        let border = self.theme.border;
771        ContainerBuilder {
772            ctx: self,
773            gap: 0,
774            align: Align::Start,
775            justify: Justify::Start,
776            border: None,
777            border_sides: BorderSides::all(),
778            border_style: Style::new().fg(border),
779            bg: None,
780            dark_bg: None,
781            dark_border_style: None,
782            group_hover_bg: None,
783            group_hover_border_style: None,
784            group_name: None,
785            padding: Padding::default(),
786            margin: Margin::default(),
787            constraints: Constraints::default(),
788            title: None,
789            grow: 0,
790            scroll_offset: None,
791        }
792    }
793
794    /// Create a scrollable container. Handles wheel scroll and drag-to-scroll automatically.
795    ///
796    /// Pass a [`ScrollState`] to persist scroll position across frames. The state
797    /// is updated in-place with the current scroll offset and bounds.
798    ///
799    /// # Example
800    ///
801    /// ```no_run
802    /// # use slt::widgets::ScrollState;
803    /// # slt::run(|ui: &mut slt::Context| {
804    /// let mut scroll = ScrollState::new();
805    /// ui.scrollable(&mut scroll).col(|ui| {
806    ///     for i in 0..100 {
807    ///         ui.text(format!("Line {i}"));
808    ///     }
809    /// });
810    /// # });
811    /// ```
812    pub fn scrollable(&mut self, state: &mut ScrollState) -> ContainerBuilder<'_> {
813        let index = self.scroll_count;
814        self.scroll_count += 1;
815        if let Some(&(ch, vh)) = self.prev_scroll_infos.get(index) {
816            state.set_bounds(ch, vh);
817            let max = ch.saturating_sub(vh) as usize;
818            state.offset = state.offset.min(max);
819        }
820
821        let next_id = self.interaction_count;
822        if let Some(rect) = self.prev_hit_map.get(next_id).copied() {
823            let inner_rects: Vec<Rect> = self
824                .prev_scroll_rects
825                .iter()
826                .enumerate()
827                .filter(|&(j, sr)| {
828                    j != index
829                        && sr.width > 0
830                        && sr.height > 0
831                        && sr.x >= rect.x
832                        && sr.right() <= rect.right()
833                        && sr.y >= rect.y
834                        && sr.bottom() <= rect.bottom()
835                })
836                .map(|(_, sr)| *sr)
837                .collect();
838            self.auto_scroll_nested(&rect, state, &inner_rects);
839        }
840
841        self.container().scroll_offset(state.offset as u32)
842    }
843
844    /// Render a scrollbar track for a [`ScrollState`].
845    ///
846    /// Displays a track (`│`) with a proportional thumb (`█`). The thumb size
847    /// and position are calculated from the scroll state's content height,
848    /// viewport height, and current offset.
849    ///
850    /// Typically placed beside a `scrollable()` container in a `row()`:
851    /// ```no_run
852    /// # use slt::widgets::ScrollState;
853    /// # slt::run(|ui: &mut slt::Context| {
854    /// let mut scroll = ScrollState::new();
855    /// ui.row(|ui| {
856    ///     ui.scrollable(&mut scroll).grow(1).col(|ui| {
857    ///         for i in 0..100 { ui.text(format!("Line {i}")); }
858    ///     });
859    ///     ui.scrollbar(&scroll);
860    /// });
861    /// # });
862    /// ```
863    pub fn scrollbar(&mut self, state: &ScrollState) {
864        let vh = state.viewport_height();
865        let ch = state.content_height();
866        if vh == 0 || ch <= vh {
867            return;
868        }
869
870        let track_height = vh;
871        let thumb_height = ((vh as f64 * vh as f64 / ch as f64).ceil() as u32).max(1);
872        let max_offset = ch.saturating_sub(vh);
873        let thumb_pos = if max_offset == 0 {
874            0
875        } else {
876            ((state.offset as f64 / max_offset as f64) * (track_height - thumb_height) as f64)
877                .round() as u32
878        };
879
880        let theme = self.theme;
881        let track_char = '│';
882        let thumb_char = '█';
883
884        self.container().w(1).h(track_height).col(|ui| {
885            for i in 0..track_height {
886                if i >= thumb_pos && i < thumb_pos + thumb_height {
887                    ui.styled(thumb_char.to_string(), Style::new().fg(theme.primary));
888                } else {
889                    ui.styled(
890                        track_char.to_string(),
891                        Style::new().fg(theme.text_dim).dim(),
892                    );
893                }
894            }
895        });
896    }
897
898    fn auto_scroll_nested(
899        &mut self,
900        rect: &Rect,
901        state: &mut ScrollState,
902        inner_scroll_rects: &[Rect],
903    ) {
904        let mut to_consume: Vec<usize> = Vec::new();
905
906        for (i, event) in self.events.iter().enumerate() {
907            if self.consumed[i] {
908                continue;
909            }
910            if let Event::Mouse(mouse) = event {
911                let in_bounds = mouse.x >= rect.x
912                    && mouse.x < rect.right()
913                    && mouse.y >= rect.y
914                    && mouse.y < rect.bottom();
915                if !in_bounds {
916                    continue;
917                }
918                let in_inner = inner_scroll_rects.iter().any(|sr| {
919                    mouse.x >= sr.x
920                        && mouse.x < sr.right()
921                        && mouse.y >= sr.y
922                        && mouse.y < sr.bottom()
923                });
924                if in_inner {
925                    continue;
926                }
927                match mouse.kind {
928                    MouseKind::ScrollUp => {
929                        state.scroll_up(1);
930                        to_consume.push(i);
931                    }
932                    MouseKind::ScrollDown => {
933                        state.scroll_down(1);
934                        to_consume.push(i);
935                    }
936                    MouseKind::Drag(MouseButton::Left) => {}
937                    _ => {}
938                }
939            }
940        }
941
942        for i in to_consume {
943            self.consumed[i] = true;
944        }
945    }
946
947    /// Shortcut for `container().border(border)`.
948    ///
949    /// Returns a [`ContainerBuilder`] pre-configured with the given border style.
950    pub fn bordered(&mut self, border: Border) -> ContainerBuilder<'_> {
951        self.container()
952            .border(border)
953            .border_sides(BorderSides::all())
954    }
955
956    fn push_container(
957        &mut self,
958        direction: Direction,
959        gap: u32,
960        f: impl FnOnce(&mut Context),
961    ) -> Response {
962        let interaction_id = self.interaction_count;
963        self.interaction_count += 1;
964        let border = self.theme.border;
965
966        self.commands.push(Command::BeginContainer {
967            direction,
968            gap,
969            align: Align::Start,
970            justify: Justify::Start,
971            border: None,
972            border_sides: BorderSides::all(),
973            border_style: Style::new().fg(border),
974            bg_color: None,
975            padding: Padding::default(),
976            margin: Margin::default(),
977            constraints: Constraints::default(),
978            title: None,
979            grow: 0,
980            group_name: None,
981        });
982        f(self);
983        self.commands.push(Command::EndContainer);
984        self.last_text_idx = None;
985
986        self.response_for(interaction_id)
987    }
988
989    pub(super) fn response_for(&self, interaction_id: usize) -> Response {
990        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
991            return Response::default();
992        }
993        if let Some(rect) = self.prev_hit_map.get(interaction_id) {
994            let clicked = self
995                .click_pos
996                .map(|(mx, my)| {
997                    mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
998                })
999                .unwrap_or(false);
1000            let hovered = self
1001                .mouse_pos
1002                .map(|(mx, my)| {
1003                    mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1004                })
1005                .unwrap_or(false);
1006            Response { clicked, hovered }
1007        } else {
1008            Response::default()
1009        }
1010    }
1011
1012    /// Returns true if the named group is currently hovered by the mouse.
1013    pub fn is_group_hovered(&self, name: &str) -> bool {
1014        if let Some(pos) = self.mouse_pos {
1015            self.prev_group_rects.iter().any(|(n, rect)| {
1016                n == name
1017                    && pos.0 >= rect.x
1018                    && pos.0 < rect.x + rect.width
1019                    && pos.1 >= rect.y
1020                    && pos.1 < rect.y + rect.height
1021            })
1022        } else {
1023            false
1024        }
1025    }
1026
1027    /// Returns true if the named group contains the currently focused widget.
1028    pub fn is_group_focused(&self, name: &str) -> bool {
1029        if self.prev_focus_count == 0 {
1030            return false;
1031        }
1032        let focused_index = self.focus_index % self.prev_focus_count;
1033        self.prev_focus_groups
1034            .get(focused_index)
1035            .and_then(|group| group.as_deref())
1036            .map(|group| group == name)
1037            .unwrap_or(false)
1038    }
1039
1040    /// Set the flex-grow factor of the last rendered text element.
1041    ///
1042    /// A value of `1` causes the element to expand and fill remaining space
1043    /// along the main axis.
1044    pub fn grow(&mut self, value: u16) -> &mut Self {
1045        if let Some(idx) = self.last_text_idx {
1046            if let Command::Text { grow, .. } = &mut self.commands[idx] {
1047                *grow = value;
1048            }
1049        }
1050        self
1051    }
1052
1053    /// Set the text alignment of the last rendered text element.
1054    pub fn align(&mut self, align: Align) -> &mut Self {
1055        if let Some(idx) = self.last_text_idx {
1056            if let Command::Text {
1057                align: text_align, ..
1058            } = &mut self.commands[idx]
1059            {
1060                *text_align = align;
1061            }
1062        }
1063        self
1064    }
1065
1066    /// Render an invisible spacer that expands to fill available space.
1067    ///
1068    /// Useful for pushing siblings to opposite ends of a row or column.
1069    pub fn spacer(&mut self) -> &mut Self {
1070        self.commands.push(Command::Spacer { grow: 1 });
1071        self.last_text_idx = None;
1072        self
1073    }
1074
1075    /// Render a form that groups input fields vertically.
1076    ///
1077    /// Use [`Context::form_field`] inside the closure to render each field.
1078    pub fn form(
1079        &mut self,
1080        state: &mut FormState,
1081        f: impl FnOnce(&mut Context, &mut FormState),
1082    ) -> &mut Self {
1083        self.col(|ui| {
1084            f(ui, state);
1085        });
1086        self
1087    }
1088
1089    /// Render a single form field with label and input.
1090    ///
1091    /// Shows a validation error below the input when present.
1092    pub fn form_field(&mut self, field: &mut FormField) -> &mut Self {
1093        self.col(|ui| {
1094            ui.styled(field.label.clone(), Style::new().bold().fg(ui.theme.text));
1095            ui.text_input(&mut field.input);
1096            if let Some(error) = field.error.as_deref() {
1097                ui.styled(error.to_string(), Style::new().dim().fg(ui.theme.error));
1098            }
1099        });
1100        self
1101    }
1102
1103    /// Render a submit button.
1104    ///
1105    /// Returns `true` when the button is clicked or activated.
1106    pub fn form_submit(&mut self, label: impl Into<String>) -> bool {
1107        self.button(label)
1108    }
1109}
1110
1111const RUST_KEYWORDS: &[&str] = &[
1112    "fn", "let", "mut", "pub", "use", "impl", "struct", "enum", "trait", "type", "const", "static",
1113    "if", "else", "match", "for", "while", "loop", "return", "break", "continue", "where", "self",
1114    "super", "crate", "mod", "async", "await", "move", "ref", "in", "as", "true", "false", "Some",
1115    "None", "Ok", "Err", "Self",
1116];
1117
1118fn render_highlighted_line(ui: &mut Context, line: &str) {
1119    let theme = ui.theme;
1120    let keyword_color = Color::Rgb(198, 120, 221);
1121    let string_color = Color::Rgb(152, 195, 121);
1122    let comment_color = theme.text_dim;
1123    let number_color = Color::Rgb(209, 154, 102);
1124    let fn_color = Color::Rgb(97, 175, 239);
1125    let macro_color = Color::Rgb(86, 182, 194);
1126
1127    let trimmed = line.trim_start();
1128    let indent = &line[..line.len() - trimmed.len()];
1129    if !indent.is_empty() {
1130        ui.text(indent);
1131    }
1132
1133    if trimmed.starts_with("//") {
1134        ui.text(trimmed).fg(comment_color).italic();
1135        return;
1136    }
1137
1138    let mut pos = 0;
1139
1140    while pos < trimmed.len() {
1141        let ch = trimmed.as_bytes()[pos];
1142
1143        if ch == b'"' {
1144            if let Some(end) = trimmed[pos + 1..].find('"') {
1145                let s = &trimmed[pos..pos + end + 2];
1146                ui.text(s).fg(string_color);
1147                pos += end + 2;
1148                continue;
1149            }
1150        }
1151
1152        if ch.is_ascii_digit() && (pos == 0 || !trimmed.as_bytes()[pos - 1].is_ascii_alphanumeric())
1153        {
1154            let end = trimmed[pos..]
1155                .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '_')
1156                .map_or(trimmed.len(), |e| pos + e);
1157            ui.text(&trimmed[pos..end]).fg(number_color);
1158            pos = end;
1159            continue;
1160        }
1161
1162        if ch.is_ascii_alphabetic() || ch == b'_' {
1163            let end = trimmed[pos..]
1164                .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
1165                .map_or(trimmed.len(), |e| pos + e);
1166            let word = &trimmed[pos..end];
1167
1168            if end < trimmed.len() && trimmed.as_bytes()[end] == b'!' {
1169                ui.text(&trimmed[pos..end + 1]).fg(macro_color);
1170                pos = end + 1;
1171            } else if end < trimmed.len()
1172                && trimmed.as_bytes()[end] == b'('
1173                && !RUST_KEYWORDS.contains(&word)
1174            {
1175                ui.text(word).fg(fn_color);
1176                pos = end;
1177            } else if RUST_KEYWORDS.contains(&word) {
1178                ui.text(word).fg(keyword_color);
1179                pos = end;
1180            } else {
1181                ui.text(word);
1182                pos = end;
1183            }
1184            continue;
1185        }
1186
1187        let end = trimmed[pos..]
1188            .find(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '"')
1189            .map_or(trimmed.len(), |e| pos + e);
1190        ui.text(&trimmed[pos..end]);
1191        pos = end;
1192    }
1193}