Skip to main content

teksilo_widgets/primitives/
divider.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Divider — a themed separator line that visually partitions content.
5//!
6//! `Divider` renders a single hairline stroke (`DIVIDER_THICKNESS` = 1 dp by
7//! default) using the theme's divider color. It comes in two orientations:
8//! horizontal (the default, spans the proposed width and has a fixed 1 dp
9//! height) and vertical (spans the proposed height, 1 dp wide). Both the
10//! thickness and the color can be overridden per-instance without a custom
11//! style.
12//!
13//! ## Accessibility
14//!
15//! The widget emits `Role::Splitter`, which matches the ARIA separator pattern
16//! and signals a structural boundary to screen readers.
17//!
18//! ```rust
19//! # use teksilo_widgets::primitives::Divider;
20//! // Horizontal rule between two content sections
21//! let _rule = Divider::new();
22//!
23//! // Vertical rule inside a toolbar
24//! let _vbar = Divider::vertical();
25//! ```
26
27use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal, StrokeStyle};
28use teksilo_core::accessibility::AccessNodeBuilder;
29use teksilo_core::color_prop::ColorProp;
30use teksilo_core::widget::{LayoutContext, PaintContext, Widget};
31#[cfg(test)]
32use teksilo_tokens::Color;
33use teksilo_tokens::Orientation;
34
35/// A themed separator line. Thickness defaults to `DividerStyle::thickness`
36/// and the color defaults to `BorderRole::Divider`; both can be overridden.
37#[derive(Debug)]
38pub struct Divider {
39    orientation: Orientation,
40    thickness: Option<f32>,
41    color: Option<ColorProp>,
42}
43
44impl Divider {
45    /// Create a horizontal `Divider` with default theme thickness and color.
46    pub fn new() -> Self {
47        Self {
48            orientation: Orientation::Horizontal,
49            thickness: None,
50            color: None,
51        }
52    }
53
54    /// Create a horizontal `Divider` — alias for `Divider::new()`.
55    pub fn horizontal() -> Self {
56        Self::new()
57    }
58
59    /// Create a vertical `Divider` that spans the proposed height.
60    pub fn vertical() -> Self {
61        Self {
62            orientation: Orientation::Vertical,
63            ..Self::new()
64        }
65    }
66
67    /// Override the stroke thickness in logical pixels; defaults to
68    /// [`DIVIDER_THICKNESS`] (1 dp).
69    pub fn thickness(mut self, thickness: f32) -> Self {
70        self.thickness = Some(thickness);
71        self
72    }
73
74    /// Override the line color. Accepts `Color`, a role (typically
75    /// [`BorderRole`](teksilo_tokens::BorderRole)), or a `Signal<Color>`.
76    pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
77        self.color = Some(color.into());
78        self
79    }
80
81    fn resolved_thickness(&self, _theme: &teksilo_core::Theme) -> f32 {
82        self.thickness.unwrap_or(DIVIDER_THICKNESS)
83    }
84}
85
86/// Default visual thickness of a `Divider` stroke. Divider has no
87/// per-widget `Recipe*Style` module, so the constant lives alongside
88/// the widget that reads it.
89pub const DIVIDER_THICKNESS: f32 = 1.0;
90
91impl Default for Divider {
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97impl Widget for Divider {
98    fn layout_response(
99        &self,
100        proposal: SizeProposal,
101        ctx: &LayoutContext,
102    ) -> teksilo_core::widget::LayoutResponse {
103        let thickness = self.resolved_thickness(ctx.theme);
104        match self.orientation {
105            Orientation::Horizontal => {
106                let width = proposal.width.unwrap_or(0.0);
107                Size::new(width, thickness)
108            }
109            Orientation::Vertical => {
110                let height = proposal.height.unwrap_or(0.0);
111                Size::new(thickness, height)
112            }
113        }
114        .into()
115    }
116
117    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
118        let color = self
119            .color
120            .as_ref()
121            .map(|c| c.resolve(ctx.theme, ctx.effective_enabled))
122            .unwrap_or(ctx.theme.colors.divider);
123        let thickness = self.resolved_thickness(ctx.theme);
124        let (from, to) = match self.orientation {
125            Orientation::Horizontal => {
126                let y = bounds.y + bounds.height / 2.0;
127                (Point::new(bounds.x, y), Point::new(bounds.right(), y))
128            }
129            Orientation::Vertical => {
130                let x = bounds.x + bounds.width / 2.0;
131                (Point::new(x, bounds.y), Point::new(x, bounds.bottom()))
132            }
133        };
134        canvas.draw_line(from, to, color, StrokeStyle::solid(thickness));
135    }
136
137    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
138        builder.set_role(teksilo_core::accesskit::Role::Splitter);
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use teksilo_core::widget_tree::WidgetTree;
146
147    #[test]
148    fn horizontal_divider_size() {
149        let mut tree = WidgetTree::new();
150        let d = tree.add(Divider::new());
151        tree.layout(SizeProposal {
152            width: Some(200.0),
153            height: None,
154        });
155        let b = tree.bounds(d);
156        assert!((b.width - 200.0).abs() < 0.01);
157        assert!((b.height - 1.0).abs() < 0.01);
158    }
159
160    #[test]
161    fn vertical_divider_size() {
162        let mut tree = WidgetTree::new();
163        let d = tree.add(Divider::vertical());
164        tree.layout(SizeProposal {
165            width: None,
166            height: Some(100.0),
167        });
168        let b = tree.bounds(d);
169        assert!((b.width - 1.0).abs() < 0.01);
170        assert!((b.height - 100.0).abs() < 0.01);
171    }
172
173    #[test]
174    fn custom_thickness() {
175        let mut tree = WidgetTree::new();
176        let d = tree.add(Divider::new().thickness(3.0));
177        tree.layout(SizeProposal {
178            width: Some(200.0),
179            height: None,
180        });
181        let b = tree.bounds(d);
182        assert!((b.height - 3.0).abs() < 0.01);
183    }
184
185    #[test]
186    fn divider_paints_line() {
187        let mut tree = WidgetTree::new();
188        tree.add(Divider::new().color(Color::RED));
189        tree.layout(SizeProposal::exact(200.0, 100.0));
190        let frame = tree.render();
191        assert!(
192            !frame.decorations.is_empty(),
193            "divider should paint a decoration"
194        );
195    }
196}