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(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 child by pre-registered ID.
110    pub fn child_id(mut self, id: WidgetId) -> Self {
111        self.pending_child = Some(PendingChild::Id(id));
112        self
113    }
114
115    /// Set an inline child widget (deferred insertion).
116    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
117        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
118        self
119    }
120
121    fn horizontal_inset(&self) -> f32 {
122        self.leading.get() + self.trailing.get()
123    }
124
125    fn vertical_inset(&self) -> f32 {
126        self.top.get() + self.bottom.get()
127    }
128}
129
130impl Widget for Padding {
131    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
132        if let Some(pending) = self.pending_child.take() {
133            self.child_id = Some(match pending {
134                PendingChild::Id(id) => id,
135                PendingChild::Deferred(w) => ctx.add_boxed(w),
136            });
137        }
138        // Register each inset prop for dirty-tracking so bound insets
139        // (e.g. a theme-derived signal) trigger a relayout when they fire.
140        let self_id = ctx.self_id();
141        let registry = ctx.binding_registry();
142        self.top.register_if_bound(
143            self_id,
144            registry,
145            teksilo_core::binding::BindingLevel::Relayout,
146        );
147        self.trailing.register_if_bound(
148            self_id,
149            registry,
150            teksilo_core::binding::BindingLevel::Relayout,
151        );
152        self.bottom.register_if_bound(
153            self_id,
154            registry,
155            teksilo_core::binding::BindingLevel::Relayout,
156        );
157        self.leading.register_if_bound(
158            self_id,
159            registry,
160            teksilo_core::binding::BindingLevel::Relayout,
161        );
162        self.child_id.into_iter().collect()
163    }
164
165    fn layout_response(
166        &self,
167        proposal: SizeProposal,
168        ctx: &LayoutContext,
169    ) -> teksilo_core::widget::LayoutResponse {
170        let h_inset = self.horizontal_inset();
171        let v_inset = self.vertical_inset();
172
173        // Query the child, then add insets — forwarding its grow weight,
174        // shrink weight, and compression floor so a padded flexible/shrinkable
175        // child stays flexible/shrinkable (the floor grows by the insets).
176        if let Some(child_id) = self.child_id {
177            let inner_proposal = SizeProposal {
178                width: proposal.width.map(|w| (w - h_inset).max(0.0)),
179                height: proposal.height.map(|h| (h - v_inset).max(0.0)),
180            };
181            if let Some(r) = ctx.child_layout_response(child_id, inner_proposal) {
182                let size = Size::new(r.size.width + h_inset, r.size.height + v_inset);
183                let min = Size::new(r.min.width + h_inset, r.min.height + v_inset);
184                return teksilo_core::widget::LayoutResponse::flexible(size, r.flex)
185                    .with_shrink(r.shrink)
186                    .with_min(min);
187            }
188        }
189
190        let size = proposal.resolve(h_inset, v_inset);
191        Size::new(size.width.max(h_inset), size.height.max(v_inset)).into()
192    }
193
194    fn place_children(
195        &self,
196        bounds: Rect,
197        _proposal: SizeProposal,
198        children: &mut [WidgetPlacement],
199        ctx: &LayoutContext,
200    ) {
201        let top = self.top.get();
202        let h_inset = self.horizontal_inset();
203        let v_inset = self.vertical_inset();
204        // Flip leading/trailing to physical left/right for RTL locales.
205        let phys_left = if ctx.is_rtl() {
206            self.trailing.get()
207        } else {
208            self.leading.get()
209        };
210        for child in children.iter_mut() {
211            child.origin = Point::new(bounds.x + phys_left, bounds.y + top);
212            child.size = Size::new(
213                (bounds.width - h_inset).max(0.0),
214                (bounds.height - v_inset).max(0.0),
215            );
216        }
217    }
218
219    fn paint(&self, _bounds: Rect, _canvas: &mut Canvas, _ctx: &PaintContext) {}
220
221    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {}
222
223    fn children(&self) -> Vec<WidgetId> {
224        self.child_id.into_iter().collect()
225    }
226}