use crate::render::{Bounds, RenderContext};
use super::{
ClickButton, Command, Msg, Widget, WidgetStyle, draw_icon_pill, glyph_label, measure_text_pill,
};
const BELL_GLYPH: &str = "\u{f0f3}"; const BELL_OFF_GLYPH: &str = "\u{f1f6}";
const DIM_PERCENT: u32 = 55;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Notifications {
count: u32,
dnd: bool,
}
impl Notifications {
pub fn new(count: u32, dnd: bool) -> Self {
Self { count, dnd }
}
pub fn count(self) -> u32 {
self.count
}
pub fn dnd(self) -> bool {
self.dnd
}
}
fn dim((r, g, b, a): (u8, u8, u8, u8)) -> (u8, u8, u8, u8) {
let scale = |c: u8| ((c as u32 * DIM_PERCENT) / 100) as u8;
(scale(r), scale(g), scale(b), a)
}
pub struct NotificationsWidget {
bounds: Bounds,
state: Option<Notifications>,
style: WidgetStyle,
}
impl NotificationsWidget {
pub fn new(bounds: Bounds) -> Self {
Self {
bounds,
state: None,
style: WidgetStyle::default(),
}
}
pub fn with_style(mut self, style: WidgetStyle) -> Self {
self.style = style;
self
}
fn display_text(&self) -> String {
match &self.state {
Some(n) => {
let glyph = if n.dnd { BELL_OFF_GLYPH } else { BELL_GLYPH };
glyph_label(self.style.glyph(glyph), "")
}
None => String::new(),
}
}
fn foreground(&self) -> (u8, u8, u8, u8) {
match self.state {
Some(n) if n.dnd => dim(self.style.foreground),
_ => self.style.foreground,
}
}
fn dot_bounds(&self, scale: u32) -> Option<Bounds> {
let n = self.state?;
if n.count() == 0 || self.bounds.width == 0 || self.bounds.height == 0 {
return None;
}
let d = (self.bounds.height / 5).max(3 * scale);
let inset = 2 * scale;
let x = (self.bounds.x + self.bounds.width).saturating_sub(d + inset);
let y = self.bounds.y + inset;
Some(Bounds::new(x, y, d, d))
}
}
impl Widget for NotificationsWidget {
fn update(&mut self, msg: &Msg) -> bool {
match msg {
Msg::Notifications(next) => {
if self.state == *next {
return false;
}
self.state = *next;
true
}
_ => false,
}
}
fn draw(&self, ctx: &mut RenderContext) {
let colors = super::StateColors {
background: self.style.background,
foreground: self.foreground(),
border: self.style.border,
};
draw_icon_pill(ctx, &self.style, self.bounds, &self.display_text(), colors);
if let Some(dot) = self.dot_bounds(ctx.scale_factor()) {
ctx.fill_rounded_rect(dot, self.style.attention.foreground, dot.width as f32 / 2.0);
}
}
fn measure(&self, ctx: &mut RenderContext, _height: u32) -> u32 {
measure_text_pill(ctx, &self.style, &self.display_text())
}
fn bounds(&self) -> Bounds {
self.bounds
}
fn set_bounds(&mut self, bounds: Bounds) {
self.bounds = bounds;
}
fn on_click(&self, px: u32, py: u32, button: ClickButton) -> Option<Command> {
self.state?;
let b = self.bounds;
if px < b.x || px >= b.x + b.width || py < b.y || py >= b.y + b.height {
return None;
}
match button {
ClickButton::Left => Some(Command::ToggleNotificationPanel),
ClickButton::Right => Some(Command::ToggleNotificationsDnd),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::IconSetting;
fn some(count: u32, dnd: bool) -> Msg {
Msg::Notifications(Some(Notifications::new(count, dnd)))
}
#[test]
fn first_reading_is_a_visible_change() {
let mut widget = NotificationsWidget::new(Bounds::new(0, 0, 100, 32));
assert!(widget.update(&some(0, false)));
}
#[test]
fn identical_reading_is_not_a_redraw() {
let mut widget = NotificationsWidget::new(Bounds::new(0, 0, 100, 32));
widget.update(&some(2, false));
assert!(!widget.update(&some(2, false)));
}
#[test]
fn count_and_dnd_changes_are_visible_changes() {
let mut widget = NotificationsWidget::new(Bounds::new(0, 0, 100, 32));
widget.update(&some(0, false));
assert!(widget.update(&some(1, false)));
assert!(widget.update(&some(1, true)));
assert!(widget.update(&Msg::Notifications(None)));
assert!(widget.update(&some(0, false)));
}
#[test]
fn unrelated_messages_are_ignored() {
let mut widget = NotificationsWidget::new(Bounds::new(0, 0, 100, 32));
widget.update(&some(1, false));
assert!(!widget.update(&Msg::tick_now()));
}
#[test]
fn display_text_is_the_bell_glyph_alone() {
let mut widget = NotificationsWidget::new(Bounds::new(0, 0, 100, 32));
widget.update(&some(3, false));
assert_eq!(widget.display_text(), BELL_GLYPH);
}
#[test]
fn dnd_swaps_to_the_slashed_bell_and_dims_the_foreground() {
let mut widget = NotificationsWidget::new(Bounds::new(0, 0, 100, 32));
widget.update(&some(0, true));
assert_eq!(widget.display_text(), BELL_OFF_GLYPH);
assert_eq!(widget.foreground(), dim(widget.style.foreground));
}
#[test]
fn a_disabled_icon_leaves_nothing_to_show() {
let mut widget =
NotificationsWidget::new(Bounds::new(0, 0, 100, 32)).with_style(WidgetStyle {
icon: IconSetting::None,
..WidgetStyle::default()
});
widget.update(&some(1, false));
assert_eq!(widget.display_text(), "");
}
#[test]
fn dim_scales_rgb_and_keeps_alpha() {
assert_eq!(dim((200, 100, 0, 0xFF)), (110, 55, 0, 0xFF));
assert_eq!(dim((0, 0, 0, 0x80)), (0, 0, 0, 0x80));
}
#[test]
fn the_widget_reserves_no_slot_before_any_reading() {
let widget = NotificationsWidget::new(Bounds::new(0, 0, 100, 32));
let mut ctx = RenderContext::new(200, 32);
assert_eq!(widget.measure(&mut ctx, 32), 0);
}
#[test]
fn a_seeded_widget_measures_its_pill() {
let mut widget = NotificationsWidget::new(Bounds::new(0, 0, 100, 32));
widget.update(&some(0, false));
let mut ctx = RenderContext::new(200, 32);
assert!(widget.measure(&mut ctx, 32) > 0);
}
#[test]
fn the_dot_appears_only_while_notifications_are_pending() {
let mut widget = NotificationsWidget::new(Bounds::new(0, 0, 40, 32));
widget.update(&some(0, false));
assert_eq!(widget.dot_bounds(1), None);
widget.update(&some(1, false));
let dot = widget.dot_bounds(1).expect("a pending count draws a dot");
assert_eq!(dot, Bounds::new(32, 2, 6, 6));
}
#[test]
fn drawing_paints_the_attention_dot_over_the_pill() {
let mut style = WidgetStyle::default();
style.attention.background = Some((200, 0, 0, 16));
style.attention.foreground = (255, 74, 77, 255);
let mut widget = NotificationsWidget::new(Bounds::new(0, 0, 40, 32)).with_style(style);
widget.update(&some(1, false));
let mut ctx = RenderContext::new(64, 32);
widget.draw(&mut ctx);
let px = ctx.pixels();
let center = ((5 * 64 + 35) * 4) as usize;
assert_eq!(&px[center..center + 4], &[255, 74, 77, 255]);
}
#[test]
fn clicks_map_buttons_to_panel_and_dnd_toggles() {
let mut widget = NotificationsWidget::new(Bounds::new(10, 0, 40, 32));
widget.update(&some(0, false));
assert_eq!(
widget.on_click(20, 16, ClickButton::Left),
Some(Command::ToggleNotificationPanel)
);
assert_eq!(
widget.on_click(20, 16, ClickButton::Right),
Some(Command::ToggleNotificationsDnd)
);
}
#[test]
fn clicks_outside_bounds_or_before_any_reading_are_ignored() {
let empty = NotificationsWidget::new(Bounds::new(10, 0, 40, 32));
assert_eq!(empty.on_click(20, 16, ClickButton::Left), None);
let mut widget = NotificationsWidget::new(Bounds::new(10, 0, 40, 32));
widget.update(&some(0, false));
assert_eq!(widget.on_click(0, 16, ClickButton::Left), None);
assert_eq!(widget.on_click(50, 16, ClickButton::Left), None);
assert_eq!(widget.on_click(20, 32, ClickButton::Right), None);
}
}