slt/context/widgets_display/layout.rs
1use super::*;
2
3impl Context {
4 /// Conditionally render content when the named screen is active.
5 ///
6 /// Each screen gets an isolated hook segment — `use_state` / `use_memo`
7 /// calls inside one screen do not interfere with another screen's hooks,
8 /// even when you switch between screens across frames.
9 ///
10 /// Focus state is saved and restored per screen automatically.
11 ///
12 /// # Example
13 ///
14 /// ```no_run
15 /// # let mut screens = slt::ScreenState::new("main");
16 /// # slt::run(|ui| {
17 /// ui.screen("main", &mut screens, |ui| {
18 /// ui.text("Main screen");
19 /// });
20 /// # });
21 /// ```
22 pub fn screen(&mut self, name: &str, screens: &mut ScreenState, f: impl FnOnce(&mut Context)) {
23 // Look up (or create) this screen's reserved hook segment
24 let (seg_start, seg_count) = *self
25 .screen_hook_map
26 .entry(name.to_string())
27 .or_insert((self.hook_states.len(), 0));
28
29 let is_active = screens.current() == name;
30
31 if is_active {
32 // Save outer focus, restore this screen's focus
33 let outer_focus_index = self.focus_index;
34 let (saved_focus_idx, _saved_focus_count) = screens.restore_focus(name);
35 self.focus_index = saved_focus_idx;
36
37 // Set hook cursor to this screen's segment start
38 self.rollback.hook_cursor = seg_start;
39 let focus_count_before = self.rollback.focus_count;
40
41 // Execute the screen's closure
42 f(self);
43
44 // Record the hook count for this screen
45 let hooks_used = self.rollback.hook_cursor - seg_start;
46 self.screen_hook_map
47 .insert(name.to_string(), (seg_start, hooks_used));
48
49 // Save this screen's focus state
50 let screen_focus_count = self.rollback.focus_count - focus_count_before;
51 screens.save_focus(name, self.focus_index, screen_focus_count);
52
53 // Restore outer focus
54 self.focus_index = outer_focus_index;
55 } else {
56 // Skip: advance hook cursor past the reserved segment
57 if seg_count > 0 && seg_start >= self.rollback.hook_cursor {
58 self.rollback.hook_cursor = seg_start + seg_count;
59 }
60 }
61 }
62
63 /// Create a vertical (column) container.
64 ///
65 /// Children are stacked top-to-bottom. Returns a [`Response`] with
66 /// click/hover state for the container area.
67 ///
68 /// # Example
69 ///
70 /// ```no_run
71 /// # slt::run(|ui: &mut slt::Context| {
72 /// ui.col(|ui| {
73 /// ui.text("line one");
74 /// ui.text("line two");
75 /// });
76 /// # });
77 /// ```
78 pub fn col(&mut self, f: impl FnOnce(&mut Context)) -> Response {
79 self.push_container(Direction::Column, 0, f)
80 }
81
82 /// Create a vertical (column) container with a gap between children.
83 ///
84 /// `gap` is the number of blank rows inserted between each child.
85 pub fn col_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
86 self.push_container(Direction::Column, gap, f)
87 }
88
89 /// Create a horizontal (row) container.
90 ///
91 /// Children are placed left-to-right. Returns a [`Response`] with
92 /// click/hover state for the container area.
93 ///
94 /// # Example
95 ///
96 /// ```no_run
97 /// # slt::run(|ui: &mut slt::Context| {
98 /// ui.row(|ui| {
99 /// ui.text("left");
100 /// ui.spacer();
101 /// ui.text("right");
102 /// });
103 /// # });
104 /// ```
105 pub fn row(&mut self, f: impl FnOnce(&mut Context)) -> Response {
106 self.push_container(Direction::Row, 0, f)
107 }
108
109 /// Create a horizontal (row) container with a gap between children.
110 ///
111 /// `gap` is the number of blank columns inserted between each child.
112 pub fn row_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
113 self.push_container(Direction::Row, gap, f)
114 }
115
116 /// Render inline text with mixed styles on a single line.
117 ///
118 /// Unlike [`row`](Context::row), `line()` is designed for rich text —
119 /// children are rendered as continuous inline text without gaps.
120 ///
121 /// It intentionally returns `&mut Self` instead of [`Response`] so you can
122 /// keep chaining display-oriented modifiers after composing the inline run.
123 ///
124 /// # Example
125 ///
126 /// ```no_run
127 /// # use slt::Color;
128 /// # slt::run(|ui: &mut slt::Context| {
129 /// ui.line(|ui| {
130 /// ui.text("Status: ");
131 /// ui.text("Online").bold().fg(Color::Green);
132 /// });
133 /// # });
134 /// ```
135 pub fn line(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
136 let _ = self.push_container(Direction::Row, 0, f);
137 self
138 }
139
140 /// Render inline text with mixed styles, wrapping at word boundaries.
141 ///
142 /// Like [`line`](Context::line), but when the combined text exceeds
143 /// the container width it wraps across multiple lines while
144 /// preserving per-segment styles.
145 ///
146 /// # Example
147 ///
148 /// ```no_run
149 /// # use slt::{Color, Style};
150 /// # slt::run(|ui: &mut slt::Context| {
151 /// ui.line_wrap(|ui| {
152 /// ui.text("This is a long ");
153 /// ui.text("important").bold().fg(Color::Red);
154 /// ui.text(" message that wraps across lines");
155 /// });
156 /// # });
157 /// ```
158 pub fn line_wrap(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
159 let start = self.commands.len();
160 f(self);
161 let has_link = self.commands[start..]
162 .iter()
163 .any(|cmd| matches!(cmd, Command::Link { .. }));
164
165 if has_link {
166 self.commands.insert(
167 start,
168 Command::BeginContainer(Box::new(BeginContainerArgs {
169 direction: Direction::Row,
170 gap: 0,
171 align: Align::Start,
172 align_self: None,
173 justify: Justify::Start,
174 border: None,
175 border_sides: BorderSides::all(),
176 border_style: Style::new(),
177 bg_color: None,
178 padding: Padding::default(),
179 margin: Margin::default(),
180 constraints: Constraints::default(),
181 title: None,
182 grow: 0,
183 group_name: None,
184 })),
185 );
186 self.commands.push(Command::EndContainer);
187 self.rollback.last_text_idx = None;
188 return self;
189 }
190
191 let mut segments: Vec<(String, Style)> = Vec::new();
192 for cmd in self.commands.drain(start..) {
193 match cmd {
194 Command::Text { content, style, .. } => {
195 segments.push((content, style));
196 }
197 Command::Link { text, style, .. } => {
198 // Preserve link text with underline styling (URL lost in RichText,
199 // but text is visible and wraps correctly)
200 segments.push((text, style));
201 }
202 _ => {}
203 }
204 }
205 self.commands.push(Command::RichText {
206 segments,
207 wrap: true,
208 align: Align::Start,
209 margin: Margin::default(),
210 constraints: Constraints::default(),
211 });
212 self.rollback.last_text_idx = None;
213 self
214 }
215
216 /// Render content in a modal overlay with dimmed background.
217 ///
218 /// ```ignore
219 /// ui.modal(|ui| {
220 /// ui.text("Are you sure?");
221 /// if ui.button("OK") { show = false; }
222 /// });
223 /// ```
224 pub fn modal(&mut self, f: impl FnOnce(&mut Context)) -> Response {
225 let interaction_id = self.next_interaction_id();
226 self.commands.push(Command::BeginOverlay { modal: true });
227 self.rollback.overlay_depth += 1;
228 self.rollback.modal_active = true;
229 self.rollback.modal_focus_start = self.rollback.focus_count;
230 f(self);
231 self.rollback.modal_focus_count = self
232 .rollback
233 .focus_count
234 .saturating_sub(self.rollback.modal_focus_start);
235 self.rollback.overlay_depth = self.rollback.overlay_depth.saturating_sub(1);
236 self.commands.push(Command::EndOverlay);
237 self.rollback.last_text_idx = None;
238 self.response_for(interaction_id)
239 }
240
241 /// Render floating content without dimming the background.
242 pub fn overlay(&mut self, f: impl FnOnce(&mut Context)) -> Response {
243 let interaction_id = self.next_interaction_id();
244 self.commands.push(Command::BeginOverlay { modal: false });
245 self.rollback.overlay_depth += 1;
246 f(self);
247 self.rollback.overlay_depth = self.rollback.overlay_depth.saturating_sub(1);
248 self.commands.push(Command::EndOverlay);
249 self.rollback.last_text_idx = None;
250 self.response_for(interaction_id)
251 }
252
253 /// Render a hover tooltip for the previously rendered interactive widget.
254 ///
255 /// Call this right after a widget or container response:
256 /// ```ignore
257 /// if ui.button("Save").clicked { save(); }
258 /// ui.tooltip("Save the current document to disk");
259 /// ```
260 pub fn tooltip(&mut self, text: impl Into<String>) {
261 let tooltip_text = text.into();
262 if tooltip_text.is_empty() {
263 return;
264 }
265 let last_interaction_id = self.rollback.interaction_count.saturating_sub(1);
266 let last_response = self.response_for(last_interaction_id);
267 if !last_response.hovered || last_response.rect.width == 0 || last_response.rect.height == 0
268 {
269 return;
270 }
271 let lines = wrap_tooltip_text(&tooltip_text, 38);
272 self.rollback.pending_tooltips.push(PendingTooltip {
273 anchor_rect: last_response.rect,
274 lines,
275 });
276 }
277
278 pub(crate) fn emit_pending_tooltips(&mut self) {
279 let tooltips = std::mem::take(&mut self.rollback.pending_tooltips);
280 if tooltips.is_empty() {
281 return;
282 }
283 let area_w = self.area_width;
284 let area_h = self.area_height;
285 let surface = self.theme.surface;
286 let border_color = self.theme.border;
287 let text_color = self.theme.surface_text;
288
289 for tooltip in tooltips {
290 let content_w = tooltip
291 .lines
292 .iter()
293 .map(|l| UnicodeWidthStr::width(l.as_str()) as u32)
294 .max()
295 .unwrap_or(0);
296 let box_w = content_w.saturating_add(4).min(area_w);
297 let box_h = (tooltip.lines.len() as u32).saturating_add(4).min(area_h);
298
299 let tooltip_x = tooltip.anchor_rect.x.min(area_w.saturating_sub(box_w));
300 let below_y = tooltip.anchor_rect.bottom();
301 let tooltip_y = if below_y.saturating_add(box_h) <= area_h {
302 below_y
303 } else {
304 tooltip.anchor_rect.y.saturating_sub(box_h)
305 };
306
307 let lines = tooltip.lines;
308 let _ = self.overlay(|ui| {
309 let _ = ui.container().w(area_w).h(area_h).col(|ui| {
310 let _ = ui
311 .container()
312 .ml(tooltip_x)
313 .mt(tooltip_y)
314 .max_w(box_w)
315 .border(Border::Rounded)
316 .border_fg(border_color)
317 .bg(surface)
318 .p(1)
319 .col(|ui| {
320 for line in &lines {
321 ui.text(line.as_str()).fg(text_color);
322 }
323 });
324 });
325 });
326 }
327 }
328
329 /// Create a named group container for shared hover/focus styling.
330 ///
331 /// ```ignore
332 /// ui.group("card").border(Border::Rounded)
333 /// .group_hover_bg(Color::Indexed(238))
334 /// .col(|ui| { ui.text("Hover anywhere"); });
335 /// ```
336 pub fn group(&mut self, name: &str) -> ContainerBuilder<'_> {
337 self.rollback.group_count = self.rollback.group_count.saturating_add(1);
338 self.rollback.group_stack.push(name.to_string());
339 self.container().group_name(name.to_string())
340 }
341
342 /// Create a container with a fluent builder.
343 ///
344 /// Use this for borders, padding, grow, constraints, and titles. Chain
345 /// configuration methods on the returned [`ContainerBuilder`], then call
346 /// `.col()` or `.row()` to finalize.
347 ///
348 /// # Example
349 ///
350 /// ```no_run
351 /// # slt::run(|ui: &mut slt::Context| {
352 /// use slt::Border;
353 /// ui.container()
354 /// .border(Border::Rounded)
355 /// .pad(1)
356 /// .title("My Panel")
357 /// .col(|ui| {
358 /// ui.text("content");
359 /// });
360 /// # });
361 /// ```
362 pub fn container(&mut self) -> ContainerBuilder<'_> {
363 let border = self.theme.border;
364 ContainerBuilder {
365 ctx: self,
366 gap: 0,
367 row_gap: None,
368 col_gap: None,
369 align: Align::Start,
370 align_self_value: None,
371 justify: Justify::Start,
372 border: None,
373 border_sides: BorderSides::all(),
374 border_style: Style::new().fg(border),
375 bg: None,
376 text_color: None,
377 dark_bg: None,
378 dark_border_style: None,
379 group_hover_bg: None,
380 group_hover_border_style: None,
381 group_name: None,
382 padding: Padding::default(),
383 margin: Margin::default(),
384 constraints: Constraints::default(),
385 title: None,
386 grow: 0,
387 scroll_offset: None,
388 }
389 }
390
391 /// Create a scrollable container. Handles wheel scroll and drag-to-scroll automatically.
392 ///
393 /// Pass a [`ScrollState`] to persist scroll position across frames. The state
394 /// is updated in-place with the current scroll offset and bounds.
395 ///
396 /// # Example
397 ///
398 /// ```no_run
399 /// # use slt::widgets::ScrollState;
400 /// # slt::run(|ui: &mut slt::Context| {
401 /// let mut scroll = ScrollState::new();
402 /// ui.scrollable(&mut scroll).col(|ui| {
403 /// for i in 0..100 {
404 /// ui.text(format!("Line {i}"));
405 /// }
406 /// });
407 /// # });
408 /// ```
409 pub fn scrollable(&mut self, state: &mut ScrollState) -> ContainerBuilder<'_> {
410 let index = self.rollback.scroll_count;
411 self.rollback.scroll_count += 1;
412 if let Some(&(ch, vh)) = self.prev_scroll_infos.get(index) {
413 state.set_bounds(ch, vh);
414 let max = ch.saturating_sub(vh) as usize;
415 state.offset = state.offset.min(max);
416 }
417
418 let next_id = self.rollback.interaction_count;
419 if let Some(rect) = self.prev_hit_map.get(next_id).copied() {
420 let inner_rects: Vec<Rect> = self
421 .prev_scroll_rects
422 .iter()
423 .enumerate()
424 .filter(|&(j, sr)| {
425 j != index
426 && sr.width > 0
427 && sr.height > 0
428 && sr.x >= rect.x
429 && sr.right() <= rect.right()
430 && sr.y >= rect.y
431 && sr.bottom() <= rect.bottom()
432 })
433 .map(|(_, sr)| *sr)
434 .collect();
435 self.auto_scroll_nested(&rect, state, &inner_rects);
436 }
437
438 self.container().scroll_offset(state.offset as u32)
439 }
440
441 /// Scrollable column container — shortcut for
442 /// `scrollable(state).grow(1).col(f)`.
443 ///
444 /// This is the form used by nearly every scrollable view: a vertical
445 /// list that fills its parent and wheels through its own content. Use
446 /// the explicit [`Context::scrollable`] builder when you need custom
447 /// `grow`, borders, padding, or a scrollbar alongside.
448 ///
449 /// # Example
450 ///
451 /// ```no_run
452 /// # use slt::widgets::ScrollState;
453 /// # slt::run(|ui: &mut slt::Context| {
454 /// let mut scroll = ScrollState::new();
455 /// ui.scroll_col(&mut scroll, |ui| {
456 /// for i in 0..100 {
457 /// ui.text(format!("Line {i}"));
458 /// }
459 /// });
460 /// # });
461 /// ```
462 pub fn scroll_col(
463 &mut self,
464 state: &mut ScrollState,
465 f: impl FnOnce(&mut Context),
466 ) -> Response {
467 self.scrollable(state).grow(1).col(f)
468 }
469
470 /// Scrollable row container — shortcut for
471 /// `scrollable(state).grow(1).row(f)`.
472 ///
473 /// Useful for horizontally-scrolling timelines, kanban boards, and
474 /// similar wide layouts.
475 pub fn scroll_row(
476 &mut self,
477 state: &mut ScrollState,
478 f: impl FnOnce(&mut Context),
479 ) -> Response {
480 self.scrollable(state).grow(1).row(f)
481 }
482
483 /// Render a scrollbar track for a [`ScrollState`].
484 ///
485 /// Displays a track (`│`) with a proportional thumb (`█`). The thumb size
486 /// and position are calculated from the scroll state's content height,
487 /// viewport height, and current offset.
488 ///
489 /// Typically placed beside a `scrollable()` container in a `row()`:
490 /// ```no_run
491 /// # use slt::widgets::ScrollState;
492 /// # slt::run(|ui: &mut slt::Context| {
493 /// let mut scroll = ScrollState::new();
494 /// ui.row(|ui| {
495 /// ui.scrollable(&mut scroll).grow(1).col(|ui| {
496 /// for i in 0..100 { ui.text(format!("Line {i}")); }
497 /// });
498 /// ui.scrollbar(&scroll);
499 /// });
500 /// # });
501 /// ```
502 pub fn scrollbar(&mut self, state: &ScrollState) {
503 let vh = state.viewport_height();
504 let ch = state.content_height();
505 if vh == 0 || ch <= vh {
506 return;
507 }
508
509 let track_height = vh;
510 let thumb_height = ((vh as f64 * vh as f64 / ch as f64).ceil() as u32).max(1);
511 let max_offset = ch.saturating_sub(vh);
512 let thumb_pos = if max_offset == 0 {
513 0
514 } else {
515 ((state.offset as f64 / max_offset as f64) * (track_height - thumb_height) as f64)
516 .round() as u32
517 };
518
519 let theme = self.theme;
520 let track_char = '│';
521 let thumb_char = '█';
522
523 let _ = self.container().w(1).h(track_height).col(|ui| {
524 for i in 0..track_height {
525 if i >= thumb_pos && i < thumb_pos + thumb_height {
526 ui.styled(thumb_char.to_string(), Style::new().fg(theme.primary));
527 } else {
528 ui.styled(
529 track_char.to_string(),
530 Style::new().fg(theme.text_dim).dim(),
531 );
532 }
533 }
534 });
535 }
536
537 fn auto_scroll_nested(
538 &mut self,
539 rect: &Rect,
540 state: &mut ScrollState,
541 inner_scroll_rects: &[Rect],
542 ) {
543 let mut to_consume = Vec::new();
544 for (i, mouse) in self.mouse_events_in_rect(*rect) {
545 let in_inner = inner_scroll_rects.iter().any(|sr| {
546 mouse.x >= sr.x && mouse.x < sr.right() && mouse.y >= sr.y && mouse.y < sr.bottom()
547 });
548 if in_inner {
549 continue;
550 }
551
552 let delta = self.scroll_lines_per_event as usize;
553 match mouse.kind {
554 MouseKind::ScrollUp => {
555 state.scroll_up(delta);
556 to_consume.push(i);
557 }
558 MouseKind::ScrollDown => {
559 state.scroll_down(delta);
560 to_consume.push(i);
561 }
562 MouseKind::Drag(MouseButton::Left) => {}
563 _ => {}
564 }
565 }
566 self.consume_indices(to_consume);
567 }
568
569 /// Shortcut for `container().border(border)`.
570 ///
571 /// Returns a [`ContainerBuilder`] pre-configured with the given border style.
572 pub fn bordered(&mut self, border: Border) -> ContainerBuilder<'_> {
573 self.container()
574 .border(border)
575 .border_sides(BorderSides::all())
576 }
577
578 fn push_container(
579 &mut self,
580 direction: Direction,
581 gap: u32,
582 f: impl FnOnce(&mut Context),
583 ) -> Response {
584 let interaction_id = self.next_interaction_id();
585 let border = self.theme.border;
586
587 self.commands
588 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
589 direction,
590 gap,
591 align: Align::Start,
592 align_self: None,
593 justify: Justify::Start,
594 border: None,
595 border_sides: BorderSides::all(),
596 border_style: Style::new().fg(border),
597 bg_color: None,
598 padding: Padding::default(),
599 margin: Margin::default(),
600 constraints: Constraints::default(),
601 title: None,
602 grow: 0,
603 group_name: None,
604 })));
605 self.rollback.text_color_stack.push(None);
606 f(self);
607 self.rollback.text_color_stack.pop();
608 self.commands.push(Command::EndContainer);
609 self.rollback.last_text_idx = None;
610
611 self.response_for(interaction_id)
612 }
613
614 pub(crate) fn response_for(&self, interaction_id: usize) -> Response {
615 if (self.rollback.modal_active || self.prev_modal_active)
616 && self.rollback.overlay_depth == 0
617 {
618 return Response::none();
619 }
620 if let Some(rect) = self.prev_hit_map.get(interaction_id) {
621 let clicked = self
622 .click_pos
623 .map(|(mx, my)| {
624 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
625 })
626 .unwrap_or(false);
627 let hovered = self
628 .mouse_pos
629 .map(|(mx, my)| {
630 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
631 })
632 .unwrap_or(false);
633 Response {
634 clicked,
635 hovered,
636 changed: false,
637 focused: false,
638 rect: *rect,
639 }
640 } else {
641 Response::none()
642 }
643 }
644
645 /// Returns true if the named group is currently hovered by the mouse.
646 pub fn is_group_hovered(&self, name: &str) -> bool {
647 if let Some(pos) = self.mouse_pos {
648 self.prev_group_rects.iter().any(|(n, rect)| {
649 n.as_ref() == name
650 && pos.0 >= rect.x
651 && pos.0 < rect.x + rect.width
652 && pos.1 >= rect.y
653 && pos.1 < rect.y + rect.height
654 })
655 } else {
656 false
657 }
658 }
659
660 /// Returns true if the named group contains the currently focused widget.
661 pub fn is_group_focused(&self, name: &str) -> bool {
662 if self.prev_focus_count == 0 {
663 return false;
664 }
665 let focused_index = self.focus_index % self.prev_focus_count;
666 self.prev_focus_groups
667 .get(focused_index)
668 .and_then(|group| group.as_deref())
669 .map(|group| group == name)
670 .unwrap_or(false)
671 }
672
673 /// Render a form that groups input fields vertically.
674 ///
675 /// Wraps the fields in a column container and forwards the form state
676 /// to the closure. Use [`Context::form_field`] inside the closure to
677 /// render each field with label + input + error display.
678 ///
679 /// Submission is driven by [`Context::form_submit`]; validation is
680 /// triggered explicitly via [`FormState::validate`].
681 pub fn form(
682 &mut self,
683 state: &mut FormState,
684 f: impl FnOnce(&mut Context, &mut FormState),
685 ) -> &mut Self {
686 let _ = self.col(|ui| {
687 f(ui, state);
688 });
689 self
690 }
691
692 /// Render a single form field with label and input.
693 ///
694 /// Shows a validation error below the input when present.
695 pub fn form_field(&mut self, field: &mut FormField) -> &mut Self {
696 let _ = self.col(|ui| {
697 ui.styled(field.label.clone(), Style::new().bold().fg(ui.theme.text));
698 let _ = ui.text_input(&mut field.input);
699 if let Some(error) = field.error.as_deref() {
700 ui.styled(error.to_string(), Style::new().dim().fg(ui.theme.error));
701 }
702 });
703 self
704 }
705
706 /// Render a primary-styled submit button.
707 ///
708 /// Distinguishes the submit affordance from incidental buttons in the
709 /// same form by rendering in the theme's primary color (via
710 /// [`ButtonVariant::Primary`]). Returns `true` in `.clicked` when the
711 /// user clicks it, presses Enter while focused, or activates it with
712 /// Space. Pair with [`FormState::validate`] to gate submission on
713 /// all fields being valid.
714 pub fn form_submit(&mut self, label: impl Into<String>) -> Response {
715 self.button_with(label, ButtonVariant::Primary)
716 }
717}