1use frus_core::{BorderRadius, Color, Rect, Scene};
5use frus_layout::{Dimension, Style};
6
7use crate::interaction::Status;
8use crate::theme::Theme;
9use crate::widget::Widget;
10
11pub const DIVIDER_SPACE: f32 = 16.0;
15pub const DIVIDER_THICKNESS: f32 = 1.0;
17
18pub struct Divider {
32 height: Option<f32>,
34 thickness: Option<f32>,
36 indent: Option<f32>,
38 end_indent: Option<f32>,
40 color: Option<Color>,
42 radius: Option<BorderRadius>,
44}
45
46impl Divider {
47 pub fn new() -> Self {
49 Self {
50 height: None,
51 thickness: None,
52 indent: None,
53 end_indent: None,
54 color: None,
55 radius: None,
56 }
57 }
58
59 pub fn height(mut self, height: f32) -> Self {
62 self.height = Some(height);
63 self
64 }
65
66 pub fn thickness(mut self, thickness: f32) -> Self {
68 self.thickness = Some(thickness);
69 self
70 }
71
72 pub fn indent(mut self, indent: f32) -> Self {
75 self.indent = Some(indent);
76 self
77 }
78
79 pub fn end_indent(mut self, end_indent: f32) -> Self {
81 self.end_indent = Some(end_indent);
82 self
83 }
84
85 pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
89 self.radius = Some(radius.into());
90 self
91 }
92
93 pub fn color(mut self, color: Color) -> Self {
95 self.color = Some(color);
96 self
97 }
98}
99
100impl Default for Divider {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106impl Divider {
107 fn space(&self, theme: Option<&Theme>) -> f32 {
109 self.height
110 .or_else(|| theme.and_then(|t| t.widgets.divider.height))
111 .unwrap_or(DIVIDER_SPACE)
112 }
113}
114
115impl<Msg> Widget<Msg> for Divider {
116 fn style(&self) -> Style {
117 Style {
118 width: Dimension::Auto,
120 height: Dimension::Length(self.space(None)),
121 ..Default::default()
122 }
123 }
124
125 fn style_themed(&self, theme: &Theme) -> Style {
126 Style {
127 height: Dimension::Length(self.space(Some(theme))),
128 ..Widget::<Msg>::style(self)
129 }
130 }
131
132 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
133 &[]
134 }
135
136 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
137 let color = self
138 .color
139 .or(theme.widgets.divider.color)
140 .unwrap_or(theme.scheme.outline_variant);
141 let thickness = self
144 .thickness
145 .or(theme.widgets.divider.thickness)
146 .unwrap_or(DIVIDER_THICKNESS)
147 .min(bounds.height);
148 let indent = self.indent.or(theme.widgets.divider.indent).unwrap_or(0.0);
149 let end_indent = self
150 .end_indent
151 .or(theme.widgets.divider.end_indent)
152 .unwrap_or(0.0);
153 let width = (bounds.width - indent - end_indent).max(0.0);
154 if width <= 0.0 || thickness <= 0.0 {
155 return;
156 }
157 let line = Rect::new(
158 bounds.x + indent,
159 bounds.y + (bounds.height - thickness) / 2.0,
160 width,
161 thickness,
162 );
163 match self.radius {
164 Some(radius) => scene.draw_rect(
165 line,
166 color.fade(status.opacity),
167 radius,
168 0.0,
169 Color::TRANSPARENT,
170 ),
171 None => scene.fill_rect(line, color.fade(status.opacity)),
172 }
173 }
174
175 fn on_click(&self) -> Option<Msg> {
176 None
177 }
178}
179
180pub struct VerticalDivider {
211 width: Option<f32>,
213 thickness: Option<f32>,
215 indent: Option<f32>,
217 end_indent: Option<f32>,
219 color: Option<Color>,
221 radius: Option<BorderRadius>,
223}
224
225impl VerticalDivider {
226 pub fn new() -> Self {
228 Self {
229 width: None,
230 thickness: None,
231 indent: None,
232 end_indent: None,
233 color: None,
234 radius: None,
235 }
236 }
237
238 pub fn width(mut self, width: f32) -> Self {
242 self.width = Some(width);
243 self
244 }
245
246 pub fn thickness(mut self, thickness: f32) -> Self {
248 self.thickness = Some(thickness);
249 self
250 }
251
252 pub fn indent(mut self, indent: f32) -> Self {
254 self.indent = Some(indent);
255 self
256 }
257
258 pub fn end_indent(mut self, end_indent: f32) -> Self {
260 self.end_indent = Some(end_indent);
261 self
262 }
263
264 pub fn color(mut self, color: Color) -> Self {
266 self.color = Some(color);
267 self
268 }
269
270 pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
272 self.radius = Some(radius.into());
273 self
274 }
275
276 fn space(&self, theme: Option<&Theme>) -> f32 {
282 self.width
283 .or_else(|| theme.and_then(|t| t.widgets.divider.height))
284 .unwrap_or(DIVIDER_SPACE)
285 }
286}
287
288impl Default for VerticalDivider {
289 fn default() -> Self {
290 Self::new()
291 }
292}
293
294impl<Msg> Widget<Msg> for VerticalDivider {
295 fn style(&self) -> Style {
296 Style {
297 width: Dimension::Length(self.space(None)),
298 height: Dimension::Auto,
300 align_self: Some(frus_layout::Align::Stretch),
301 ..Default::default()
302 }
303 }
304
305 fn style_themed(&self, theme: &Theme) -> Style {
306 Style {
307 width: Dimension::Length(self.space(Some(theme))),
308 ..Widget::<Msg>::style(self)
309 }
310 }
311
312 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
313 &[]
314 }
315
316 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
317 let color = self
318 .color
319 .or(theme.widgets.divider.color)
320 .unwrap_or(theme.scheme.outline_variant);
321 let thickness = self
322 .thickness
323 .or(theme.widgets.divider.thickness)
324 .unwrap_or(DIVIDER_THICKNESS)
325 .min(bounds.width);
326 let indent = self.indent.or(theme.widgets.divider.indent).unwrap_or(0.0);
327 let end_indent = self
328 .end_indent
329 .or(theme.widgets.divider.end_indent)
330 .unwrap_or(0.0);
331 let height = (bounds.height - indent - end_indent).max(0.0);
332 if height <= 0.0 || thickness <= 0.0 {
333 return;
334 }
335 let line = Rect::new(
336 bounds.x + (bounds.width - thickness) / 2.0,
337 bounds.y + indent,
338 thickness,
339 height,
340 );
341 match self.radius {
342 Some(radius) => scene.draw_rect(
343 line,
344 color.fade(status.opacity),
345 radius,
346 0.0,
347 Color::TRANSPARENT,
348 ),
349 None => scene.fill_rect(line, color.fade(status.opacity)),
350 }
351 }
352
353 fn on_click(&self) -> Option<Msg> {
354 None
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use frus_core::Primitive;
362
363 #[test]
368 fn a_vertical_rule_declares_its_width_and_takes_the_rows_height() {
369 let across = Widget::<()>::style(&Divider::new());
370 assert_eq!(across.height, Dimension::Length(DIVIDER_SPACE));
371 assert_eq!(across.width, Dimension::Auto);
372
373 let down = Widget::<()>::style(&VerticalDivider::new());
374 assert_eq!(down.width, Dimension::Length(DIVIDER_SPACE));
375 assert_eq!(down.height, Dimension::Auto);
376 }
377
378 #[test]
383 fn a_vertical_rule_fills_its_row_whatever_the_row_said() {
384 assert_eq!(
385 Widget::<()>::style(&VerticalDivider::new()).align_self,
386 Some(frus_layout::Align::Stretch)
387 );
388 assert_eq!(
389 Widget::<()>::style(&Divider::new()).align_self,
390 None,
391 "the horizontal one has no such problem: a column stretches it by default and \
392 a column that does not is a column with a reason"
393 );
394 }
395
396 #[test]
399 fn a_vertical_rule_is_centred_across_its_box_and_indented_down_it() {
400 let mut scene = Scene::new();
401 Widget::<()>::paint(
402 &VerticalDivider::new()
403 .thickness(2.0)
404 .indent(10.0)
405 .end_indent(6.0),
406 Rect::new(0.0, 0.0, DIVIDER_SPACE, 100.0),
407 Status::default(),
408 &Theme::default(),
409 &mut scene,
410 );
411 let line = scene
412 .primitives()
413 .iter()
414 .find_map(|p| match p {
415 Primitive::Rect { rect, .. } => Some(*rect),
416 _ => None,
417 })
418 .expect("it draws one");
419 assert_eq!(line.width, 2.0, "the thickness is across the row");
420 assert_eq!(line.x, (DIVIDER_SPACE - 2.0) / 2.0, "centred in its box");
421 assert_eq!(line.y, 10.0, "the indent is from the top");
422 assert_eq!(
423 line.height,
424 100.0 - 10.0 - 6.0,
425 "and the end from the bottom"
426 );
427 }
428
429 #[test]
434 fn both_rules_read_the_same_theme_field() {
435 let mut theme = Theme::default();
436 theme.widgets.divider.height = Some(4.0);
437 assert_eq!(
438 Widget::<()>::style_themed(&Divider::new(), &theme).height,
439 Dimension::Length(4.0)
440 );
441 assert_eq!(
442 Widget::<()>::style_themed(&VerticalDivider::new(), &theme).width,
443 Dimension::Length(4.0)
444 );
445 }
446
447 #[test]
451 fn a_rule_can_round_its_ends() {
452 let corners = |divider: &Divider| {
453 let mut scene = Scene::new();
454 Widget::<()>::paint(
455 divider,
456 Rect::new(0.0, 0.0, 200.0, 16.0),
457 Status::default(),
458 &Theme::default(),
459 &mut scene,
460 );
461 scene.primitives().iter().find_map(|p| match p {
462 frus_core::Primitive::Rect { radius, .. } => Some(*radius),
463 _ => None,
464 })
465 };
466
467 assert_eq!(
468 corners(&Divider::new().thickness(6.0).radius(3.0)),
469 Some(frus_core::BorderRadius::uniform(3.0))
470 );
471 assert_eq!(
472 corners(&Divider::new().thickness(6.0)),
473 Some(frus_core::BorderRadius::ZERO),
474 "a hairline is still square, which is what a hairline wants"
475 );
476 }
477
478 fn painted(divider: &Divider, bounds: Rect) -> Option<Rect> {
479 let mut scene = Scene::new();
480 Widget::<()>::paint(
481 divider,
482 bounds,
483 Status::default(),
484 &Theme::default(),
485 &mut scene,
486 );
487 scene.primitives().iter().find_map(|p| match p {
488 Primitive::Rect { rect, .. } => Some(*rect),
489 _ => None,
490 })
491 }
492
493 #[test]
494 fn paints_a_border_line() {
495 let divider = Divider::new();
496 let line = painted(÷r, Rect::new(0.0, 0.0, 120.0, 1.0)).expect("a line");
497 assert_eq!(line.width, 120.0);
498 }
499
500 #[test]
501 fn the_box_and_the_line_are_two_different_measurements() {
502 let divider = Divider::new();
506 let style = Widget::<()>::style(÷r);
507 assert_eq!(style.height, Dimension::Length(DIVIDER_SPACE));
508
509 let line = painted(÷r, Rect::new(0.0, 100.0, 200.0, DIVIDER_SPACE)).expect("a line");
510 assert_eq!(line.height, DIVIDER_THICKNESS, "one pixel of line");
511 assert_eq!(
512 line.y,
513 100.0 + (DIVIDER_SPACE - DIVIDER_THICKNESS) / 2.0,
514 "centred in its box, with air on both sides"
515 );
516 }
517
518 #[test]
519 fn a_flush_hairline_is_one_call_away() {
520 let divider = Divider::new().height(1.0);
521 assert_eq!(Widget::<()>::style(÷r).height, Dimension::Length(1.0));
522 let line = painted(÷r, Rect::new(0.0, 0.0, 200.0, 1.0)).expect("a line");
523 assert_eq!((line.y, line.height), (0.0, 1.0), "it fills its own box");
524 }
525
526 #[test]
527 fn the_indents_inset_the_line_and_not_the_box() {
528 let divider = Divider::new().indent(16.0).end_indent(8.0);
529 let line = painted(÷r, Rect::new(10.0, 0.0, 200.0, DIVIDER_SPACE)).expect("a line");
530 assert_eq!(line.x, 26.0, "inset from the leading edge");
531 assert_eq!(line.width, 200.0 - 16.0 - 8.0);
532 }
533
534 #[test]
535 fn a_line_thicker_than_its_box_is_clamped_rather_than_overflowing() {
536 let divider = Divider::new().height(2.0).thickness(10.0);
537 let line = painted(÷r, Rect::new(0.0, 0.0, 100.0, 2.0)).expect("a line");
538 assert_eq!(line.height, 2.0);
539 }
540
541 #[test]
542 fn indents_wider_than_the_box_draw_nothing() {
543 let divider = Divider::new().indent(120.0).end_indent(120.0);
544 assert!(painted(÷r, Rect::new(0.0, 0.0, 100.0, DIVIDER_SPACE)).is_none());
545 }
546
547 #[test]
548 fn the_colour_is_the_callers_when_they_give_one() {
549 let mine = Color::rgb8(255, 0, 0);
550 let divider = Divider::new().color(mine);
551 let mut scene = Scene::new();
552 Widget::<()>::paint(
553 ÷r,
554 Rect::new(0.0, 0.0, 100.0, DIVIDER_SPACE),
555 Status::default(),
556 &Theme::default(),
557 &mut scene,
558 );
559 let color = scene.primitives().iter().find_map(|p| match p {
560 Primitive::Rect { color, .. } => Some(*color),
561 _ => None,
562 });
563 assert_eq!(color, Some(mine));
564 }
565}