Skip to main content

denise_ui/widgets/
divider.rs

1//! A line, optionally with a label in the middle.
2
3use alloc::string::String;
4
5use denise::Pen;
6use denise::{Point, Rect, Role};
7use denise_text::TextStyle;
8
9use crate::widget::{PaintCtx, Widget};
10use crate::widgets::describe::{
11    Describe, DynDescribe, Group, Mismatch, ORIENTATIONS, Property, PropertyKind, ROLES, Value,
12};
13use crate::widgets::style::{Orientation, interactive_pair};
14
15/// A rule between two groups of content.
16///
17/// The smallest widget here, and worth having only because everybody draws it
18/// slightly differently otherwise — one pixel or two, `Base300` or a faded
19/// `BaseContent`, inset or full width. It is here so a panel is consistent with
20/// itself.
21///
22/// Not interactive, not focusable, no messages.
23///
24/// # A label only makes sense across
25///
26/// A horizontal divider with a label draws line, gap, text, gap, line. A
27/// **vertical** one ignores its label and draws an unbroken rule, because the
28/// text would have to be rotated and there is no rotated text in the rasteriser.
29/// Ignoring it is better than drawing horizontal text through a vertical line and
30/// calling that a feature.
31///
32/// # About "one pixel"
33///
34/// The thickness comes from [`Metrics::border`](denise::theme::Metrics::border),
35/// which is a *logical* pixel — one at scale factor 1, two under
36/// [`Metrics::TOUCH`], and whatever the application's scale factor makes of it
37/// on a dense display: a scale-aware application passes
38/// `theme.scaled(factor)` at construction and this widget's rule thickens with
39/// everything else. See `docs/design.md` for the pattern.
40///
41/// [`Metrics::TOUCH`]: denise::theme::Metrics::TOUCH
42#[derive(Clone, Debug)]
43pub struct Divider {
44    label: String,
45    orientation: Orientation,
46    role: Role,
47    style: TextStyle,
48}
49
50impl Divider {
51    /// A horizontal rule with no label.
52    pub fn new() -> Self {
53        Self {
54            label: String::new(),
55            orientation: Orientation::Horizontal,
56            role: Role::Base300,
57            style: TextStyle::built_in(16),
58        }
59    }
60
61    /// A horizontal rule with `label` in the middle.
62    pub fn labelled(label: impl Into<String>) -> Self {
63        Self {
64            label: label.into(),
65            ..Self::new()
66        }
67    }
68
69    /// A vertical rule. Any label is ignored — see the type documentation.
70    pub fn vertical() -> Self {
71        Self {
72            orientation: Orientation::Vertical,
73            ..Self::new()
74        }
75    }
76
77    /// Sets the line's colour role.
78    pub fn with_role(mut self, role: Role) -> Self {
79        self.role = role;
80        self
81    }
82
83    /// Sets the label's font and size.
84    pub fn with_style(mut self, style: TextStyle) -> Self {
85        self.style = style;
86        self
87    }
88
89    /// The current label, empty if there is none.
90    #[inline]
91    pub fn label(&self) -> &str {
92        &self.label
93    }
94
95    /// Replaces the label.
96    pub fn set_label(&mut self, label: impl Into<String>) {
97        self.label = label.into();
98    }
99
100    /// Replaces the label's font and size.
101    pub fn set_style(&mut self, style: TextStyle) {
102        self.style = style;
103    }
104
105    /// Which way it runs.
106    #[inline]
107    pub const fn orientation(&self) -> Orientation {
108        self.orientation
109    }
110}
111
112impl Default for Divider {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118/// Space between the rule and the label at each side.
119#[inline]
120const fn gap(size_px: u16) -> i32 {
121    // `Ord::max` is not const yet.
122    let half = size_px as i32 / 2;
123    if half < 1 { 1 } else { half }
124}
125
126/// The two line segments and the text box between them.
127///
128/// `None` for the text box when there is no label, or when the label plus its
129/// gaps would leave no room for a rule on either side — at which point drawing a
130/// two-pixel stub each end says less than an unbroken line does. That is the
131/// "degrades sensibly" case: the label still draws, and it draws over the whole
132/// width rather than between two fragments.
133fn layout(bounds: Rect, thickness: i32, text_width: i32, gap: i32) -> (Rect, Option<Rect>, Rect) {
134    let y = bounds.y + (bounds.height - thickness) / 2;
135    let full = Rect::new(bounds.x, y, bounds.width, thickness);
136    if text_width <= 0 {
137        return (full, None, Rect::new(bounds.right(), y, 0, thickness));
138    }
139
140    // A rule is worth drawing only if there is a visible amount of it. Below
141    // this the label takes the whole width.
142    const LEAST_RULE: i32 = 8;
143    let side = (bounds.width - text_width - gap * 2) / 2;
144    if side < LEAST_RULE {
145        return (
146            Rect::new(bounds.x, y, 0, thickness),
147            Some(bounds),
148            Rect::new(bounds.right(), y, 0, thickness),
149        );
150    }
151
152    let left = Rect::new(bounds.x, y, side, thickness);
153    let text = Rect::new(bounds.x + side + gap, bounds.y, text_width, bounds.height);
154    // Computed from the right edge rather than from the left segment's width, so
155    // the two ends are equal even when the odd pixel of the division has to go
156    // somewhere.
157    let right_x = bounds.right() - side;
158    let right = Rect::new(right_x, y, side, thickness);
159    (left, Some(text), right)
160}
161
162impl<M: 'static> Widget<M> for Divider {
163    fn describe(&self) -> Option<&dyn DynDescribe> {
164        Some(self)
165    }
166
167    fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
168        Some(self)
169    }
170    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
171        let bounds = ctx.bounds;
172        if bounds.is_empty() {
173            return;
174        }
175        let thickness = ctx.theme.metrics.border.max(1);
176        let line = ctx.theme.color(self.role);
177
178        if self.orientation == Orientation::Vertical {
179            let x = bounds.x + (bounds.width - thickness) / 2;
180            canvas.fill_rect(Rect::new(x, bounds.y, thickness, bounds.height), line);
181            return;
182        }
183
184        if self.label.is_empty() {
185            let y = bounds.y + (bounds.height - thickness) / 2;
186            canvas.fill_rect(Rect::new(bounds.x, y, bounds.width, thickness), line);
187            return;
188        }
189
190        // Measured through the engine rather than guessed from the character
191        // count, so the gaps are right with a proportional font.
192        let text_width = ctx.text.measure_line(self.style, &self.label);
193        let (left, text, right) = layout(bounds, thickness, text_width, gap(self.style.size_px));
194
195        if left.width > 0 {
196            canvas.fill_rect(left, line);
197        }
198        if right.width > 0 {
199            canvas.fill_rect(right, line);
200        }
201        let Some(text_bounds) = text else {
202            return;
203        };
204
205        let extent = ctx.text.measure(self.style, &self.label);
206        let at = Point::new(
207            text_bounds.x + (text_bounds.width - extent.width as i32) / 2,
208            text_bounds.y + (text_bounds.height - extent.height as i32) / 2,
209        );
210        // The label is content on the panel, not part of the rule, so it takes
211        // the base pairing and mutes with the rest of a disabled group.
212        let content = interactive_pair(ctx.theme, Role::Base100, ctx.state).1;
213        ctx.text.draw(canvas, self.style, at, &self.label, content);
214    }
215}
216
217impl Describe for Divider {
218    const KIND: &'static str = "divider";
219    const DOC: &'static str = "A line between things, with an optional label in the middle.";
220    const GROUP: Group = Group::Display;
221    const ICON: &'static denise::icon::Icon = &super::icons::DIVIDER;
222
223    const PROPERTIES: &'static [Property] = &[
224        Property::new(
225            "label",
226            PropertyKind::Text,
227            "An optional label sitting in the rule.",
228        ),
229        Property::new(
230            "orientation",
231            PropertyKind::Enum(ORIENTATIONS),
232            "Which way the rule runs.",
233        ),
234        Property::new("role", PropertyKind::Enum(ROLES), "The rule's colour."),
235        Property::new(
236            "size",
237            PropertyKind::Int { min: 6, max: 96 },
238            "Text size in logical pixels; only a labelled divider draws text.",
239        )
240        .in_pixels(),
241    ];
242
243    fn get(&self, name: &str) -> Option<Value> {
244        Some(match name {
245            // An empty label *is* the absence of one — the field is a `String`
246            // rather than an `Option` because painting treats the two the same —
247            // so an unlabelled divider reports nothing and writes nothing.
248            "label" if self.label.is_empty() => return None,
249            "label" => Value::text(self.label.as_str()),
250            "orientation" => Value::orientation(self.orientation),
251            "role" => Value::role(self.role),
252            "size" => Value::Int(i32::from(self.style.size_px)),
253            _ => return None,
254        })
255    }
256
257    fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
258        match name {
259            "label" => self.label = value.as_text()?,
260            "orientation" => self.orientation = value.as_orientation()?,
261            "role" => self.role = value.as_role()?,
262            "size" => self.style.size_px = value.as_size()?,
263            _ => return Err(Mismatch::Unknown),
264        }
265        Ok(())
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    const THICKNESS: i32 = 1;
274    const GAP: i32 = 8;
275
276    /// The label sits in the middle and the two rules are the same length. An
277    /// off-centre label is the one thing anybody would notice about a divider.
278    #[test]
279    fn the_two_segments_are_equal_and_the_label_is_centred() {
280        // An odd leftover on purpose: 201 wide, 40 of text, 8 gaps — the halves
281        // cannot both be whole, and the pixel has to go somewhere invisible.
282        let bounds = Rect::new(10, 4, 201, 24);
283        let (left, text, right) = layout(bounds, THICKNESS, 40, GAP);
284        let text = text.expect("a labelled divider has a text box");
285
286        assert_eq!(
287            left.width, right.width,
288            "one side is longer than the other: {left:?} {right:?}"
289        );
290        assert_eq!(left.x, bounds.x, "the left rule starts at the left edge");
291        assert_eq!(
292            right.right(),
293            bounds.right(),
294            "and the right one ends at the right edge"
295        );
296        assert!(text.x > left.right(), "the label overlaps the left rule");
297        assert!(text.right() < right.x, "the label overlaps the right rule");
298    }
299
300    /// The rules and the label are vertically centred on the same line.
301    #[test]
302    fn the_rule_is_centred_in_the_height_it_is_given() {
303        let bounds = Rect::new(0, 100, 300, 30);
304        let (left, _, right) = layout(bounds, 2, 40, GAP);
305        assert_eq!(left.y, right.y);
306        assert_eq!(left.height, 2, "the thickness is what it was given");
307        let above = left.y - bounds.y;
308        let below = bounds.bottom() - left.bottom();
309        assert_eq!(
310            above, below,
311            "the rule is not centred: {above} above, {below} below"
312        );
313    }
314
315    /// A label wider than the space degrades to a label on its own rather than
316    /// to two stubs, or to a rule drawn under the text.
317    #[test]
318    fn a_label_too_wide_for_its_divider_takes_the_whole_width() {
319        let bounds = Rect::new(0, 0, 60, 24);
320        let (left, text, right) = layout(bounds, THICKNESS, 200, GAP);
321        assert_eq!(left.width, 0, "no stub on the left");
322        assert_eq!(right.width, 0, "nor on the right");
323        assert_eq!(text, Some(bounds), "the label gets the whole rectangle");
324    }
325
326    /// And the boundary between the two behaviours is not a cliff into negative
327    /// widths.
328    #[test]
329    fn every_width_produces_segments_with_sane_geometry() {
330        for width in 0..240 {
331            let bounds = Rect::new(3, 0, width, 20);
332            let (left, text, right) = layout(bounds, THICKNESS, 40, GAP);
333            assert!(left.width >= 0, "width {width}: negative left rule");
334            assert!(right.width >= 0, "width {width}: negative right rule");
335            if left.width > 0 {
336                let text = text.expect("segments imply a text box");
337                assert!(
338                    left.right() <= text.x && text.right() <= right.x,
339                    "width {width}: the pieces overlap"
340                );
341            }
342        }
343    }
344
345    /// With no label there is one unbroken rule across the whole width.
346    #[test]
347    fn an_unlabelled_divider_is_one_unbroken_rule() {
348        let bounds = Rect::new(5, 5, 120, 10);
349        let (left, text, right) = layout(bounds, THICKNESS, 0, GAP);
350        assert_eq!(left.width, bounds.width);
351        assert_eq!(left.x, bounds.x);
352        assert_eq!(text, None);
353        assert_eq!(right.width, 0);
354    }
355
356    /// The constructors say what they build, so a vertical divider cannot be
357    /// mistaken for a horizontal one that happens to be narrow.
358    #[test]
359    fn the_constructors_pick_the_orientation_and_the_label() {
360        assert_eq!(Divider::new().orientation(), Orientation::Horizontal);
361        assert_eq!(Divider::vertical().orientation(), Orientation::Vertical);
362        assert_eq!(Divider::labelled("eller").label(), "eller");
363        assert_eq!(Divider::new().label(), "");
364        assert_eq!(Divider::default().orientation(), Orientation::Horizontal);
365    }
366}