Skip to main content

slt/context/widgets_display/
layout.rs

1use super::*;
2use std::sync::OnceLock;
3
4static SEP_LINE: OnceLock<String> = OnceLock::new();
5
6fn sep_line() -> &'static str {
7    SEP_LINE.get_or_init(|| "─".repeat(200))
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    pub fn separator(&mut self) -> &mut Self {
163        // The cached `sep_line()` is much wider than any reasonable terminal,
164        // so the cross-axis (column-direction) clip in `Buffer::set_string`
165        // truncates the trailing chars. Keeping `grow = 0` means a column
166        // layout doesn't stretch the separator vertically, and `truncate =
167        // false` avoids the ellipsis fallback which would otherwise replace
168        // the last cell with `…`.
169        self.commands.push(Command::Text {
170            content: sep_line().to_owned(),
171            cursor_offset: None,
172            style: Style::new().fg(self.theme.border).dim(),
173            grow: 0,
174            align: Align::Start,
175            wrap: false,
176            truncate: false,
177            margin: Margin::default(),
178            constraints: Constraints::default(),
179        });
180        self.rollback.last_text_idx = Some(self.commands.len() - 1);
181        self
182    }
183
184    /// Render a horizontal separator line with a custom color.
185    pub fn separator_colored(&mut self, color: Color) -> &mut Self {
186        self.commands.push(Command::Text {
187            content: sep_line().to_owned(),
188            cursor_offset: None,
189            style: Style::new().fg(color),
190            grow: 0,
191            align: Align::Start,
192            wrap: false,
193            truncate: false,
194            margin: Margin::default(),
195            constraints: Constraints::default(),
196        });
197        self.rollback.last_text_idx = Some(self.commands.len() - 1);
198        self
199    }
200
201    /// Conditionally render content when the named screen is active.
202    ///
203    /// Each screen gets an isolated hook segment — `use_state` / `use_memo`
204    /// calls inside one screen do not interfere with another screen's hooks,
205    /// even when you switch between screens across frames.
206    ///
207    /// Focus state is saved and restored per screen automatically.
208    ///
209    /// # Example
210    ///
211    /// ```no_run
212    /// # let mut screens = slt::ScreenState::new("main");
213    /// # slt::run(|ui| {
214    /// ui.screen("main", &mut screens, |ui| {
215    ///     ui.text("Main screen");
216    /// });
217    /// # });
218    /// ```
219    pub fn screen(&mut self, name: &str, screens: &mut ScreenState, f: impl FnOnce(&mut Context)) {
220        // Look up (or create) this screen's reserved hook segment.
221        //
222        // Cache-hit path is the steady state — every frame after the first.
223        // Avoid the unconditional `name.to_string()` `entry()` allocation by
224        // checking first via `&str` lookup. Only the first frame for a
225        // given screen pays the `to_string()` cost. Closes #134 (Option B).
226        let (seg_start, seg_count) = if let Some(&v) = self.screen_hook_map.get(name) {
227            v
228        } else {
229            let v = (self.hook_states.len(), 0);
230            self.screen_hook_map.insert(name.to_string(), v);
231            v
232        };
233
234        let is_active = screens.current() == name;
235
236        if is_active {
237            // Save outer focus, restore this screen's focus
238            let outer_focus_index = self.focus_index;
239            let (saved_focus_idx, _saved_focus_count) = screens.restore_focus(name);
240            self.focus_index = saved_focus_idx;
241
242            // Set hook cursor to this screen's segment start
243            self.rollback.hook_cursor = seg_start;
244            let focus_count_before = self.rollback.focus_count;
245
246            // Execute the screen's closure
247            f(self);
248
249            // Record the hook count for this screen.
250            //
251            // The first-frame path above already inserted an owned `String`
252            // key for this screen; subsequent frames reuse it. Locate that
253            // existing slot via `&str` and overwrite the value in place,
254            // avoiding a second `to_string()` allocation per active frame.
255            let hooks_used = self.rollback.hook_cursor - seg_start;
256            if let Some(slot) = self.screen_hook_map.get_mut(name) {
257                *slot = (seg_start, hooks_used);
258            } else {
259                self.screen_hook_map
260                    .insert(name.to_string(), (seg_start, hooks_used));
261            }
262
263            // Save this screen's focus state
264            let screen_focus_count = self.rollback.focus_count - focus_count_before;
265            screens.save_focus(name, self.focus_index, screen_focus_count);
266
267            // Restore outer focus
268            self.focus_index = outer_focus_index;
269        } else {
270            // Skip: advance hook cursor past the reserved segment
271            if seg_count > 0 && seg_start >= self.rollback.hook_cursor {
272                self.rollback.hook_cursor = seg_start + seg_count;
273            }
274        }
275    }
276
277    /// Create a vertical (column) container.
278    ///
279    /// Children are stacked top-to-bottom. Returns a [`Response`] with
280    /// click/hover state for the container area.
281    ///
282    /// # Example
283    ///
284    /// ```no_run
285    /// # slt::run(|ui: &mut slt::Context| {
286    /// ui.col(|ui| {
287    ///     ui.text("line one");
288    ///     ui.text("line two");
289    /// });
290    /// # });
291    /// ```
292    pub fn col(&mut self, f: impl FnOnce(&mut Context)) -> Response {
293        self.push_container(Direction::Column, 0, f)
294    }
295
296    /// Create a vertical (column) container with a gap between children.
297    ///
298    /// `gap` is the number of blank rows inserted between each child.
299    ///
300    /// **Deprecated since 0.20.1**: the name collides with
301    /// [`ContainerBuilder::col_gap`], which sets the *row-finalize* main-axis
302    /// gap (Tailwind `gap-x` axis convention) and so means the opposite thing.
303    /// Use `ui.container().gap(n).col(f)` instead — same output, no collision.
304    #[deprecated(
305        since = "0.20.1",
306        note = "Use `ui.container().gap(n).col(f)` instead — same output, no name collision with `ContainerBuilder::col_gap`."
307    )]
308    pub fn col_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
309        self.push_container(Direction::Column, gap, f)
310    }
311
312    /// Create a horizontal (row) container.
313    ///
314    /// Children are placed left-to-right. Returns a [`Response`] with
315    /// click/hover state for the container area.
316    ///
317    /// # Example
318    ///
319    /// ```no_run
320    /// # slt::run(|ui: &mut slt::Context| {
321    /// ui.row(|ui| {
322    ///     ui.text("left");
323    ///     ui.spacer();
324    ///     ui.text("right");
325    /// });
326    /// # });
327    /// ```
328    pub fn row(&mut self, f: impl FnOnce(&mut Context)) -> Response {
329        self.push_container(Direction::Row, 0, f)
330    }
331
332    /// Create a horizontal (row) container with a gap between children.
333    ///
334    /// `gap` is the number of blank columns inserted between each child.
335    ///
336    /// **Deprecated since 0.20.1**: the name collides with
337    /// [`ContainerBuilder::row_gap`], which sets the *column-finalize*
338    /// main-axis gap (Tailwind `gap-y` axis convention) and so means the
339    /// opposite thing. Use `ui.container().gap(n).row(f)` instead — same
340    /// output, no collision.
341    #[deprecated(
342        since = "0.20.1",
343        note = "Use `ui.container().gap(n).row(f)` instead — same output, no name collision with `ContainerBuilder::row_gap`."
344    )]
345    pub fn row_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
346        self.push_container(Direction::Row, gap, f)
347    }
348
349    /// Render inline text with mixed styles on a single line.
350    ///
351    /// Unlike [`row`](Context::row), `line()` is designed for rich text —
352    /// children are rendered as continuous inline text without gaps.
353    ///
354    /// It intentionally returns `&mut Self` instead of [`Response`] so you can
355    /// keep chaining display-oriented modifiers after composing the inline run.
356    ///
357    /// # Example
358    ///
359    /// ```no_run
360    /// # use slt::Color;
361    /// # slt::run(|ui: &mut slt::Context| {
362    /// ui.line(|ui| {
363    ///     ui.text("Status: ");
364    ///     ui.text("Online").bold().fg(Color::Green);
365    /// });
366    /// # });
367    /// ```
368    pub fn line(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
369        let _ = self.push_container(Direction::Row, 0, f);
370        self
371    }
372
373    /// Render inline text with mixed styles, wrapping at word boundaries.
374    ///
375    /// Like [`line`](Context::line), but when the combined text exceeds
376    /// the container width it wraps across multiple lines while
377    /// preserving per-segment styles.
378    ///
379    /// # Example
380    ///
381    /// ```no_run
382    /// # use slt::{Color, Style};
383    /// # slt::run(|ui: &mut slt::Context| {
384    /// ui.line_wrap(|ui| {
385    ///     ui.text("This is a long ");
386    ///     ui.text("important").bold().fg(Color::Red);
387    ///     ui.text(" message that wraps across lines");
388    /// });
389    /// # });
390    /// ```
391    pub fn line_wrap(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
392        let start = self.commands.len();
393        f(self);
394        let has_link = self.commands[start..]
395            .iter()
396            .any(|cmd| matches!(cmd, Command::Link { .. }));
397
398        if has_link {
399            self.commands.insert(
400                start,
401                Command::BeginContainer(Box::new(BeginContainerArgs {
402                    direction: Direction::Row,
403                    gap: 0,
404                    align: Align::Start,
405                    align_self: None,
406                    justify: Justify::Start,
407                    border: None,
408                    border_sides: BorderSides::all(),
409                    border_style: Style::new(),
410                    bg_color: None,
411                    padding: Padding::default(),
412                    margin: Margin::default(),
413                    constraints: Constraints::default(),
414                    title: None,
415                    grow: 0,
416                    group_name: None,
417                })),
418            );
419            self.commands.push(Command::EndContainer);
420            self.rollback.last_text_idx = None;
421            return self;
422        }
423
424        let mut segments: Vec<(String, Style)> = Vec::new();
425        for cmd in self.commands.drain(start..) {
426            match cmd {
427                Command::Text { content, style, .. } => {
428                    segments.push((content, style));
429                }
430                Command::Link { text, style, .. } => {
431                    // Preserve link text with underline styling (URL lost in RichText,
432                    // but text is visible and wraps correctly)
433                    segments.push((text, style));
434                }
435                _ => {}
436            }
437        }
438        self.commands.push(Command::RichText {
439            segments,
440            wrap: true,
441            align: Align::Start,
442            margin: Margin::default(),
443            constraints: Constraints::default(),
444        });
445        self.rollback.last_text_idx = None;
446        self
447    }
448
449    /// Render content in a modal overlay with dimmed background.
450    ///
451    /// ```no_run
452    /// # let mut show = true;
453    /// # slt::run(|ui: &mut slt::Context| {
454    /// if show {
455    ///     ui.modal(|ui| {
456    ///         ui.text("Are you sure?");
457    ///         if ui.button("OK").clicked { show = false; }
458    ///     });
459    /// }
460    /// # });
461    /// ```
462    pub fn modal(&mut self, f: impl FnOnce(&mut Context)) -> Response {
463        // Default `modal()` preserves legacy behavior (tab_trap = false).
464        // `modal_with(ModalOptions::default(), ...)` opts into the WCAG 2.1
465        // SC 2.4.3 focus-trap default. This split keeps existing callers
466        // bit-identical until they migrate.
467        self.modal_with(ModalOptions { tab_trap: false }, f)
468    }
469
470    /// Render content in a modal overlay with configurable options.
471    ///
472    /// Like [`modal`](Self::modal), but accepts a [`ModalOptions`] struct.
473    /// Use this to opt into focus trapping (`tab_trap: true`) or future
474    /// modal flags without breaking the bare `modal()` API.
475    ///
476    /// When `opts.tab_trap` is `true`, focus cannot escape the modal's
477    /// focusable range — Tab/Shift+Tab keep cycling within the modal even
478    /// if [`Context::set_focus_index`] or a mouse click moved focus to a
479    /// background widget. WCAG 2.1 SC 2.4.3 (Focus Order) recommends
480    /// trapping focus inside modal dialogs.
481    ///
482    /// # Example
483    ///
484    /// ```no_run
485    /// # let mut show = true;
486    /// # slt::run(|ui: &mut slt::Context| {
487    /// if show {
488    ///     ui.modal_with(slt::context::ModalOptions { tab_trap: true }, |ui| {
489    ///         ui.text("Are you sure?");
490    ///         if ui.button("OK").clicked { show = false; }
491    ///     });
492    /// }
493    /// # });
494    /// ```
495    pub fn modal_with(&mut self, opts: ModalOptions, f: impl FnOnce(&mut Context)) -> Response {
496        let interaction_id = self.next_interaction_id();
497        self.commands.push(Command::BeginOverlay { modal: true });
498        self.rollback.overlay_depth += 1;
499        self.rollback.modal_active = true;
500        let modal_focus_start = self.rollback.focus_count;
501        self.rollback.modal_focus_start = modal_focus_start;
502
503        f(self);
504        let modal_focus_count = self.rollback.focus_count.saturating_sub(modal_focus_start);
505        self.rollback.modal_focus_count = modal_focus_count;
506
507        // Tab trap: when enabled, ensure `focus_index` lies in this frame's
508        // modal range `[start, start + count)`. If `set_focus_index` from a
509        // previous frame (or a stale state) left focus pointing at a
510        // background widget, clamp it to the first modal focusable so the
511        // next [`process_focus_keys`] tick cycles cleanly within the modal.
512        //
513        // WCAG 2.1 SC 2.4.3 (Focus Order) requirement: the user must not be
514        // able to navigate to content outside an active modal dialog.
515        if opts.tab_trap && modal_focus_count > 0 {
516            let lo = modal_focus_start;
517            let hi = lo.saturating_add(modal_focus_count);
518            if self.focus_index < lo || self.focus_index >= hi {
519                self.focus_index = lo;
520            }
521        }
522
523        self.rollback.overlay_depth = self.rollback.overlay_depth.saturating_sub(1);
524        self.commands.push(Command::EndOverlay);
525        self.rollback.last_text_idx = None;
526        self.response_for(interaction_id)
527    }
528
529    /// Render floating content without dimming the background.
530    pub fn overlay(&mut self, f: impl FnOnce(&mut Context)) -> Response {
531        let interaction_id = self.next_interaction_id();
532        self.commands.push(Command::BeginOverlay { modal: false });
533        self.rollback.overlay_depth += 1;
534        f(self);
535        self.rollback.overlay_depth = self.rollback.overlay_depth.saturating_sub(1);
536        self.commands.push(Command::EndOverlay);
537        self.rollback.last_text_idx = None;
538        self.response_for(interaction_id)
539    }
540
541    /// Render floating content anchored to one of the 9 compass positions.
542    ///
543    /// Wraps [`overlay`](Self::overlay) with a full-area column that pins the
544    /// content to the requested anchor via flexbox `align`/`justify`. The
545    /// inner column gets `grow(1)` so the wrapper consumes the screen, giving
546    /// `align`/`justify` room to push the content to the corner.
547    ///
548    /// ```no_run
549    /// # use slt::Anchor;
550    /// # slt::run(|ui: &mut slt::Context| {
551    /// ui.overlay_at(Anchor::TopRight, |ui| {
552    ///     ui.text("0:42").bold();
553    /// });
554    /// # });
555    /// ```
556    pub fn overlay_at(&mut self, anchor: Anchor, f: impl FnOnce(&mut Context)) -> Response {
557        self.overlay(|ui| {
558            let (align, justify) = anchor_to_align_justify(anchor);
559            let _ = ui.container().grow(1).align(align).justify(justify).col(f);
560        })
561    }
562
563    /// Render a modal overlay anchored to one of the 9 compass positions.
564    ///
565    /// Like [`modal`](Self::modal) but pinned to a corner / edge / center via
566    /// the same anchor wrapping as [`overlay_at`](Self::overlay_at).
567    pub fn modal_at(&mut self, anchor: Anchor, f: impl FnOnce(&mut Context)) -> Response {
568        self.modal(|ui| {
569            let (align, justify) = anchor_to_align_justify(anchor);
570            let _ = ui.container().grow(1).align(align).justify(justify).col(f);
571        })
572    }
573
574    /// Render `f` at `anchor` with cell offset `(dx, dy)` from the anchored edge.
575    ///
576    /// This is the SLT analog of CSS `position: absolute; top/right/bottom/left`,
577    /// or Flutter's `Positioned(top:, right:, ...)`. The 9-cell [`Anchor`]
578    /// chooses which edge to anchor to; `(dx, dy)` insets toward the center.
579    ///
580    /// # Sign convention
581    /// Positive `dx` / `dy` always inset toward the viewport center. So
582    /// `overlay_at_offset(Anchor::BottomRight, 2, 1, ...)` places the widget
583    /// 2 cells left and 1 cell up from the bottom-right corner.
584    ///
585    /// For [`Anchor::Center`] (and other centered axes) negative values shift
586    /// in the opposite direction — `(dx=-2, dy=-1)` shifts 2 cells left and 1
587    /// cell up. For corner / edge anchors, negative values would push the
588    /// content off-screen, so they are clamped to 0; use a different anchor
589    /// instead of negative offsets to escape an edge.
590    ///
591    /// # CSS analogy
592    /// ```text
593    /// CSS:    place-self: end end; bottom: 1px; right: 2px;
594    /// SLT:    overlay_at_offset(Anchor::BottomRight, 2, 1, |ui| { ... })
595    /// ```
596    ///
597    /// # Example
598    ///
599    /// ```no_run
600    /// # use slt::Anchor;
601    /// # slt::run(|ui: &mut slt::Context| {
602    /// // Inset corner badge — 2 cells from the right, 1 row from the bottom.
603    /// ui.overlay_at_offset(Anchor::BottomRight, 2, 1, |ui| {
604    ///     ui.text("v0.19.3").dim();
605    /// });
606    /// # });
607    /// ```
608    pub fn overlay_at_offset(
609        &mut self,
610        anchor: Anchor,
611        dx: i32,
612        dy: i32,
613        f: impl FnOnce(&mut Context),
614    ) -> Response {
615        self.overlay(|ui| {
616            let (align, justify) = anchor_to_align_justify(anchor);
617            let margin = anchor_offset_to_margin(anchor, dx, dy);
618            // Apply margin on the outer (grow=1) column so flexbox's parent
619            // (the synthetic overlay root) shrinks the column's area before
620            // align/justify pick a position. This avoids a wrapper container
621            // around `f`, which would expose a flexbox limitation where
622            // `Align::End` shifts the immediate child's `pos` but does not
623            // propagate the shift down to grandchildren.
624            let _ = ui
625                .container()
626                .grow(1)
627                .align(align)
628                .justify(justify)
629                .margin(margin)
630                .col(f);
631        })
632    }
633
634    /// Modal variant of [`overlay_at_offset`](Self::overlay_at_offset).
635    ///
636    /// Like [`modal_at`](Self::modal_at) but with a `(dx, dy)` cell inset
637    /// from the anchored edge. Positive values inset toward the center —
638    /// see [`overlay_at_offset`](Self::overlay_at_offset) for the full sign
639    /// convention.
640    ///
641    /// # Example
642    ///
643    /// ```no_run
644    /// # use slt::{Anchor, Border};
645    /// # slt::run(|ui: &mut slt::Context| {
646    /// ui.modal_at_offset(Anchor::TopRight, 2, 1, |ui| {
647    ///     ui.bordered(Border::Rounded).p(1).col(|ui| {
648    ///         ui.text("Saved!");
649    ///     });
650    /// });
651    /// # });
652    /// ```
653    pub fn modal_at_offset(
654        &mut self,
655        anchor: Anchor,
656        dx: i32,
657        dy: i32,
658        f: impl FnOnce(&mut Context),
659    ) -> Response {
660        self.modal(|ui| {
661            let (align, justify) = anchor_to_align_justify(anchor);
662            let margin = anchor_offset_to_margin(anchor, dx, dy);
663            // See `overlay_at_offset` for why margin lives on the outer
664            // grow-1 column rather than a wrapper around `f`.
665            let _ = ui
666                .container()
667                .grow(1)
668                .align(align)
669                .justify(justify)
670                .margin(margin)
671                .col(f);
672        })
673    }
674
675    /// Render a hover tooltip for the previously rendered interactive widget.
676    ///
677    /// Call this right after a widget or container response:
678    /// ```ignore
679    /// if ui.button("Save").clicked { save(); }
680    /// ui.tooltip("Save the current document to disk");
681    /// ```
682    pub fn tooltip(&mut self, text: impl Into<String>) {
683        let tooltip_text = text.into();
684        if tooltip_text.is_empty() {
685            return;
686        }
687        let last_interaction_id = self.rollback.interaction_count.saturating_sub(1);
688        let last_response = self.response_for(last_interaction_id);
689        if !last_response.hovered || last_response.rect.width == 0 || last_response.rect.height == 0
690        {
691            return;
692        }
693        let lines = wrap_tooltip_text(&tooltip_text, 38);
694        self.pending_tooltips.push(PendingTooltip {
695            anchor_rect: last_response.rect,
696            lines,
697        });
698    }
699
700    pub(crate) fn emit_pending_tooltips(&mut self) {
701        let tooltips = std::mem::take(&mut self.pending_tooltips);
702        if tooltips.is_empty() {
703            return;
704        }
705        let area_w = self.area_width;
706        let area_h = self.area_height;
707        let surface = self.theme.surface;
708        let border_color = self.theme.border;
709        let text_color = self.theme.surface_text;
710
711        for tooltip in tooltips {
712            let content_w = tooltip
713                .lines
714                .iter()
715                .map(|l| UnicodeWidthStr::width(l.as_str()) as u32)
716                .max()
717                .unwrap_or(0);
718            let box_w = content_w.saturating_add(4).min(area_w);
719            let box_h = (tooltip.lines.len() as u32).saturating_add(4).min(area_h);
720
721            let tooltip_x = tooltip.anchor_rect.x.min(area_w.saturating_sub(box_w));
722            let below_y = tooltip.anchor_rect.bottom();
723            let tooltip_y = if below_y.saturating_add(box_h) <= area_h {
724                below_y
725            } else {
726                tooltip.anchor_rect.y.saturating_sub(box_h)
727            };
728
729            let lines = tooltip.lines;
730            let pad = self.theme.spacing.xs();
731            let _ = self.overlay(|ui| {
732                let _ = ui.container().w(area_w).h(area_h).col(|ui| {
733                    let _ = ui
734                        .container()
735                        .ml(tooltip_x)
736                        .mt(tooltip_y)
737                        .max_w(box_w)
738                        .border(Border::Rounded)
739                        .border_fg(border_color)
740                        .bg(surface)
741                        .p(pad)
742                        .col(|ui| {
743                            for line in &lines {
744                                ui.text(line.as_str()).fg(text_color);
745                            }
746                        });
747                });
748            });
749        }
750    }
751
752    /// Create a named group container for shared hover/focus styling.
753    ///
754    /// ```ignore
755    /// ui.group("card").border(Border::Rounded)
756    ///     .group_hover_bg(Color::Indexed(238))
757    ///     .col(|ui| { ui.text("Hover anywhere"); });
758    /// ```
759    pub fn group(&mut self, name: &str) -> ContainerBuilder<'_> {
760        // Materialize the name once; subsequent uses are cheap `Arc::clone`
761        // pointer bumps. Closes #145 (double `to_string` allocation) and
762        // completes the `Arc<str>` migration tracked by #139.
763        self.rollback.group_count = self.rollback.group_count.saturating_add(1);
764        let name_arc: std::sync::Arc<str> = std::sync::Arc::from(name);
765        self.rollback
766            .group_stack
767            .push(std::sync::Arc::clone(&name_arc));
768        self.container().group_name_arc(name_arc)
769    }
770
771    /// Create a container with a fluent builder.
772    ///
773    /// Use this for borders, padding, grow, constraints, and titles. Chain
774    /// configuration methods on the returned [`ContainerBuilder`], then call
775    /// `.col()` or `.row()` to finalize.
776    ///
777    /// # Example
778    ///
779    /// ```no_run
780    /// # slt::run(|ui: &mut slt::Context| {
781    /// use slt::Border;
782    /// ui.container()
783    ///     .border(Border::Rounded)
784    ///     .p(1)
785    ///     .title("My Panel")
786    ///     .col(|ui| {
787    ///         ui.text("content");
788    ///     });
789    /// # });
790    /// ```
791    pub fn container(&mut self) -> ContainerBuilder<'_> {
792        let border = self.theme.border;
793        ContainerBuilder {
794            ctx: self,
795            gap: 0,
796            row_gap: None,
797            col_gap: None,
798            align: Align::Start,
799            align_self_value: None,
800            justify: Justify::Start,
801            border: None,
802            border_sides: BorderSides::all(),
803            border_style: Style::new().fg(border),
804            bg: None,
805            text_color: None,
806            dark_bg: None,
807            dark_border_style: None,
808            group_hover_bg: None,
809            group_hover_border_style: None,
810            group_name: None,
811            padding: Padding::default(),
812            margin: Margin::default(),
813            constraints: Constraints::default(),
814            title: None,
815            grow: 0,
816            shrink_flag: false,
817            scroll_offset: None,
818            theme_override: None,
819        }
820    }
821
822    /// Create a scrollable container. Handles wheel scroll and drag-to-scroll automatically.
823    ///
824    /// Pass a [`ScrollState`] to persist scroll position across frames. The state
825    /// is updated in-place with the current scroll offset and bounds.
826    ///
827    /// # Example
828    ///
829    /// ```no_run
830    /// # use slt::widgets::ScrollState;
831    /// # slt::run(|ui: &mut slt::Context| {
832    /// let mut scroll = ScrollState::new();
833    /// ui.scrollable(&mut scroll).col(|ui| {
834    ///     for i in 0..100 {
835    ///         ui.text(format!("Line {i}"));
836    ///     }
837    /// });
838    /// # });
839    /// ```
840    pub fn scrollable(&mut self, state: &mut ScrollState) -> ContainerBuilder<'_> {
841        let index = self.rollback.scroll_count;
842        self.rollback.scroll_count += 1;
843        if let Some(&(ch, vh)) = self.prev_scroll_infos.get(index) {
844            state.set_bounds(ch, vh);
845            let max = ch.saturating_sub(vh) as usize;
846            state.offset = state.offset.min(max);
847        }
848
849        let next_id = self.rollback.interaction_count;
850        if let Some(rect) = self.prev_hit_map.get(next_id).copied() {
851            let inner_rects: Vec<Rect> = self
852                .prev_scroll_rects
853                .iter()
854                .enumerate()
855                .filter(|&(j, sr)| {
856                    j != index
857                        && sr.width > 0
858                        && sr.height > 0
859                        && sr.x >= rect.x
860                        && sr.right() <= rect.right()
861                        && sr.y >= rect.y
862                        && sr.bottom() <= rect.bottom()
863                })
864                .map(|(_, sr)| *sr)
865                .collect();
866            self.auto_scroll_nested(&rect, state, &inner_rects);
867        }
868
869        self.container().scroll_offset(state.offset as u32)
870    }
871
872    /// Scrollable column container — shortcut for
873    /// `scrollable(state).grow(1).col(f)`.
874    ///
875    /// This is the form used by nearly every scrollable view: a vertical
876    /// list that fills its parent and wheels through its own content. Use
877    /// the explicit [`Context::scrollable`] builder when you need custom
878    /// `grow`, borders, padding, or a scrollbar alongside.
879    ///
880    /// # Example
881    ///
882    /// ```no_run
883    /// # use slt::widgets::ScrollState;
884    /// # slt::run(|ui: &mut slt::Context| {
885    /// let mut scroll = ScrollState::new();
886    /// ui.scroll_col(&mut scroll, |ui| {
887    ///     for i in 0..100 {
888    ///         ui.text(format!("Line {i}"));
889    ///     }
890    /// });
891    /// # });
892    /// ```
893    pub fn scroll_col(
894        &mut self,
895        state: &mut ScrollState,
896        f: impl FnOnce(&mut Context),
897    ) -> Response {
898        self.scrollable(state).grow(1).col(f)
899    }
900
901    /// Scrollable row container — shortcut for
902    /// `scrollable(state).grow(1).row(f)`.
903    ///
904    /// Useful for horizontally-scrolling timelines, kanban boards, and
905    /// similar wide layouts.
906    pub fn scroll_row(
907        &mut self,
908        state: &mut ScrollState,
909        f: impl FnOnce(&mut Context),
910    ) -> Response {
911        self.scrollable(state).grow(1).row(f)
912    }
913
914    /// Render a scrollbar track for a [`ScrollState`].
915    ///
916    /// Displays a track (`│`) with a proportional thumb (`█`). The thumb size
917    /// and position are calculated from the scroll state's content height,
918    /// viewport height, and current offset.
919    ///
920    /// Typically placed beside a `scrollable()` container in a `row()`:
921    /// ```no_run
922    /// # use slt::widgets::ScrollState;
923    /// # slt::run(|ui: &mut slt::Context| {
924    /// let mut scroll = ScrollState::new();
925    /// ui.row(|ui| {
926    ///     ui.scrollable(&mut scroll).grow(1).col(|ui| {
927    ///         for i in 0..100 { ui.text(format!("Line {i}")); }
928    ///     });
929    ///     ui.scrollbar(&scroll);
930    /// });
931    /// # });
932    /// ```
933    ///
934    /// # Returns
935    ///
936    /// Currently always returns [`Response::none()`]. The [`Response`] return
937    /// type reserves an extension point so future click-to-jump and
938    /// drag-to-scroll handling can be added without a further breaking change.
939    pub fn scrollbar(&mut self, state: &ScrollState) -> Response {
940        let vh = state.viewport_height();
941        let ch = state.content_height();
942        if vh == 0 || ch <= vh {
943            return Response::none();
944        }
945
946        let track_height = vh;
947        let thumb_height = ((vh as f64 * vh as f64 / ch as f64).ceil() as u32).max(1);
948        let max_offset = ch.saturating_sub(vh);
949        let thumb_pos = if max_offset == 0 {
950            0
951        } else {
952            ((state.offset as f64 / max_offset as f64) * (track_height - thumb_height) as f64)
953                .round() as u32
954        };
955
956        let theme = self.theme;
957        const THUMB: &str = "█";
958        const TRACK: &str = "│";
959
960        let _ = self.container().w(1).h(track_height).col(|ui| {
961            for i in 0..track_height {
962                if i >= thumb_pos && i < thumb_pos + thumb_height {
963                    ui.styled(THUMB, Style::new().fg(theme.primary));
964                } else {
965                    ui.styled(TRACK, Style::new().fg(theme.text_dim).dim());
966                }
967            }
968        });
969
970        Response::none()
971    }
972
973    fn auto_scroll_nested(
974        &mut self,
975        rect: &Rect,
976        state: &mut ScrollState,
977        inner_scroll_rects: &[Rect],
978    ) {
979        let mut to_consume = Vec::new();
980        for (i, mouse) in self.mouse_events_in_rect(*rect) {
981            let in_inner = inner_scroll_rects.iter().any(|sr| {
982                mouse.x >= sr.x && mouse.x < sr.right() && mouse.y >= sr.y && mouse.y < sr.bottom()
983            });
984            if in_inner {
985                continue;
986            }
987
988            let delta = self.scroll_lines_per_event as usize;
989            match mouse.kind {
990                MouseKind::ScrollUp => {
991                    state.scroll_up(delta);
992                    to_consume.push(i);
993                }
994                MouseKind::ScrollDown => {
995                    state.scroll_down(delta);
996                    to_consume.push(i);
997                }
998                MouseKind::Drag(MouseButton::Left) => {}
999                _ => {}
1000            }
1001        }
1002        self.consume_indices(to_consume);
1003    }
1004
1005    /// Shortcut for `container().border(border)`.
1006    ///
1007    /// Returns a [`ContainerBuilder`] pre-configured with the given border style.
1008    pub fn bordered(&mut self, border: Border) -> ContainerBuilder<'_> {
1009        self.container()
1010            .border(border)
1011            .border_sides(BorderSides::all())
1012    }
1013
1014    fn push_container(
1015        &mut self,
1016        direction: Direction,
1017        gap: u32,
1018        f: impl FnOnce(&mut Context),
1019    ) -> Response {
1020        let interaction_id = self.next_interaction_id();
1021        let border = self.theme.border;
1022
1023        self.commands
1024            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1025                direction,
1026                gap,
1027                align: Align::Start,
1028                align_self: None,
1029                justify: Justify::Start,
1030                border: None,
1031                border_sides: BorderSides::all(),
1032                border_style: Style::new().fg(border),
1033                bg_color: None,
1034                padding: Padding::default(),
1035                margin: Margin::default(),
1036                constraints: Constraints::default(),
1037                title: None,
1038                grow: 0,
1039                group_name: None,
1040            })));
1041        self.rollback.text_color_stack.push(None);
1042        f(self);
1043        self.rollback.text_color_stack.pop();
1044        self.commands.push(Command::EndContainer);
1045        self.rollback.last_text_idx = None;
1046
1047        self.response_for(interaction_id)
1048    }
1049
1050    pub(crate) fn response_for(&self, interaction_id: usize) -> Response {
1051        if (self.rollback.modal_active || self.prev_modal_active)
1052            && self.rollback.overlay_depth == 0
1053        {
1054            return Response::none();
1055        }
1056        if let Some(rect) = self.prev_hit_map.get(interaction_id) {
1057            let clicked = self
1058                .click_pos
1059                .map(|(mx, my)| {
1060                    mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1061                })
1062                .unwrap_or(false);
1063            // Issue #208: right-click hit-test uses the same rect as the
1064            // existing left-click logic. Keeps modal suppression (the early
1065            // return above) consistent for both buttons.
1066            let right_clicked = self
1067                .right_click_pos
1068                .map(|(mx, my)| {
1069                    mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1070                })
1071                .unwrap_or(false);
1072            let hovered = self
1073                .mouse_pos
1074                .map(|(mx, my)| {
1075                    mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1076                })
1077                .unwrap_or(false);
1078            Response {
1079                clicked,
1080                right_clicked,
1081                hovered,
1082                changed: false,
1083                focused: false,
1084                gained_focus: false,
1085                lost_focus: false,
1086                rect: *rect,
1087            }
1088        } else {
1089            Response::none()
1090        }
1091    }
1092
1093    /// Returns true if the named group is currently hovered by the mouse.
1094    ///
1095    /// Uses the per-frame `hovered_groups` `HashSet` populated by
1096    /// `Context::build_hovered_groups()`; turns the previous O(n) scan over
1097    /// `prev_group_rects` into an O(1) lookup. Closes the cache half of
1098    /// #136 / #139.
1099    pub fn is_group_hovered(&self, name: &str) -> bool {
1100        if self.mouse_pos.is_none() {
1101            return false;
1102        }
1103        // `HashSet<Arc<str>>::contains` accepts `&str` via `Borrow<str>`, so
1104        // there is no allocation on the hot path.
1105        self.hovered_groups.contains(name)
1106    }
1107
1108    /// Returns true if the named group contains the currently focused widget.
1109    pub fn is_group_focused(&self, name: &str) -> bool {
1110        if self.prev_focus_count == 0 {
1111            return false;
1112        }
1113        let focused_index = self.focus_index % self.prev_focus_count;
1114        self.prev_focus_groups
1115            .get(focused_index)
1116            .and_then(|group| group.as_deref())
1117            .map(|group| group == name)
1118            .unwrap_or(false)
1119    }
1120
1121    /// Render a form that groups input fields vertically.
1122    ///
1123    /// Wraps the fields in a column container and forwards the form state
1124    /// to the closure. Use [`Context::form_field`] inside the closure to
1125    /// render each field with label + input + error display.
1126    ///
1127    /// Submission is driven by [`Context::form_submit`]; validation is
1128    /// triggered explicitly via [`FormState::validate`].
1129    pub fn form(
1130        &mut self,
1131        state: &mut FormState,
1132        f: impl FnOnce(&mut Context, &mut FormState),
1133    ) -> &mut Self {
1134        let _ = self.col(|ui| {
1135            f(ui, state);
1136        });
1137        self
1138    }
1139
1140    /// Render a single form field with label and input.
1141    ///
1142    /// Shows a validation error below the input when present.
1143    pub fn form_field(&mut self, field: &mut FormField) -> &mut Self {
1144        let _ = self.col(|ui| {
1145            ui.styled(field.label.as_str(), Style::new().bold().fg(ui.theme.text));
1146            let _ = ui.text_input(&mut field.input);
1147            if let Some(error) = field.error.as_deref() {
1148                ui.styled(error, Style::new().dim().fg(ui.theme.error));
1149            }
1150        });
1151        self
1152    }
1153
1154    /// Render a primary-styled submit button.
1155    ///
1156    /// Distinguishes the submit affordance from incidental buttons in the
1157    /// same form by rendering in the theme's primary color (via
1158    /// [`ButtonVariant::Primary`]). Returns `true` in `.clicked` when the
1159    /// user clicks it, presses Enter while focused, or activates it with
1160    /// Space. Pair with [`FormState::validate`] to gate submission on
1161    /// all fields being valid.
1162    pub fn form_submit(&mut self, label: impl Into<String>) -> Response {
1163        self.button_with(label, ButtonVariant::Primary)
1164    }
1165}