use crate::core::{Color, HorizontalAlignment, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::menu_toolbar::popup_reveal::PopupReveal;
use crate::widget::metrics::effective_font;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
use core::cell::{Ref, RefCell};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotificationLevel {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NotificationItem {
pub id: String,
pub title: String,
pub message: String,
pub level: NotificationLevel,
pub read: bool,
pub timestamp_ms: u64,
}
impl NotificationItem {
pub fn new(
id: impl Into<String>,
title: impl Into<String>,
message: impl Into<String>,
level: NotificationLevel,
) -> Self {
Self {
id: id.into(),
title: title.into(),
message: message.into(),
level,
read: false,
timestamp_ms: 0,
}
}
pub fn with_timestamp(mut self, timestamp_ms: u64) -> Self {
self.timestamp_ms = timestamp_ms;
self
}
pub fn day_index(&self) -> Option<u64> {
(self.timestamp_ms > 0).then_some(self.timestamp_ms / 86_400_000)
}
}
pub struct NotificationCenter {
base: BaseWidget,
items: Vec<NotificationItem>,
layout_revision: u64,
layout_cache: LayoutCache,
selected_index: Option<usize>,
row_height: u32,
reveal: PopupReveal,
scroll_offset: usize,
show_timestamps: bool,
show_clear_all: bool,
show_mark_all_read: bool,
mark_all_read_explicit: bool,
group_by_day: bool,
level_filter: Option<NotificationLevel>,
max_items: usize,
pub notification_selected: Signal1<String>,
pub notification_activated: Signal1<String>,
pub unread_count_changed: Signal1<usize>,
pub notification_dismissed: Signal1<String>,
pub clear_all_requested: Signal1<()>,
pub all_read_requested: Signal1<()>,
}
struct RowLayout {
item: usize,
rect: Rect,
day_label: Option<String>,
stamp: Option<String>,
}
type LayoutCache = RefCell<Option<((u64, Rect, u32, usize, bool), Vec<RowLayout>)>>;
impl NotificationCenter {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::ListView, geometry, "NotificationCenter"),
items: Vec::new(),
layout_revision: 1,
layout_cache: RefCell::new(None),
selected_index: None,
row_height: 36,
reveal: PopupReveal::new(true),
scroll_offset: 0,
show_timestamps: false,
show_clear_all: false,
show_mark_all_read: false,
mark_all_read_explicit: false,
group_by_day: false,
level_filter: None,
max_items: 0,
notification_selected: Signal1::new(),
notification_activated: Signal1::new(),
unread_count_changed: Signal1::new(),
notification_dismissed: Signal1::new(),
clear_all_requested: Signal1::new(),
all_read_requested: Signal1::new(),
}
}
pub fn set_show_timestamps(&mut self, show: bool) {
if self.show_timestamps == show {
return;
}
self.show_timestamps = show;
self.invalidate_layout();
self.base.request_redraw();
}
pub fn show_timestamps(&self) -> bool {
self.show_timestamps
}
pub fn set_show_clear_all(&mut self, show: bool) {
if self.show_clear_all == show {
return;
}
self.show_clear_all = show;
if !self.mark_all_read_explicit {
self.show_mark_all_read = show;
}
self.invalidate_layout();
self.base.request_redraw();
}
pub fn show_clear_all(&self) -> bool {
self.show_clear_all
}
pub fn set_show_mark_all_read(&mut self, show: bool) {
self.mark_all_read_explicit = true;
if self.show_mark_all_read == show {
return;
}
self.show_mark_all_read = show;
self.invalidate_layout();
self.base.request_redraw();
}
pub fn show_mark_all_read(&self) -> bool {
self.show_mark_all_read
}
pub fn set_group_by_day(&mut self, group: bool) {
if self.group_by_day == group {
return;
}
self.group_by_day = group;
self.invalidate_layout();
self.base.request_redraw();
}
pub fn group_by_day(&self) -> bool {
self.group_by_day
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn scroll_by(&mut self, delta: isize) {
let capacity = (self.geometry().height / self.row_height.max(1)) as usize;
let max = self.visible_count().saturating_sub(capacity.max(1));
let next = (self.scroll_offset as isize + delta).clamp(0, max as isize) as usize;
if self.scroll_offset == next {
return;
}
self.scroll_offset = next;
self.invalidate_layout();
self.base.request_redraw();
}
pub fn remove(&mut self, id: &str) -> bool {
let Some(index) = self.items.iter().position(|item| item.id == id) else {
return false;
};
let removed = self.items.remove(index);
if self.items.is_empty() {
self.selected_index = None;
} else if self.selected_index.is_some_and(|selected| selected >= self.items.len()) {
self.selected_index = Some(self.items.len() - 1);
}
let capacity = (self.geometry().height / self.row_height.max(1)) as usize;
self.scroll_offset =
self.scroll_offset.min(self.items.len().saturating_sub(capacity.max(1)));
self.notification_dismissed.emit(removed.id);
if !removed.read {
self.unread_count_changed.emit(self.unread_count());
}
self.invalidate_layout();
self.base.request_layout();
self.base.request_redraw();
true
}
fn invalidate_layout(&mut self) {
self.layout_revision = self.layout_revision.wrapping_add(1);
}
fn header_height(&self) -> i32 {
let has_action = (self.show_clear_all || self.show_mark_all_read) && !self.items.is_empty();
if has_action {
18
} else {
0
}
}
fn clear_all_rect(&self) -> Option<Rect> {
if !self.show_clear_all || self.items.is_empty() {
return None;
}
self.header_button_rect(0)
}
fn mark_all_read_rect(&self) -> Option<Rect> {
if !self.show_mark_all_read || self.items.is_empty() {
return None;
}
let slot = i32::from(self.show_clear_all);
self.header_button_rect(slot)
}
fn header_button_rect(&self, slot: i32) -> Option<Rect> {
let rect = self.geometry();
let width = 64.min(rect.width);
let gap = 4;
Some(Rect::new(
rect.x + rect.width as i32 - width as i32 - 4 - slot * (width as i32 + gap),
rect.y + 2,
width,
14,
))
}
fn layout_rows(&self) -> Ref<'_, Vec<RowLayout>> {
let rect = self.geometry();
let key =
(self.layout_revision, rect, self.row_height, self.scroll_offset, self.group_by_day);
let fresh = self.layout_cache.borrow().as_ref().is_some_and(|(cached, _)| *cached == key);
if !fresh {
let rows = self.build_rows(rect);
*self.layout_cache.borrow_mut() = Some((key, rows));
}
Ref::map(self.layout_cache.borrow(), |cache| {
&cache.as_ref().expect("the row layout was just built").1
})
}
fn build_rows(&self, rect: Rect) -> Vec<RowLayout> {
let mut y = rect.y + self.header_height();
let mut rows = Vec::new();
let mut previous_day = None;
for (index, item) in self.items.iter().enumerate().skip(self.scroll_offset) {
if !self.item_matches_filter(item) {
continue;
}
let mut day_label = None;
if self.group_by_day && item.day_index().is_some() && item.day_index() != previous_day {
y += 14;
day_label = Self::day_label(item);
}
if item.day_index().is_some() {
previous_day = item.day_index();
}
if y >= rect.y + rect.height as i32 {
break;
}
rows.push(RowLayout {
item: index,
rect: Rect::new(rect.x, y, rect.width, self.row_height),
day_label,
stamp: (self.show_timestamps && item.timestamp_ms > 0)
.then(|| Self::stamp_label(item.timestamp_ms)),
});
y += self.row_height as i32;
}
rows
}
fn stamp_label(timestamp_ms: u64) -> String {
let seconds = timestamp_ms / 1000;
let hour = (seconds / 3600) % 24;
let minute = (seconds / 60) % 60;
format!("{hour:02}:{minute:02}")
}
fn day_label(item: &NotificationItem) -> Option<String> {
let day = item.day_index()?;
let seconds = item.timestamp_ms / 1000;
let hour = (seconds / 3600) % 24;
let minute = (seconds / 60) % 60;
Some(format!("Day {day} {hour:02}:{minute:02}"))
}
pub fn items(&self) -> &[NotificationItem] {
&self.items
}
pub fn push(&mut self, item: NotificationItem) {
self.items.push(item);
if self.selected_index.is_none() {
self.selected_index = Some(0);
}
self.reveal.jump_to(false);
self.reveal.set_open(true);
self.enforce_item_cap();
self.clamp_scroll_to_visible();
self.invalidate_layout();
self.unread_count_changed.emit(self.unread_count());
self.base.request_layout();
self.base.request_redraw();
}
pub fn clear(&mut self) {
self.items.clear();
self.selected_index = None;
self.invalidate_layout();
self.unread_count_changed.emit(0);
self.base.request_layout();
self.base.request_redraw();
}
pub fn set_level_filter(&mut self, level: Option<NotificationLevel>) {
if self.level_filter == level {
return;
}
self.level_filter = level;
self.clamp_scroll_to_visible();
self.invalidate_layout();
self.base.request_layout();
self.base.request_redraw();
}
pub fn level_filter(&self) -> Option<NotificationLevel> {
self.level_filter
}
pub fn set_max_items(&mut self, max_items: usize) {
if self.max_items == max_items {
return;
}
self.max_items = max_items;
self.enforce_item_cap();
self.invalidate_layout();
self.base.request_layout();
self.base.request_redraw();
}
pub fn max_items(&self) -> usize {
self.max_items
}
fn item_matches_filter(&self, item: &NotificationItem) -> bool {
self.level_filter.is_none_or(|level| item.level == level)
}
fn visible_count(&self) -> usize {
self.items.iter().filter(|item| self.item_matches_filter(item)).count()
}
fn enforce_item_cap(&mut self) {
if self.max_items == 0 {
return;
}
while self.items.len() > self.max_items {
let Some(id) = self.items.first().map(|item| item.id.clone()) else {
break;
};
if !self.remove(&id) {
break;
}
}
}
fn clamp_scroll_to_visible(&mut self) {
let capacity = (self.geometry().height / self.row_height.max(1)) as usize;
let max = self.visible_count().saturating_sub(capacity.max(1));
self.scroll_offset = self.scroll_offset.min(max);
}
pub fn unread_count(&self) -> usize {
self.items.iter().filter(|item| !item.read).count()
}
pub fn set_read(&mut self, id: &str, read: bool) -> bool {
let before = self.unread_count();
for item in &mut self.items {
if item.id == id {
item.read = read;
let after = self.unread_count();
if before != after {
self.unread_count_changed.emit(after);
}
self.base.request_redraw();
return true;
}
}
false
}
pub fn mark_all_read(&mut self) {
let mut changed = false;
for item in &mut self.items {
if !item.read {
item.read = true;
changed = true;
}
}
if changed {
self.unread_count_changed.emit(0);
self.base.request_redraw();
}
}
pub fn selected_id(&self) -> Option<&str> {
let index = self.selected_index?;
self.items.get(index).map(|item| item.id.as_str())
}
pub fn select_index(&mut self, index: usize) -> bool {
if index >= self.items.len() {
return false;
}
if self.selected_index == Some(index) {
return true;
}
self.selected_index = Some(index);
if let Some(item) = self.items.get(index) {
self.notification_selected.emit(item.id.clone());
}
self.base.request_redraw();
true
}
pub fn activate_selected(&mut self) -> bool {
let Some(index) = self.selected_index else {
return false;
};
if index >= self.items.len() {
return false;
}
let id = self.items[index].id.clone();
let changed = !self.items[index].read;
self.items[index].read = true;
self.notification_activated.emit(id);
if changed {
self.unread_count_changed.emit(self.unread_count());
}
self.base.request_redraw();
true
}
fn row_at(&self, pos: Point) -> Option<usize> {
let rows = self.layout_rows();
rows.iter().find(|row| row.rect.contains_point(pos)).map(|row| row.item)
}
pub fn row_height(&self) -> u32 {
self.row_height
}
pub fn set_row_height(&mut self, row_height: u32) {
let row_height = row_height.max(1);
if self.row_height == row_height {
return;
}
self.row_height = row_height;
self.invalidate_layout();
self.base.request_layout();
self.base.request_redraw();
}
}
impl Widget for NotificationCenter {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(320, 400)
}
fn tick(&mut self, delta_ms: u32) -> bool {
self.reveal.tick(delta_ms)
}
fn is_animating(&self) -> bool {
self.reveal.is_animating()
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"notification_selected" => Some(EventSignalRef::mapped(
"notification_selected",
&self.notification_selected,
|v| CapabilityValue::String(v.clone()),
)),
"notification_activated" => Some(EventSignalRef::mapped(
"notification_activated",
&self.notification_activated,
|v| CapabilityValue::String(v.clone()),
)),
"unread_count_changed" => Some(EventSignalRef::mapped(
"unread_count_changed",
&self.unread_count_changed,
|v| CapabilityValue::UInt(*v as u64),
)),
"clear_all_requested" => Some(EventSignalRef::mapped(
"clear_all_requested",
&self.clear_all_requested,
|_| crate::widget::capability::CapabilityValue::Null,
)),
"notification_dismissed" => Some(EventSignalRef::mapped(
"notification_dismissed",
&self.notification_dismissed,
|v| crate::widget::capability::CapabilityValue::String(v.clone()),
)),
"all_read_requested" => {
Some(EventSignalRef::mapped("all_read_requested", &self.all_read_requested, |_| {
crate::widget::capability::CapabilityValue::Null
}))
}
_ => None,
}
}
}
impl WidgetProperties for NotificationCenter {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"item_count" => Ok(CapabilityValue::UInt(self.items().len() as u64)),
"unread_count" => Ok(CapabilityValue::UInt(self.unread_count() as u64)),
"selected_index" => {
let index = self
.items()
.iter()
.position(|item| Some(item.id.as_str()) == self.selected_id());
match index {
Some(index) => Ok(CapabilityValue::UInt(index as u64)),
None => Ok(CapabilityValue::Null),
}
}
"row_height" => Ok(CapabilityValue::UInt(self.row_height() as u64)),
"level_filter" => Ok(CapabilityValue::String(
match self.level_filter() {
None => "all",
Some(NotificationLevel::Info) => "info",
Some(NotificationLevel::Warning) => "warning",
Some(NotificationLevel::Error) => "error",
}
.to_string(),
)),
"max_items" => Ok(CapabilityValue::UInt(self.max_items() as u64)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"row_height" => match value {
CapabilityValue::UInt(height) => {
let height =
u32::try_from(height).map_err(|_| CapabilityAccessError::TypeMismatch)?;
self.set_row_height(height);
Ok(())
}
_ => Err(CapabilityAccessError::TypeMismatch),
},
"level_filter" => {
let token = crate::widget::capability::coercion::expect_string(value)?;
let level = match token.to_ascii_lowercase().as_str() {
"all" => None,
"info" => Some(NotificationLevel::Info),
"warning" => Some(NotificationLevel::Warning),
"error" => Some(NotificationLevel::Error),
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_level_filter(level);
Ok(())
}
"max_items" => {
let max = crate::widget::capability::coercion::expect_usize(value)?;
self.set_max_items(max);
Ok(())
}
"item_count" | "unread_count" | "selected_index" => {
Err(CapabilityAccessError::ReadOnlyProperty)
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"item_count",
"unread_count",
"selected_index",
"row_height",
"level_filter",
"max_items",
BASE_PROPERTY_NAMES
]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"clear" => {
self.clear();
Ok(())
}
"mark_all_read" => {
self.mark_all_read();
Ok(())
}
"activate_selected" => {
if self.activate_selected() {
Ok(())
} else {
Err(CapabilityAccessError::OutOfRange)
}
}
"push" | "set_read" | "select_index" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl EventHandler for NotificationCenter {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button: 1, .. } => {
if self.clear_all_rect().is_some_and(|rect| rect.contains_point(*pos)) {
self.clear_all_requested.emit(());
return;
}
if self.mark_all_read_rect().is_some_and(|rect| rect.contains_point(*pos)) {
self.all_read_requested.emit(());
self.mark_all_read();
return;
}
let hit = {
let rows = self.layout_rows();
rows.iter()
.find(|row| row.rect.contains_point(*pos))
.map(|row| (row.item, row.rect))
};
if let Some((index, rect)) = hit {
if pos.x >= rect.x + rect.width as i32 - 22 {
let id = self.items[index].id.clone();
let _ = self.remove(&id);
return;
}
let _ = self.select_index(index);
}
}
Event::MouseDoubleClick { pos, button: 1 } => {
if let Some(index) = self.row_at(*pos) {
let _ = self.select_index(index);
let _ = self.activate_selected();
}
}
Event::Wheel { delta, .. } => {
if delta.y != 0 {
self.scroll_by(if delta.y > 0 { 1 } else { -1 });
}
}
Event::KeyPress { key, modifiers: _ } => match *key {
38 => {
if let Some(index) = self.selected_index {
if index > 0 {
let _ = self.select_index(index - 1);
}
} else if !self.items.is_empty() {
let _ = self.select_index(0);
}
}
40 => {
if let Some(index) = self.selected_index {
if index + 1 < self.items.len() {
let _ = self.select_index(index + 1);
}
} else if !self.items.is_empty() {
let _ = self.select_index(0);
}
}
13 => {
let _ = self.activate_selected();
}
46 => {
if let Some(id) = self.selected_id().map(str::to_owned) {
let _ = self.remove(&id);
}
}
_ => {}
},
_ => {}
}
}
}
impl Draw for NotificationCenter {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
let style = self.base.style();
let theme = crate::style::resolved_theme_style_shared("notification_center");
let resolved = style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
.unwrap_or(Color::WHITE);
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.unwrap_or_else(|| crate::core::Color::border_of(resolved));
let text_color = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(Color::BLACK);
let background = resolved.blend(&text_color, 0.08);
let selected_background = background.blend(&text_color, 0.14);
let unread_background = background.blend(&text_color, 0.05);
let separator = background.blend(&text_color, 0.12);
context.face_with_gradient(
rect,
background,
self.style().background_gradient.as_ref(),
self.style().surface.unwrap_or_default(),
self.style().border_radius.unwrap_or(0),
Color::BLACK,
);
crate::widget::draw::stroke_surface_border(
context,
rect,
self.style().border_radius.unwrap_or(0),
border,
1,
);
if let Some(button) = self.clear_all_rect() {
context.fill_rect(button, background.blend(&text_color, 0.10));
context.draw_rect(button, separator);
context.draw_text_line_cow(
button,
"Clear all",
effective_font(style),
text_color,
HorizontalAlignment::Center,
);
}
if let Some(button) = self.mark_all_read_rect() {
context.fill_rect(button, background.blend(&text_color, 0.10));
context.draw_rect(button, separator);
context.draw_text_line_cow(
button,
"Mark all read",
effective_font(style),
text_color,
HorizontalAlignment::Center,
);
}
let rows = self.layout_rows();
for row in rows.iter() {
let index = row.item;
let row_rect = row.rect;
let Some(item) = self.items.get(index) else {
continue;
};
if let Some(label) = row.day_label.as_deref() {
let header = Rect::new(row_rect.x + 4, row_rect.y - 14, row_rect.width, 12);
context.draw_text_line_cow(
header,
label,
effective_font(style),
text_color.blend(&background, 0.2),
HorizontalAlignment::Left,
);
}
let y = row_rect.y;
let rect = self.geometry();
let is_arriving = index + 1 == self.items.len();
let row_rect = if is_arriving && self.reveal.is_animating() {
let offset = (self.row_height as f32 * (1.0 - self.reveal.value())) as i32;
Rect::new(row_rect.x + offset, row_rect.y, row_rect.width, row_rect.height)
} else {
row_rect
};
let bg = if self.selected_index == Some(index) {
selected_background
} else if !item.read {
unread_background
} else {
background
};
context.fill_rect(row_rect, bg);
let badge_color = crate::style::semantic_color(match item.level {
NotificationLevel::Info => crate::style::SemanticColor::Info,
NotificationLevel::Warning => crate::style::SemanticColor::Warning,
NotificationLevel::Error => crate::style::SemanticColor::Error,
})
.map(|token| token.blend(&background, 0.25))
.unwrap_or_else(|| background.blend(&text_color, 0.5));
let indent = (self.row_height / 3).max(8) as i32;
let badge_size = 8u32.min(self.row_height);
let badge_y = y + (self.row_height.saturating_sub(badge_size) / 2) as i32;
context.fill_rect(
Rect::new(rect.x + indent, badge_y, badge_size, badge_size),
badge_color,
);
let text_x = rect.x + indent + badge_size as i32 + 6;
let trailing = 22 + if self.show_timestamps && item.timestamp_ms > 0 { 40 } else { 0 };
let line_height = (self.row_height / 2).max(1);
let title_band = Rect::new(
text_x,
y,
(rect.width as i32 - (text_x - rect.x) - trailing).max(0) as u32,
line_height,
);
context.draw_text_fitted_cow(
title_band,
&item.title,
effective_font(style),
text_color,
HorizontalAlignment::Left,
);
if !item.message.is_empty() {
let message_band = Rect::new(
text_x,
y + line_height as i32,
(rect.width as i32 - (text_x - rect.x) - trailing).max(0) as u32,
line_height,
);
context.draw_text_fitted_cow(
message_band,
&item.message,
effective_font(style),
text_color.blend(&background, 0.25),
HorizontalAlignment::Left,
);
}
context.draw_line(
Point::new(rect.x, y + self.row_height as i32),
Point::new(rect.x + rect.width as i32, y + self.row_height as i32),
separator,
);
let close_x = row_rect.x + row_rect.width as i32 - 14;
let close_y = row_rect.y + row_rect.height as i32 / 2;
let close_ink = text_color.with_alpha(150);
context.draw_line_stroke(
Point::new(close_x - 3, close_y - 3),
Point::new(close_x + 3, close_y + 3),
close_ink,
1,
);
context.draw_line_stroke(
Point::new(close_x + 3, close_y - 3),
Point::new(close_x - 3, close_y + 3),
close_ink,
1,
);
if let Some(stamp) = row.stamp.as_deref() {
let band = Rect::new(
row_rect.x + row_rect.width as i32 - 56,
row_rect.y,
36,
row_rect.height,
);
context.draw_text_line_cow(
band,
stamp,
effective_font(style),
text_color.blend(&background, 0.25),
HorizontalAlignment::Right,
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
fn sample_center() -> NotificationCenter {
let mut center = NotificationCenter::new(Rect::new(0, 0, 420, 160));
center.push(NotificationItem::new(
"n1",
"Build finished",
"All checks passed",
NotificationLevel::Info,
));
center.push(NotificationItem::new(
"n2",
"Deploy warning",
"One region has latency spike",
NotificationLevel::Warning,
));
center.push(NotificationItem::new(
"n3",
"Runtime error",
"Worker crashed",
NotificationLevel::Error,
));
center
}
#[test]
fn unread_count_and_mark_read_work() {
let mut center = sample_center();
assert_eq!(center.unread_count(), 3);
assert!(center.set_read("n2", true));
assert_eq!(center.unread_count(), 2);
center.mark_all_read();
assert_eq!(center.unread_count(), 0);
}
#[test]
fn level_filter_and_item_cap_bound_the_list() {
let mut center = sample_center();
center.set_level_filter(Some(NotificationLevel::Warning));
{
let rows = center.layout_rows();
assert_eq!(rows.len(), 1, "only the warning row is laid out");
assert_eq!(center.items()[rows[0].item].id, "n2");
}
center.set_level_filter(None);
let dismissed = Arc::new(Mutex::new(Vec::<String>::new()));
center.notification_dismissed.connect({
let dismissed = Arc::clone(&dismissed);
move |id: Arc<String>| dismissed.lock().unwrap().push((*id).clone())
});
center.set_max_items(2);
assert_eq!(center.items().len(), 2, "the cap evicts down to two rows");
assert_eq!(*dismissed.lock().unwrap(), vec!["n1".to_string()]);
assert_eq!(center.items()[0].id, "n2");
}
#[test]
fn activate_selected_marks_read_and_emits() {
let mut center = sample_center();
let activated = Arc::new(Mutex::new(Vec::<String>::new()));
let sink = activated.clone();
center.notification_activated.connect(move |id| {
if let Ok(mut guard) = sink.lock() {
guard.push(id.as_ref().clone());
}
});
assert!(center.select_index(1));
assert!(center.activate_selected());
assert_eq!(center.selected_id(), Some("n2"));
assert_eq!(center.unread_count(), 2);
let got = activated.lock().ok().map(|guard| guard.clone()).unwrap_or_default();
assert_eq!(got, vec!["n2".to_string()]);
}
#[test]
fn keyboard_navigation_changes_selection() {
let mut center = sample_center();
center.handle_event(&Event::key_press(40, 0));
assert_eq!(center.selected_id(), Some("n2"));
center.handle_event(&Event::key_press(40, 0));
assert_eq!(center.selected_id(), Some("n3"));
center.handle_event(&Event::key_press(38, 0));
assert_eq!(center.selected_id(), Some("n2"));
}
#[test]
fn new_creates_default_state() {
let center = NotificationCenter::new(Rect::new(0, 0, 800, 600));
assert!(center.items().is_empty());
assert_eq!(center.selected_id(), None);
assert_eq!(center.unread_count(), 0);
}
#[test]
fn items_returns_items() {
let center = sample_center();
assert_eq!(center.items().len(), 3);
assert_eq!(center.items()[0].id, "n1");
assert_eq!(center.items()[1].id, "n2");
assert_eq!(center.items()[2].id, "n3");
}
#[test]
fn push_sets_selection_to_first() {
let mut center = NotificationCenter::new(Rect::new(0, 0, 800, 600));
center.push(NotificationItem::new("n1", "Title", "Body", NotificationLevel::Info));
assert_eq!(center.selected_id(), Some("n1"));
}
#[test]
fn clear_removes_all_and_resets_state() {
let mut center = sample_center();
center.clear();
assert!(center.items().is_empty());
assert_eq!(center.selected_id(), None);
assert_eq!(center.unread_count(), 0);
}
#[test]
fn select_index_invalid_returns_false() {
let mut center = NotificationCenter::new(Rect::new(0, 0, 800, 600));
assert!(!center.select_index(0));
center.push(NotificationItem::new("n1", "Title", "Body", NotificationLevel::Info));
assert!(!center.select_index(5));
assert_eq!(center.selected_id(), Some("n1"));
}
#[test]
fn unread_count_changed_signal_on_push() {
let mut center = NotificationCenter::new(Rect::new(0, 0, 800, 600));
let counts = Arc::new(Mutex::new(Vec::<usize>::new()));
let sink = counts.clone();
center.unread_count_changed.connect(move |count| {
if let Ok(mut guard) = sink.lock() {
guard.push(*count);
}
});
center.push(NotificationItem::new("n1", "Title", "Body", NotificationLevel::Info));
let got = counts.lock().ok().map(|g| g.clone()).unwrap_or_default();
assert_eq!(got, vec![1]);
}
#[test]
fn keyboard_navigation_on_empty_does_nothing() {
let mut center = NotificationCenter::new(Rect::new(0, 0, 800, 600));
center.handle_event(&Event::key_press(40, 0));
assert_eq!(center.selected_id(), None);
center.handle_event(&Event::key_press(38, 0));
assert_eq!(center.selected_id(), None);
}
#[test]
fn activate_selected_marks_read_and_updates_unread_count() {
let mut center = NotificationCenter::new(Rect::new(0, 0, 800, 600));
center.push(NotificationItem::new("n1", "Title", "Body", NotificationLevel::Info));
assert_eq!(center.unread_count(), 1);
assert!(center.activate_selected());
assert_eq!(center.unread_count(), 0);
}
#[test]
fn set_read_nonexistent_returns_false() {
let mut center = sample_center();
assert!(!center.set_read("nonexistent", true));
assert_eq!(center.unread_count(), 3);
}
#[test]
fn select_index_duplicate_guard_returns_true() {
let mut center = sample_center();
assert!(center.select_index(1));
let emitted = Arc::new(Mutex::new(Vec::<String>::new()));
let sink = emitted.clone();
center.notification_selected.connect(move |id| {
if let Ok(mut guard) = sink.lock() {
guard.push(id.as_ref().clone());
}
});
assert!(center.select_index(0));
assert!(center.select_index(0));
let got = emitted.lock().ok().map(|g| g.clone()).unwrap_or_default();
assert_eq!(got.len(), 1);
}
#[test]
fn enter_key_activates_selected() {
let mut center = sample_center();
let activated = Arc::new(Mutex::new(Vec::<String>::new()));
let sink = activated.clone();
center.notification_activated.connect(move |id| {
if let Ok(mut guard) = sink.lock() {
guard.push(id.as_ref().clone());
}
});
center.handle_event(&Event::key_press(40, 0));
center.handle_event(&Event::key_press(13, 0));
let got = activated.lock().ok().map(|guard| guard.clone()).unwrap_or_default();
assert_eq!(got, vec!["n2".to_string()]);
}
}