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