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}