Skip to main content

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}