use alloc::string::{String, ToString};
use denise::Role;
use denise_render::Canvas;
use denise_text::{TextEngine, TextStyle};
use crate::widget::{PaintCtx, Widget};
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 paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Canvas<'_>) {
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,
);
}
}
#[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);
}
}