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 pub fn separator(&mut self) -> &mut Self {
163 // The cached `sep_line()` is much wider than any reasonable terminal,
164 // so the cross-axis (column-direction) clip in `Buffer::set_string`
165 // truncates the trailing chars. Keeping `grow = 0` means a column
166 // layout doesn't stretch the separator vertically, and `truncate =
167 // false` avoids the ellipsis fallback which would otherwise replace
168 // the last cell with `…`.
169 self.commands.push(Command::Text {
170 content: sep_line().to_owned(),
171 cursor_offset: None,
172 style: Style::new().fg(self.theme.border).dim(),
173 grow: 0,
174 align: Align::Start,
175 wrap: false,
176 truncate: false,
177 margin: Margin::default(),
178 constraints: Constraints::default(),
179 });
180 self.rollback.last_text_idx = Some(self.commands.len() - 1);
181 self
182 }
183
184 /// Render a horizontal separator line with a custom color.
185 pub fn separator_colored(&mut self, color: Color) -> &mut Self {
186 self.commands.push(Command::Text {
187 content: sep_line().to_owned(),
188 cursor_offset: None,
189 style: Style::new().fg(color),
190 grow: 0,
191 align: Align::Start,
192 wrap: false,
193 truncate: false,
194 margin: Margin::default(),
195 constraints: Constraints::default(),
196 });
197 self.rollback.last_text_idx = Some(self.commands.len() - 1);
198 self
199 }
200
201 /// Conditionally render content when the named screen is active.
202 ///
203 /// Each screen gets an isolated hook segment — `use_state` / `use_memo`
204 /// calls inside one screen do not interfere with another screen's hooks,
205 /// even when you switch between screens across frames.
206 ///
207 /// Focus state is saved and restored per screen automatically.
208 ///
209 /// # Example
210 ///
211 /// ```no_run
212 /// # let mut screens = slt::ScreenState::new("main");
213 /// # slt::run(|ui| {
214 /// ui.screen("main", &mut screens, |ui| {
215 /// ui.text("Main screen");
216 /// });
217 /// # });
218 /// ```
219 pub fn screen(&mut self, name: &str, screens: &mut ScreenState, f: impl FnOnce(&mut Context)) {
220 // Look up (or create) this screen's reserved hook segment.
221 //
222 // Cache-hit path is the steady state — every frame after the first.
223 // Avoid the unconditional `name.to_string()` `entry()` allocation by
224 // checking first via `&str` lookup. Only the first frame for a
225 // given screen pays the `to_string()` cost. Closes #134 (Option B).
226 let (seg_start, seg_count) = if let Some(&v) = self.screen_hook_map.get(name) {
227 v
228 } else {
229 let v = (self.hook_states.len(), 0);
230 self.screen_hook_map.insert(name.to_string(), v);
231 v
232 };
233
234 let is_active = screens.current() == name;
235
236 if is_active {
237 // Save outer focus, restore this screen's focus
238 let outer_focus_index = self.focus_index;
239 let (saved_focus_idx, _saved_focus_count) = screens.restore_focus(name);
240 self.focus_index = saved_focus_idx;
241
242 // Set hook cursor to this screen's segment start
243 self.rollback.hook_cursor = seg_start;
244 let focus_count_before = self.rollback.focus_count;
245
246 // Execute the screen's closure
247 f(self);
248
249 // Record the hook count for this screen.
250 //
251 // The first-frame path above already inserted an owned `String`
252 // key for this screen; subsequent frames reuse it. Locate that
253 // existing slot via `&str` and overwrite the value in place,
254 // avoiding a second `to_string()` allocation per active frame.
255 let hooks_used = self.rollback.hook_cursor - seg_start;
256 if let Some(slot) = self.screen_hook_map.get_mut(name) {
257 *slot = (seg_start, hooks_used);
258 } else {
259 self.screen_hook_map
260 .insert(name.to_string(), (seg_start, hooks_used));
261 }
262
263 // Save this screen's focus state
264 let screen_focus_count = self.rollback.focus_count - focus_count_before;
265 screens.save_focus(name, self.focus_index, screen_focus_count);
266
267 // Restore outer focus
268 self.focus_index = outer_focus_index;
269 } else {
270 // Skip: advance hook cursor past the reserved segment
271 if seg_count > 0 && seg_start >= self.rollback.hook_cursor {
272 self.rollback.hook_cursor = seg_start + seg_count;
273 }
274 }
275 }
276
277 /// Create a vertical (column) container.
278 ///
279 /// Children are stacked top-to-bottom. Returns a [`Response`] with
280 /// click/hover state for the container area.
281 ///
282 /// # Example
283 ///
284 /// ```no_run
285 /// # slt::run(|ui: &mut slt::Context| {
286 /// ui.col(|ui| {
287 /// ui.text("line one");
288 /// ui.text("line two");
289 /// });
290 /// # });
291 /// ```
292 pub fn col(&mut self, f: impl FnOnce(&mut Context)) -> Response {
293 self.push_container(Direction::Column, 0, f)
294 }
295
296 /// Create a vertical (column) container with a gap between children.
297 ///
298 /// `gap` is the number of blank rows inserted between each child.
299 ///
300 /// **Deprecated since 0.20.1**: the name collides with
301 /// [`ContainerBuilder::col_gap`], which sets the *row-finalize* main-axis
302 /// gap (Tailwind `gap-x` axis convention) and so means the opposite thing.
303 /// Use `ui.container().gap(n).col(f)` instead — same output, no collision.
304 #[deprecated(
305 since = "0.20.1",
306 note = "Use `ui.container().gap(n).col(f)` instead — same output, no name collision with `ContainerBuilder::col_gap`."
307 )]
308 pub fn col_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
309 self.push_container(Direction::Column, gap, f)
310 }
311
312 /// Create a horizontal (row) container.
313 ///
314 /// Children are placed left-to-right. Returns a [`Response`] with
315 /// click/hover state for the container area.
316 ///
317 /// # Example
318 ///
319 /// ```no_run
320 /// # slt::run(|ui: &mut slt::Context| {
321 /// ui.row(|ui| {
322 /// ui.text("left");
323 /// ui.spacer();
324 /// ui.text("right");
325 /// });
326 /// # });
327 /// ```
328 pub fn row(&mut self, f: impl FnOnce(&mut Context)) -> Response {
329 self.push_container(Direction::Row, 0, f)
330 }
331
332 /// Create a horizontal (row) container with a gap between children.
333 ///
334 /// `gap` is the number of blank columns inserted between each child.
335 ///
336 /// **Deprecated since 0.20.1**: the name collides with
337 /// [`ContainerBuilder::row_gap`], which sets the *column-finalize*
338 /// main-axis gap (Tailwind `gap-y` axis convention) and so means the
339 /// opposite thing. Use `ui.container().gap(n).row(f)` instead — same
340 /// output, no collision.
341 #[deprecated(
342 since = "0.20.1",
343 note = "Use `ui.container().gap(n).row(f)` instead — same output, no name collision with `ContainerBuilder::row_gap`."
344 )]
345 pub fn row_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
346 self.push_container(Direction::Row, gap, f)
347 }
348
349 /// Render inline text with mixed styles on a single line.
350 ///
351 /// Unlike [`row`](Context::row), `line()` is designed for rich text —
352 /// children are rendered as continuous inline text without gaps.
353 ///
354 /// It intentionally returns `&mut Self` instead of [`Response`] so you can
355 /// keep chaining display-oriented modifiers after composing the inline run.
356 ///
357 /// # Example
358 ///
359 /// ```no_run
360 /// # use slt::Color;
361 /// # slt::run(|ui: &mut slt::Context| {
362 /// ui.line(|ui| {
363 /// ui.text("Status: ");
364 /// ui.text("Online").bold().fg(Color::Green);
365 /// });
366 /// # });
367 /// ```
368 pub fn line(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
369 let _ = self.push_container(Direction::Row, 0, f);
370 self
371 }
372
373 /// Render inline text with mixed styles, wrapping at word boundaries.
374 ///
375 /// Like [`line`](Context::line), but when the combined text exceeds
376 /// the container width it wraps across multiple lines while
377 /// preserving per-segment styles.
378 ///
379 /// # Example
380 ///
381 /// ```no_run
382 /// # use slt::{Color, Style};
383 /// # slt::run(|ui: &mut slt::Context| {
384 /// ui.line_wrap(|ui| {
385 /// ui.text("This is a long ");
386 /// ui.text("important").bold().fg(Color::Red);
387 /// ui.text(" message that wraps across lines");
388 /// });
389 /// # });
390 /// ```
391 pub fn line_wrap(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
392 let start = self.commands.len();
393 f(self);
394 let has_link = self.commands[start..]
395 .iter()
396 .any(|cmd| matches!(cmd, Command::Link { .. }));
397
398 if has_link {
399 self.commands.insert(
400 start,
401 Command::BeginContainer(Box::new(BeginContainerArgs {
402 direction: Direction::Row,
403 gap: 0,
404 align: Align::Start,
405 align_self: None,
406 justify: Justify::Start,
407 border: None,
408 border_sides: BorderSides::all(),
409 border_style: Style::new(),
410 bg_color: None,
411 padding: Padding::default(),
412 margin: Margin::default(),
413 constraints: Constraints::default(),
414 title: None,
415 grow: 0,
416 group_name: None,
417 })),
418 );
419 self.commands.push(Command::EndContainer);
420 self.rollback.last_text_idx = None;
421 return self;
422 }
423
424 let mut segments: Vec<(String, Style)> = Vec::new();
425 for cmd in self.commands.drain(start..) {
426 match cmd {
427 Command::Text { content, style, .. } => {
428 segments.push((content, style));
429 }
430 Command::Link { text, style, .. } => {
431 // Preserve link text with underline styling (URL lost in RichText,
432 // but text is visible and wraps correctly)
433 segments.push((text, style));
434 }
435 _ => {}
436 }
437 }
438 self.commands.push(Command::RichText {
439 segments,
440 wrap: true,
441 align: Align::Start,
442 margin: Margin::default(),
443 constraints: Constraints::default(),
444 });
445 self.rollback.last_text_idx = None;
446 self
447 }
448
449 /// Render content in a modal overlay with dimmed background.
450 ///
451 /// ```no_run
452 /// # let mut show = true;
453 /// # slt::run(|ui: &mut slt::Context| {
454 /// if show {
455 /// ui.modal(|ui| {
456 /// ui.text("Are you sure?");
457 /// if ui.button("OK").clicked { show = false; }
458 /// });
459 /// }
460 /// # });
461 /// ```
462 pub fn modal(&mut self, f: impl FnOnce(&mut Context)) -> Response {
463 // Default `modal()` preserves legacy behavior (tab_trap = false).
464 // `modal_with(ModalOptions::default(), ...)` opts into the WCAG 2.1
465 // SC 2.4.3 focus-trap default. This split keeps existing callers
466 // bit-identical until they migrate.
467 self.modal_with(ModalOptions { tab_trap: false }, f)
468 }
469
470 /// Render content in a modal overlay with configurable options.
471 ///
472 /// Like [`modal`](Self::modal), but accepts a [`ModalOptions`] struct.
473 /// Use this to opt into focus trapping (`tab_trap: true`) or future
474 /// modal flags without breaking the bare `modal()` API.
475 ///
476 /// When `opts.tab_trap` is `true`, focus cannot escape the modal's
477 /// focusable range — Tab/Shift+Tab keep cycling within the modal even
478 /// if [`Context::set_focus_index`] or a mouse click moved focus to a
479 /// background widget. WCAG 2.1 SC 2.4.3 (Focus Order) recommends
480 /// trapping focus inside modal dialogs.
481 ///
482 /// # Example
483 ///
484 /// ```no_run
485 /// # let mut show = true;
486 /// # slt::run(|ui: &mut slt::Context| {
487 /// if show {
488 /// ui.modal_with(slt::context::ModalOptions { tab_trap: true }, |ui| {
489 /// ui.text("Are you sure?");
490 /// if ui.button("OK").clicked { show = false; }
491 /// });
492 /// }
493 /// # });
494 /// ```
495 pub fn modal_with(&mut self, opts: ModalOptions, f: impl FnOnce(&mut Context)) -> Response {
496 let interaction_id = self.next_interaction_id();
497 self.commands.push(Command::BeginOverlay { modal: true });
498 self.rollback.overlay_depth += 1;
499 self.rollback.modal_active = true;
500 let modal_focus_start = self.rollback.focus_count;
501 self.rollback.modal_focus_start = modal_focus_start;
502
503 f(self);
504 let modal_focus_count = self.rollback.focus_count.saturating_sub(modal_focus_start);
505 self.rollback.modal_focus_count = modal_focus_count;
506
507 // Tab trap: when enabled, ensure `focus_index` lies in this frame's
508 // modal range `[start, start + count)`. If `set_focus_index` from a
509 // previous frame (or a stale state) left focus pointing at a
510 // background widget, clamp it to the first modal focusable so the
511 // next [`process_focus_keys`] tick cycles cleanly within the modal.
512 //
513 // WCAG 2.1 SC 2.4.3 (Focus Order) requirement: the user must not be
514 // able to navigate to content outside an active modal dialog.
515 if opts.tab_trap && modal_focus_count > 0 {
516 let lo = modal_focus_start;
517 let hi = lo.saturating_add(modal_focus_count);
518 if self.focus_index < lo || self.focus_index >= hi {
519 self.focus_index = lo;
520 }
521 }
522
523 self.rollback.overlay_depth = self.rollback.overlay_depth.saturating_sub(1);
524 self.commands.push(Command::EndOverlay);
525 self.rollback.last_text_idx = None;
526 self.response_for(interaction_id)
527 }
528
529 /// Render floating content without dimming the background.
530 pub fn overlay(&mut self, f: impl FnOnce(&mut Context)) -> Response {
531 let interaction_id = self.next_interaction_id();
532 self.commands.push(Command::BeginOverlay { modal: false });
533 self.rollback.overlay_depth += 1;
534 f(self);
535 self.rollback.overlay_depth = self.rollback.overlay_depth.saturating_sub(1);
536 self.commands.push(Command::EndOverlay);
537 self.rollback.last_text_idx = None;
538 self.response_for(interaction_id)
539 }
540
541 /// Render floating content anchored to one of the 9 compass positions.
542 ///
543 /// Wraps [`overlay`](Self::overlay) with a full-area column that pins the
544 /// content to the requested anchor via flexbox `align`/`justify`. The
545 /// inner column gets `grow(1)` so the wrapper consumes the screen, giving
546 /// `align`/`justify` room to push the content to the corner.
547 ///
548 /// ```no_run
549 /// # use slt::Anchor;
550 /// # slt::run(|ui: &mut slt::Context| {
551 /// ui.overlay_at(Anchor::TopRight, |ui| {
552 /// ui.text("0:42").bold();
553 /// });
554 /// # });
555 /// ```
556 pub fn overlay_at(&mut self, anchor: Anchor, f: impl FnOnce(&mut Context)) -> Response {
557 self.overlay(|ui| {
558 let (align, justify) = anchor_to_align_justify(anchor);
559 let _ = ui.container().grow(1).align(align).justify(justify).col(f);
560 })
561 }
562
563 /// Render a modal overlay anchored to one of the 9 compass positions.
564 ///
565 /// Like [`modal`](Self::modal) but pinned to a corner / edge / center via
566 /// the same anchor wrapping as [`overlay_at`](Self::overlay_at).
567 pub fn modal_at(&mut self, anchor: Anchor, f: impl FnOnce(&mut Context)) -> Response {
568 self.modal(|ui| {
569 let (align, justify) = anchor_to_align_justify(anchor);
570 let _ = ui.container().grow(1).align(align).justify(justify).col(f);
571 })
572 }
573
574 /// Render `f` at `anchor` with cell offset `(dx, dy)` from the anchored edge.
575 ///
576 /// This is the SLT analog of CSS `position: absolute; top/right/bottom/left`,
577 /// or Flutter's `Positioned(top:, right:, ...)`. The 9-cell [`Anchor`]
578 /// chooses which edge to anchor to; `(dx, dy)` insets toward the center.
579 ///
580 /// # Sign convention
581 /// Positive `dx` / `dy` always inset toward the viewport center. So
582 /// `overlay_at_offset(Anchor::BottomRight, 2, 1, ...)` places the widget
583 /// 2 cells left and 1 cell up from the bottom-right corner.
584 ///
585 /// For [`Anchor::Center`] (and other centered axes) negative values shift
586 /// in the opposite direction — `(dx=-2, dy=-1)` shifts 2 cells left and 1
587 /// cell up. For corner / edge anchors, negative values would push the
588 /// content off-screen, so they are clamped to 0; use a different anchor
589 /// instead of negative offsets to escape an edge.
590 ///
591 /// # CSS analogy
592 /// ```text
593 /// CSS: place-self: end end; bottom: 1px; right: 2px;
594 /// SLT: overlay_at_offset(Anchor::BottomRight, 2, 1, |ui| { ... })
595 /// ```
596 ///
597 /// # Example
598 ///
599 /// ```no_run
600 /// # use slt::Anchor;
601 /// # slt::run(|ui: &mut slt::Context| {
602 /// // Inset corner badge — 2 cells from the right, 1 row from the bottom.
603 /// ui.overlay_at_offset(Anchor::BottomRight, 2, 1, |ui| {
604 /// ui.text("v0.19.3").dim();
605 /// });
606 /// # });
607 /// ```
608 pub fn overlay_at_offset(
609 &mut self,
610 anchor: Anchor,
611 dx: i32,
612 dy: i32,
613 f: impl FnOnce(&mut Context),
614 ) -> Response {
615 self.overlay(|ui| {
616 let (align, justify) = anchor_to_align_justify(anchor);
617 let margin = anchor_offset_to_margin(anchor, dx, dy);
618 // Apply margin on the outer (grow=1) column so flexbox's parent
619 // (the synthetic overlay root) shrinks the column's area before
620 // align/justify pick a position. This avoids a wrapper container
621 // around `f`, which would expose a flexbox limitation where
622 // `Align::End` shifts the immediate child's `pos` but does not
623 // propagate the shift down to grandchildren.
624 let _ = ui
625 .container()
626 .grow(1)
627 .align(align)
628 .justify(justify)
629 .margin(margin)
630 .col(f);
631 })
632 }
633
634 /// Modal variant of [`overlay_at_offset`](Self::overlay_at_offset).
635 ///
636 /// Like [`modal_at`](Self::modal_at) but with a `(dx, dy)` cell inset
637 /// from the anchored edge. Positive values inset toward the center —
638 /// see [`overlay_at_offset`](Self::overlay_at_offset) for the full sign
639 /// convention.
640 ///
641 /// # Example
642 ///
643 /// ```no_run
644 /// # use slt::{Anchor, Border};
645 /// # slt::run(|ui: &mut slt::Context| {
646 /// ui.modal_at_offset(Anchor::TopRight, 2, 1, |ui| {
647 /// ui.bordered(Border::Rounded).p(1).col(|ui| {
648 /// ui.text("Saved!");
649 /// });
650 /// });
651 /// # });
652 /// ```
653 pub fn modal_at_offset(
654 &mut self,
655 anchor: Anchor,
656 dx: i32,
657 dy: i32,
658 f: impl FnOnce(&mut Context),
659 ) -> Response {
660 self.modal(|ui| {
661 let (align, justify) = anchor_to_align_justify(anchor);
662 let margin = anchor_offset_to_margin(anchor, dx, dy);
663 // See `overlay_at_offset` for why margin lives on the outer
664 // grow-1 column rather than a wrapper around `f`.
665 let _ = ui
666 .container()
667 .grow(1)
668 .align(align)
669 .justify(justify)
670 .margin(margin)
671 .col(f);
672 })
673 }
674
675 /// Render a hover tooltip for the previously rendered interactive widget.
676 ///
677 /// Call this right after a widget or container response:
678 /// ```ignore
679 /// if ui.button("Save").clicked { save(); }
680 /// ui.tooltip("Save the current document to disk");
681 /// ```
682 pub fn tooltip(&mut self, text: impl Into<String>) {
683 let tooltip_text = text.into();
684 if tooltip_text.is_empty() {
685 return;
686 }
687 let last_interaction_id = self.rollback.interaction_count.saturating_sub(1);
688 let last_response = self.response_for(last_interaction_id);
689 if !last_response.hovered || last_response.rect.width == 0 || last_response.rect.height == 0
690 {
691 return;
692 }
693 let lines = wrap_tooltip_text(&tooltip_text, 38);
694 self.pending_tooltips.push(PendingTooltip {
695 anchor_rect: last_response.rect,
696 lines,
697 });
698 }
699
700 pub(crate) fn emit_pending_tooltips(&mut self) {
701 let tooltips = std::mem::take(&mut self.pending_tooltips);
702 if tooltips.is_empty() {
703 return;
704 }
705 let area_w = self.area_width;
706 let area_h = self.area_height;
707 let surface = self.theme.surface;
708 let border_color = self.theme.border;
709 let text_color = self.theme.surface_text;
710
711 for tooltip in tooltips {
712 let content_w = tooltip
713 .lines
714 .iter()
715 .map(|l| UnicodeWidthStr::width(l.as_str()) as u32)
716 .max()
717 .unwrap_or(0);
718 let box_w = content_w.saturating_add(4).min(area_w);
719 let box_h = (tooltip.lines.len() as u32).saturating_add(4).min(area_h);
720
721 let tooltip_x = tooltip.anchor_rect.x.min(area_w.saturating_sub(box_w));
722 let below_y = tooltip.anchor_rect.bottom();
723 let tooltip_y = if below_y.saturating_add(box_h) <= area_h {
724 below_y
725 } else {
726 tooltip.anchor_rect.y.saturating_sub(box_h)
727 };
728
729 let lines = tooltip.lines;
730 let pad = self.theme.spacing.xs();
731 let _ = self.overlay(|ui| {
732 let _ = ui.container().w(area_w).h(area_h).col(|ui| {
733 let _ = ui
734 .container()
735 .ml(tooltip_x)
736 .mt(tooltip_y)
737 .max_w(box_w)
738 .border(Border::Rounded)
739 .border_fg(border_color)
740 .bg(surface)
741 .p(pad)
742 .col(|ui| {
743 for line in &lines {
744 ui.text(line.as_str()).fg(text_color);
745 }
746 });
747 });
748 });
749 }
750 }
751
752 /// Create a named group container for shared hover/focus styling.
753 ///
754 /// ```ignore
755 /// ui.group("card").border(Border::Rounded)
756 /// .group_hover_bg(Color::Indexed(238))
757 /// .col(|ui| { ui.text("Hover anywhere"); });
758 /// ```
759 pub fn group(&mut self, name: &str) -> ContainerBuilder<'_> {
760 // Materialize the name once; subsequent uses are cheap `Arc::clone`
761 // pointer bumps. Closes #145 (double `to_string` allocation) and
762 // completes the `Arc<str>` migration tracked by #139.
763 self.rollback.group_count = self.rollback.group_count.saturating_add(1);
764 let name_arc: std::sync::Arc<str> = std::sync::Arc::from(name);
765 self.rollback
766 .group_stack
767 .push(std::sync::Arc::clone(&name_arc));
768 self.container().group_name_arc(name_arc)
769 }
770
771 /// Create a container with a fluent builder.
772 ///
773 /// Use this for borders, padding, grow, constraints, and titles. Chain
774 /// configuration methods on the returned [`ContainerBuilder`], then call
775 /// `.col()` or `.row()` to finalize.
776 ///
777 /// # Example
778 ///
779 /// ```no_run
780 /// # slt::run(|ui: &mut slt::Context| {
781 /// use slt::Border;
782 /// ui.container()
783 /// .border(Border::Rounded)
784 /// .p(1)
785 /// .title("My Panel")
786 /// .col(|ui| {
787 /// ui.text("content");
788 /// });
789 /// # });
790 /// ```
791 pub fn container(&mut self) -> ContainerBuilder<'_> {
792 let border = self.theme.border;
793 ContainerBuilder {
794 ctx: self,
795 gap: 0,
796 row_gap: None,
797 col_gap: None,
798 align: Align::Start,
799 align_self_value: None,
800 justify: Justify::Start,
801 border: None,
802 border_sides: BorderSides::all(),
803 border_style: Style::new().fg(border),
804 bg: None,
805 text_color: None,
806 dark_bg: None,
807 dark_border_style: None,
808 group_hover_bg: None,
809 group_hover_border_style: None,
810 group_name: None,
811 padding: Padding::default(),
812 margin: Margin::default(),
813 constraints: Constraints::default(),
814 title: None,
815 grow: 0,
816 shrink_flag: false,
817 scroll_offset: None,
818 theme_override: None,
819 }
820 }
821
822 /// Create a scrollable container. Handles wheel scroll and drag-to-scroll automatically.
823 ///
824 /// Pass a [`ScrollState`] to persist scroll position across frames. The state
825 /// is updated in-place with the current scroll offset and bounds.
826 ///
827 /// # Example
828 ///
829 /// ```no_run
830 /// # use slt::widgets::ScrollState;
831 /// # slt::run(|ui: &mut slt::Context| {
832 /// let mut scroll = ScrollState::new();
833 /// ui.scrollable(&mut scroll).col(|ui| {
834 /// for i in 0..100 {
835 /// ui.text(format!("Line {i}"));
836 /// }
837 /// });
838 /// # });
839 /// ```
840 pub fn scrollable(&mut self, state: &mut ScrollState) -> ContainerBuilder<'_> {
841 let index = self.rollback.scroll_count;
842 self.rollback.scroll_count += 1;
843 if let Some(&(ch, vh)) = self.prev_scroll_infos.get(index) {
844 state.set_bounds(ch, vh);
845 let max = ch.saturating_sub(vh) as usize;
846 state.offset = state.offset.min(max);
847 }
848
849 let next_id = self.rollback.interaction_count;
850 if let Some(rect) = self.prev_hit_map.get(next_id).copied() {
851 let inner_rects: Vec<Rect> = self
852 .prev_scroll_rects
853 .iter()
854 .enumerate()
855 .filter(|&(j, sr)| {
856 j != index
857 && sr.width > 0
858 && sr.height > 0
859 && sr.x >= rect.x
860 && sr.right() <= rect.right()
861 && sr.y >= rect.y
862 && sr.bottom() <= rect.bottom()
863 })
864 .map(|(_, sr)| *sr)
865 .collect();
866 self.auto_scroll_nested(&rect, state, &inner_rects);
867 }
868
869 self.container().scroll_offset(state.offset as u32)
870 }
871
872 /// Scrollable column container — shortcut for
873 /// `scrollable(state).grow(1).col(f)`.
874 ///
875 /// This is the form used by nearly every scrollable view: a vertical
876 /// list that fills its parent and wheels through its own content. Use
877 /// the explicit [`Context::scrollable`] builder when you need custom
878 /// `grow`, borders, padding, or a scrollbar alongside.
879 ///
880 /// # Example
881 ///
882 /// ```no_run
883 /// # use slt::widgets::ScrollState;
884 /// # slt::run(|ui: &mut slt::Context| {
885 /// let mut scroll = ScrollState::new();
886 /// ui.scroll_col(&mut scroll, |ui| {
887 /// for i in 0..100 {
888 /// ui.text(format!("Line {i}"));
889 /// }
890 /// });
891 /// # });
892 /// ```
893 pub fn scroll_col(
894 &mut self,
895 state: &mut ScrollState,
896 f: impl FnOnce(&mut Context),
897 ) -> Response {
898 self.scrollable(state).grow(1).col(f)
899 }
900
901 /// Scrollable row container — shortcut for
902 /// `scrollable(state).grow(1).row(f)`.
903 ///
904 /// Useful for horizontally-scrolling timelines, kanban boards, and
905 /// similar wide layouts.
906 pub fn scroll_row(
907 &mut self,
908 state: &mut ScrollState,
909 f: impl FnOnce(&mut Context),
910 ) -> Response {
911 self.scrollable(state).grow(1).row(f)
912 }
913
914 /// Render a scrollbar track for a [`ScrollState`].
915 ///
916 /// Displays a track (`│`) with a proportional thumb (`█`). The thumb size
917 /// and position are calculated from the scroll state's content height,
918 /// viewport height, and current offset.
919 ///
920 /// Typically placed beside a `scrollable()` container in a `row()`:
921 /// ```no_run
922 /// # use slt::widgets::ScrollState;
923 /// # slt::run(|ui: &mut slt::Context| {
924 /// let mut scroll = ScrollState::new();
925 /// ui.row(|ui| {
926 /// ui.scrollable(&mut scroll).grow(1).col(|ui| {
927 /// for i in 0..100 { ui.text(format!("Line {i}")); }
928 /// });
929 /// ui.scrollbar(&scroll);
930 /// });
931 /// # });
932 /// ```
933 ///
934 /// # Returns
935 ///
936 /// Currently always returns [`Response::none()`]. The [`Response`] return
937 /// type reserves an extension point so future click-to-jump and
938 /// drag-to-scroll handling can be added without a further breaking change.
939 pub fn scrollbar(&mut self, state: &ScrollState) -> Response {
940 let vh = state.viewport_height();
941 let ch = state.content_height();
942 if vh == 0 || ch <= vh {
943 return Response::none();
944 }
945
946 let track_height = vh;
947 let thumb_height = ((vh as f64 * vh as f64 / ch as f64).ceil() as u32).max(1);
948 let max_offset = ch.saturating_sub(vh);
949 let thumb_pos = if max_offset == 0 {
950 0
951 } else {
952 ((state.offset as f64 / max_offset as f64) * (track_height - thumb_height) as f64)
953 .round() as u32
954 };
955
956 let theme = self.theme;
957 const THUMB: &str = "█";
958 const TRACK: &str = "│";
959
960 let _ = self.container().w(1).h(track_height).col(|ui| {
961 for i in 0..track_height {
962 if i >= thumb_pos && i < thumb_pos + thumb_height {
963 ui.styled(THUMB, Style::new().fg(theme.primary));
964 } else {
965 ui.styled(TRACK, Style::new().fg(theme.text_dim).dim());
966 }
967 }
968 });
969
970 Response::none()
971 }
972
973 fn auto_scroll_nested(
974 &mut self,
975 rect: &Rect,
976 state: &mut ScrollState,
977 inner_scroll_rects: &[Rect],
978 ) {
979 let mut to_consume = Vec::new();
980 for (i, mouse) in self.mouse_events_in_rect(*rect) {
981 let in_inner = inner_scroll_rects.iter().any(|sr| {
982 mouse.x >= sr.x && mouse.x < sr.right() && mouse.y >= sr.y && mouse.y < sr.bottom()
983 });
984 if in_inner {
985 continue;
986 }
987
988 let delta = self.scroll_lines_per_event as usize;
989 match mouse.kind {
990 MouseKind::ScrollUp => {
991 state.scroll_up(delta);
992 to_consume.push(i);
993 }
994 MouseKind::ScrollDown => {
995 state.scroll_down(delta);
996 to_consume.push(i);
997 }
998 MouseKind::Drag(MouseButton::Left) => {}
999 _ => {}
1000 }
1001 }
1002 self.consume_indices(to_consume);
1003 }
1004
1005 /// Shortcut for `container().border(border)`.
1006 ///
1007 /// Returns a [`ContainerBuilder`] pre-configured with the given border style.
1008 pub fn bordered(&mut self, border: Border) -> ContainerBuilder<'_> {
1009 self.container()
1010 .border(border)
1011 .border_sides(BorderSides::all())
1012 }
1013
1014 fn push_container(
1015 &mut self,
1016 direction: Direction,
1017 gap: u32,
1018 f: impl FnOnce(&mut Context),
1019 ) -> Response {
1020 let interaction_id = self.next_interaction_id();
1021 let border = self.theme.border;
1022
1023 self.commands
1024 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1025 direction,
1026 gap,
1027 align: Align::Start,
1028 align_self: None,
1029 justify: Justify::Start,
1030 border: None,
1031 border_sides: BorderSides::all(),
1032 border_style: Style::new().fg(border),
1033 bg_color: None,
1034 padding: Padding::default(),
1035 margin: Margin::default(),
1036 constraints: Constraints::default(),
1037 title: None,
1038 grow: 0,
1039 group_name: None,
1040 })));
1041 self.rollback.text_color_stack.push(None);
1042 f(self);
1043 self.rollback.text_color_stack.pop();
1044 self.commands.push(Command::EndContainer);
1045 self.rollback.last_text_idx = None;
1046
1047 self.response_for(interaction_id)
1048 }
1049
1050 pub(crate) fn response_for(&self, interaction_id: usize) -> Response {
1051 if (self.rollback.modal_active || self.prev_modal_active)
1052 && self.rollback.overlay_depth == 0
1053 {
1054 return Response::none();
1055 }
1056 if let Some(rect) = self.prev_hit_map.get(interaction_id) {
1057 let clicked = self
1058 .click_pos
1059 .map(|(mx, my)| {
1060 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1061 })
1062 .unwrap_or(false);
1063 // Issue #208: right-click hit-test uses the same rect as the
1064 // existing left-click logic. Keeps modal suppression (the early
1065 // return above) consistent for both buttons.
1066 let right_clicked = self
1067 .right_click_pos
1068 .map(|(mx, my)| {
1069 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1070 })
1071 .unwrap_or(false);
1072 let hovered = self
1073 .mouse_pos
1074 .map(|(mx, my)| {
1075 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1076 })
1077 .unwrap_or(false);
1078 Response {
1079 clicked,
1080 right_clicked,
1081 hovered,
1082 changed: false,
1083 focused: false,
1084 gained_focus: false,
1085 lost_focus: false,
1086 rect: *rect,
1087 }
1088 } else {
1089 Response::none()
1090 }
1091 }
1092
1093 /// Returns true if the named group is currently hovered by the mouse.
1094 ///
1095 /// Uses the per-frame `hovered_groups` `HashSet` populated by
1096 /// `Context::build_hovered_groups()`; turns the previous O(n) scan over
1097 /// `prev_group_rects` into an O(1) lookup. Closes the cache half of
1098 /// #136 / #139.
1099 pub fn is_group_hovered(&self, name: &str) -> bool {
1100 if self.mouse_pos.is_none() {
1101 return false;
1102 }
1103 // `HashSet<Arc<str>>::contains` accepts `&str` via `Borrow<str>`, so
1104 // there is no allocation on the hot path.
1105 self.hovered_groups.contains(name)
1106 }
1107
1108 /// Returns true if the named group contains the currently focused widget.
1109 pub fn is_group_focused(&self, name: &str) -> bool {
1110 if self.prev_focus_count == 0 {
1111 return false;
1112 }
1113 let focused_index = self.focus_index % self.prev_focus_count;
1114 self.prev_focus_groups
1115 .get(focused_index)
1116 .and_then(|group| group.as_deref())
1117 .map(|group| group == name)
1118 .unwrap_or(false)
1119 }
1120
1121 /// Render a form that groups input fields vertically.
1122 ///
1123 /// Wraps the fields in a column container and forwards the form state
1124 /// to the closure. Use [`Context::form_field`] inside the closure to
1125 /// render each field with label + input + error display.
1126 ///
1127 /// Submission is driven by [`Context::form_submit`]; validation is
1128 /// triggered explicitly via [`FormState::validate`].
1129 pub fn form(
1130 &mut self,
1131 state: &mut FormState,
1132 f: impl FnOnce(&mut Context, &mut FormState),
1133 ) -> &mut Self {
1134 let _ = self.col(|ui| {
1135 f(ui, state);
1136 });
1137 self
1138 }
1139
1140 /// Render a single form field with label and input.
1141 ///
1142 /// Shows a validation error below the input when present.
1143 pub fn form_field(&mut self, field: &mut FormField) -> &mut Self {
1144 let _ = self.col(|ui| {
1145 ui.styled(field.label.as_str(), Style::new().bold().fg(ui.theme.text));
1146 let _ = ui.text_input(&mut field.input);
1147 if let Some(error) = field.error.as_deref() {
1148 ui.styled(error, Style::new().dim().fg(ui.theme.error));
1149 }
1150 });
1151 self
1152 }
1153
1154 /// Render a primary-styled submit button.
1155 ///
1156 /// Distinguishes the submit affordance from incidental buttons in the
1157 /// same form by rendering in the theme's primary color (via
1158 /// [`ButtonVariant::Primary`]). Returns `true` in `.clicked` when the
1159 /// user clicks it, presses Enter while focused, or activates it with
1160 /// Space. Pair with [`FormState::validate`] to gate submission on
1161 /// all fields being valid.
1162 pub fn form_submit(&mut self, label: impl Into<String>) -> Response {
1163 self.button_with(label, ButtonVariant::Primary)
1164 }
1165}