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 child by pre-registered ID.
60    pub fn child_id(mut self, id: WidgetId) -> Self {
61        self.pending_child = Some(PendingChild::Id(id));
62        self
63    }
64
65    /// Set an inline child widget (deferred insertion).
66    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
67        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
68        self
69    }
70
71    /// Bind width to a reactive state. When the state changes, relayout is triggered.
72    pub fn width(mut self, state: impl Into<Prop<f32>>) -> Self {
73        self.width = Some(state.into());
74        self
75    }
76
77    /// Bind height to a reactive state. When the state changes, relayout is triggered.
78    pub fn height(mut self, state: impl Into<Prop<f32>>) -> Self {
79        self.height = Some(state.into());
80        self
81    }
82}
83
84impl Default for FixedSize {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90impl Widget for FixedSize {
91    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
92        if let Some(pending) = self.pending_child.take() {
93            self.child_id = Some(match pending {
94                PendingChild::Id(id) => id,
95                PendingChild::Deferred(w) => ctx.add_boxed(w),
96            });
97        }
98        // Register reactive bindings
99        let self_id = ctx.self_id();
100        let registry = ctx.binding_registry();
101        if let Some(ref w) = self.width {
102            w.register_if_bound(
103                self_id,
104                registry,
105                teksilo_core::binding::BindingLevel::Relayout,
106            );
107        }
108        if let Some(ref h) = self.height {
109            h.register_if_bound(
110                self_id,
111                registry,
112                teksilo_core::binding::BindingLevel::Relayout,
113            );
114        }
115        self.child_id.into_iter().collect()
116    }
117
118    fn layout_response(
119        &self,
120        _proposal: SizeProposal,
121        ctx: &LayoutContext,
122    ) -> teksilo_core::widget::LayoutResponse {
123        // Forward the bound width/height to the child as its size proposal so
124        // wrap-aware children (TextWidget in TextOverflow::Wrap, ScrollArea,
125        // etc.) can compute their intrinsic cross-axis size against the same
126        // constraint we will place them into. Unbound dimensions stay
127        // unspecified so the child falls back to its own natural size.
128        let bound_width = self.width.as_ref().map(|r| r.get());
129        let bound_height = self.height.as_ref().map(|r| r.get());
130        let child_proposal = SizeProposal {
131            width: bound_width,
132            height: bound_height,
133        };
134        let child_size = self
135            .child_id
136            .and_then(|id| ctx.child_size(id, child_proposal))
137            .unwrap_or(Size::ZERO);
138
139        let w = bound_width.unwrap_or(child_size.width);
140        let h = bound_height.unwrap_or(child_size.height);
141        Size::new(w, h).into()
142    }
143
144    fn place_children(
145        &self,
146        bounds: Rect,
147        _proposal: SizeProposal,
148        children: &mut [WidgetPlacement],
149        _ctx: &LayoutContext,
150    ) {
151        for child in children.iter_mut() {
152            child.origin = bounds.origin();
153            child.size = bounds.size();
154        }
155    }
156
157    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
158
159    fn children(&self) -> Vec<WidgetId> {
160        self.child_id.into_iter().collect()
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use teksilo_core::signal::Signal;
168    use teksilo_core::widget_tree::WidgetTree;
169
170    #[derive(Debug)]
171    struct FixedLeaf(f32, f32);
172    impl Widget for FixedLeaf {
173        fn layout_response(
174            &self,
175            _proposal: SizeProposal,
176            _ctx: &LayoutContext,
177        ) -> teksilo_core::widget::LayoutResponse {
178            Size::new(self.0, self.1).into()
179        }
180    }
181
182    #[test]
183    fn reports_child_natural_size() {
184        let mut tree = WidgetTree::new();
185        let child = tree.add(FixedLeaf(40.0, 20.0));
186        let fixed = tree.add(FixedSize::new().child_id(child));
187        tree.layout(SizeProposal::unspecified());
188
189        let fb = tree.bounds(fixed);
190        assert!((fb.width - 40.0).abs() < 0.01);
191        assert!((fb.height - 20.0).abs() < 0.01);
192    }
193
194    #[test]
195    fn ignores_parent_proposal() {
196        let mut tree = WidgetTree::new();
197        let child = tree.add(FixedLeaf(40.0, 20.0));
198        let fixed = tree.add(FixedSize::new().child_id(child));
199        tree.layout(SizeProposal::unspecified());
200
201        let fb = tree.bounds(fixed);
202        assert!((fb.width - 40.0).abs() < 0.01);
203        assert!((fb.height - 20.0).abs() < 0.01);
204    }
205
206    #[test]
207    fn width_constrains_size() {
208        let width = Signal::new(150.0_f32);
209        let mut tree = WidgetTree::new();
210        let child = tree.add(FixedLeaf(40.0, 20.0));
211        let fixed = tree.add(FixedSize::new().width(width.clone()).child_id(child));
212        tree.layout(SizeProposal::unspecified());
213
214        let fb = tree.bounds(fixed);
215        assert!((fb.width - 150.0).abs() < 0.01); // bound width
216        assert!((fb.height - 20.0).abs() < 0.01); // child's natural height
217    }
218
219    #[test]
220    fn width_triggers_relayout_on_change() {
221        let width = Signal::new(200.0_f32);
222        let mut tree = WidgetTree::new();
223        let child = tree.add(FixedLeaf(40.0, 20.0));
224        let fixed = tree.add(FixedSize::new().width(width.clone()).child_id(child));
225        tree.layout(SizeProposal::unspecified());
226        assert!((tree.bounds(fixed).width - 200.0).abs() < 0.01);
227
228        width.set(100.0);
229        tree.layout(SizeProposal::unspecified());
230        assert!((tree.bounds(fixed).width - 100.0).abs() < 0.01);
231    }
232}