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