1use 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#[derive(Clone, Debug)]
43pub struct Divider {
44 label: String,
45 orientation: Orientation,
46 role: Role,
47 style: TextStyle,
48}
49
50impl Divider {
51 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 pub fn labelled(label: impl Into<String>) -> Self {
63 Self {
64 label: label.into(),
65 ..Self::new()
66 }
67 }
68
69 pub fn vertical() -> Self {
71 Self {
72 orientation: Orientation::Vertical,
73 ..Self::new()
74 }
75 }
76
77 pub fn with_role(mut self, role: Role) -> Self {
79 self.role = role;
80 self
81 }
82
83 pub fn with_style(mut self, style: TextStyle) -> Self {
85 self.style = style;
86 self
87 }
88
89 #[inline]
91 pub fn label(&self) -> &str {
92 &self.label
93 }
94
95 pub fn set_label(&mut self, label: impl Into<String>) {
97 self.label = label.into();
98 }
99
100 pub fn set_style(&mut self, style: TextStyle) {
102 self.style = style;
103 }
104
105 #[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#[inline]
120const fn gap(size_px: u16) -> i32 {
121 let half = size_px as i32 / 2;
123 if half < 1 { 1 } else { half }
124}
125
126fn 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 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 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 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 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 "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 #[test]
279 fn the_two_segments_are_equal_and_the_label_is_centred() {
280 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 #[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 #[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 #[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 #[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 #[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}