Skip to main content

teksilo_widgets/primitives/
fixed_size.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! FixedSize — a layout modifier that pins a child to its natural size,
5//! optionally overriding one or both dimensions with a reactive value.
6//!
7//! Without bindings, `FixedSize` ignores the parent's size proposal and
8//! always reports the child's intrinsic size. This is useful for widgets
9//! that must not be stretched or compressed by their containing stack —
10//! icons, chips, or thumbnails that must stay at their designed size
11//! regardless of the surrounding layout.
12//!
13//! With [`width`](FixedSize::width) or
14//! [`height`](FixedSize::height), the corresponding dimension is
15//! locked to a reactive `Signal<f32>` value; the signal change triggers a
16//! relayout automatically. Unbound dimensions still fall back to the child's
17//! natural size.
18//!
19//! ```rust
20//! # use teksilo_widgets::primitives::{FixedSize, RectWidget};
21//! # use teksilo_core::signal::Signal;
22//! let sidebar_width = Signal::new(240.0_f32);
23//! // Pin the sidebar width to a reactive signal
24//! let _sidebar = FixedSize::new()
25//!     .width(sidebar_width)
26//!     .child(RectWidget::new());
27//! ```
28
29use teksilo_canvas::{Rect, Size, SizeProposal};
30use teksilo_core::signal::Prop;
31use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
32use teksilo_core::widget_id::WidgetId;
33
34/// Layout modifier that prevents a widget from expanding beyond its natural size,
35/// or constrains it to specific reactive dimensions.
36///
37/// Without bindings, reports the child's natural size (ignoring parent proposal).
38/// With `width`/`height`, constrains to the bound values.
39#[derive(Debug)]
40pub struct FixedSize {
41    child_id: Option<WidgetId>,
42    pending_child: Option<PendingChild>,
43    width: Option<Prop<f32>>,
44    height: Option<Prop<f32>>,
45}
46
47impl FixedSize {
48    /// Create a `FixedSize` with no child and no dimension bindings; the child's
49    /// natural size will be used for both axes.
50    pub fn new() -> Self {
51        Self {
52            child_id: None,
53            pending_child: None,
54            width: None,
55            height: None,
56        }
57    }
58
59    /// Set an inline child widget (deferred insertion).
60    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
61        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
62        self
63    }
64    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
65    ///
66    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
67    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
68    /// adds no arena node, so nothing is laid out, painted, or published to the
69    /// accessibility tree, and a stack applies no spacing around it.
70    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
71        match widget {
72            Some(w) => self.child(w),
73            None => self,
74        }
75    }
76
77    /// Bind width to a reactive state. When the state changes, relayout is triggered.
78    pub fn width(mut self, state: impl Into<Prop<f32>>) -> Self {
79        self.width = Some(state.into());
80        self
81    }
82
83    /// Bind height to a reactive state. When the state changes, relayout is triggered.
84    pub fn height(mut self, state: impl Into<Prop<f32>>) -> Self {
85        self.height = Some(state.into());
86        self
87    }
88}
89
90impl Default for FixedSize {
91    fn default() -> Self {
92        Self::new()
93    }
94}
95
96impl Widget for FixedSize {
97    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
98        if let Some(pending) = self.pending_child.take() {
99            self.child_id = Some(match pending {
100                PendingChild::Id(id) => id,
101                PendingChild::Deferred(w) => ctx.add_boxed(w),
102            });
103        }
104        // Register reactive bindings
105        let self_id = ctx.self_id();
106        let registry = ctx.binding_registry();
107        if let Some(ref w) = self.width {
108            w.register_if_bound(
109                self_id,
110                registry,
111                teksilo_core::binding::BindingLevel::Relayout,
112            );
113        }
114        if let Some(ref h) = self.height {
115            h.register_if_bound(
116                self_id,
117                registry,
118                teksilo_core::binding::BindingLevel::Relayout,
119            );
120        }
121        self.child_id.into_iter().collect()
122    }
123
124    fn layout_response(
125        &self,
126        _proposal: SizeProposal,
127        ctx: &LayoutContext,
128    ) -> teksilo_core::widget::LayoutResponse {
129        // Forward the bound width/height to the child as its size proposal so
130        // wrap-aware children (TextWidget in TextOverflow::Wrap, ScrollArea,
131        // etc.) can compute their intrinsic cross-axis size against the same
132        // constraint we will place them into. Unbound dimensions stay
133        // unspecified so the child falls back to its own natural size.
134        let bound_width = self.width.as_ref().map(|r| r.get());
135        let bound_height = self.height.as_ref().map(|r| r.get());
136        let child_proposal = SizeProposal {
137            width: bound_width,
138            height: bound_height,
139        };
140        let child_size = self
141            .child_id
142            .and_then(|id| ctx.child_size(id, child_proposal))
143            .unwrap_or(Size::ZERO);
144
145        let w = bound_width.unwrap_or(child_size.width);
146        let h = bound_height.unwrap_or(child_size.height);
147        Size::new(w, h).into()
148    }
149
150    fn place_children(
151        &self,
152        bounds: Rect,
153        _proposal: SizeProposal,
154        children: &mut [WidgetPlacement],
155        _ctx: &LayoutContext,
156    ) {
157        for child in children.iter_mut() {
158            child.origin = bounds.origin();
159            child.size = bounds.size();
160        }
161    }
162
163    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
164
165    fn children(&self) -> Vec<WidgetId> {
166        self.child_id.into_iter().collect()
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use teksilo_core::signal::Signal;
174    use teksilo_core::widget_tree::WidgetTree;
175
176    #[derive(Debug)]
177    struct FixedLeaf(f32, f32);
178    impl Widget for FixedLeaf {
179        fn layout_response(
180            &self,
181            _proposal: SizeProposal,
182            _ctx: &LayoutContext,
183        ) -> teksilo_core::widget::LayoutResponse {
184            Size::new(self.0, self.1).into()
185        }
186    }
187
188    #[test]
189    fn reports_child_natural_size() {
190        let mut tree = WidgetTree::new();
191        let child = tree.add(FixedLeaf(40.0, 20.0));
192        let fixed = tree.add(FixedSize::new().child(child));
193        tree.layout(SizeProposal::unspecified());
194
195        let fb = tree.bounds(fixed);
196        assert!((fb.width - 40.0).abs() < 0.01);
197        assert!((fb.height - 20.0).abs() < 0.01);
198    }
199
200    #[test]
201    fn ignores_parent_proposal() {
202        let mut tree = WidgetTree::new();
203        let child = tree.add(FixedLeaf(40.0, 20.0));
204        let fixed = tree.add(FixedSize::new().child(child));
205        tree.layout(SizeProposal::unspecified());
206
207        let fb = tree.bounds(fixed);
208        assert!((fb.width - 40.0).abs() < 0.01);
209        assert!((fb.height - 20.0).abs() < 0.01);
210    }
211
212    #[test]
213    fn width_constrains_size() {
214        let width = Signal::new(150.0_f32);
215        let mut tree = WidgetTree::new();
216        let child = tree.add(FixedLeaf(40.0, 20.0));
217        let fixed = tree.add(FixedSize::new().width(width.clone()).child(child));
218        tree.layout(SizeProposal::unspecified());
219
220        let fb = tree.bounds(fixed);
221        assert!((fb.width - 150.0).abs() < 0.01); // bound width
222        assert!((fb.height - 20.0).abs() < 0.01); // child's natural height
223    }
224
225    #[test]
226    fn width_triggers_relayout_on_change() {
227        let width = Signal::new(200.0_f32);
228        let mut tree = WidgetTree::new();
229        let child = tree.add(FixedLeaf(40.0, 20.0));
230        let fixed = tree.add(FixedSize::new().width(width.clone()).child(child));
231        tree.layout(SizeProposal::unspecified());
232        assert!((tree.bounds(fixed).width - 200.0).abs() < 0.01);
233
234        width.set(100.0);
235        tree.layout(SizeProposal::unspecified());
236        assert!((tree.bounds(fixed).width - 100.0).abs() < 0.01);
237    }
238}