Skip to main content

teksilo_widgets/primitives/
max_size.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! MaxSize — a layout modifier that caps a child to a maximum width and/or height.
5//!
6//! The child is proposed the lesser of the parent's proposal and the configured
7//! maximum on each axis; the reported size is then clamped again so a child that
8//! intrinsically overshoots the cap is always contained. Axes with no maximum set
9//! are passed through unchanged.
10//!
11//! `MaxSize` clips its child when a maximum is active (`clips_children() == true`)
12//! so content that still overflows after layout does not bleed into adjacent widgets.
13//! Maximum values can be static or bound to a reactive [`Signal<f32>`](teksilo_core::signal::Signal)
14//! for animated or data-driven constraints.
15//!
16//! For the inverse operation (ensuring a minimum size) see [`MinSize`](super::MinSize).
17//!
18//! ```rust
19//! # use teksilo_widgets::primitives::{MaxSize, TextWidget};
20//! # use teksilo_i18n::lit;
21//! // Cap a text widget to 240 logical pixels wide.
22//! let _w = MaxSize::width(240.0)
23//!     .child(TextWidget::new(lit!("This text will not exceed 240 dp.")));
24//! ```
25
26use teksilo_canvas::{Rect, Size, SizeProposal};
27use teksilo_core::accessibility::AccessNodeBuilder;
28use teksilo_core::signal::Prop;
29use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
30use teksilo_core::widget_id::WidgetId;
31
32/// Layout modifier that enforces a maximum width and/or height on a single child widget.
33///
34/// Constraints can be static or bound to a reactive `Signal<f32>` for dynamic resizing.
35#[derive(Debug)]
36pub struct MaxSize {
37    child_id: Option<WidgetId>,
38    pending_child: Option<PendingChild>,
39    max_width: Option<Prop<f32>>,
40    max_height: Option<Prop<f32>>,
41}
42
43impl MaxSize {
44    /// Cap both axes: the child's width will not exceed `width` and its height will not exceed `height`.
45    pub fn new(width: f32, height: f32) -> Self {
46        Self {
47            child_id: None,
48            pending_child: None,
49            max_width: Some(Prop::Static(width)),
50            max_height: Some(Prop::Static(height)),
51        }
52    }
53
54    /// Cap only the width axis; the height axis is unconstrained by this modifier.
55    pub fn width(width: f32) -> Self {
56        Self {
57            child_id: None,
58            pending_child: None,
59            max_width: Some(Prop::Static(width)),
60            max_height: None,
61        }
62    }
63
64    /// Cap only the height axis; the width axis is unconstrained by this modifier.
65    pub fn height(height: f32) -> Self {
66        Self {
67            child_id: None,
68            pending_child: None,
69            max_width: None,
70            max_height: Some(Prop::Static(height)),
71        }
72    }
73
74    /// Bind max width to a reactive state.
75    pub fn max_width(mut self, state: impl Into<Prop<f32>>) -> Self {
76        self.max_width = Some(state.into());
77        self
78    }
79
80    /// Bind max height to a reactive state.
81    pub fn max_height(mut self, state: impl Into<Prop<f32>>) -> Self {
82        self.max_height = Some(state.into());
83        self
84    }
85
86    /// Set an inline child widget (deferred insertion).
87    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
88        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
89        self
90    }
91    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
92    ///
93    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
94    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
95    /// adds no arena node, so nothing is laid out, painted, or published to the
96    /// accessibility tree, and a stack applies no spacing around it.
97    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
98        match widget {
99            Some(w) => self.child(w),
100            None => self,
101        }
102    }
103}
104
105impl Widget for MaxSize {
106    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
107        if let Some(pending) = self.pending_child.take() {
108            self.child_id = Some(match pending {
109                PendingChild::Id(id) => id,
110                PendingChild::Deferred(w) => ctx.add_boxed(w),
111            });
112        }
113        let self_id = ctx.self_id();
114        let registry = ctx.binding_registry();
115        if let Some(ref w) = self.max_width {
116            w.register_if_bound(
117                self_id,
118                registry,
119                teksilo_core::binding::BindingLevel::Relayout,
120            );
121        }
122        if let Some(ref h) = self.max_height {
123            h.register_if_bound(
124                self_id,
125                registry,
126                teksilo_core::binding::BindingLevel::Relayout,
127            );
128        }
129        self.child_id.into_iter().collect()
130    }
131
132    fn layout_response(
133        &self,
134        proposal: SizeProposal,
135        ctx: &LayoutContext,
136    ) -> teksilo_core::widget::LayoutResponse {
137        let max_w = self.max_width.as_ref().map(|r| r.get());
138        let max_h = self.max_height.as_ref().map(|r| r.get());
139
140        let clamped_proposal = SizeProposal {
141            width: match (proposal.width, max_w) {
142                (Some(w), Some(max)) => Some(w.min(max)),
143                (None, Some(max)) => Some(max),
144                (w, None) => w,
145            },
146            height: match (proposal.height, max_h) {
147                (Some(h), Some(max)) => Some(h.min(max)),
148                (None, Some(max)) => Some(max),
149                (h, None) => h,
150            },
151        };
152
153        let child_size = self
154            .child_id
155            .and_then(|id| ctx.child_size(id, clamped_proposal))
156            .unwrap_or(Size::ZERO);
157
158        let w = match max_w {
159            Some(max) => child_size.width.min(max),
160            None => child_size.width,
161        };
162        let h = match max_h {
163            Some(max) => child_size.height.min(max),
164            None => child_size.height,
165        };
166        Size::new(w, h).into()
167    }
168
169    fn place_children(
170        &self,
171        bounds: Rect,
172        _proposal: SizeProposal,
173        children: &mut [WidgetPlacement],
174        _ctx: &LayoutContext,
175    ) {
176        for child in children.iter_mut() {
177            child.origin = bounds.origin();
178            child.size = bounds.size();
179        }
180    }
181
182    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
183
184    fn clips_children(&self) -> bool {
185        self.max_width.is_some() || self.max_height.is_some()
186    }
187
188    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
189        // A layout wrapper says nothing of its own: a bare `GenericContainer`,
190        // which the walker prunes and every adapter drops with its child
191        // kept. Never `set_hidden()`, which an adapter reads as hiding the
192        // whole subtree: whatever this wraps, a capped menu's rows among them, went with it.
193        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
194    }
195
196    fn children(&self) -> Vec<WidgetId> {
197        self.child_id.into_iter().collect()
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use teksilo_core::signal::Signal;
205    use teksilo_core::widget_tree::WidgetTree;
206
207    #[derive(Debug)]
208    struct FixedLeaf(f32, f32);
209    impl Widget for FixedLeaf {
210        fn layout_response(
211            &self,
212            _proposal: SizeProposal,
213            _ctx: &LayoutContext,
214        ) -> teksilo_core::widget::LayoutResponse {
215            Size::new(self.0, self.1).into()
216        }
217    }
218
219    #[test]
220    fn clamps_large_child_to_maximum() {
221        let mut tree = WidgetTree::new();
222        let child = tree.add(FixedLeaf(800.0, 600.0));
223        let max = tree.add(MaxSize::new(400.0, 300.0).child(child));
224        tree.layout(SizeProposal::unspecified());
225
226        let mb = tree.bounds(max);
227        assert!((mb.width - 400.0).abs() < 0.01);
228        assert!((mb.height - 300.0).abs() < 0.01);
229    }
230
231    #[test]
232    fn small_child_is_not_clamped() {
233        let mut tree = WidgetTree::new();
234        let child = tree.add(FixedLeaf(100.0, 50.0));
235        let max = tree.add(MaxSize::new(400.0, 300.0).child(child));
236        tree.layout(SizeProposal::unspecified());
237
238        let mb = tree.bounds(max);
239        assert!((mb.width - 100.0).abs() < 0.01);
240        assert!((mb.height - 50.0).abs() < 0.01);
241    }
242
243    #[test]
244    fn max_width_only() {
245        let mut tree = WidgetTree::new();
246        let child = tree.add(FixedLeaf(800.0, 50.0));
247        let max = tree.add(MaxSize::width(400.0).child(child));
248        tree.layout(SizeProposal::unspecified());
249
250        let mb = tree.bounds(max);
251        assert!((mb.width - 400.0).abs() < 0.01);
252        assert!((mb.height - 50.0).abs() < 0.01);
253    }
254
255    #[test]
256    fn max_width_dynamic() {
257        let max_w = Signal::new(400.0_f32);
258        let mut tree = WidgetTree::new();
259        let child = tree.add(FixedLeaf(800.0, 50.0));
260        let max = tree.add(MaxSize::width(9999.0).max_width(max_w.clone()).child(child));
261        tree.layout(SizeProposal::unspecified());
262        assert!((tree.bounds(max).width - 400.0).abs() < 0.01);
263
264        max_w.set(200.0);
265        tree.layout(SizeProposal::unspecified());
266        assert!((tree.bounds(max).width - 200.0).abs() < 0.01);
267    }
268}