use alloc::string::String;
use denise::Pen;
use denise::{Point, Rect, Role};
use denise_text::TextStyle;
use crate::widget::{PaintCtx, Widget};
use crate::widgets::describe::{
Describe, DynDescribe, Group, Mismatch, ORIENTATIONS, Property, PropertyKind, ROLES, Value,
};
use crate::widgets::style::{Orientation, interactive_pair};
#[derive(Clone, Debug)]
pub struct Divider {
label: String,
orientation: Orientation,
role: Role,
style: TextStyle,
}
impl Divider {
pub fn new() -> Self {
Self {
label: String::new(),
orientation: Orientation::Horizontal,
role: Role::Base300,
style: TextStyle::built_in(16),
}
}
pub fn labelled(label: impl Into<String>) -> Self {
Self {
label: label.into(),
..Self::new()
}
}
pub fn vertical() -> Self {
Self {
orientation: Orientation::Vertical,
..Self::new()
}
}
pub fn with_role(mut self, role: Role) -> Self {
self.role = role;
self
}
pub fn with_style(mut self, style: TextStyle) -> Self {
self.style = style;
self
}
#[inline]
pub fn label(&self) -> &str {
&self.label
}
pub fn set_label(&mut self, label: impl Into<String>) {
self.label = label.into();
}
pub fn set_style(&mut self, style: TextStyle) {
self.style = style;
}
#[inline]
pub const fn orientation(&self) -> Orientation {
self.orientation
}
}
impl Default for Divider {
fn default() -> Self {
Self::new()
}
}
#[inline]
const fn gap(size_px: u16) -> i32 {
let half = size_px as i32 / 2;
if half < 1 { 1 } else { half }
}
fn layout(bounds: Rect, thickness: i32, text_width: i32, gap: i32) -> (Rect, Option<Rect>, Rect) {
let y = bounds.y + (bounds.height - thickness) / 2;
let full = Rect::new(bounds.x, y, bounds.width, thickness);
if text_width <= 0 {
return (full, None, Rect::new(bounds.right(), y, 0, thickness));
}
const LEAST_RULE: i32 = 8;
let side = (bounds.width - text_width - gap * 2) / 2;
if side < LEAST_RULE {
return (
Rect::new(bounds.x, y, 0, thickness),
Some(bounds),
Rect::new(bounds.right(), y, 0, thickness),
);
}
let left = Rect::new(bounds.x, y, side, thickness);
let text = Rect::new(bounds.x + side + gap, bounds.y, text_width, bounds.height);
let right_x = bounds.right() - side;
let right = Rect::new(right_x, y, side, thickness);
(left, Some(text), right)
}
impl<M: 'static> Widget<M> for Divider {
fn describe(&self) -> Option<&dyn DynDescribe> {
Some(self)
}
fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
Some(self)
}
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
let bounds = ctx.bounds;
if bounds.is_empty() {
return;
}
let thickness = ctx.theme.metrics.border.max(1);
let line = ctx.theme.color(self.role);
if self.orientation == Orientation::Vertical {
let x = bounds.x + (bounds.width - thickness) / 2;
canvas.fill_rect(Rect::new(x, bounds.y, thickness, bounds.height), line);
return;
}
if self.label.is_empty() {
let y = bounds.y + (bounds.height - thickness) / 2;
canvas.fill_rect(Rect::new(bounds.x, y, bounds.width, thickness), line);
return;
}
let text_width = ctx.text.measure_line(self.style, &self.label);
let (left, text, right) = layout(bounds, thickness, text_width, gap(self.style.size_px));
if left.width > 0 {
canvas.fill_rect(left, line);
}
if right.width > 0 {
canvas.fill_rect(right, line);
}
let Some(text_bounds) = text else {
return;
};
let extent = ctx.text.measure(self.style, &self.label);
let at = Point::new(
text_bounds.x + (text_bounds.width - extent.width as i32) / 2,
text_bounds.y + (text_bounds.height - extent.height as i32) / 2,
);
let content = interactive_pair(ctx.theme, Role::Base100, ctx.state).1;
ctx.text.draw(canvas, self.style, at, &self.label, content);
}
}
impl Describe for Divider {
const KIND: &'static str = "divider";
const DOC: &'static str = "A line between things, with an optional label in the middle.";
const GROUP: Group = Group::Display;
const ICON: &'static denise::icon::Icon = &super::icons::DIVIDER;
const PROPERTIES: &'static [Property] = &[
Property::new(
"label",
PropertyKind::Text,
"An optional label sitting in the rule.",
),
Property::new(
"orientation",
PropertyKind::Enum(ORIENTATIONS),
"Which way the rule runs.",
),
Property::new("role", PropertyKind::Enum(ROLES), "The rule's colour."),
Property::new(
"size",
PropertyKind::Int { min: 6, max: 96 },
"Text size in logical pixels; only a labelled divider draws text.",
)
.in_pixels(),
];
fn get(&self, name: &str) -> Option<Value> {
Some(match name {
"label" if self.label.is_empty() => return None,
"label" => Value::text(self.label.as_str()),
"orientation" => Value::orientation(self.orientation),
"role" => Value::role(self.role),
"size" => Value::Int(i32::from(self.style.size_px)),
_ => return None,
})
}
fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
match name {
"label" => self.label = value.as_text()?,
"orientation" => self.orientation = value.as_orientation()?,
"role" => self.role = value.as_role()?,
"size" => self.style.size_px = value.as_size()?,
_ => return Err(Mismatch::Unknown),
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
const THICKNESS: i32 = 1;
const GAP: i32 = 8;
#[test]
fn the_two_segments_are_equal_and_the_label_is_centred() {
let bounds = Rect::new(10, 4, 201, 24);
let (left, text, right) = layout(bounds, THICKNESS, 40, GAP);
let text = text.expect("a labelled divider has a text box");
assert_eq!(
left.width, right.width,
"one side is longer than the other: {left:?} {right:?}"
);
assert_eq!(left.x, bounds.x, "the left rule starts at the left edge");
assert_eq!(
right.right(),
bounds.right(),
"and the right one ends at the right edge"
);
assert!(text.x > left.right(), "the label overlaps the left rule");
assert!(text.right() < right.x, "the label overlaps the right rule");
}
#[test]
fn the_rule_is_centred_in_the_height_it_is_given() {
let bounds = Rect::new(0, 100, 300, 30);
let (left, _, right) = layout(bounds, 2, 40, GAP);
assert_eq!(left.y, right.y);
assert_eq!(left.height, 2, "the thickness is what it was given");
let above = left.y - bounds.y;
let below = bounds.bottom() - left.bottom();
assert_eq!(
above, below,
"the rule is not centred: {above} above, {below} below"
);
}
#[test]
fn a_label_too_wide_for_its_divider_takes_the_whole_width() {
let bounds = Rect::new(0, 0, 60, 24);
let (left, text, right) = layout(bounds, THICKNESS, 200, GAP);
assert_eq!(left.width, 0, "no stub on the left");
assert_eq!(right.width, 0, "nor on the right");
assert_eq!(text, Some(bounds), "the label gets the whole rectangle");
}
#[test]
fn every_width_produces_segments_with_sane_geometry() {
for width in 0..240 {
let bounds = Rect::new(3, 0, width, 20);
let (left, text, right) = layout(bounds, THICKNESS, 40, GAP);
assert!(left.width >= 0, "width {width}: negative left rule");
assert!(right.width >= 0, "width {width}: negative right rule");
if left.width > 0 {
let text = text.expect("segments imply a text box");
assert!(
left.right() <= text.x && text.right() <= right.x,
"width {width}: the pieces overlap"
);
}
}
}
#[test]
fn an_unlabelled_divider_is_one_unbroken_rule() {
let bounds = Rect::new(5, 5, 120, 10);
let (left, text, right) = layout(bounds, THICKNESS, 0, GAP);
assert_eq!(left.width, bounds.width);
assert_eq!(left.x, bounds.x);
assert_eq!(text, None);
assert_eq!(right.width, 0);
}
#[test]
fn the_constructors_pick_the_orientation_and_the_label() {
assert_eq!(Divider::new().orientation(), Orientation::Horizontal);
assert_eq!(Divider::vertical().orientation(), Orientation::Vertical);
assert_eq!(Divider::labelled("eller").label(), "eller");
assert_eq!(Divider::new().label(), "");
assert_eq!(Divider::default().orientation(), Orientation::Horizontal);
}
}