use alloc::string::{String, ToString};
use denise::Pen;
use denise::Role;
use denise_text::{TextEngine, TextStyle};
use crate::widget::{MeasureCtx, Measured, Offer, PaintCtx, Widget};
use crate::widgets::describe::{
Describe, DynDescribe, Group, Mismatch, Property, PropertyKind, ROLES, Value,
};
use crate::widgets::style::{Align, draw_aligned, interactive_pair};
#[derive(Clone, Debug)]
pub struct Badge {
text: String,
role: Role,
style: TextStyle,
}
impl Badge {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
role: Role::Primary,
style: TextStyle::built_in(14),
}
}
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
}
pub fn with_size(mut self, size_px: u16) -> Self {
self.style.size_px = size_px;
self
}
#[inline]
pub fn text(&self) -> &str {
&self.text
}
pub fn set_text(&mut self, text: impl Into<String>) {
self.text = text.into();
}
pub fn update(&mut self, text: &str) -> bool {
let changed = self.text != text;
if changed {
self.text = text.to_string();
}
changed
}
pub fn set_role(&mut self, role: Role) {
self.role = role;
}
pub fn set_style(&mut self, style: TextStyle) {
self.style = style;
}
#[inline]
pub const fn style(&self) -> TextStyle {
self.style
}
pub fn preferred_width(&self, engine: &mut TextEngine) -> i32 {
let text = engine.measure_line(self.style, &self.text);
(text + padding(self.style.size_px) * 2).max(self.preferred_height(engine))
}
pub fn preferred_height(&self, engine: &mut TextEngine) -> i32 {
let extent = engine.measure(self.style, &self.text);
extent.height as i32 + padding(self.style.size_px)
}
}
#[inline]
const fn padding(size_px: u16) -> i32 {
let half = size_px as i32 / 2;
if half < 2 { 2 } else { half }
}
impl<M: 'static> Widget<M> for Badge {
fn describe(&self) -> Option<&dyn DynDescribe> {
Some(self)
}
fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
Some(self)
}
fn measure(&self, ctx: &mut MeasureCtx<'_>, _offered: Offer) -> Measured {
Measured::both(
self.preferred_width(ctx.text),
self.preferred_height(ctx.text),
)
}
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
let bounds = ctx.bounds;
if bounds.is_empty() {
return;
}
let radius = (bounds.height / 2).min(bounds.width / 2);
let (fill, content) = interactive_pair(ctx.theme, self.role, ctx.state);
canvas.fill_rounded_rect(bounds, radius, fill);
if self.text.is_empty() {
return;
}
draw_aligned(
canvas,
ctx.text,
self.style,
bounds,
(Align::Center, Align::Center),
&self.text,
content,
);
}
}
impl Describe for Badge {
const KIND: &'static str = "badge";
const DOC: &'static str = "A count, a status or a short word, in a pill of its role's colour.";
const GROUP: Group = Group::Display;
const ICON: &'static denise::icon::Icon = &super::icons::BADGE;
const PROPERTIES: &'static [Property] = &[
Property::new("text", PropertyKind::Text, "The text."),
Property::new(
"role",
PropertyKind::Enum(ROLES),
"Colour role the pill is filled with; the text takes its content colour.",
),
Property::new(
"size",
PropertyKind::Int { min: 6, max: 96 },
"Text size in logical pixels.",
)
.in_pixels(),
];
fn get(&self, name: &str) -> Option<Value> {
Some(match name {
"text" => Value::text(self.text.as_str()),
"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 {
"text" => self.text = value.as_text()?,
"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::*;
use denise::Theme;
use denise::theme;
use crate::widget::VisualState;
#[test]
fn a_single_character_badge_is_at_least_as_wide_as_it_is_tall() {
let mut engine = TextEngine::new();
let badge = Badge::new("3");
let width = badge.preferred_width(&mut engine);
let height = badge.preferred_height(&mut engine);
assert!(
width >= height,
"a single digit gave {width}x{height}, which is a squashed pill"
);
}
#[test]
fn a_longer_badge_grows_with_its_text() {
let mut engine = TextEngine::new();
let short = Badge::new("1").preferred_width(&mut engine);
let medium = Badge::new("PÅ").preferred_width(&mut engine);
let long = Badge::new("VEDLIKEHOLD").preferred_width(&mut engine);
assert!(medium >= short);
assert!(long > medium, "{long} is not wider than {medium}");
}
#[test]
fn the_width_is_the_measured_text_plus_padding() {
let mut engine = TextEngine::new();
let badge = Badge::new("VEDLIKEHOLD");
let text = engine.measure_line(badge.style(), badge.text());
assert_eq!(
badge.preferred_width(&mut engine),
text + padding(14) * 2,
"padding should be one half-size at each end"
);
}
#[test]
fn an_empty_badge_still_has_a_size() {
let mut engine = TextEngine::new();
let badge = Badge::new("");
assert!(badge.preferred_width(&mut engine) > 0);
assert!(badge.preferred_height(&mut engine) > 0);
}
#[test]
fn writing_the_same_text_reports_no_change() {
let mut badge = Badge::new("3");
assert!(!badge.update("3"));
assert!(badge.update("4"));
assert_eq!(badge.text(), "4");
}
#[test]
fn every_role_keeps_its_text_readable_in_every_theme() {
use denise::theme::{AA_LARGE, contrast_x100};
for theme in Theme::BUILT_IN {
for role in [
Role::Primary,
Role::Secondary,
Role::Accent,
Role::Neutral,
Role::Info,
Role::Success,
Role::Warning,
Role::Error,
] {
for state in [VisualState::NONE, VisualState::DISABLED] {
let (fill, content) = interactive_pair(&theme, role, state);
let ratio = contrast_x100(fill, content);
assert!(
ratio >= AA_LARGE,
"{} {role:?} {state:?}: text on badge is {ratio}, floor is \
{AA_LARGE}",
theme.name
);
}
}
}
}
#[test]
fn padding_survives_an_absurdly_small_font() {
assert!(padding(0) >= 2);
assert!(padding(1) >= 2);
assert!(padding(u16::MAX) > 0);
}
#[test]
fn the_default_role_is_a_theme_role_not_a_colour() {
let badge = Badge::new("3");
assert_eq!(badge.role, Role::Primary);
let _ = theme::DARK.color(badge.role);
}
}