Skip to main content

frust_widgets/
divider.rs

1//! `Divider`: a themeless hairline separator.
2//!
3//! [`divider`] builds a horizontal line ([`DividerView`]) by default —
4//! [`DividerView::vertical`] switches it to a vertical one. Either way the
5//! divider is a leaf: no child, no events, no semantics node of its own
6//! (mirrors [`crate::sized::SizedBox`]'s childless-spacer shape, minus the
7//! spacer's own nothing-painted case — a divider always paints its line).
8//!
9//! # Sizing
10//!
11//! A horizontal divider (the default) fills the incoming max **width**
12//! (the cross axis of the vertical `Column` it typically separates rows
13//! inside) and takes a fixed `.thickness(...)` on the **height** — its own
14//! main axis, in the sense that a `Column`'s main axis is vertical.
15//! [`DividerView::vertical`] flips both: fixed thickness on width, fills the
16//! incoming max height (the typical `Row`-of-panels separator).
17//!
18//! The fill axis uses the incoming `bc.max()` when it is a real bound, but
19//! **collapses to `bc.min()`** (usually `0`) when that axis is unbounded
20//! (`max == f64::INFINITY`) — the shape `Flex`'s inflexible-child
21//! intrinsic-probe pass, `ScrollView`'s content layout, and `ListView`'s row
22//! layout all hand a child measuring its natural extent along that axis. A
23//! fixed "larger than any real viewport" sentinel cannot stand in for the
24//! fill axis here: `BoxConstraints::constrain`'s clamp is a no-op against an
25//! infinite max (`x.clamp(min, f64::INFINITY)` never lowers `x`), so any
26//! such sentinel would leak through unclamped and report a fictitious
27//! multi-thousand-pixel divider to the caller. So a divider dropped into a
28//! genuinely unbounded axis (no enclosing `Column`/`Row`/other bound
29//! constraining it) is effectively invisible on that axis — `0`-width (or
30//! `0`-height) under a loose incoming minimum — rather than growing
31//! unbounded; the thickness axis is unaffected either way. See
32//! [`crate::container`]'s `.expand()` doc for the same collapse rule, shared
33//! with `Container`'s childless expand case.
34//!
35//! # Color: required, no `Theme` dependency
36//!
37//! Unlike `Text`/`Button`/`Checkbox`/`Slider` — every themed baseline widget
38//! that resolves a default color via `Theme::from_paint_ctx`/
39//! `from_layout_ctx` with an unthemed-fallback constant (see
40//! `docs/WIDGETS_CODE_STANDARDS.md`'s token-resolution precedence) —
41//! `divider` has no `Theme` dependency at all and no default color,
42//! matching [`crate::container`]'s own theme-free design (that module never
43//! touches `Theme`, and its `.fill`/`.border` colors have no default either).
44//! No baseline widget sources a default paint color *without* consulting
45//! `Theme` first, so there is no neutral constant to fall back to here that
46//! wouldn't itself need `Theme` — [`divider`] instead takes `color` as a
47//! required constructor argument, overridable via [`DividerView::color`].
48
49use frust_core::{
50    BoxConstraints, BuildCtx, ChangeFlags, LayoutCtx, PaintCtx, PaintScene, View, Widget,
51};
52use kurbo::Size;
53use peniko::Color;
54
55/// The hairline thickness [`divider`] uses unless overridden via
56/// [`DividerView::thickness`] — a standard 1px separator (Material's
57/// `Divider` spec and the Human Interface Guidelines' hairline separator
58/// both converge on 1px at 1x scale).
59const DEFAULT_THICKNESS: f64 = 1.0;
60
61/// A declarative hairline separator. See the [module docs](self).
62pub struct DividerView {
63    color: Color,
64    thickness: f64,
65    vertical: bool,
66}
67
68/// A horizontal hairline of `color`, [`DEFAULT_THICKNESS`] (1px) thick,
69/// filling the available width. `color` is a required argument, not
70/// defaulted — see the [module docs](self)' "Color: required, no `Theme`
71/// dependency" section.
72pub fn divider(color: Color) -> DividerView {
73    DividerView {
74        color,
75        thickness: DEFAULT_THICKNESS,
76        vertical: false,
77    }
78}
79
80impl DividerView {
81    /// Override the line color set at construction.
82    pub fn color(mut self, color: Color) -> Self {
83        self.color = color;
84        self
85    }
86
87    /// Set the line's thickness, in logical px. [`DEFAULT_THICKNESS`] (1px)
88    /// by default.
89    pub fn thickness(mut self, thickness: f64) -> Self {
90        self.thickness = thickness;
91        self
92    }
93
94    /// Switch to a vertical line: fixed thickness on width, filling the
95    /// available height — the typical separator between side-by-side
96    /// panels. Horizontal (fixed thickness on height, filling width) by
97    /// default; see the [module docs](self)' "Sizing" section.
98    pub fn vertical(mut self) -> Self {
99        self.vertical = true;
100        self
101    }
102}
103
104/// The retained widget for a [`DividerView`]. See the [module docs](self).
105pub struct DividerWidget {
106    color: Color,
107    thickness: f64,
108    vertical: bool,
109}
110
111impl<State: 'static> View<State> for DividerView {
112    type Element = DividerWidget;
113
114    fn build(&self, _ctx: &mut BuildCtx<'_>) -> DividerWidget {
115        DividerWidget {
116            color: self.color,
117            thickness: self.thickness,
118            vertical: self.vertical,
119        }
120    }
121
122    fn rebuild(
123        &self,
124        prev: &Self,
125        element: &mut DividerWidget,
126        _ctx: &mut BuildCtx<'_>,
127    ) -> ChangeFlags {
128        let mut flags = ChangeFlags::NONE;
129        if prev.color != self.color {
130            element.color = self.color;
131            flags |= ChangeFlags::PAINT;
132        }
133        if prev.thickness != self.thickness || prev.vertical != self.vertical {
134            element.thickness = self.thickness;
135            element.vertical = self.vertical;
136            flags |= ChangeFlags::LAYOUT;
137        }
138        flags
139    }
140}
141
142impl Widget for DividerWidget {
143    fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
144        // Fill axis: the incoming max when it's a real bound, else collapse
145        // to the incoming min (see the module docs' "Sizing" section) —
146        // `BoxConstraints::constrain`'s clamp can't be relied on to cap an
147        // unbounded max, so a finite "large enough" intrinsic would leak
148        // straight through unclamped.
149        let fill = |max: f64, min: f64| if max.is_finite() { max } else { min };
150        let intrinsic = if self.vertical {
151            Size::new(self.thickness, fill(bc.max().height, bc.min().height))
152        } else {
153            Size::new(fill(bc.max().width, bc.min().width), self.thickness)
154        };
155        bc.constrain(intrinsic)
156    }
157
158    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
159        scene.fill_rect(ctx.origin(), ctx.size(), self.color);
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use frust_core::BuildCtx;
167    use kurbo::Point;
168
169    fn build(view: &DividerView) -> DividerWidget {
170        let mut counter = 0u64;
171        View::<()>::build(view, &mut BuildCtx::new(&mut counter))
172    }
173
174    // ---- Layout: fills cross-axis, thickness on main -----------------
175
176    #[test]
177    fn horizontal_divider_fills_width_and_takes_thickness_on_height() {
178        let view = divider(Color::BLACK);
179        let mut w = build(&view);
180        let mut lctx = LayoutCtx::new();
181        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(320.0, 640.0)));
182        assert_eq!(size, Size::new(320.0, DEFAULT_THICKNESS));
183    }
184
185    #[test]
186    fn horizontal_divider_honors_a_custom_thickness() {
187        let view = divider(Color::BLACK).thickness(4.0);
188        let mut w = build(&view);
189        let mut lctx = LayoutCtx::new();
190        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
191        assert_eq!(size, Size::new(200.0, 4.0));
192    }
193
194    #[test]
195    fn vertical_divider_fills_height_and_takes_thickness_on_width() {
196        let view = divider(Color::BLACK).vertical();
197        let mut w = build(&view);
198        let mut lctx = LayoutCtx::new();
199        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(320.0, 640.0)));
200        assert_eq!(size, Size::new(DEFAULT_THICKNESS, 640.0));
201    }
202
203    #[test]
204    fn a_tight_constraint_forces_the_divider_to_it_regardless_of_thickness() {
205        // `BoxConstraints::constrain` always wins — mirrors `Container`'s own
206        // `.expand()` precedent under a tight constraint.
207        let view = divider(Color::BLACK);
208        let mut w = build(&view);
209        let mut lctx = LayoutCtx::new();
210        let size = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(50.0, 50.0)));
211        assert_eq!(size, Size::new(50.0, 50.0));
212    }
213
214    #[test]
215    fn an_unbounded_fill_axis_collapses_to_the_incoming_minimum() {
216        // No enclosing Column/Row bounding the fill axis (width, here):
217        // `BoxConstraints::constrain`'s clamp is a no-op against an infinite
218        // max, so the fill axis instead collapses to `bc.min()` (zero under
219        // a loose incoming constraint) — the same "no real max to fill"
220        // fallback `Container::expand` shares (see `container.rs`'s module
221        // doc). The thickness axis (height) is unaffected either way.
222        let view = divider(Color::BLACK);
223        let mut w = build(&view);
224        let mut lctx = LayoutCtx::new();
225        let bc = BoxConstraints::loose(Size::new(f64::INFINITY, 200.0));
226        let size = w.layout(&mut lctx, &bc);
227        assert_eq!(size, Size::new(0.0, DEFAULT_THICKNESS));
228    }
229
230    #[test]
231    fn an_unbounded_fill_axis_collapses_to_a_nonzero_incoming_minimum() {
232        // A non-zero `bc.min()` on the unbounded axis is honored, not
233        // silently zeroed — the collapse target is genuinely `bc.min()`,
234        // not a hardcoded zero. The thickness axis (width, here) stays
235        // loose enough that its own minimum doesn't also force it.
236        let view = divider(Color::BLACK).vertical();
237        let mut w = build(&view);
238        let mut lctx = LayoutCtx::new();
239        let bc = BoxConstraints::new(Size::new(0.0, 12.0), Size::new(500.0, f64::INFINITY));
240        let size = w.layout(&mut lctx, &bc);
241        assert_eq!(size, Size::new(DEFAULT_THICKNESS, 12.0));
242    }
243
244    // ---- Paint ---------------------------------------------------------
245
246    #[derive(Default)]
247    struct Recorder {
248        rects: Vec<(Point, Size, Color)>,
249    }
250
251    impl PaintScene for Recorder {
252        fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
253            self.rects.push((origin, size, color));
254        }
255        fn draw_text(&mut self, _origin: Point, _text: &str) {}
256    }
257
258    #[test]
259    fn paint_fills_exactly_one_rect_in_the_divider_color() {
260        let view = divider(Color::from_rgb8(0xCC, 0xCC, 0xCC));
261        let mut w = build(&view);
262        let mut lctx = LayoutCtx::new();
263        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(100.0, 100.0)));
264
265        let mut rec = Recorder::default();
266        let mut pctx = PaintCtx::new(Point::ZERO, size);
267        w.paint(&mut pctx, &mut rec);
268
269        assert_eq!(
270            rec.rects,
271            vec![(Point::ZERO, size, Color::from_rgb8(0xCC, 0xCC, 0xCC))]
272        );
273    }
274
275    #[test]
276    fn color_override_wins_over_the_constructor_argument() {
277        let view = divider(Color::BLACK).color(Color::from_rgb8(0x11, 0x22, 0x33));
278        let mut w = build(&view);
279        let mut lctx = LayoutCtx::new();
280        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(10.0, 10.0)));
281
282        let mut rec = Recorder::default();
283        let mut pctx = PaintCtx::new(Point::ZERO, size);
284        w.paint(&mut pctx, &mut rec);
285
286        assert_eq!(
287            rec.rects,
288            vec![(Point::ZERO, size, Color::from_rgb8(0x11, 0x22, 0x33))]
289        );
290    }
291}