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