Skip to main content

teksilo_widgets/primitives/
padding.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Padding — a single-child layout container that adds insets around its child.
5//!
6//! `Padding` shrink-wraps a child widget and enlarges it by configurable insets
7//! on each of the four sides. Horizontal insets are **leading/trailing**
8//! (logical), not left/right (physical), so they flip automatically in RTL
9//! locales. Each inset accepts a static `f32` or a reactive `Signal<f32>`; a
10//! bound inset schedules a relayout whenever the signal fires, so theme-derived
11//! spacing values take effect without rebuilding the widget tree.
12//!
13//! The grow weight, shrink weight, and compression floor reported by the child
14//! are forwarded through the padding so a flexible or shrinkable child inside a
15//! `Padding` stays flexible or shrinkable from the parent's perspective.
16//!
17//! ## When to use
18//!
19//! - Adding whitespace around a widget without wrapping it in a stack.
20//! - Applying asymmetric insets (e.g. extra leading inset for a list item).
21//! - Reacting to a `Signal`-driven spacing token.
22//!
23//! Use [`Padding::uniform`] when all four sides are equal, and
24//! [`Padding::symmetric`] when horizontal and vertical insets differ.
25//!
26//! ```rust
27//! # use teksilo_widgets::primitives::{Padding, TextWidget};
28//! # use teksilo_i18n::lit;
29//! // 12 dp padding on every side:
30//! let _w = Padding::uniform(12.0)
31//!     .child(TextWidget::new(lit!("Hello")));
32//! ```
33
34use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
35
36use teksilo_core::WidgetId;
37use teksilo_core::accessibility::AccessNodeBuilder;
38use teksilo_core::signal::Prop;
39use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
40
41/// A layout container that adds padding (insets) around a single child.
42///
43/// See the [module documentation](self) for the full feature description and
44/// an example. Construct with [`Padding::new`], [`Padding::uniform`], or
45/// [`Padding::symmetric`]; attach a child with `.child(widget)` or
46/// `.child(id)`.
47#[derive(Debug)]
48pub struct Padding {
49    top: Prop<f32>,
50    trailing: Prop<f32>,
51    bottom: Prop<f32>,
52    leading: Prop<f32>,
53    child_id: Option<WidgetId>,
54    pending_child: Option<PendingChild>,
55}
56
57impl Padding {
58    /// Create a padding with explicit per-side insets.
59    ///
60    /// Argument order mirrors CSS shorthand: `(top, trailing, bottom, leading)`.
61    /// `trailing` and `leading` are **logical** — they map to physical right and
62    /// left in LTR and are swapped in RTL.
63    pub fn new(
64        top: impl Into<Prop<f32>>,
65        trailing: impl Into<Prop<f32>>,
66        bottom: impl Into<Prop<f32>>,
67        leading: impl Into<Prop<f32>>,
68    ) -> Self {
69        Self {
70            top: top.into(),
71            trailing: trailing.into(),
72            bottom: bottom.into(),
73            leading: leading.into(),
74            child_id: None,
75            pending_child: None,
76        }
77    }
78
79    /// Create a padding with the same inset on all four sides.
80    pub fn uniform(amount: impl Into<Prop<f32>>) -> Self {
81        let amount = amount.into();
82        Self {
83            top: amount.clone(),
84            trailing: amount.clone(),
85            bottom: amount.clone(),
86            leading: amount,
87            child_id: None,
88            pending_child: None,
89        }
90    }
91
92    /// Create a padding with equal top/bottom insets and equal leading/trailing insets.
93    ///
94    /// `vertical` applies to both top and bottom; `horizontal` applies to both
95    /// leading and trailing sides (logical, RTL-aware).
96    pub fn symmetric(vertical: impl Into<Prop<f32>>, horizontal: impl Into<Prop<f32>>) -> Self {
97        let vertical = vertical.into();
98        let horizontal = horizontal.into();
99        Self {
100            top: vertical.clone(),
101            trailing: horizontal.clone(),
102            bottom: vertical,
103            leading: horizontal,
104            child_id: None,
105            pending_child: None,
106        }
107    }
108
109    /// Set an inline child widget (deferred insertion).
110    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
111        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
112        self
113    }
114    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
115    ///
116    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
117    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
118    /// adds no arena node, so nothing is laid out, painted, or published to the
119    /// accessibility tree, and a stack applies no spacing around it.
120    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
121        match widget {
122            Some(w) => self.child(w),
123            None => self,
124        }
125    }
126
127    fn horizontal_inset(&self) -> f32 {
128        self.leading.get() + self.trailing.get()
129    }
130
131    fn vertical_inset(&self) -> f32 {
132        self.top.get() + self.bottom.get()
133    }
134}
135
136impl Widget for Padding {
137    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
138        if let Some(pending) = self.pending_child.take() {
139            self.child_id = Some(match pending {
140                PendingChild::Id(id) => id,
141                PendingChild::Deferred(w) => ctx.add_boxed(w),
142            });
143        }
144        // Register each inset prop for dirty-tracking so bound insets
145        // (e.g. a theme-derived signal) trigger a relayout when they fire.
146        let self_id = ctx.self_id();
147        let registry = ctx.binding_registry();
148        self.top.register_if_bound(
149            self_id,
150            registry,
151            teksilo_core::binding::BindingLevel::Relayout,
152        );
153        self.trailing.register_if_bound(
154            self_id,
155            registry,
156            teksilo_core::binding::BindingLevel::Relayout,
157        );
158        self.bottom.register_if_bound(
159            self_id,
160            registry,
161            teksilo_core::binding::BindingLevel::Relayout,
162        );
163        self.leading.register_if_bound(
164            self_id,
165            registry,
166            teksilo_core::binding::BindingLevel::Relayout,
167        );
168        self.child_id.into_iter().collect()
169    }
170
171    fn layout_response(
172        &self,
173        proposal: SizeProposal,
174        ctx: &LayoutContext,
175    ) -> teksilo_core::widget::LayoutResponse {
176        let h_inset = self.horizontal_inset();
177        let v_inset = self.vertical_inset();
178
179        // Query the child, then add insets — forwarding its grow weight,
180        // shrink weight, and compression floor so a padded flexible/shrinkable
181        // child stays flexible/shrinkable (the floor grows by the insets).
182        if let Some(child_id) = self.child_id {
183            let inner_proposal = SizeProposal {
184                width: proposal.width.map(|w| (w - h_inset).max(0.0)),
185                height: proposal.height.map(|h| (h - v_inset).max(0.0)),
186            };
187            if let Some(r) = ctx.child_layout_response(child_id, inner_proposal) {
188                let size = Size::new(r.size.width + h_inset, r.size.height + v_inset);
189                let min = Size::new(r.min.width + h_inset, r.min.height + v_inset);
190                return teksilo_core::widget::LayoutResponse::flexible(size, r.flex)
191                    .with_shrink(r.shrink)
192                    .with_min(min);
193            }
194        }
195
196        let size = proposal.resolve(h_inset, v_inset);
197        Size::new(size.width.max(h_inset), size.height.max(v_inset)).into()
198    }
199
200    fn place_children(
201        &self,
202        bounds: Rect,
203        _proposal: SizeProposal,
204        children: &mut [WidgetPlacement],
205        ctx: &LayoutContext,
206    ) {
207        let top = self.top.get();
208        let h_inset = self.horizontal_inset();
209        let v_inset = self.vertical_inset();
210        // Flip leading/trailing to physical left/right for RTL locales.
211        let phys_left = if ctx.is_rtl() {
212            self.trailing.get()
213        } else {
214            self.leading.get()
215        };
216        for child in children.iter_mut() {
217            child.origin = Point::new(bounds.x + phys_left, bounds.y + top);
218            child.size = Size::new(
219                (bounds.width - h_inset).max(0.0),
220                (bounds.height - v_inset).max(0.0),
221            );
222        }
223    }
224
225    fn paint(&self, _bounds: Rect, _canvas: &mut Canvas, _ctx: &PaintContext) {}
226
227    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {}
228
229    fn children(&self) -> Vec<WidgetId> {
230        self.child_id.into_iter().collect()
231    }
232}