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