Skip to main content

teksilo_core/widget/
layout_context.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use crate::widget_id::WidgetId;
5
6use super::LayoutResponse;
7
8/// The main (distribution) axis of the linear stack currently negotiating a
9/// child's layout, if any. Set by `HStack`/`VStack` while they query children
10/// so an orientation-agnostic flexible child (notably `Spacer`) can place its
11/// minimum length on the *main* axis only and report `0` on the cross axis —
12/// otherwise it imposes a spurious cross-axis floor on the stack. `None` when
13/// the parent is not a linear stack (or in test contexts).
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum StackAxis {
16    Horizontal,
17    Vertical,
18}
19
20/// Context available during layout.
21pub struct LayoutContext<'a> {
22    /// The enclosing linear stack's main axis, when a stack is querying this
23    /// child (see [`StackAxis`]). `None` otherwise.
24    pub stack_main_axis: Option<StackAxis>,
25    pub theme: &'a crate::styles::Theme,
26    pub layout_direction: crate::environment::LayoutDirection,
27    /// Host window HiDPI device scale (physical px per logical px). The layout
28    /// pass is otherwise fully logical, and the renderer applies this scale at
29    /// the vertex stage — so **ordinary widgets must ignore this**. It exists
30    /// only as the escape hatch for widgets that bridge to a device-pixel OS
31    /// resource (e.g. a `WebView` sizing its native subview, which on some
32    /// toolkits — WebKitGTK on X11 — ignores fractional scaling and needs
33    /// device pixels). 1.0 in headless / test contexts. A widget that reads
34    /// it binds `BuildContext::device_scale_signal` at `Relayout`: a scale
35    /// change with an unchanged logical size relayouts nothing on its own.
36    pub scale_factor: f32,
37    /// Combined user×OS text-scale factor (`1.0` = 100 %). Distinct from
38    /// `scale_factor` (HiDPI device pixels): this is the *logical* accessibility
39    /// magnification. Widgets that size text from `Theme.typography` already
40    /// scale via the effective theme and should ignore this; it exists for
41    /// widgets that size from another source (`IconWidget`, the rich-text engine
42    /// default size, scene text) and need the raw factor. `1.0` in test contexts.
43    pub text_scale: f32,
44    /// Text backend for accurate text measurement during layout.
45    pub text_backend: Option<&'a std::rc::Rc<std::cell::RefCell<dyn teksilo_canvas::TextBackend>>>,
46    /// Arena reference for querying child widget sizes.
47    pub(crate) arena: Option<&'a crate::arena::WidgetArena>,
48    /// Optional bundle of read-only tree state (focus, shortcuts,
49    /// overlays). Carried through layout for debug-tooling consumers
50    /// like the inspector's Focus / Shortcuts / Overlays tabs. `None`
51    /// in test contexts.
52    pub(crate) extras: Option<LayoutExtras<'a>>,
53}
54
55/// Read-only handles to tree-level state that some widgets (notably
56/// the debug inspector) want to query from `layout_response`. Threaded
57/// through the recursive layout pass alongside the arena.
58#[derive(Clone, Copy)]
59pub(crate) struct LayoutExtras<'a> {
60    pub focused: Option<WidgetId>,
61    /// Every widget that currently owns live interaction state the framework
62    /// would destroy if its subtree were parked dormant: the focused node,
63    /// each pointer's captor, and the source of an in-flight drag. Collected
64    /// once per layout pass; empty on an idle tree.
65    pub interaction_anchors: &'a [WidgetId],
66    pub shortcut_registry: Option<&'a crate::shortcut::ShortcutRegistry>,
67    pub overlay_manager: Option<&'a crate::overlay::OverlayManager>,
68}
69
70impl<'a> LayoutContext<'a> {
71    /// Create a LayoutContext for testing (no arena access).
72    pub fn for_testing(theme: &'a crate::styles::Theme) -> Self {
73        Self {
74            theme,
75            layout_direction: crate::environment::LayoutDirection::LeftToRight,
76            scale_factor: 1.0,
77            text_scale: 1.0,
78            text_backend: None,
79            arena: None,
80            extras: None,
81            stack_main_axis: None,
82        }
83    }
84
85    /// The enclosing linear stack's main axis, if a stack is currently
86    /// querying this child. See [`StackAxis`].
87    pub fn stack_main_axis(&self) -> Option<StackAxis> {
88        self.stack_main_axis
89    }
90
91    /// Derive a context that advertises `axis` as the enclosing stack's main
92    /// axis. Used by `HStack`/`VStack` when querying children so a `Spacer`
93    /// (and any other orientation-agnostic flexible leaf) can size correctly
94    /// per axis. All other fields are shared with `self`.
95    pub fn with_stack_main_axis(&self, axis: StackAxis) -> LayoutContext<'a> {
96        LayoutContext {
97            stack_main_axis: Some(axis),
98            ..*self
99        }
100    }
101
102    /// The currently focused widget id, if any. Returns `None` when
103    /// the layout pass is unrelated to a tree (test contexts).
104    pub fn focused(&self) -> Option<WidgetId> {
105        self.extras.as_ref().and_then(|e| e.focused)
106    }
107
108    /// Call `f` with every widget that holds live interaction state, and with
109    /// each of its ancestors up to a root.
110    ///
111    /// "Live interaction state" is what the framework destroys when it parks a
112    /// subtree dormant: the keyboard focus (cleared by
113    /// `WidgetTree::revalidate_interaction_state`), a captured pointer and an
114    /// in-flight drag source (both cancelled with
115    /// [`CancelReason::SubtreeParked`](crate::pointer::CancelReason)). Losing any
116    /// of them is fine when the *user* navigated away and wrong when the
117    /// *container* moved.
118    ///
119    /// So a container that culls by viewport intersects this with its own
120    /// children and pins whichever of them the user is in the middle of: a
121    /// card being typed in, or drag-selected inside, stays live wherever the
122    /// camera goes.
123    ///
124    /// Reported from the anchors *upward* rather than tested per child on
125    /// purpose — that makes the pin cost proportional to the number of live
126    /// interactions (almost always zero or one, times the tree depth) instead
127    /// of to the number of children, which is the quantity a culling container
128    /// exists to stop paying. Nothing is reported outside a real layout pass.
129    pub fn for_each_interaction_ancestor(&self, mut f: impl FnMut(WidgetId)) {
130        let (Some(arena), Some(extras)) = (self.arena, self.extras.as_ref()) else {
131            return;
132        };
133        for &anchor in extras.interaction_anchors {
134            let mut cur = Some(anchor);
135            while let Some(id) = cur {
136                f(id);
137                cur = arena.parent(id);
138            }
139        }
140    }
141
142    /// Borrow the tree's shortcut registry. Returns `None` outside a
143    /// real layout pass. Intended for read-only inspection by the
144    /// debug inspector.
145    pub fn shortcut_registry(&self) -> Option<&crate::shortcut::ShortcutRegistry> {
146        self.extras.as_ref().and_then(|e| e.shortcut_registry)
147    }
148
149    /// Borrow the tree's overlay manager. Returns `None` outside a
150    /// real layout pass. Intended for read-only inspection by the
151    /// debug inspector.
152    pub fn overlay_manager(&self) -> Option<&crate::overlay::OverlayManager> {
153        self.extras.as_ref().and_then(|e| e.overlay_manager)
154    }
155
156    /// Query a child widget's full layout response (wanted size + flex weight).
157    /// Returns None if the child doesn't exist, is dormant, or the arena is not available.
158    pub fn child_layout_response(
159        &self,
160        child_id: WidgetId,
161        proposal: teksilo_canvas::SizeProposal,
162    ) -> Option<LayoutResponse> {
163        // Routed through the arena's per-pass memoization cache so the
164        // main-then-cross queries that height-for-width negotiation issues do
165        // not recompute the same `(child, proposal)` repeatedly. The cache
166        // handles the active-state check and the `cacheable_layout()` opt-out.
167        let arena = self.arena?;
168        arena.cached_layout_response(child_id, proposal, self)
169    }
170
171    /// Measure a widget's intrinsic size for `proposal`, **regardless of
172    /// activation** — works even for dormant/collapsed widgets (and their
173    /// dormant subtrees), unlike [`child_size`](Self::child_size) /
174    /// [`child_layout_response`](Self::child_layout_response), which return
175    /// `None` for inactive widgets.
176    ///
177    /// Intended for adaptive layouts that hide some children but still need
178    /// their size to decide when to reveal them — e.g. an overflow `Toolbar`
179    /// collapsing actions into a chevron menu. Runs uncached and re-entrant-
180    /// safe; calls `layout_response`, which must be idempotent.
181    pub fn measure_intrinsic(
182        &self,
183        id: WidgetId,
184        proposal: teksilo_canvas::SizeProposal,
185    ) -> Option<teksilo_canvas::Size> {
186        self.arena?.measure_intrinsic(id, proposal, self)
187    }
188
189    /// Query a child widget's wanted size only (drops the flex weight).
190    /// Convenience over [`child_layout_response`](Self::child_layout_response).
191    pub fn child_size(
192        &self,
193        child_id: WidgetId,
194        proposal: teksilo_canvas::SizeProposal,
195    ) -> Option<teksilo_canvas::Size> {
196        self.child_layout_response(child_id, proposal)
197            .map(|r| r.size)
198    }
199
200    /// Query the laid-out bounds of any active widget. Returns `None`
201    /// when the arena is not available (test contexts) — otherwise
202    /// returns the widget's current bounds (`Rect::ZERO` if unknown).
203    /// Useful for inspector-style widgets that need to mirror another
204    /// widget's geometry into a `Signal` during the layout pass.
205    pub fn widget_bounds(&self, id: WidgetId) -> Option<teksilo_canvas::Rect> {
206        let arena = self.arena?;
207        if !arena.is_active(id) {
208            return None;
209        }
210        Some(arena.bounds(id))
211    }
212
213    /// Hit-test the active widget tree at `point` and return the
214    /// deepest widget under it. Honors `event_pass_through`. The
215    /// `exclude` argument lets the caller skip a specific subtree
216    /// (e.g. the inspector's picker overlay so it doesn't pick
217    /// itself). Returns `None` outside layout (no arena available).
218    pub fn widget_at_point(
219        &self,
220        point: teksilo_canvas::Point,
221        exclude: Option<WidgetId>,
222    ) -> Option<WidgetId> {
223        let arena = self.arena?;
224        arena.hit_test_at(point, exclude)
225    }
226
227    /// Borrow the underlying arena. Returns `None` outside a layout
228    /// pass (test contexts). Intended for read-only introspection by
229    /// debug tooling (the inspector's tree view) — use the typed
230    /// accessors above when possible.
231    pub fn arena(&self) -> Option<&crate::arena::WidgetArena> {
232        self.arena
233    }
234
235    /// Query a child's per-widget alignment override, if any.
236    pub fn child_alignment(&self, child_id: WidgetId) -> Option<teksilo_tokens::Alignment> {
237        let arena = self.arena?;
238        arena.alignment_override(child_id)
239    }
240
241    /// Whether the layout direction is right-to-left.
242    pub fn is_rtl(&self) -> bool {
243        self.layout_direction == crate::environment::LayoutDirection::RightToLeft
244    }
245}