Skip to main content

frust_material/
divider.rs

1//! The Material 3 Expressive `Divider`: a thin line grouping content in
2//! lists and containers, in either orientation, with optional leading/
3//! trailing insets.
4//!
5//! Ported from `material_3_expressive` v1.0.8's `M3EDivider`/`M3EDividerTheme`
6//! (MIT, © 2026 Paa Developments;
7//! `tmp/material_3_expressive/lib/components/divider/m3e_divider.dart`,
8//! `styles/m3e_divider_theme.dart`, retrieved 2026-08-20).
9//! Upstream: <https://github.com/paadevelopments/material_3_expressive>
10//!
11//! # Relationship to `frust::divider`
12//!
13//! Baseline `frust-widgets` already ships [`frust::divider`] — a themeless
14//! hairline with no default color, no insets, and a required `color` argument
15//! (see that module's own doc for why: divider has no `Theme` dependency at
16//! all, deliberately). This module reuses the plain `divider`/`DividerView`
17//! names in `frust_material` on purpose, the same cross-crate naming
18//! precedent [`mod@crate::checkbox`]/[`mod@crate::switch`] already set against
19//! their own baseline counterparts — an app picks exactly one by import path
20//! (`frust_material::divider` vs. `frust::divider`), never both at the same
21//! call site.
22//!
23//! [`divider`] layers the reference's three additions over that primitive:
24//! a theme-resolved default color (`outlineVariant`, `m3e_divider_theme.dart`'s
25//! `M3EDividerTheme::color`), leading/trailing insets (`indent`/`endIndent`),
26//! and a live per-repaint theme re-resolution baseline's own theme-free
27//! design can't offer.
28//!
29//! # Composition, not reimplementation
30//!
31//! [`DividerWidget::layout`]/[`DividerWidget::paint`] never draw a line of
32//! their own — both construct a genuine `frust::divider(..)` (via
33//! [`base_divider`]) and delegate the *entire* sizing algorithm (the
34//! fill-axis-collapse rule, thickness handling, tight-constraint clamping —
35//! see [`frust::authoring`]'s worked composition example and
36//! `frust-widgets`' `divider` module docs for what that buys) and the *entire*
37//! paint (`PaintScene::fill_rect`) to it. Only the leading/trailing inset
38//! arithmetic — reducing the fill axis before layout, offsetting the origin
39//! before paint, both a two-line rect adjustment rather than a drawing
40//! primitive — is this module's own.
41//!
42//! # Color resolution: paint-time, not build-time
43//!
44//! Unlike most of this crate's themed defaults (resolved once and cached),
45//! [`DividerWidget::paint`] resolves `outlineVariant` fresh from
46//! [`Theme::from_paint_ctx`] on *every* paint call, matching every other
47//! themed widget here (`crate::card`, `crate::list_item`, …). This is load
48//! -bearing, not decorative: `BuildCtx` carries no theme (see
49//! `crates/frust-core/src/view.rs`), and an app-forced theme flip
50//! (`frust::set_app_theme`/`clear_app_theme`) is explicitly documented as
51//! **not** tracked by any `Component::build` — only a widget's own paint/
52//! layout sees it live. Baking the resolved color into the `View` tree once
53//! (the `AvatarGroupCountView`-style delegation `frust-shadcn` uses elsewhere)
54//! would silently freeze the divider's color across a theme-only flip; this
55//! module resolves the default fresh every frame instead, at the small,
56//! well-contained cost of a throwaway `BuildCtx`/`View::build` pair per
57//! layout/paint call (harmless here: baseline `DividerView::build`/`rebuild`
58//! never touch `ctx`, so the scratch id counter is never read).
59//!
60//! # Color role: `MaterialTokens`-adjacent, not extension-scoped
61//!
62//! `outlineVariant` is a baseline `ColorScheme` role (`Theme::scheme()`), not
63//! one of the nine [`crate::MaterialTokens`]-only semantic roles (`emphasis`/
64//! `info`/`success`/…) — see `crate::tokens`'s module docs' role-partition
65//! table. This module therefore never touches `MaterialTokens` or
66//! `frust::StatusPalette`; it resolves the plain `ColorScheme` role a themed
67//! `Theme` always carries, honoring this crate's binding rule of never
68//! reaching for `StatusPalette` where a Material-native role exists.
69
70use frust::authoring::{
71    BoxConstraints, BuildCtx, ChangeFlags, LayoutCtx, PaintCtx, PaintScene, View, Widget,
72};
73use frust::{Theme, divider as base_divider};
74use kurbo::{Point, Size};
75use peniko::Color;
76
77/// The hairline thickness [`divider`] uses unless overridden via
78/// [`DividerView::thickness`] — matches baseline `frust::divider`'s own
79/// `DEFAULT_THICKNESS` (1px, `M3EDividerTheme.defaults`'s `thickness: 1`).
80const DEFAULT_THICKNESS: f64 = 1.0;
81
82/// Unthemed-fallback divider line color (a theme resolves this from
83/// `colors.outline_variant`) — the M3 baseline light `outlineVariant` tone,
84/// matching [`mod@crate::card`]'s own `OUTLINE_VARIANT` fallback exactly.
85const FALLBACK_OUTLINE_VARIANT: Color = Color::from_rgb8(0xCA, 0xC4, 0xD0);
86
87/// A declarative M3 Expressive divider. See the [module docs](self).
88#[derive(Clone, Copy, Debug, PartialEq)]
89pub struct DividerView {
90    vertical: bool,
91    thickness: f64,
92    indent: f64,
93    end_indent: f64,
94    color: Option<Color>,
95}
96
97/// A horizontal M3 Expressive divider, `outlineVariant`-colored unless
98/// overridden. See the [module docs](self).
99pub fn divider() -> DividerView {
100    DividerView {
101        vertical: false,
102        thickness: DEFAULT_THICKNESS,
103        indent: 0.0,
104        end_indent: 0.0,
105        color: None,
106    }
107}
108
109impl DividerView {
110    /// Switch to a vertical line (fixed thickness on width, filling height) —
111    /// mirrors [`frust::DividerView::vertical`]. Horizontal by default.
112    pub fn vertical(mut self) -> Self {
113        self.vertical = true;
114        self
115    }
116
117    /// Override the line's thickness, in logical px. [`DEFAULT_THICKNESS`]
118    /// (1px) by default.
119    pub fn thickness(mut self, thickness: f64) -> Self {
120        self.thickness = thickness;
121        self
122    }
123
124    /// Inset the line's leading edge (left for horizontal, top for vertical)
125    /// by `indent` logical px — the reference's `indent`. `0` by default.
126    pub fn indent(mut self, indent: f64) -> Self {
127        self.indent = indent;
128        self
129    }
130
131    /// Inset the line's trailing edge (right for horizontal, bottom for
132    /// vertical) by `end_indent` logical px — the reference's `endIndent`.
133    /// `0` by default. Setting both [`Self::indent`] and this produces the
134    /// reference's "middle" inset (both edges pulled in).
135    pub fn end_indent(mut self, end_indent: f64) -> Self {
136        self.end_indent = end_indent;
137        self
138    }
139
140    /// Override the theme-resolved `outlineVariant` default with an explicit
141    /// color.
142    pub fn color(mut self, color: Color) -> Self {
143        self.color = Some(color);
144        self
145    }
146}
147
148/// The retained widget for a [`DividerView`]. See the [module docs](self).
149pub struct DividerWidget {
150    vertical: bool,
151    thickness: f64,
152    indent: f64,
153    end_indent: f64,
154    color: Option<Color>,
155}
156
157impl<State: 'static> View<State> for DividerView {
158    type Element = DividerWidget;
159
160    fn build(&self, _ctx: &mut BuildCtx<'_>) -> DividerWidget {
161        DividerWidget {
162            vertical: self.vertical,
163            thickness: self.thickness,
164            indent: self.indent,
165            end_indent: self.end_indent,
166            color: self.color,
167        }
168    }
169
170    fn rebuild(
171        &self,
172        prev: &Self,
173        element: &mut DividerWidget,
174        _ctx: &mut BuildCtx<'_>,
175    ) -> ChangeFlags {
176        let mut flags = ChangeFlags::NONE;
177        if prev.color != self.color {
178            element.color = self.color;
179            flags |= ChangeFlags::PAINT;
180        }
181        if prev.vertical != self.vertical
182            || prev.thickness != self.thickness
183            || prev.indent != self.indent
184            || prev.end_indent != self.end_indent
185        {
186            element.vertical = self.vertical;
187            element.thickness = self.thickness;
188            element.indent = self.indent;
189            element.end_indent = self.end_indent;
190            flags |= ChangeFlags::LAYOUT;
191        }
192        flags
193    }
194}
195
196impl DividerWidget {
197    /// The `(leading, trailing)` inset amounts, clamped non-negative —
198    /// mirrors `frust::Padding`'s own clamp-negative-to-zero rule.
199    fn insets(&self) -> (f64, f64) {
200        (self.indent.max(0.0), self.end_indent.max(0.0))
201    }
202
203    /// Build a fresh, one-shot baseline [`frust::divider`] at `color` for
204    /// this widget's current axis/thickness — the single construction point
205    /// both [`Widget::layout`] and [`Widget::paint`] call through, so the
206    /// two never drift (see the [module docs](self)' Composition section).
207    fn base(&self, color: Color) -> frust::DividerView {
208        let mut view = base_divider(color).thickness(self.thickness);
209        if self.vertical {
210            view = view.vertical();
211        }
212        view
213    }
214
215    /// Resolve this instance's paint color: explicit override, else the
216    /// active theme's `outlineVariant`, else [`FALLBACK_OUTLINE_VARIANT`] —
217    /// see the [module docs](self)' Color resolution section for why this
218    /// runs fresh every paint rather than once.
219    fn resolve_color(&self, theme: Option<&Theme>) -> Color {
220        if let Some(color) = self.color {
221            return color;
222        }
223        match theme {
224            Some(theme) => theme.scheme().outline_variant,
225            None => FALLBACK_OUTLINE_VARIANT,
226        }
227    }
228}
229
230/// Build a throwaway `DividerWidget` element for `view` — the scratch
231/// `BuildCtx` this needs is never read by baseline `DividerView::build`
232/// (see the [module docs](self)' Color resolution section), so allocating
233/// one per call is a harmless formality, not a real id allocation.
234fn build_line<State: 'static>(view: &frust::DividerView) -> frust::DividerWidget {
235    let mut counter = 0u64;
236    View::<State>::build(view, &mut BuildCtx::new(&mut counter))
237}
238
239impl Widget for DividerWidget {
240    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
241        let (before, after) = self.insets();
242        let total = before + after;
243        let inner_bc = if self.vertical {
244            BoxConstraints::new(
245                Size::new(bc.min().width, (bc.min().height - total).max(0.0)),
246                Size::new(bc.max().width, (bc.max().height - total).max(0.0)),
247            )
248        } else {
249            BoxConstraints::new(
250                Size::new((bc.min().width - total).max(0.0), bc.min().height),
251                Size::new((bc.max().width - total).max(0.0), bc.max().height),
252            )
253        };
254        let view = self.base(FALLBACK_OUTLINE_VARIANT); // color has zero effect on sizing
255        let mut line = build_line::<()>(&view);
256        let inner_size = line.layout(ctx, &inner_bc);
257        let full = if self.vertical {
258            Size::new(inner_size.width, inner_size.height + total)
259        } else {
260            Size::new(inner_size.width + total, inner_size.height)
261        };
262        bc.constrain(full)
263    }
264
265    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
266        let theme = Theme::from_paint_ctx(ctx);
267        let color = self.resolve_color(theme);
268        let (before, after) = self.insets();
269        let total = before + after;
270        let (inner_origin, inner_size) = if self.vertical {
271            (
272                Point::new(ctx.origin().x, ctx.origin().y + before),
273                Size::new(ctx.size().width, (ctx.size().height - total).max(0.0)),
274            )
275        } else {
276            (
277                Point::new(ctx.origin().x + before, ctx.origin().y),
278                Size::new((ctx.size().width - total).max(0.0), ctx.size().height),
279            )
280        };
281        let view = self.base(color);
282        let mut line = build_line::<()>(&view);
283        // Baseline `DividerWidget::paint` reads geometry solely from the
284        // `PaintCtx` it's handed (never its own internal state), so no prior
285        // `layout()` call on this throwaway instance is needed — see the
286        // module docs' Composition section.
287        let mut inner_ctx = PaintCtx::new(inner_origin, inner_size);
288        line.paint(&mut inner_ctx, scene);
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use kurbo::Point;
296
297    fn build(view: &DividerView) -> DividerWidget {
298        let mut counter = 0u64;
299        View::<()>::build(view, &mut BuildCtx::new(&mut counter))
300    }
301
302    /// [`Theme::neutral`] with `outline_variant` overridden — `Brightness`
303    /// stays `Light`, matching `neutral`'s own default, so `theme.scheme()`
304    /// resolves `theme.light` (the one we mutate).
305    fn themed(outline_variant: Color) -> Theme {
306        let mut theme = Theme::neutral();
307        theme.light.outline_variant = outline_variant;
308        theme
309    }
310
311    // ---- Layout ----------------------------------------------------------
312
313    #[test]
314    fn horizontal_default_fills_width_and_takes_default_thickness_on_height() {
315        let mut w = build(&divider());
316        let mut lctx = LayoutCtx::new();
317        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(320.0, 640.0)));
318        assert_eq!(size, Size::new(320.0, DEFAULT_THICKNESS));
319    }
320
321    #[test]
322    fn vertical_fills_height_and_takes_thickness_on_width() {
323        let mut w = build(&divider().vertical());
324        let mut lctx = LayoutCtx::new();
325        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(320.0, 640.0)));
326        assert_eq!(size, Size::new(DEFAULT_THICKNESS, 640.0));
327    }
328
329    #[test]
330    fn custom_thickness_is_honored() {
331        let mut w = build(&divider().thickness(4.0));
332        let mut lctx = LayoutCtx::new();
333        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
334        assert_eq!(size, Size::new(200.0, 4.0));
335    }
336
337    #[test]
338    fn insets_shrink_the_line_but_the_wrapper_still_reports_the_full_fill_size() {
339        let mut w = build(&divider().indent(8.0).end_indent(4.0));
340        let mut lctx = LayoutCtx::new();
341        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(100.0, 50.0)));
342        // The outer box still fills to the incoming max — only the painted
343        // line (see the paint test below) is narrower.
344        assert_eq!(size, Size::new(100.0, DEFAULT_THICKNESS));
345    }
346
347    #[test]
348    fn vertical_insets_apply_to_the_fill_axis_too() {
349        let mut w = build(&divider().vertical().indent(10.0).end_indent(5.0));
350        let mut lctx = LayoutCtx::new();
351        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 200.0)));
352        assert_eq!(size, Size::new(DEFAULT_THICKNESS, 200.0));
353    }
354
355    #[test]
356    fn a_tight_constraint_forces_the_divider_to_it_regardless_of_thickness() {
357        let mut w = build(&divider());
358        let mut lctx = LayoutCtx::new();
359        let size = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(50.0, 50.0)));
360        assert_eq!(size, Size::new(50.0, 50.0));
361    }
362
363    // ---- Paint -------------------------------------------------------------
364
365    #[derive(Default)]
366    struct Recorder {
367        rects: Vec<(Point, Size, Color)>,
368    }
369
370    impl PaintScene for Recorder {
371        fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
372            self.rects.push((origin, size, color));
373        }
374        fn draw_text(&mut self, _origin: Point, _text: &str) {}
375    }
376
377    #[test]
378    fn paint_with_no_theme_uses_the_unthemed_fallback() {
379        let mut w = build(&divider());
380        let mut lctx = LayoutCtx::new();
381        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(100.0, 100.0)));
382
383        let mut rec = Recorder::default();
384        let mut pctx = PaintCtx::new(Point::ZERO, size);
385        w.paint(&mut pctx, &mut rec);
386
387        assert_eq!(
388            rec.rects,
389            vec![(Point::ZERO, size, FALLBACK_OUTLINE_VARIANT)]
390        );
391    }
392
393    #[test]
394    fn paint_resolves_the_theme_s_outline_variant_when_no_explicit_color() {
395        let mut w = build(&divider());
396        let mut lctx = LayoutCtx::new();
397        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(100.0, 100.0)));
398
399        let expected = Color::from_rgb8(0x11, 0x22, 0x33);
400        let theme = themed(expected);
401        let mut rec = Recorder::default();
402        let mut pctx = PaintCtx::new(Point::ZERO, size).with_theme(&theme);
403        w.paint(&mut pctx, &mut rec);
404
405        assert_eq!(rec.rects, vec![(Point::ZERO, size, expected)]);
406    }
407
408    #[test]
409    fn explicit_color_wins_over_the_theme() {
410        let explicit = Color::from_rgb8(0xAA, 0xBB, 0xCC);
411        let mut w = build(&divider().color(explicit));
412        let mut lctx = LayoutCtx::new();
413        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(100.0, 100.0)));
414
415        let theme = themed(Color::from_rgb8(0x11, 0x22, 0x33));
416        let mut rec = Recorder::default();
417        let mut pctx = PaintCtx::new(Point::ZERO, size).with_theme(&theme);
418        w.paint(&mut pctx, &mut rec);
419
420        assert_eq!(rec.rects, vec![(Point::ZERO, size, explicit)]);
421    }
422
423    #[test]
424    fn horizontal_insets_offset_and_shrink_the_painted_line() {
425        let mut w = build(&divider().indent(8.0).end_indent(4.0));
426        let mut lctx = LayoutCtx::new();
427        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(100.0, 50.0)));
428
429        let mut rec = Recorder::default();
430        let mut pctx = PaintCtx::new(Point::new(2.0, 3.0), size);
431        w.paint(&mut pctx, &mut rec);
432
433        assert_eq!(
434            rec.rects,
435            vec![(
436                Point::new(2.0 + 8.0, 3.0),
437                Size::new(100.0 - 8.0 - 4.0, DEFAULT_THICKNESS),
438                FALLBACK_OUTLINE_VARIANT
439            )]
440        );
441    }
442
443    #[test]
444    fn vertical_insets_offset_and_shrink_the_painted_line() {
445        let mut w = build(&divider().vertical().indent(10.0).end_indent(5.0));
446        let mut lctx = LayoutCtx::new();
447        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 200.0)));
448
449        let mut rec = Recorder::default();
450        let mut pctx = PaintCtx::new(Point::ZERO, size);
451        w.paint(&mut pctx, &mut rec);
452
453        assert_eq!(
454            rec.rects,
455            vec![(
456                Point::new(0.0, 10.0),
457                Size::new(DEFAULT_THICKNESS, 200.0 - 10.0 - 5.0),
458                FALLBACK_OUTLINE_VARIANT
459            )]
460        );
461    }
462
463    #[test]
464    fn insets_larger_than_the_line_collapse_to_zero_rather_than_going_negative() {
465        let mut w = build(&divider().indent(80.0).end_indent(80.0));
466        let mut lctx = LayoutCtx::new();
467        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(100.0, 50.0)));
468
469        let mut rec = Recorder::default();
470        let mut pctx = PaintCtx::new(Point::ZERO, size);
471        w.paint(&mut pctx, &mut rec);
472
473        assert_eq!(rec.rects[0].1.width, 0.0);
474    }
475}