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