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