Skip to main content

slt/context/widgets_display/
layout.rs

1use super::*;
2use std::sync::LazyLock;
3
4static SEP_LINE: LazyLock<String> = LazyLock::new(|| "─".repeat(200));
5
6fn sep_line() -> &'static str {
7    &SEP_LINE
8}
9
10/// Compass-rose anchor for [`Context::overlay_at`] / [`Context::modal_at`].
11///
12/// Each variant maps to a (cross-axis [`Align`], main-axis [`Justify`]) pair
13/// that pins overlay content to the requested screen position. The `_at`
14/// helpers expand to a full-screen wrapper (so flexbox has slack to push
15/// against), then place the user's content per the selected anchor.
16///
17/// ```no_run
18/// # use slt::Anchor;
19/// # slt::run(|ui: &mut slt::Context| {
20/// ui.overlay_at(Anchor::BottomRight, |ui| {
21///     ui.text("v0.19.3").dim();
22/// });
23/// # });
24/// ```
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Anchor {
27    /// Top-left corner.
28    TopLeft,
29    /// Top edge, horizontally centered.
30    TopCenter,
31    /// Top-right corner.
32    TopRight,
33    /// Left edge, vertically centered.
34    CenterLeft,
35    /// Screen center.
36    Center,
37    /// Right edge, vertically centered.
38    CenterRight,
39    /// Bottom-left corner.
40    BottomLeft,
41    /// Bottom edge, horizontally centered.
42    BottomCenter,
43    /// Bottom-right corner.
44    BottomRight,
45}
46
47/// Map [`Anchor`] to the wrapper column's (cross-axis align, main-axis justify).
48///
49/// The inner column is `Direction::Column`, so:
50///   - `Justify` controls the vertical (main-axis) position.
51///   - `Align`   controls the horizontal (cross-axis) position.
52fn anchor_to_align_justify(anchor: Anchor) -> (Align, Justify) {
53    match anchor {
54        Anchor::TopLeft => (Align::Start, Justify::Start),
55        Anchor::TopCenter => (Align::Center, Justify::Start),
56        Anchor::TopRight => (Align::End, Justify::Start),
57        Anchor::CenterLeft => (Align::Start, Justify::Center),
58        Anchor::Center => (Align::Center, Justify::Center),
59        Anchor::CenterRight => (Align::End, Justify::Center),
60        Anchor::BottomLeft => (Align::Start, Justify::End),
61        Anchor::BottomCenter => (Align::Center, Justify::End),
62        Anchor::BottomRight => (Align::End, Justify::End),
63    }
64}
65
66/// Resolve `(dx, dy)` to a [`Margin`] for the outer grow-1 anchor column,
67/// given an [`Anchor`].
68///
69/// Sign convention: **positive `dx` / `dy` inset toward the viewport center**
70/// (mirrors the CSS `inset` shorthand intuition). The margin shrinks the
71/// column's slack on the side adjacent to the anchored edge, so subsequent
72/// flexbox `align`/`justify` push the user's content inward by `(dx, dy)`:
73///   - `BottomRight` + `(dx=2, dy=1)` → `mr=2, mb=1` (push 2 left, 1 up)
74///   - `TopLeft`     + `(dx=2, dy=1)` → `ml=2, mt=1` (push 2 right, 1 down)
75///   - `Center`      + `(dx=2, dy=1)` → `ml=2, mt=1` (shift 2 right, 1 down)
76///   - `Center`      + `(dx=-2, dy=-1)` → `mr=2, mb=1` (shift 2 left, 1 up)
77///
78/// Negative values for corner / edge anchors would push the content
79/// off-screen (no opposite-side slack to consume), so they are clamped to 0;
80/// see [`Context::overlay_at_offset`] for the documented contract.
81fn anchor_offset_to_margin(anchor: Anchor, dx: i32, dy: i32) -> Margin {
82    let mut margin = Margin::default();
83
84    // Horizontal axis: positive dx insets toward center.
85    let h_anchor = match anchor {
86        Anchor::TopLeft | Anchor::CenterLeft | Anchor::BottomLeft => HSide::Left,
87        Anchor::TopRight | Anchor::CenterRight | Anchor::BottomRight => HSide::Right,
88        Anchor::TopCenter | Anchor::Center | Anchor::BottomCenter => HSide::Center,
89    };
90    match h_anchor {
91        HSide::Left => {
92            // Anchored to left edge: positive dx pushes right via ml.
93            // Negative dx would push left (offscreen) — no slack on the
94            // opposite side, and `u32` margin can't represent negatives,
95            // so we clamp to 0. See `Context::overlay_at_offset` doc.
96            if dx > 0 {
97                margin.left = dx as u32;
98            }
99        }
100        HSide::Right => {
101            // Anchored to right edge: positive dx pushes left via mr.
102            if dx > 0 {
103                margin.right = dx as u32;
104            }
105        }
106        HSide::Center => {
107            // Centered: positive dx shifts right (ml), negative shifts left (mr).
108            if dx > 0 {
109                margin.left = dx as u32;
110            } else if dx < 0 {
111                margin.right = dx.unsigned_abs();
112            }
113        }
114    }
115
116    // Vertical axis: positive dy insets toward center.
117    let v_anchor = match anchor {
118        Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => VSide::Top,
119        Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => VSide::Bottom,
120        Anchor::CenterLeft | Anchor::Center | Anchor::CenterRight => VSide::Center,
121    };
122    match v_anchor {
123        VSide::Top => {
124            if dy > 0 {
125                margin.top = dy as u32;
126            }
127        }
128        VSide::Bottom => {
129            if dy > 0 {
130                margin.bottom = dy as u32;
131            }
132        }
133        VSide::Center => {
134            if dy > 0 {
135                margin.top = dy as u32;
136            } else if dy < 0 {
137                margin.bottom = dy.unsigned_abs();
138            }
139        }
140    }
141
142    margin
143}
144
145enum HSide {
146    Left,
147    Right,
148    Center,
149}
150
151enum VSide {
152    Top,
153    Bottom,
154    Center,
155}
156
157impl Context {
158    /// Render a horizontal divider line.
159    ///
160    /// The line is drawn with the theme's border color and expands to fill the
161    /// container width.
162    ///
163    /// Returns a [`Response`] so the divider's hit-test rect is available for
164    /// hover detection. Prior to v0.21.0 this returned `&mut Self`, but the
165    /// chained style mutators (`.bold()`, `.fg()`) were a no-op — the cached
166    /// separator string is already finalized — so the chain was dropped.
167    /// Statement-form callers (`ui.separator();`) compile unchanged.
168    ///
169    /// ```no_run
170    /// # slt::run(|ui: &mut slt::Context| {
171    /// ui.separator();
172    /// # });
173    /// ```
174    pub fn separator(&mut self) -> Response {
175        let response = self.interaction();
176        // The cached `sep_line()` is much wider than any reasonable terminal,
177        // so the cross-axis (column-direction) clip in `Buffer::set_string`
178        // truncates the trailing chars. Keeping `grow = 0` means a column
179        // layout doesn't stretch the separator vertically, and `truncate =
180        // false` avoids the ellipsis fallback which would otherwise replace
181        // the last cell with `…`.
182        self.commands.push(Command::Text {
183            content: sep_line().to_owned(),
184            cursor_offset: None,
185            style: Style::new().fg(self.theme.border).dim(),
186            grow: 0,
187            align: Align::Start,
188            wrap: false,
189            truncate: false,
190            margin: Margin::default(),
191            constraints: Constraints::default(),
192        });
193        self.rollback.last_text_idx = Some(self.commands.len() - 1);
194        response
195    }
196
197    /// Render a horizontal separator line with a custom color.
198    ///
199    /// Returns a [`Response`] for hover detection; see [`Context::separator`]
200    /// for the v0.21.0 return-shape change. Statement-form callers compile
201    /// unchanged.
202    ///
203    /// ```no_run
204    /// # use slt::Color;
205    /// # slt::run(|ui: &mut slt::Context| {
206    /// ui.separator_colored(Color::Cyan);
207    /// # });
208    /// ```
209    pub fn separator_colored(&mut self, color: Color) -> Response {
210        let response = self.interaction();
211        self.commands.push(Command::Text {
212            content: sep_line().to_owned(),
213            cursor_offset: None,
214            style: Style::new().fg(color),
215            grow: 0,
216            align: Align::Start,
217            wrap: false,
218            truncate: false,
219            margin: Margin::default(),
220            constraints: Constraints::default(),
221        });
222        self.rollback.last_text_idx = Some(self.commands.len() - 1);
223        response
224    }
225
226    /// Conditionally render content when the named screen is active.
227    ///
228    /// Each screen gets an isolated hook segment — `use_state` / `use_memo`
229    /// calls inside one screen do not interfere with another screen's hooks,
230    /// even when you switch between screens across frames.
231    ///
232    /// Focus state is saved and restored per screen automatically.
233    /// Navigation requested inside a screen mutates `ScreenState` when that
234    /// closure returns, while the frame finishes rendering the screen that was
235    /// active at its start. The destination renders on the next frame, so
236    /// source and destination content are never composed together.
237    ///
238    /// # Example
239    ///
240    /// ```no_run
241    /// # let mut screens = slt::ScreenState::new("main");
242    /// # slt::run(|ui| {
243    /// ui.screen("main", &mut screens, |ui| {
244    ///     ui.text("Main screen");
245    /// });
246    /// # });
247    /// ```
248    pub fn screen(&mut self, name: &str, screens: &mut ScreenState, f: impl FnOnce(&mut Context)) {
249        let screen_key = screens as *mut ScreenState as usize;
250        let screen_state_id = screens.id();
251        let is_active = self
252            .screen_nav_render_origins
253            .get(&screen_key)
254            .map_or_else(|| screens.current() == name, |origin| origin == name);
255
256        if is_active {
257            // Allocate a segment only when this exact ScreenState/name pair is
258            // active for the first time. Inactive declarations do not reserve
259            // the same zero-length segment as a later active screen.
260            let existing = self
261                .screen_hook_map
262                .get(&screen_state_id)
263                .and_then(|hooks| hooks.get(name))
264                .copied();
265            let (seg_start, _seg_count) = existing.unwrap_or_else(|| {
266                let value = (self.hook_states.len(), 0);
267                self.screen_hook_map
268                    .entry(screen_state_id)
269                    .or_default()
270                    .insert(name.to_string(), value);
271                value
272            });
273            // Save outer focus, restore this screen's focus
274            let outer_focus_index = self.focus_index;
275            let (saved_focus_idx, _saved_focus_count) = screens.restore_focus(name);
276            self.focus_index = saved_focus_idx;
277
278            // Set hook cursor to this screen's segment start
279            self.rollback.hook_cursor = seg_start;
280            let focus_count_before = self.rollback.focus_count;
281
282            // Scope deferred navigation to this exact screen closure. Nested
283            // screens get their own range and cannot drain an outer request.
284            let nav_scope_start = self.pending_screen_nav.len();
285            self.screen_nav_depth += 1;
286
287            // Execute the screen's closure
288            f(self);
289
290            self.screen_nav_depth = self
291                .screen_nav_depth
292                .checked_sub(1)
293                .expect("active screen must own a navigation scope");
294
295            // Record the hook count for this screen.
296            //
297            // The first-frame path above already inserted an owned `String`
298            // key for this screen; subsequent frames reuse it. Locate that
299            // existing slot via `&str` and overwrite the value in place,
300            // avoiding a second `to_string()` allocation per active frame.
301            let hooks_used = self.rollback.hook_cursor - seg_start;
302            if let Some(slot) = self
303                .screen_hook_map
304                .get_mut(&screen_state_id)
305                .and_then(|hooks| hooks.get_mut(name))
306            {
307                *slot = (seg_start, hooks_used);
308            } else {
309                self.screen_hook_map
310                    .entry(screen_state_id)
311                    .or_default()
312                    .insert(name.to_string(), (seg_start, hooks_used));
313            }
314
315            // Save this screen's focus state
316            let screen_focus_count = self.rollback.focus_count - focus_count_before;
317            screens.save_focus(name, self.focus_index, screen_focus_count);
318
319            // Restore outer focus
320            self.focus_index = outer_focus_index;
321
322            // Issue #279: apply navigation requested from inside the closure
323            // now that the closure's `&mut Context` borrow has ended. We still
324            // hold `&mut screens` here, so there is no double mutable borrow —
325            // app code can call `ui.push_screen(...)` / `ui.pop_screen()` from
326            // within the closure without the borrow conflict from the issue.
327            if self.pending_screen_nav.len() > nav_scope_start {
328                // Preserve the screen that rendered at the start of this
329                // transition. ScreenState changes immediately, but later
330                // declarations wait until the next frame to render the new
331                // destination, avoiding a one-frame source/destination blend.
332                self.screen_nav_render_origins
333                    .entry(screen_key)
334                    .or_insert_with(|| screens.current().to_owned());
335                for nav in self.pending_screen_nav.drain(nav_scope_start..) {
336                    screens.apply_nav(nav);
337                }
338            }
339        } else {
340            // Skip an already-initialized segment without allocating one for a
341            // screen that has never been active.
342            if let Some(&(seg_start, seg_count)) = self
343                .screen_hook_map
344                .get(&screen_state_id)
345                .and_then(|hooks| hooks.get(name))
346                && seg_count > 0
347                && seg_start >= self.rollback.hook_cursor
348            {
349                self.rollback.hook_cursor = seg_start.saturating_add(seg_count);
350            }
351        }
352    }
353
354    /// Request pushing a new screen onto the active [`ScreenState`] stack.
355    ///
356    /// Call this from inside a [`Context::screen`] closure to navigate forward.
357    /// The push is deferred and applied to your `ScreenState` the moment the
358    /// closure returns, so it does not conflict with the `&mut ScreenState`
359    /// already borrowed by `screen(...)` (issue #279). Rendering stays on the
360    /// source screen for that transition frame; the destination appears on the
361    /// next frame.
362    ///
363    /// # Example
364    ///
365    /// ```no_run
366    /// # let mut screens = slt::ScreenState::new("home");
367    /// # slt::run(|ui| {
368    /// ui.screen("home", &mut screens, |ui| {
369    ///     if ui.button("Settings").clicked {
370    ///         ui.push_screen("settings");
371    ///     }
372    /// });
373    /// # });
374    /// ```
375    ///
376    /// # Panics
377    ///
378    /// Panics when called outside an active [`Context::screen`] closure. Use
379    /// [`ScreenState::push`] directly when navigating outside a screen.
380    #[track_caller]
381    pub fn push_screen(&mut self, name: impl Into<String>) {
382        self.assert_screen_nav_scope();
383        self.pending_screen_nav.push(ScreenNav::Push(name.into()));
384    }
385
386    /// Request popping the current screen off the active [`ScreenState`] stack
387    /// (the root screen is preserved).
388    ///
389    /// Like [`Self::push_screen`], the pop is deferred and applied when the
390    /// enclosing [`Context::screen`] closure returns (issue #279).
391    ///
392    /// # Panics
393    ///
394    /// Panics when called outside an active [`Context::screen`] closure. Use
395    /// [`ScreenState::pop`] directly when navigating outside a screen.
396    #[track_caller]
397    pub fn pop_screen(&mut self) {
398        self.assert_screen_nav_scope();
399        self.pending_screen_nav.push(ScreenNav::Pop);
400    }
401
402    /// Request resetting the active [`ScreenState`] stack to just its root
403    /// screen.
404    ///
405    /// Deferred and applied when the enclosing [`Context::screen`] closure
406    /// returns (issue #279).
407    ///
408    /// # Panics
409    ///
410    /// Panics when called outside an active [`Context::screen`] closure. Use
411    /// [`ScreenState::reset`] directly when navigating outside a screen.
412    #[track_caller]
413    pub fn reset_screen(&mut self) {
414        self.assert_screen_nav_scope();
415        self.pending_screen_nav.push(ScreenNav::Reset);
416    }
417
418    #[track_caller]
419    fn assert_screen_nav_scope(&self) {
420        assert!(
421            self.screen_nav_depth > 0,
422            "screen navigation helpers can only be called inside an active Context::screen closure; mutate ScreenState directly outside a screen"
423        );
424    }
425
426    /// Remove retained hook/focus state for an inactive screen.
427    ///
428    /// Returns `false` when `name` is still present in `screens`' stack. Call
429    /// this after popping runtime-generated detail screens to release their
430    /// isolated hook segment and saved focus entry.
431    pub fn remove_screen_state(&mut self, screens: &mut ScreenState, name: &str) -> bool {
432        if screens.contains(name) {
433            return false;
434        }
435        let focus_removed = screens.remove_inactive(name);
436        let hooks_removed = self.remove_screen_hooks(screens.id(), name);
437        focus_removed || hooks_removed
438    }
439
440    /// Retain inactive screen states accepted by `keep`.
441    ///
442    /// Screens currently present in `screens`' stack are always kept. Returns
443    /// the number of screen names removed from either the hook map or the
444    /// saved-focus map.
445    pub fn retain_screen_state(
446        &mut self,
447        screens: &mut ScreenState,
448        mut keep: impl FnMut(&str) -> bool,
449    ) -> usize {
450        let screen_id = screens.id();
451        let remove_names: Vec<String> = self
452            .screen_hook_map
453            .get(&screen_id)
454            .into_iter()
455            .flat_map(|hooks| hooks.keys())
456            .filter(|name| !screens.contains(name) && !keep(name))
457            .cloned()
458            .collect();
459
460        let mut removed = 0;
461        for name in remove_names {
462            if self.remove_screen_state(screens, &name) {
463                removed += 1;
464            }
465        }
466        removed + screens.retain_inactive(keep)
467    }
468
469    /// Number of retained screen hook segments.
470    ///
471    /// Diagnostic helper for apps that generate screen names from runtime ids.
472    pub fn screen_state_count(&self) -> usize {
473        self.screen_hook_map.values().map(|hooks| hooks.len()).sum()
474    }
475
476    fn remove_screen_hooks(&mut self, screen_id: u64, name: &str) -> bool {
477        let Some((seg_start, seg_count)) = self
478            .screen_hook_map
479            .get_mut(&screen_id)
480            .and_then(|hooks| hooks.remove(name))
481        else {
482            return false;
483        };
484        if self
485            .screen_hook_map
486            .get(&screen_id)
487            .is_some_and(|hooks| hooks.is_empty())
488        {
489            self.screen_hook_map.remove(&screen_id);
490        }
491
492        if seg_count == 0 {
493            return true;
494        }
495
496        let end = seg_start
497            .saturating_add(seg_count)
498            .min(self.hook_states.len());
499        if seg_start >= end {
500            return true;
501        }
502
503        let removed = end - seg_start;
504        self.hook_states.drain(seg_start..end);
505        for hooks in self.screen_hook_map.values_mut() {
506            for (other_start, _) in hooks.values_mut() {
507                if *other_start > seg_start {
508                    *other_start = other_start.saturating_sub(removed);
509                }
510            }
511        }
512        if self.rollback.hook_cursor > seg_start {
513            self.rollback.hook_cursor = self.rollback.hook_cursor.saturating_sub(removed);
514        }
515        true
516    }
517
518    /// Create a vertical (column) container.
519    ///
520    /// Children are stacked top-to-bottom. Returns a [`Response`] with
521    /// click/hover state for the container area.
522    ///
523    /// # Example
524    ///
525    /// ```no_run
526    /// # slt::run(|ui: &mut slt::Context| {
527    /// ui.col(|ui| {
528    ///     ui.text("line one");
529    ///     ui.text("line two");
530    /// });
531    /// # });
532    /// ```
533    pub fn col(&mut self, f: impl FnOnce(&mut Context)) -> Response {
534        self.push_container(Direction::Column, 0, f)
535    }
536
537    /// Create a vertical (column) container with a gap between children.
538    ///
539    /// `gap` is the number of blank rows inserted between each child.
540    ///
541    /// **Deprecated since 0.20.1**: the name collides with
542    /// [`ContainerBuilder::col_gap`], which sets the *row-finalize* main-axis
543    /// gap (Tailwind `gap-x` axis convention) and so means the opposite thing.
544    /// Use `ui.container().gap(n).col(f)` instead — same output, no collision.
545    #[deprecated(
546        since = "0.20.1",
547        note = "Use `ui.container().gap(n).col(f)` instead — same output, no name collision with `ContainerBuilder::col_gap`."
548    )]
549    pub fn col_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
550        self.push_container(Direction::Column, gap, f)
551    }
552
553    /// Create a horizontal (row) container.
554    ///
555    /// Children are placed left-to-right. Returns a [`Response`] with
556    /// click/hover state for the container area.
557    ///
558    /// # Example
559    ///
560    /// ```no_run
561    /// # slt::run(|ui: &mut slt::Context| {
562    /// ui.row(|ui| {
563    ///     ui.text("left");
564    ///     ui.spacer();
565    ///     ui.text("right");
566    /// });
567    /// # });
568    /// ```
569    pub fn row(&mut self, f: impl FnOnce(&mut Context)) -> Response {
570        self.push_container(Direction::Row, 0, f)
571    }
572
573    /// Create a horizontal (row) container with a gap between children.
574    ///
575    /// `gap` is the number of blank columns inserted between each child.
576    ///
577    /// **Deprecated since 0.20.1**: the name collides with
578    /// [`ContainerBuilder::row_gap`], which sets the *column-finalize*
579    /// main-axis gap (Tailwind `gap-y` axis convention) and so means the
580    /// opposite thing. Use `ui.container().gap(n).row(f)` instead — same
581    /// output, no collision.
582    #[deprecated(
583        since = "0.20.1",
584        note = "Use `ui.container().gap(n).row(f)` instead — same output, no name collision with `ContainerBuilder::row_gap`."
585    )]
586    pub fn row_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
587        self.push_container(Direction::Row, gap, f)
588    }
589
590    /// Render inline text with mixed styles on a single line.
591    ///
592    /// Unlike [`row`](Context::row), `line()` is designed for rich text —
593    /// children are rendered as continuous inline text without gaps.
594    ///
595    /// It intentionally returns `&mut Self` instead of [`Response`] so you can
596    /// keep chaining display-oriented modifiers after composing the inline run.
597    ///
598    /// # Example
599    ///
600    /// ```no_run
601    /// # use slt::Color;
602    /// # slt::run(|ui: &mut slt::Context| {
603    /// ui.line(|ui| {
604    ///     ui.text("Status: ");
605    ///     ui.text("Online").bold().fg(Color::Green);
606    /// });
607    /// # });
608    /// ```
609    pub fn line(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
610        let _ = self.push_container(Direction::Row, 0, f);
611        self
612    }
613
614    /// Render inline text with mixed styles, wrapping at word boundaries.
615    ///
616    /// Like [`line`](Context::line), but when the combined text exceeds
617    /// the container width it wraps across multiple lines while
618    /// preserving per-segment styles.
619    ///
620    /// # Example
621    ///
622    /// ```no_run
623    /// # use slt::{Color, Style};
624    /// # slt::run(|ui: &mut slt::Context| {
625    /// ui.line_wrap(|ui| {
626    ///     ui.text("This is a long ");
627    ///     ui.text("important").bold().fg(Color::Red);
628    ///     ui.text(" message that wraps across lines");
629    /// });
630    /// # });
631    /// ```
632    pub fn line_wrap(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
633        let start = self.commands.len();
634        f(self);
635        let has_link = self.commands[start..]
636            .iter()
637            .any(|cmd| matches!(cmd, Command::Link { .. }));
638
639        if has_link {
640            for command in &mut self.commands[start..] {
641                match command {
642                    Command::Text { wrap, .. } | Command::Link { wrap, .. } => *wrap = true,
643                    _ => {}
644                }
645            }
646            self.commands.insert(start, Command::WrapMarker(0));
647            self.commands.insert(
648                start + 1,
649                Command::BeginContainer(Box::new(BeginContainerArgs {
650                    direction: Direction::Row,
651                    gap: 0,
652                    align: Align::Start,
653                    align_self: None,
654                    justify: Justify::Start,
655                    border: None,
656                    border_sides: BorderSides::all(),
657                    border_style: Style::new(),
658                    bg_color: None,
659                    padding: Padding::default(),
660                    margin: Margin::default(),
661                    constraints: Constraints::default(),
662                    title: None,
663                    grow: 0,
664                    group_name: None,
665                })),
666            );
667            self.commands.push(Command::EndContainer);
668            self.rollback.last_text_idx = None;
669            return self;
670        }
671
672        let mut segments: Vec<(String, Style)> = Vec::new();
673        for cmd in self.commands.drain(start..) {
674            match cmd {
675                Command::Text { content, style, .. } => {
676                    segments.push((content, style));
677                }
678                Command::Link { text, style, .. } => {
679                    // Preserve link text with underline styling (URL lost in RichText,
680                    // but text is visible and wraps correctly)
681                    segments.push((text, style));
682                }
683                _ => {}
684            }
685        }
686        self.commands.push(Command::RichText {
687            segments,
688            wrap: true,
689            align: Align::Start,
690            margin: Margin::default(),
691            constraints: Constraints::default(),
692        });
693        self.rollback.last_text_idx = None;
694        self
695    }
696
697    /// Render content in a modal overlay with dimmed background.
698    ///
699    /// ```no_run
700    /// # let mut show = true;
701    /// # slt::run(|ui: &mut slt::Context| {
702    /// if show {
703    ///     ui.modal(|ui| {
704    ///         ui.text("Are you sure?");
705    ///         if ui.button("OK").clicked { show = false; }
706    ///     });
707    /// }
708    /// # });
709    /// ```
710    pub fn modal(&mut self, f: impl FnOnce(&mut Context)) -> Response {
711        // Default `modal()` preserves legacy behavior (tab_trap = false).
712        // `modal_with(ModalOptions::default(), ...)` opts into the WCAG 2.1
713        // SC 2.4.3 focus-trap default. This split keeps existing callers
714        // bit-identical until they migrate.
715        self.modal_with(ModalOptions { tab_trap: false }, f)
716    }
717
718    /// Render content in a modal overlay with configurable options.
719    ///
720    /// Like [`modal`](Self::modal), but accepts a [`ModalOptions`] struct.
721    /// Use this to opt into focus trapping (`tab_trap: true`) or future
722    /// modal flags without breaking the bare `modal()` API.
723    ///
724    /// When `opts.tab_trap` is `true`, focus cannot escape the modal's
725    /// focusable range — Tab/Shift+Tab keep cycling within the modal even
726    /// if [`Context::set_focus_index`] or a mouse click moved focus to a
727    /// background widget. WCAG 2.1 SC 2.4.3 (Focus Order) recommends
728    /// trapping focus inside modal dialogs.
729    ///
730    /// # Example
731    ///
732    /// ```no_run
733    /// # let mut show = true;
734    /// # slt::run(|ui: &mut slt::Context| {
735    /// if show {
736    ///     ui.modal_with(slt::context::ModalOptions { tab_trap: true }, |ui| {
737    ///         ui.text("Are you sure?");
738    ///         if ui.button("OK").clicked { show = false; }
739    ///     });
740    /// }
741    /// # });
742    /// ```
743    pub fn modal_with(&mut self, opts: ModalOptions, f: impl FnOnce(&mut Context)) -> Response {
744        let interaction_id = self.next_interaction_id();
745        self.commands.push(Command::BeginOverlay { modal: true });
746        self.rollback.overlay_depth += 1;
747        self.rollback.modal_active = true;
748        let modal_focus_start = self.rollback.focus_count;
749        self.rollback.modal_focus_start = modal_focus_start;
750
751        f(self);
752        let modal_focus_count = self.rollback.focus_count.saturating_sub(modal_focus_start);
753        self.rollback.modal_focus_count = modal_focus_count;
754
755        // Tab trap: when enabled, ensure `focus_index` lies in this frame's
756        // modal range `[start, start + count)`. If `set_focus_index` from a
757        // previous frame (or a stale state) left focus pointing at a
758        // background widget, clamp it to the first modal focusable so the
759        // next [`process_focus_keys`] tick cycles cleanly within the modal.
760        //
761        // WCAG 2.1 SC 2.4.3 (Focus Order) requirement: the user must not be
762        // able to navigate to content outside an active modal dialog.
763        if opts.tab_trap && modal_focus_count > 0 {
764            let lo = modal_focus_start;
765            let hi = lo.saturating_add(modal_focus_count);
766            if self.focus_index < lo || self.focus_index >= hi {
767                self.focus_index = lo;
768            }
769        }
770
771        self.rollback.overlay_depth = self.rollback.overlay_depth.saturating_sub(1);
772        self.commands.push(Command::EndOverlay);
773        self.rollback.last_text_idx = None;
774        self.response_for(interaction_id)
775    }
776
777    /// Render floating content without dimming the background.
778    pub fn overlay(&mut self, f: impl FnOnce(&mut Context)) -> Response {
779        let interaction_id = self.next_interaction_id();
780        self.commands.push(Command::BeginOverlay { modal: false });
781        self.rollback.overlay_depth += 1;
782        f(self);
783        self.rollback.overlay_depth = self.rollback.overlay_depth.saturating_sub(1);
784        self.commands.push(Command::EndOverlay);
785        self.rollback.last_text_idx = None;
786        self.response_for(interaction_id)
787    }
788
789    /// Render floating content anchored to one of the 9 compass positions.
790    ///
791    /// Wraps [`overlay`](Self::overlay) with a full-area column that pins the
792    /// content to the requested anchor via flexbox `align`/`justify`. The
793    /// inner column gets `grow(1)` so the wrapper consumes the screen, giving
794    /// `align`/`justify` room to push the content to the corner.
795    ///
796    /// ```no_run
797    /// # use slt::Anchor;
798    /// # slt::run(|ui: &mut slt::Context| {
799    /// ui.overlay_at(Anchor::TopRight, |ui| {
800    ///     ui.text("0:42").bold();
801    /// });
802    /// # });
803    /// ```
804    pub fn overlay_at(&mut self, anchor: Anchor, f: impl FnOnce(&mut Context)) -> Response {
805        self.overlay(|ui| {
806            let (align, justify) = anchor_to_align_justify(anchor);
807            let _ = ui.container().grow(1).align(align).justify(justify).col(f);
808        })
809    }
810
811    /// Render a modal overlay anchored to one of the 9 compass positions.
812    ///
813    /// Like [`modal`](Self::modal) but pinned to a corner / edge / center via
814    /// the same anchor wrapping as [`overlay_at`](Self::overlay_at).
815    pub fn modal_at(&mut self, anchor: Anchor, f: impl FnOnce(&mut Context)) -> Response {
816        self.modal(|ui| {
817            let (align, justify) = anchor_to_align_justify(anchor);
818            let _ = ui.container().grow(1).align(align).justify(justify).col(f);
819        })
820    }
821
822    /// Render `f` at `anchor` with cell offset `(dx, dy)` from the anchored edge.
823    ///
824    /// This is the SLT analog of CSS `position: absolute; top/right/bottom/left`,
825    /// or Flutter's `Positioned(top:, right:, ...)`. The 9-cell [`Anchor`]
826    /// chooses which edge to anchor to; `(dx, dy)` insets toward the center.
827    ///
828    /// # Sign convention
829    /// Positive `dx` / `dy` always inset toward the viewport center. So
830    /// `overlay_at_offset(Anchor::BottomRight, 2, 1, ...)` places the widget
831    /// 2 cells left and 1 cell up from the bottom-right corner.
832    ///
833    /// For [`Anchor::Center`] (and other centered axes) negative values shift
834    /// in the opposite direction — `(dx=-2, dy=-1)` shifts 2 cells left and 1
835    /// cell up. For corner / edge anchors, negative values would push the
836    /// content off-screen, so they are clamped to 0; use a different anchor
837    /// instead of negative offsets to escape an edge.
838    ///
839    /// # CSS analogy
840    /// ```text
841    /// CSS:    place-self: end end; bottom: 1px; right: 2px;
842    /// SLT:    overlay_at_offset(Anchor::BottomRight, 2, 1, |ui| { ... })
843    /// ```
844    ///
845    /// # Example
846    ///
847    /// ```no_run
848    /// # use slt::Anchor;
849    /// # slt::run(|ui: &mut slt::Context| {
850    /// // Inset corner badge — 2 cells from the right, 1 row from the bottom.
851    /// ui.overlay_at_offset(Anchor::BottomRight, 2, 1, |ui| {
852    ///     ui.text("v0.19.3").dim();
853    /// });
854    /// # });
855    /// ```
856    pub fn overlay_at_offset(
857        &mut self,
858        anchor: Anchor,
859        dx: i32,
860        dy: i32,
861        f: impl FnOnce(&mut Context),
862    ) -> Response {
863        self.overlay(|ui| {
864            let (align, justify) = anchor_to_align_justify(anchor);
865            let margin = anchor_offset_to_margin(anchor, dx, dy);
866            // Apply margin on the outer (grow=1) column so flexbox's parent
867            // (the synthetic overlay root) shrinks the column's area before
868            // align/justify pick a position. This avoids a wrapper container
869            // around `f`, which would expose a flexbox limitation where
870            // `Align::End` shifts the immediate child's `pos` but does not
871            // propagate the shift down to grandchildren.
872            let _ = ui
873                .container()
874                .grow(1)
875                .align(align)
876                .justify(justify)
877                .margin(margin)
878                .col(f);
879        })
880    }
881
882    /// Modal variant of [`overlay_at_offset`](Self::overlay_at_offset).
883    ///
884    /// Like [`modal_at`](Self::modal_at) but with a `(dx, dy)` cell inset
885    /// from the anchored edge. Positive values inset toward the center —
886    /// see [`overlay_at_offset`](Self::overlay_at_offset) for the full sign
887    /// convention.
888    ///
889    /// # Example
890    ///
891    /// ```no_run
892    /// # use slt::{Anchor, Border};
893    /// # slt::run(|ui: &mut slt::Context| {
894    /// ui.modal_at_offset(Anchor::TopRight, 2, 1, |ui| {
895    ///     ui.bordered(Border::Rounded).p(1).col(|ui| {
896    ///         ui.text("Saved!");
897    ///     });
898    /// });
899    /// # });
900    /// ```
901    pub fn modal_at_offset(
902        &mut self,
903        anchor: Anchor,
904        dx: i32,
905        dy: i32,
906        f: impl FnOnce(&mut Context),
907    ) -> Response {
908        self.modal(|ui| {
909            let (align, justify) = anchor_to_align_justify(anchor);
910            let margin = anchor_offset_to_margin(anchor, dx, dy);
911            // See `overlay_at_offset` for why margin lives on the outer
912            // grow-1 column rather than a wrapper around `f`.
913            let _ = ui
914                .container()
915                .grow(1)
916                .align(align)
917                .justify(justify)
918                .margin(margin)
919                .col(f);
920        })
921    }
922
923    /// Render a hover tooltip for the previously rendered interactive widget.
924    ///
925    /// Call this right after a widget or container response:
926    /// ```ignore
927    /// if ui.button("Save").clicked { save(); }
928    /// ui.tooltip("Save the current document to disk");
929    /// ```
930    pub fn tooltip(&mut self, text: impl Into<String>) {
931        let tooltip_text = text.into();
932        if tooltip_text.is_empty() {
933            return;
934        }
935        let last_interaction_id = self.rollback.interaction_count.saturating_sub(1);
936        let last_response = self.response_for(last_interaction_id);
937        if !last_response.hovered || last_response.rect.width == 0 || last_response.rect.height == 0
938        {
939            return;
940        }
941        let lines = wrap_tooltip_text(&tooltip_text, 38);
942        self.pending_tooltips.push(PendingTooltip {
943            anchor_rect: last_response.rect,
944            lines,
945        });
946    }
947
948    pub(crate) fn emit_pending_tooltips(&mut self) {
949        let tooltips = std::mem::take(&mut self.pending_tooltips);
950        if tooltips.is_empty() {
951            return;
952        }
953        let area_w = self.area_width;
954        let area_h = self.area_height;
955        let surface = self.theme.surface;
956        let border_color = self.theme.border;
957        let text_color = self.theme.surface_text;
958
959        for tooltip in tooltips {
960            let content_w = tooltip
961                .lines
962                .iter()
963                .map(|l| UnicodeWidthStr::width(l.as_str()) as u32)
964                .max()
965                .unwrap_or(0);
966            let box_w = content_w.saturating_add(4).min(area_w);
967            let box_h = (tooltip.lines.len() as u32).saturating_add(4).min(area_h);
968
969            let tooltip_x = tooltip.anchor_rect.x.min(area_w.saturating_sub(box_w));
970            let below_y = tooltip.anchor_rect.bottom();
971            let tooltip_y = if below_y.saturating_add(box_h) <= area_h {
972                below_y
973            } else {
974                tooltip.anchor_rect.y.saturating_sub(box_h)
975            };
976
977            let lines = tooltip.lines;
978            let pad = self.theme.spacing.xs();
979            let _ = self.overlay(|ui| {
980                let _ = ui.container().w(area_w).h(area_h).col(|ui| {
981                    let _ = ui
982                        .container()
983                        .ml(tooltip_x)
984                        .mt(tooltip_y)
985                        .max_w(box_w)
986                        .border(Border::Rounded)
987                        .border_fg(border_color)
988                        .bg(surface)
989                        .p(pad)
990                        .col(|ui| {
991                            for line in &lines {
992                                ui.text(line.as_str()).fg(text_color);
993                            }
994                        });
995                });
996            });
997        }
998    }
999
1000    /// Create a named group container for shared hover/focus styling.
1001    ///
1002    /// ```ignore
1003    /// ui.group("card").border(Border::Rounded)
1004    ///     .group_hover_bg(Color::Indexed(238))
1005    ///     .col(|ui| { ui.text("Hover anywhere"); });
1006    /// ```
1007    pub fn group(&mut self, name: &str) -> ContainerBuilder<'_> {
1008        // Materialize the name once; subsequent uses are cheap `Arc::clone`
1009        // pointer bumps. Closes #145 (double `to_string` allocation) and
1010        // completes the `Arc<str>` migration tracked by #139.
1011        self.rollback.group_count = self.rollback.group_count.saturating_add(1);
1012        let name_arc: std::sync::Arc<str> = std::sync::Arc::from(name);
1013        self.rollback
1014            .group_stack
1015            .push(std::sync::Arc::clone(&name_arc));
1016        self.container().group_name_arc(name_arc)
1017    }
1018
1019    /// Create a container with a fluent builder.
1020    ///
1021    /// Use this for borders, padding, grow, constraints, and titles. Chain
1022    /// configuration methods on the returned [`ContainerBuilder`], then call
1023    /// `.col()` or `.row()` to finalize.
1024    ///
1025    /// # Example
1026    ///
1027    /// ```no_run
1028    /// # slt::run(|ui: &mut slt::Context| {
1029    /// use slt::Border;
1030    /// ui.container()
1031    ///     .border(Border::Rounded)
1032    ///     .p(1)
1033    ///     .title("My Panel")
1034    ///     .col(|ui| {
1035    ///         ui.text("content");
1036    ///     });
1037    /// # });
1038    /// ```
1039    pub fn container(&mut self) -> ContainerBuilder<'_> {
1040        let border = self.theme.border;
1041        ContainerBuilder {
1042            ctx: self,
1043            gap: 0,
1044            row_gap: None,
1045            col_gap: None,
1046            align: Align::Start,
1047            align_self_value: None,
1048            justify: Justify::Start,
1049            border: None,
1050            border_sides: BorderSides::all(),
1051            border_style: Style::new().fg(border),
1052            bg: None,
1053            text_color: None,
1054            dark_bg: None,
1055            dark_border_style: None,
1056            group_hover_bg: None,
1057            group_hover_border_style: None,
1058            group_name: None,
1059            padding: Padding::default(),
1060            margin: Margin::default(),
1061            constraints: Constraints::default(),
1062            title: None,
1063            grow: 0,
1064            shrink_flag: false,
1065            wrap_flag: false,
1066            basis: None,
1067            scroll_offset: None,
1068            scroll_offset_x: None,
1069            theme_override: None,
1070        }
1071    }
1072
1073    /// Create a scrollable container. Handles wheel scroll and drag-to-scroll automatically.
1074    ///
1075    /// Pass a [`ScrollState`] to persist scroll position across frames. The state
1076    /// is updated in-place with the current scroll offset and bounds.
1077    ///
1078    /// # Example
1079    ///
1080    /// ```no_run
1081    /// # use slt::widgets::ScrollState;
1082    /// # slt::run(|ui: &mut slt::Context| {
1083    /// let mut scroll = ScrollState::new();
1084    /// ui.scrollable(&mut scroll).col(|ui| {
1085    ///     for i in 0..100 {
1086    ///         ui.text(format!("Line {i}"));
1087    ///     }
1088    /// });
1089    /// # });
1090    /// ```
1091    pub fn scrollable(&mut self, state: &mut ScrollState) -> ContainerBuilder<'_> {
1092        let index = self.rollback.scroll_count;
1093        self.rollback.scroll_count += 1;
1094        // #247: the previous frame recorded the scroll axis (`is_horizontal`)
1095        // because this binding runs before `.row()` / `.col()` is known. Bind
1096        // the matching axis so a horizontal scrollable updates `offset_x` while
1097        // a vertical one keeps the byte-identical `offset` path.
1098        let mut is_horizontal = false;
1099        if let Some(&(content, viewport, horizontal)) = self.prev_scroll_infos.get(index) {
1100            is_horizontal = horizontal;
1101            let max = content.saturating_sub(viewport) as usize;
1102            if horizontal {
1103                state.set_bounds_x(content, viewport);
1104                state.offset_x = state.offset_x.min(max);
1105            } else {
1106                state.set_bounds(content, viewport);
1107                state.offset = state.offset.min(max);
1108            }
1109        }
1110
1111        let next_id = self.rollback.interaction_count;
1112        if let Some(rect) = self.prev_hit_map.get(next_id).copied() {
1113            let inner_rects: Vec<Rect> = self
1114                .prev_scroll_rects
1115                .iter()
1116                .enumerate()
1117                .filter(|&(j, sr)| {
1118                    j != index
1119                        && sr.width > 0
1120                        && sr.height > 0
1121                        && sr.x >= rect.x
1122                        && sr.right() <= rect.right()
1123                        && sr.y >= rect.y
1124                        && sr.bottom() <= rect.bottom()
1125                })
1126                .map(|(_, sr)| *sr)
1127                .collect();
1128            self.auto_scroll_nested(&rect, state, &inner_rects, is_horizontal);
1129        }
1130
1131        // Carry both axis offsets; the tree builder applies the one matching
1132        // the finalizing `.row()` / `.col()` direction (#247).
1133        let mut builder = self.container().scroll_offset(state.offset as u32);
1134        builder.scroll_offset_x = Some(state.offset_x as u32);
1135        builder
1136    }
1137
1138    /// Scrollable column container — shortcut for
1139    /// `scrollable(state).grow(1).col(f)`.
1140    ///
1141    /// This is the form used by nearly every scrollable view: a vertical
1142    /// list that fills its parent and wheels through its own content. Use
1143    /// the explicit [`Context::scrollable`] builder when you need custom
1144    /// `grow`, borders, padding, or a scrollbar alongside.
1145    ///
1146    /// # Example
1147    ///
1148    /// ```no_run
1149    /// # use slt::widgets::ScrollState;
1150    /// # slt::run(|ui: &mut slt::Context| {
1151    /// let mut scroll = ScrollState::new();
1152    /// ui.scroll_col(&mut scroll, |ui| {
1153    ///     for i in 0..100 {
1154    ///         ui.text(format!("Line {i}"));
1155    ///     }
1156    /// });
1157    /// # });
1158    /// ```
1159    pub fn scroll_col(
1160        &mut self,
1161        state: &mut ScrollState,
1162        f: impl FnOnce(&mut Context),
1163    ) -> Response {
1164        self.scrollable(state).grow(1).col(f)
1165    }
1166
1167    /// Scrollable row container — shortcut for
1168    /// `scrollable(state).grow(1).row(f)`.
1169    ///
1170    /// Lays children out left-to-right and scrolls **horizontally** when their
1171    /// combined width exceeds the viewport: useful for timelines, kanban
1172    /// boards, wide tables, Gantt strips, and long single-line log entries
1173    /// (#247). The horizontal axis is driven by
1174    /// [`ScrollState::scroll_left`] / [`ScrollState::scroll_right`], native
1175    /// horizontal mouse wheel, and shift+wheel. Nest a `scroll_row` inside a
1176    /// [`scroll_col`](Self::scroll_col) to scroll both axes.
1177    ///
1178    /// # Example
1179    ///
1180    /// ```no_run
1181    /// # use slt::widgets::ScrollState;
1182    /// # slt::run(|ui: &mut slt::Context| {
1183    /// let mut scroll = ScrollState::new();
1184    /// ui.scroll_row(&mut scroll, |ui| {
1185    ///     for i in 0..40 {
1186    ///         ui.text(format!("col-{i:02}  "));
1187    ///     }
1188    /// });
1189    /// # });
1190    /// ```
1191    pub fn scroll_row(
1192        &mut self,
1193        state: &mut ScrollState,
1194        f: impl FnOnce(&mut Context),
1195    ) -> Response {
1196        self.scrollable(state).grow(1).row(f)
1197    }
1198
1199    /// Render a scrollbar track for a [`ScrollState`].
1200    ///
1201    /// Displays a track (`│`) with a proportional thumb (`█`). The thumb size
1202    /// and position are calculated from the scroll state's content height,
1203    /// viewport height, and current offset.
1204    ///
1205    /// Typically placed beside a `scrollable()` container in a `row()`:
1206    /// ```no_run
1207    /// # use slt::widgets::ScrollState;
1208    /// # slt::run(|ui: &mut slt::Context| {
1209    /// let mut scroll = ScrollState::new();
1210    /// ui.row(|ui| {
1211    ///     ui.scrollable(&mut scroll).grow(1).col(|ui| {
1212    ///         for i in 0..100 { ui.text(format!("Line {i}")); }
1213    ///     });
1214    ///     ui.scrollbar(&mut scroll);
1215    /// });
1216    /// # });
1217    /// ```
1218    ///
1219    /// # Interaction (since 0.21.0)
1220    ///
1221    /// The bar is a real input surface, mirroring `split_pane`'s drag handle:
1222    ///
1223    /// - **Click-to-jump on the track:** a left mouse-down inside the track but
1224    ///   outside the thumb jumps `state.offset` so the clicked row maps
1225    ///   proportionally to the content (top cell → offset 0, bottom cell →
1226    ///   `max_offset`).
1227    /// - **Drag-to-scroll on the thumb:** a left mouse-down on the thumb sets
1228    ///   [`ScrollState::dragging`]; subsequent drag events scroll proportionally
1229    ///   to the cursor's y within the track (even when the cursor leaves the
1230    ///   track on the x-axis); mouse-up clears `dragging`.
1231    ///
1232    /// Only the mouse events the bar acts on are consumed, so wheel scrolling
1233    /// over a sibling [`scrollable`](Self::scrollable) keeps working unchanged.
1234    /// Like every mouse handler the bar is inert while a modal is active and
1235    /// the bar is not inside it.
1236    ///
1237    /// # Returns
1238    ///
1239    /// A [`Response`] whose hit-test rect covers the scrollbar track — it is
1240    /// the track container's own interaction response, so `.clicked`,
1241    /// `.hovered`, and `.rect` are populated for the track region. `.changed`
1242    /// is `true` on a frame where a scrollbar interaction moved the offset.
1243    /// When the content fits the viewport nothing is rendered and
1244    /// [`Response::none()`] is returned. Prior to v0.21.0 the receiver was
1245    /// `&ScrollState`; pass `&mut scroll` instead.
1246    pub fn scrollbar(&mut self, state: &mut ScrollState) -> Response {
1247        let vh = state.viewport_height();
1248        let ch = state.content_height();
1249        if vh == 0 || ch <= vh {
1250            // No overflow: render nothing, consume nothing, leave drag state
1251            // untouched. Matches the pre-interaction behavior exactly.
1252            return Response::none();
1253        }
1254
1255        let track_height = vh;
1256        let thumb_height = ((vh as f64 * vh as f64 / ch as f64).ceil() as u32).max(1);
1257        let max_offset = ch.saturating_sub(vh);
1258
1259        // The upcoming `self.container()…col()` allocates the next interaction
1260        // slot, so its id is the current `interaction_count`. We hit-test
1261        // against THAT slot's rect from the previous frame, exactly as
1262        // `scrollable()` and `consume_split_pane_drag` do.
1263        let track_id = self.rollback.interaction_count;
1264        let thumb_pos =
1265            Self::scrollbar_thumb_pos(state.offset, max_offset, track_height, thumb_height);
1266        let changed = if let Some(rect) = self.prev_hit_map.get(track_id).copied() {
1267            self.handle_scrollbar_drag(rect, state, thumb_pos, thumb_height, max_offset)
1268        } else {
1269            false
1270        };
1271
1272        // Recompute the thumb position AFTER handling so the same frame's draw
1273        // reflects an offset moved by a click/drag this frame.
1274        let thumb_pos =
1275            Self::scrollbar_thumb_pos(state.offset, max_offset, track_height, thumb_height);
1276
1277        let theme = self.theme;
1278        const THUMB: &str = "█";
1279        const TRACK: &str = "│";
1280
1281        // The track container carries its own interaction slot (every
1282        // `col`/`row` reserves one), so its `Response` is the hit-test rect
1283        // for click-to-jump — no separate `interaction()` call is needed.
1284        let mut response = self.container().w(1).h(track_height).col(|ui| {
1285            for i in 0..track_height {
1286                if i >= thumb_pos && i < thumb_pos + thumb_height {
1287                    ui.styled(THUMB, Style::new().fg(theme.primary));
1288                } else {
1289                    ui.styled(TRACK, Style::new().fg(theme.text_dim).dim());
1290                }
1291            }
1292        });
1293        response.changed = changed;
1294        response
1295    }
1296
1297    /// Map a scroll `offset` to the thumb's top row within the track.
1298    ///
1299    /// Pure helper shared by the render path and the interaction path so both
1300    /// agree on where the thumb sits.
1301    fn scrollbar_thumb_pos(
1302        offset: usize,
1303        max_offset: u32,
1304        track_height: u32,
1305        thumb_height: u32,
1306    ) -> u32 {
1307        if max_offset == 0 {
1308            0
1309        } else {
1310            let travel = track_height.saturating_sub(thumb_height);
1311            ((offset as f64 / max_offset as f64) * travel as f64).round() as u32
1312        }
1313    }
1314
1315    /// Map a cursor row `y` (absolute) to a clamped scroll offset for the
1316    /// track rect at `track_y` with height `track_h`.
1317    ///
1318    /// The thumb is centered on the cursor: the cursor row relative to the
1319    /// track maps to the thumb top (minus half the thumb), which then maps
1320    /// linearly onto `[0, max_offset]`. The result is always in
1321    /// `[0, max_offset]` and monotonically non-decreasing in `y`. Extracted
1322    /// as an associated function so it is `proptest`-able without driving a
1323    /// full frame.
1324    pub(crate) fn scrollbar_offset_for_y(
1325        y: u32,
1326        track_y: u32,
1327        track_h: u32,
1328        thumb_height: u32,
1329        max_offset: u32,
1330    ) -> usize {
1331        let travel = track_h.saturating_sub(thumb_height);
1332        if travel == 0 {
1333            return 0;
1334        }
1335        let rel = y.saturating_sub(track_y).min(track_h.saturating_sub(1));
1336        let thumb_top = rel.saturating_sub(thumb_height / 2).min(travel);
1337        ((thumb_top as f64 / travel as f64) * max_offset as f64).round() as usize
1338    }
1339
1340    /// Hit-test the previous-frame track `rect` against this frame's mouse
1341    /// events and apply click-to-jump / thumb-drag to `state`.
1342    ///
1343    /// Returns `true` if the offset moved. Mirrors `consume_split_pane_drag`:
1344    /// snapshots the unconsumed mouse events, mutates `state`, then consumes
1345    /// only the events it acted on so wheel scroll on a sibling container is
1346    /// never double-counted.
1347    fn handle_scrollbar_drag(
1348        &mut self,
1349        rect: Rect,
1350        state: &mut ScrollState,
1351        thumb_pos: u32,
1352        thumb_height: u32,
1353        max_offset: u32,
1354    ) -> bool {
1355        // Modal suppression: while a modal is active and the bar is not inside
1356        // an overlay, the bar is inert — consistent with `mouse_down`'s guard.
1357        if (self.rollback.modal_active || self.prev_modal_active)
1358            && self.rollback.overlay_depth == 0
1359        {
1360            return false;
1361        }
1362        if rect.width == 0 || rect.height == 0 {
1363            return false;
1364        }
1365
1366        // Snapshot so `consume_indices` (mutable borrow) can run after the loop.
1367        // `MouseKind` is not `Copy`, so clone it (mirrors `consume_split_pane_drag`).
1368        let events: Vec<(usize, MouseKind, u32, u32)> = self
1369            .events
1370            .iter()
1371            .enumerate()
1372            .filter_map(|(i, e)| match e {
1373                Event::Mouse(m) if !self.consumed[i] => Some((i, m.kind.clone(), m.x, m.y)),
1374                _ => None,
1375            })
1376            .collect();
1377
1378        let track_y = rect.y;
1379        let track_h = rect.height;
1380        let thumb_top = track_y + thumb_pos;
1381        let thumb_bottom = thumb_top + thumb_height;
1382
1383        let mut consumed: Vec<usize> = Vec::new();
1384        let mut changed = false;
1385        for (i, kind, mx, my) in events {
1386            let in_track = mx >= rect.x && mx < rect.right() && my >= track_y && my < rect.bottom();
1387            match kind {
1388                MouseKind::Down(MouseButton::Left) if in_track => {
1389                    let on_thumb = my >= thumb_top && my < thumb_bottom;
1390                    if on_thumb {
1391                        // Grab the thumb; offset only moves on subsequent drags.
1392                        state.dragging = true;
1393                    } else {
1394                        // Click-to-jump on the track.
1395                        let before = state.offset;
1396                        state.set_offset(Self::scrollbar_offset_for_y(
1397                            my,
1398                            track_y,
1399                            track_h,
1400                            thumb_height,
1401                            max_offset,
1402                        ));
1403                        changed |= state.offset != before;
1404                    }
1405                    consumed.push(i);
1406                }
1407                MouseKind::Drag(MouseButton::Left) if state.dragging => {
1408                    // Drag tracks the cursor's y even outside the track on x.
1409                    let before = state.offset;
1410                    state.set_offset(Self::scrollbar_offset_for_y(
1411                        my,
1412                        track_y,
1413                        track_h,
1414                        thumb_height,
1415                        max_offset,
1416                    ));
1417                    changed |= state.offset != before;
1418                    consumed.push(i);
1419                }
1420                MouseKind::Up(MouseButton::Left) if state.dragging => {
1421                    state.dragging = false;
1422                    consumed.push(i);
1423                }
1424                _ => {}
1425            }
1426        }
1427        self.consume_indices(consumed);
1428        changed
1429    }
1430
1431    fn auto_scroll_nested(
1432        &mut self,
1433        rect: &Rect,
1434        state: &mut ScrollState,
1435        inner_scroll_rects: &[Rect],
1436        is_horizontal: bool,
1437    ) {
1438        let mut to_consume = Vec::new();
1439        let shift = crate::event::KeyModifiers::SHIFT;
1440        for (i, mouse) in self.mouse_events_in_rect(*rect) {
1441            let in_inner = inner_scroll_rects.iter().any(|sr| {
1442                mouse.x >= sr.x && mouse.x < sr.right() && mouse.y >= sr.y && mouse.y < sr.bottom()
1443            });
1444            if in_inner {
1445                continue;
1446            }
1447
1448            let delta = self.scroll_lines_per_event as usize;
1449            if is_horizontal {
1450                // #247: a horizontal scrollable consumes native horizontal wheel
1451                // events (`ScrollLeft` / `ScrollRight`) and shift+vertical-wheel
1452                // (the common terminal convention for sideways scroll on a
1453                // mouse with only a vertical wheel).
1454                let shifted = mouse.modifiers.contains(shift);
1455                match mouse.kind {
1456                    MouseKind::ScrollLeft => {
1457                        state.scroll_left(delta);
1458                        to_consume.push(i);
1459                    }
1460                    MouseKind::ScrollRight => {
1461                        state.scroll_right(delta);
1462                        to_consume.push(i);
1463                    }
1464                    MouseKind::ScrollUp if shifted => {
1465                        state.scroll_left(delta);
1466                        to_consume.push(i);
1467                    }
1468                    MouseKind::ScrollDown if shifted => {
1469                        state.scroll_right(delta);
1470                        to_consume.push(i);
1471                    }
1472                    _ => {}
1473                }
1474            } else {
1475                match mouse.kind {
1476                    MouseKind::ScrollUp => {
1477                        state.scroll_up(delta);
1478                        to_consume.push(i);
1479                    }
1480                    MouseKind::ScrollDown => {
1481                        state.scroll_down(delta);
1482                        to_consume.push(i);
1483                    }
1484                    MouseKind::Drag(MouseButton::Left) => {}
1485                    _ => {}
1486                }
1487            }
1488        }
1489        self.consume_indices(to_consume);
1490    }
1491
1492    /// Shortcut for `container().border(border)`.
1493    ///
1494    /// Returns a [`ContainerBuilder`] pre-configured with the given border style.
1495    pub fn bordered(&mut self, border: Border) -> ContainerBuilder<'_> {
1496        self.container()
1497            .border(border)
1498            .border_sides(BorderSides::all())
1499    }
1500
1501    fn push_container(
1502        &mut self,
1503        direction: Direction,
1504        gap: u32,
1505        f: impl FnOnce(&mut Context),
1506    ) -> Response {
1507        let interaction_id = self.next_interaction_id();
1508        let border = self.theme.border;
1509
1510        self.commands
1511            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1512                direction,
1513                // `BeginContainerArgs::gap` is signed since #222; this helper's
1514                // public `u32` callers (`row`/`col_gap`/…) never overlap.
1515                gap: gap as i32,
1516                align: Align::Start,
1517                align_self: None,
1518                justify: Justify::Start,
1519                border: None,
1520                border_sides: BorderSides::all(),
1521                border_style: Style::new().fg(border),
1522                bg_color: None,
1523                padding: Padding::default(),
1524                margin: Margin::default(),
1525                constraints: Constraints::default(),
1526                title: None,
1527                grow: 0,
1528                group_name: None,
1529            })));
1530        self.rollback.text_color_stack.push(None);
1531        f(self);
1532        self.rollback.text_color_stack.pop();
1533        self.commands.push(Command::EndContainer);
1534        self.rollback.last_text_idx = None;
1535
1536        self.response_for(interaction_id)
1537    }
1538
1539    pub(crate) fn response_for(&self, interaction_id: usize) -> Response {
1540        if (self.rollback.modal_active || self.prev_modal_active)
1541            && self.rollback.overlay_depth == 0
1542        {
1543            return Response::none();
1544        }
1545        if let Some(rect) = self.prev_hit_map.get(interaction_id) {
1546            let clicked = self
1547                .click_pos
1548                .map(|(mx, my)| {
1549                    mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1550                })
1551                .unwrap_or(false);
1552            // Issue #208: right-click hit-test uses the same rect as the
1553            // existing left-click logic. Keeps modal suppression (the early
1554            // return above) consistent for both buttons.
1555            let right_clicked = self
1556                .right_click_pos
1557                .map(|(mx, my)| {
1558                    mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1559                })
1560                .unwrap_or(false);
1561            // v0.21.1: double-click hit-test mirrors the left-click logic. The
1562            // second click of a double also reports `clicked`, so callers that
1563            // only check `clicked` are unaffected.
1564            let double_clicked = self
1565                .double_click_pos
1566                .map(|(mx, my)| {
1567                    mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1568                })
1569                .unwrap_or(false);
1570            let hovered = self
1571                .mouse_pos
1572                .map(|(mx, my)| {
1573                    mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1574                })
1575                .unwrap_or(false);
1576            // v0.21.1: per-widget wheel delta is hover-gated — only the widget
1577            // under the cursor when the wheel moved sees a non-zero delta.
1578            let scroll_delta = self
1579                .scroll_pos
1580                .map(|(mx, my)| {
1581                    if mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom() {
1582                        self.scroll_delta_frame
1583                    } else {
1584                        0
1585                    }
1586                })
1587                .unwrap_or(0);
1588            Response {
1589                clicked,
1590                right_clicked,
1591                double_clicked,
1592                hovered,
1593                changed: false,
1594                focused: false,
1595                gained_focus: false,
1596                lost_focus: false,
1597                submitted: false,
1598                scroll_delta,
1599                rect: *rect,
1600            }
1601        } else {
1602            Response::none()
1603        }
1604    }
1605
1606    /// Returns true if the named group is currently hovered by the mouse.
1607    ///
1608    /// Uses the per-frame `hovered_groups` `HashSet` populated by
1609    /// `Context::build_hovered_groups()`; turns the previous O(n) scan over
1610    /// `prev_group_rects` into an O(1) lookup. Closes the cache half of
1611    /// #136 / #139.
1612    pub fn is_group_hovered(&self, name: &str) -> bool {
1613        if self.mouse_pos.is_none() {
1614            return false;
1615        }
1616        // `HashSet<Arc<str>>::contains` accepts `&str` via `Borrow<str>`, so
1617        // there is no allocation on the hot path.
1618        self.hovered_groups.contains(name)
1619    }
1620
1621    /// Returns true if the named group contains the currently focused widget.
1622    pub fn is_group_focused(&self, name: &str) -> bool {
1623        if self.prev_focus_count == 0 {
1624            return false;
1625        }
1626        let focused_index = self.focus_index % self.prev_focus_count;
1627        self.prev_focus_groups
1628            .get(focused_index)
1629            .and_then(|group| group.as_deref())
1630            .map(|group| group == name)
1631            .unwrap_or(false)
1632    }
1633
1634    /// Render a form that groups input fields vertically.
1635    ///
1636    /// Wraps the fields in a column container and forwards the form state
1637    /// to the closure. Use [`Context::form_field`] inside the closure to
1638    /// render each field with label + input + error display.
1639    ///
1640    /// Submission is driven by [`Context::form_submit`]. Per-field validators
1641    /// attached via [`FormField::validate`](crate::widgets::FormField::validate)
1642    /// run automatically inside [`Context::form_field`]; aggregate validity is
1643    /// read via [`FormState::is_valid`](crate::widgets::FormState::is_valid).
1644    pub fn form(
1645        &mut self,
1646        state: &mut FormState,
1647        f: impl FnOnce(&mut Context, &mut FormState),
1648    ) -> &mut Self {
1649        let _ = self.col(|ui| {
1650            f(ui, state);
1651        });
1652        self
1653    }
1654
1655    /// Render a single form field with label and input, running its validators.
1656    ///
1657    /// The field's own validators (attached via
1658    /// [`FormField::validate`](crate::widgets::FormField::validate)) run
1659    /// automatically according to its
1660    /// [`trigger`](crate::widgets::FormField::trigger):
1661    /// [`OnChange`](crate::widgets::ValidateTrigger::OnChange) re-validates on
1662    /// each keystroke, [`OnBlur`](crate::widgets::ValidateTrigger::OnBlur)
1663    /// (the default) re-validates when focus leaves the field, and
1664    /// [`Manual`](crate::widgets::ValidateTrigger::Manual) never auto-validates.
1665    /// The resulting [`error`](crate::widgets::FormField::error) is shown below
1666    /// the input.
1667    ///
1668    /// With the `async` feature, any in-flight
1669    /// [`validate_async`](crate::widgets::FormField::validate_async) check is
1670    /// polled each frame and its result surfaced as the field error.
1671    ///
1672    /// # Example
1673    ///
1674    /// ```no_run
1675    /// # use slt::widgets::{FormField, validators};
1676    /// # slt::run(|ui: &mut slt::Context| {
1677    /// let mut field = FormField::new("Email")
1678    ///     .validate(validators::email()); // OnBlur by default
1679    /// ui.form_field(&mut field);
1680    /// # });
1681    /// ```
1682    pub fn form_field(&mut self, field: &mut FormField) -> &mut Self {
1683        #[cfg(feature = "async")]
1684        let async_resolved = field.poll_async();
1685        let mut resp = Response::none();
1686        let _ = self.col(|ui| {
1687            ui.styled(field.label.as_str(), Style::new().bold().fg(ui.theme.text));
1688            resp = ui.text_input(&mut field.input);
1689            if let Some(error) = field.error.as_deref() {
1690                ui.styled(error, Style::new().dim().fg(ui.theme.error));
1691            }
1692        });
1693        #[cfg(feature = "async")]
1694        let _ = async_resolved;
1695        // `text_input` reports `.focused` reliably but does not yet populate
1696        // `.lost_focus` on its container-assembled response, so blur is derived
1697        // from the focus edge tracked on the field itself.
1698        let lost_focus = field.observe_focus(resp.focused);
1699        match field.trigger {
1700            ValidateTrigger::OnChange if resp.changed => {
1701                field.run_validators();
1702            }
1703            ValidateTrigger::OnBlur if lost_focus => {
1704                field.run_validators();
1705            }
1706            _ => {}
1707        }
1708        self
1709    }
1710
1711    /// Render a primary-styled submit button.
1712    ///
1713    /// Distinguishes the submit affordance from incidental buttons in the
1714    /// same form by rendering in the theme's primary color (via
1715    /// [`ButtonVariant::Primary`]). Returns `true` in `.clicked` when the
1716    /// user clicks it, presses Enter while focused, or activates it with
1717    /// Space. Pair with
1718    /// [`FormState::validate_all`](crate::widgets::FormState::validate_all) /
1719    /// [`FormState::is_valid`](crate::widgets::FormState::is_valid) to gate
1720    /// submission on all fields being valid.
1721    pub fn form_submit(&mut self, label: impl Into<String>) -> Response {
1722        self.button_with(label, ButtonVariant::Primary)
1723    }
1724}
1725
1726#[cfg(test)]
1727mod scrollbar_tests {
1728    use super::*;
1729
1730    // ── #249: scrollbar() pixel ↔ offset mapping (pure helpers) ──────────
1731
1732    #[test]
1733    fn offset_for_y_top_cell_maps_to_zero() {
1734        // Track at y=0..20, thumb 4 tall → travel 16, max_offset 80.
1735        let off = Context::scrollbar_offset_for_y(0, 0, 20, 4, 80);
1736        assert_eq!(off, 0);
1737    }
1738
1739    #[test]
1740    fn offset_for_y_bottom_cell_maps_to_max() {
1741        // Clicking the last track cell jumps to the bottom of the content.
1742        let off = Context::scrollbar_offset_for_y(19, 0, 20, 4, 80);
1743        assert_eq!(off, 80);
1744    }
1745
1746    #[test]
1747    fn offset_for_y_middle_is_near_half_max() {
1748        // Vertical midpoint → ~max_offset / 2 (within a few rows of slop).
1749        let off = Context::scrollbar_offset_for_y(10, 0, 20, 4, 80) as i64;
1750        assert!((off - 40).abs() <= 5, "midpoint offset {off} not near 40");
1751    }
1752
1753    #[test]
1754    fn offset_for_y_respects_track_origin() {
1755        // Track offset by track_y=3; the top cell of that track yields 0.
1756        let off = Context::scrollbar_offset_for_y(3, 3, 20, 4, 80);
1757        assert_eq!(off, 0);
1758    }
1759
1760    #[test]
1761    fn offset_for_y_zero_travel_is_zero() {
1762        // Thumb fills the whole track → nowhere to move → always 0.
1763        let off = Context::scrollbar_offset_for_y(7, 0, 5, 5, 0);
1764        assert_eq!(off, 0);
1765    }
1766
1767    #[test]
1768    fn thumb_pos_endpoints() {
1769        // offset 0 → thumb at top; offset == max → thumb at travel.
1770        assert_eq!(Context::scrollbar_thumb_pos(0, 80, 20, 4), 0);
1771        assert_eq!(Context::scrollbar_thumb_pos(80, 80, 20, 4), 16);
1772    }
1773
1774    proptest::proptest! {
1775        /// `scrollbar_offset_for_y` is always in `[0, max_offset]` and
1776        /// monotonically non-decreasing in the cursor row.
1777        #[test]
1778        fn offset_for_y_is_clamped_and_monotonic(
1779            content_height in 2u32..500,
1780            viewport_height in 1u32..200,
1781            y in 0u32..600,
1782        ) {
1783            // Derive the same track / thumb geometry the widget uses.
1784            proptest::prop_assume!(content_height > viewport_height);
1785            let track_h = viewport_height;
1786            let thumb_height = ((viewport_height as f64 * viewport_height as f64
1787                / content_height as f64)
1788                .ceil() as u32)
1789                .max(1);
1790            let max_offset = content_height.saturating_sub(viewport_height);
1791
1792            let off = Context::scrollbar_offset_for_y(y, 0, track_h, thumb_height, max_offset);
1793            proptest::prop_assert!(off <= max_offset as usize);
1794
1795            // Monotonic: a strictly lower cursor row never yields a smaller offset.
1796            let off_lower = Context::scrollbar_offset_for_y(
1797                y.saturating_add(1),
1798                0,
1799                track_h,
1800                thumb_height,
1801                max_offset,
1802            );
1803            proptest::prop_assert!(off_lower >= off);
1804        }
1805    }
1806}