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}