use alloc::string::{String, ToString};
use denise::Pen;
use denise::{Point, Radius, 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::interactive_pair;
#[derive(Clone, Debug)]
pub struct Alert {
text: String,
icon: Option<char>,
role: Role,
style: TextStyle,
}
impl Alert {
pub fn new(role: Role, text: impl Into<String>) -> Self {
Self {
text: text.into(),
icon: None,
role,
style: TextStyle::built_in(16),
}
}
pub fn with_icon(mut self, icon: char) -> Self {
self.icon = Some(icon);
self
}
pub fn with_style(mut self, style: TextStyle) -> Self {
self.style = style;
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;
}
#[inline]
pub const fn role(&self) -> Role {
self.role
}
pub fn set_icon(&mut self, icon: Option<char>) {
self.icon = icon;
}
pub fn preferred_height(&self, engine: &mut TextEngine, width: i32) -> i32 {
let inset = padding(self.style.size_px);
let available = self.text_width(engine, width);
engine.wrapped_height(self.style, &self.text, available) + inset * 2
}
fn text_width(&self, engine: &mut TextEngine, width: i32) -> i32 {
let inset = padding(self.style.size_px);
let icon = self.icon_width(engine);
(width - inset * 2 - icon).max(1)
}
fn icon_width(&self, engine: &mut TextEngine) -> i32 {
let Some(icon) = self.icon else {
return 0;
};
let mut buffer = [0u8; 4];
let glyph = icon.encode_utf8(&mut buffer);
engine.measure_line(self.style, glyph) + padding(self.style.size_px)
}
}
#[inline]
const fn padding(size_px: u16) -> i32 {
let half = size_px as i32 / 2;
if half < 4 { 4 } else { half }
}
impl<M: 'static> Widget<M> for Alert {
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 {
width: None,
height: offered.width.map(|w| self.preferred_height(ctx.text, w)),
}
}
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
let bounds = ctx.bounds;
if bounds.is_empty() {
return;
}
let (fill, content) = interactive_pair(ctx.theme, self.role, ctx.state);
canvas.fill_rounded_rect(bounds, ctx.theme.radius(Radius::Box), fill);
let inset = padding(self.style.size_px);
let line_height = ctx.text.line_height(self.style);
let mut x = bounds.x + inset;
if let Some(icon) = self.icon {
let mut buffer = [0u8; 4];
let glyph = icon.encode_utf8(&mut buffer);
let width = ctx.text.measure_line(self.style, glyph);
ctx.text.draw(
canvas,
self.style,
Point::new(x, bounds.y + inset),
glyph,
content,
);
x += width + inset;
}
let available = (bounds.right() - inset - x).max(1);
let lines: alloc::vec::Vec<&str> = ctx.text.wrap(self.style, &self.text, available);
for (index, line) in lines.iter().enumerate() {
let y = bounds.y + inset + index as i32 * line_height;
if y >= bounds.bottom() {
break;
}
ctx.text
.draw(canvas, self.style, Point::new(x, y), line, content);
}
}
}
impl Describe for Alert {
const KIND: &'static str = "alert";
const DOC: &'static str =
"A coloured banner saying something happened, in the place it happened.";
const GROUP: Group = Group::Display;
const ICON: &'static denise::icon::Icon = &super::icons::ALERT;
const PROPERTIES: &'static [Property] = &[
Property::new("text", PropertyKind::Text, "The message."),
Property::new(
"role",
PropertyKind::Enum(ROLES),
"The status this banner reports; an alert with no status is a label.",
),
Property::new(
"icon",
PropertyKind::Text,
"A single character drawn before the text.",
),
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),
"icon" => Value::Text(self.icon?.to_string()),
"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()?,
"icon" => self.icon = value.as_text()?.chars().next(),
"size" => self.style.size_px = value.as_size()?,
_ => return Err(Mismatch::Unknown),
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use denise::Theme;
use crate::widget::VisualState;
fn engine() -> TextEngine {
TextEngine::new()
}
#[test]
fn a_longer_message_needs_a_taller_banner_at_the_same_width() {
let mut engine = engine();
let short = Alert::new(Role::Info, "Lagret").preferred_height(&mut engine, 200);
let long = Alert::new(
Role::Error,
"Kunne ikke lagre fordi disken er full og det er ingen plass igjen",
)
.preferred_height(&mut engine, 200);
assert!(long > short, "{long} is not taller than {short}");
}
#[test]
fn a_wider_banner_needs_less_height_for_the_same_message() {
let mut engine = engine();
let alert = Alert::new(Role::Warning, "en to tre fire fem seks sju atte ni ti");
let narrow = alert.preferred_height(&mut engine, 120);
let wide = alert.preferred_height(&mut engine, 600);
assert!(narrow > wide, "narrow {narrow} should exceed wide {wide}");
}
#[test]
fn an_icon_takes_its_space_from_the_message() {
let mut engine = engine();
let text = "en to tre fire fem seks sju atte";
let bare = Alert::new(Role::Info, text).preferred_height(&mut engine, 160);
let iconed = Alert::new(Role::Info, text)
.with_icon('!')
.preferred_height(&mut engine, 160);
assert!(
iconed >= bare,
"an icon should not make the banner shorter: {iconed} < {bare}"
);
assert!(Alert::new(Role::Info, text).icon_width(&mut engine) == 0);
assert!(
Alert::new(Role::Info, text)
.with_icon('!')
.icon_width(&mut engine)
> 0
);
}
#[test]
fn an_absurdly_narrow_banner_still_leaves_a_column_for_the_text() {
let mut engine = engine();
for width in [-100, 0, 1, 5, 20] {
let alert = Alert::new(Role::Error, "feil").with_icon('!');
assert!(
alert.text_width(&mut engine, width) >= 1,
"width {width} left no room at all"
);
assert!(alert.preferred_height(&mut engine, width) > 0);
}
}
#[test]
fn an_empty_message_still_has_height() {
let mut engine = engine();
let height = Alert::new(Role::Info, "").preferred_height(&mut engine, 200);
assert!(height > 0);
}
#[test]
fn writing_the_same_message_reports_no_change() {
let mut alert = Alert::new(Role::Info, "Lagret");
assert!(!alert.update("Lagret"));
assert!(alert.update("Lagret kl. 12:01"));
}
#[test]
fn every_role_keeps_its_message_readable_in_every_theme() {
use denise::theme::{AA_LARGE, contrast_x100};
for theme in Theme::BUILT_IN {
for role in [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:?}: message on banner is {ratio}, floor \
is {AA_LARGE}",
theme.name
);
}
}
}
}
#[test]
fn a_multi_byte_icon_survives_being_measured() {
let mut engine = engine();
for icon in ['!', 'æ', '✓', '⚠'] {
let alert = Alert::new(Role::Info, "melding").with_icon(icon);
assert!(
alert.icon_width(&mut engine) > 0,
"{icon} measured as nothing"
);
}
}
}