use crate::core::{Color, Font, HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::{GenericSignal, Signal1};
use crate::widget::capability::coercion::{expect_bool, expect_string};
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::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
const DISMISS_SIZE: u32 = 24;
const EDGE_PADDING: u32 = 8;
const ACTION_GAP: u32 = 8;
const ACTION_WIDTH: u32 = 72;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BannerSeverity {
#[default]
Info,
Success,
Warning,
Error,
}
impl BannerSeverity {
pub fn token(self) -> &'static str {
match self {
BannerSeverity::Info => "info",
BannerSeverity::Success => "success",
BannerSeverity::Warning => "warning",
BannerSeverity::Error => "error",
}
}
pub fn from_token(token: &str) -> Option<Self> {
match token {
"info" => Some(BannerSeverity::Info),
"success" => Some(BannerSeverity::Success),
"warning" => Some(BannerSeverity::Warning),
"error" => Some(BannerSeverity::Error),
_ => None,
}
}
fn background(self) -> Color {
match self {
BannerSeverity::Info => Color::rgb(219, 229, 249),
BannerSeverity::Success => Color::rgb(214, 239, 221),
BannerSeverity::Warning => Color::rgb(252, 234, 205),
BannerSeverity::Error => Color::rgb(250, 219, 219),
}
}
fn border(self) -> Color {
match self {
BannerSeverity::Info => Color::rgb(157, 184, 230),
BannerSeverity::Success => Color::rgb(155, 210, 170),
BannerSeverity::Warning => Color::rgb(226, 183, 106),
BannerSeverity::Error => Color::rgb(226, 158, 158),
}
}
fn default_text(self) -> Color {
match self {
BannerSeverity::Info => Color::rgb(31, 51, 88),
BannerSeverity::Success => Color::rgb(24, 69, 39),
BannerSeverity::Warning => Color::rgb(94, 63, 12),
BannerSeverity::Error => Color::rgb(104, 29, 29),
}
}
}
pub struct Banner {
base: BaseWidget,
text: String,
severity: BannerSeverity,
dismissible: bool,
is_dismissed: bool,
actions: Vec<String>,
hovered_action: Option<usize>,
pub action_clicked: Signal1<String>,
pub dismissed: GenericSignal,
}
impl Banner {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Banner, geometry, "Banner"),
text: String::new(),
severity: BannerSeverity::Info,
dismissible: true,
is_dismissed: false,
actions: Vec::new(),
hovered_action: None,
action_clicked: Signal1::new(),
dismissed: GenericSignal::new(),
}
}
pub fn text(&self) -> &str {
&self.text
}
pub fn set_text(&mut self, text: impl Into<String>) {
self.text = text.into();
self.base.request_redraw();
}
pub fn severity(&self) -> BannerSeverity {
self.severity
}
pub fn set_severity(&mut self, severity: BannerSeverity) {
self.severity = severity;
self.base.request_redraw();
}
pub fn dismissible(&self) -> bool {
self.dismissible
}
pub fn set_dismissible(&mut self, dismissible: bool) {
self.dismissible = dismissible;
self.base.request_redraw();
}
pub fn is_dismissed(&self) -> bool {
self.is_dismissed
}
pub fn dismiss(&mut self) -> bool {
if !self.dismissible || self.is_dismissed {
return false;
}
self.is_dismissed = true;
self.dismissed_signal();
self.base.request_redraw();
true
}
pub fn show(&mut self) {
self.is_dismissed = false;
self.hovered_action = None;
self.base.request_redraw();
}
pub fn actions(&self) -> &[String] {
&self.actions
}
pub fn set_actions(&mut self, actions: Vec<String>) {
self.actions = actions;
self.hovered_action = None;
self.base.request_redraw();
}
pub fn action_count(&self) -> usize {
self.actions.len()
}
pub fn activate_action(&mut self, index: usize) -> bool {
if self.is_dismissed {
return false;
}
let Some(label) = self.actions.get(index).cloned() else {
return false;
};
self.action_clicked.emit(label);
true
}
pub fn dismiss_action_rect(&self) -> Option<Rect> {
if !self.dismissible || self.is_dismissed {
return None;
}
let rect = self.geometry();
if rect.width < DISMISS_SIZE + EDGE_PADDING || rect.height < DISMISS_SIZE {
return None;
}
Some(Rect::new(
rect.x + rect.width as i32 - (DISMISS_SIZE + EDGE_PADDING) as i32,
rect.y + (rect.height as i32 - DISMISS_SIZE as i32) / 2,
DISMISS_SIZE,
DISMISS_SIZE,
))
}
pub fn action_rect(&self, index: usize) -> Option<Rect> {
if index >= self.actions.len() {
return None;
}
let rect = self.geometry();
let height = DISMISS_SIZE.min(rect.height);
if height == 0 {
return None;
}
let reserved = if self.dismissible { DISMISS_SIZE + EDGE_PADDING } else { EDGE_PADDING };
let right = rect.x + rect.width as i32 - reserved as i32;
let step = (ACTION_WIDTH + ACTION_GAP) as i32;
let x = right - step * (index as i32 + 1) + ACTION_GAP as i32;
Some(Rect::new(x, rect.y + (rect.height as i32 - height as i32) / 2, ACTION_WIDTH, height))
}
fn dismissed_signal(&self) {
self.dismissed.emit();
}
fn action_index_at(&self, pos: Point) -> Option<usize> {
if self.is_dismissed {
return None;
}
(0..self.actions.len())
.find(|index| self.action_rect(*index).is_some_and(|rect| point_in_rect(pos, rect)))
}
fn hits_dismiss(&self, pos: Point) -> bool {
self.dismiss_action_rect().is_some_and(|rect| point_in_rect(pos, rect))
}
}
fn point_in_rect(pos: Point, rect: Rect) -> bool {
pos.x >= rect.x
&& pos.x < rect.x + rect.width as i32
&& pos.y >= rect.y
&& pos.y < rect.y + rect.height as i32
}
impl Widget for Banner {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
Size::new(360, 48)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for Banner {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"text" => Ok(CapabilityValue::String(self.text().to_string())),
"severity" => Ok(CapabilityValue::String(self.severity().token().to_string())),
"dismissible" => Ok(CapabilityValue::Bool(self.dismissible())),
"dismissed" => Ok(CapabilityValue::Bool(self.is_dismissed())),
"action_count" => Ok(CapabilityValue::UInt(self.action_count() as u64)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"text" => {
self.set_text(expect_string(value)?);
Ok(())
}
"severity" => {
let token = expect_string(value)?;
let severity = BannerSeverity::from_token(&token)
.ok_or(CapabilityAccessError::TypeMismatch)?;
self.set_severity(severity);
Ok(())
}
"dismissible" => {
self.set_dismissible(expect_bool(value)?);
Ok(())
}
"dismissed" | "action_count" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"text",
"severity",
"dismissible",
"dismissed",
"action_count",
BASE_PROPERTY_NAMES
]
}
}
impl EventHandler for Banner {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() || self.is_dismissed {
return;
}
match event {
Event::MousePress { pos, button: 1 } => {
if self.hits_dismiss(*pos) {
let _ = self.dismiss();
return;
}
if let Some(index) = self.action_index_at(*pos) {
let _ = self.activate_action(index);
}
}
Event::MouseMove { pos } => {
self.hovered_action = self.action_index_at(*pos);
}
Event::MouseLeave { .. } => {
self.hovered_action = None;
}
_ => {}
}
}
}
impl Draw for Banner {
fn draw(&mut self, context: &mut RenderContext) {
if self.is_dismissed() {
return;
}
let rect = self.base.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
let style = self.base.style().clone();
let background = self.severity.background();
let text_color = style.text_color.unwrap_or_else(|| self.severity.default_text());
let border_color = style.border_color.unwrap_or_else(|| self.severity.border());
context.fill_rect(rect, background);
let controls_left = self.controls_left_edge();
let text_width = (controls_left - (rect.x + EDGE_PADDING as i32)).max(0) as u32;
if text_width > 0 {
let font = Font::simple("Sans", (rect.height as f32 * 0.3).clamp(10.0, 16.0));
context.draw_text(
Point::new(rect.x + EDGE_PADDING as i32, rect.y + (rect.height as i32) / 2),
&self.text,
&font,
text_color,
HorizontalAlignment::Left,
);
}
for (index, label) in self.actions.iter().enumerate() {
let Some(action_rect) = self.action_rect(index) else {
continue;
};
let hovered = self.hovered_action == Some(index);
if hovered {
context.fill_rect(action_rect, background.blend(&text_color, 0.16));
}
context.draw_rect(action_rect, border_color);
context.draw_text(
Point::new(action_rect.x + (action_rect.width as i32) / 2, action_rect.y + 16),
label,
&Font::simple("Sans", 12.0),
text_color,
HorizontalAlignment::Center,
);
}
if let Some(dismiss_rect) = self.dismiss_action_rect() {
context.draw_rect(dismiss_rect, border_color);
let inset = 7;
let left = dismiss_rect.x + inset;
let right = dismiss_rect.x + dismiss_rect.width as i32 - inset;
let top = dismiss_rect.y + inset;
let bottom = dismiss_rect.y + dismiss_rect.height as i32 - inset;
context.draw_line(Point::new(left, top), Point::new(right, bottom), text_color);
context.draw_line(Point::new(right, top), Point::new(left, bottom), text_color);
}
context.draw_rect(rect, border_color);
}
fn uses_custom_drawing(&self) -> bool {
true
}
}
impl Banner {
fn controls_left_edge(&self) -> i32 {
if !self.actions.is_empty() {
if let Some(last) = self.action_rect(self.actions.len() - 1) {
return last.x - ACTION_GAP as i32;
}
}
match self.dismiss_action_rect() {
Some(rect) => rect.x - EDGE_PADDING as i32,
None => self.geometry().x + self.geometry().width as i32 - EDGE_PADDING as i32,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
fn banner() -> Banner {
Banner::new(Rect::new(0, 0, 400, 48))
}
fn action_sink(banner: &mut Banner) -> Arc<Mutex<Vec<String>>> {
let seen = Arc::new(Mutex::new(Vec::<String>::new()));
let sink = Arc::clone(&seen);
banner.action_clicked.connect(move |label| {
sink.lock().expect("signal sink poisoned").push(label.as_ref().clone());
});
seen
}
fn dismiss_sink(banner: &mut Banner) -> Arc<Mutex<u32>> {
let count = Arc::new(Mutex::new(0u32));
let sink = Arc::clone(&count);
banner.dismissed.connect(move || {
*sink.lock().expect("signal sink poisoned") += 1;
});
count
}
#[test]
fn dismiss_is_refused_when_not_dismissible() {
let mut banner = banner();
banner.set_dismissible(false);
assert!(!banner.dismiss(), "a non-dismissible banner cannot be dismissed");
assert!(!banner.is_dismissed(), "a refused dismiss must not change state");
}
#[test]
fn dismiss_succeeds_and_emits() {
let mut banner = banner();
let count = dismiss_sink(&mut banner);
assert!(banner.dismiss());
assert!(banner.is_dismissed());
assert_eq!(*count.lock().expect("signal sink poisoned"), 1);
}
#[test]
fn dismiss_twice_reports_refusal_the_second_time() {
let mut banner = banner();
let count = dismiss_sink(&mut banner);
assert!(banner.dismiss());
assert!(!banner.dismiss(), "an already dismissed banner cannot be dismissed again");
assert!(banner.is_dismissed());
assert_eq!(
*count.lock().expect("signal sink poisoned"),
1,
"the dismissal must be announced once, not once per call"
);
}
#[test]
fn show_clears_the_dismissed_state() {
let mut banner = banner();
banner.dismiss();
assert!(banner.is_dismissed());
banner.show();
assert!(!banner.is_dismissed(), "show must un-dismiss");
assert!(banner.dismiss());
}
#[test]
fn clicking_the_dismiss_rect_dismisses() {
let mut banner = banner();
let rect =
banner.dismiss_action_rect().expect("a dismissible banner offers a dismiss area");
banner.handle_event(&Event::mouse_press(rect.x + 2, rect.y + 2, 1));
assert!(banner.is_dismissed());
}
#[test]
fn clicking_an_action_emits_its_label() {
let mut banner = banner();
banner.set_actions(vec!["Retry".to_string(), "Details".to_string()]);
let seen = action_sink(&mut banner);
let rect = banner.action_rect(0).expect("action 0 has a rect");
banner.handle_event(&Event::mouse_press(rect.x + 2, rect.y + 2, 1));
let got = seen.lock().expect("signal sink poisoned").clone();
assert_eq!(got, vec!["Retry".to_string()]);
assert!(!banner.is_dismissed(), "activating an action is not a dismissal");
}
#[test]
fn each_action_rect_maps_to_its_own_action() {
let mut banner = banner();
banner.set_actions(vec!["First".to_string(), "Second".to_string(), "Third".to_string()]);
let seen = action_sink(&mut banner);
let rect = banner.action_rect(0).expect("action 0 has a rect");
let second = banner.action_rect(1).expect("action 1 has a rect");
assert_ne!(rect, second, "two actions must not share a hit area");
banner.handle_event(&Event::mouse_press(second.x + 2, second.y + 2, 1));
let got = seen.lock().expect("signal sink poisoned").clone();
assert_eq!(got, vec!["Second".to_string()]);
assert_eq!(banner.action_rect(3), None, "there is no fourth action");
}
#[test]
fn a_dismissed_banner_ignores_clicks() {
let mut banner = banner();
banner.set_actions(vec!["Retry".to_string()]);
let seen = action_sink(&mut banner);
let action = banner.action_rect(0).expect("action 0 has a rect");
let dismiss = banner.dismiss_action_rect().expect("a dismiss area exists");
banner.dismiss();
banner.handle_event(&Event::mouse_press(action.x + 2, action.y + 2, 1));
banner.handle_event(&Event::mouse_press(dismiss.x + 2, dismiss.y + 2, 1));
assert!(banner.is_dismissed());
assert!(
seen.lock().expect("signal sink poisoned").is_empty(),
"a dismissed banner must not activate an action"
);
assert_eq!(banner.dismiss_action_rect(), None, "nothing is drawn, so nothing is clickable");
}
#[test]
fn activate_action_rejects_an_out_of_range_index() {
let mut banner = banner();
banner.set_actions(vec!["Only".to_string()]);
let seen = action_sink(&mut banner);
assert!(!banner.activate_action(1), "index 1 does not exist");
assert!(!banner.activate_action(usize::MAX));
assert!(banner.activate_action(0));
assert_eq!(banner.action_count(), 1);
let got = seen.lock().expect("signal sink poisoned").clone();
assert_eq!(got, vec!["Only".to_string()], "only the valid activation may emit");
}
#[test]
fn a_click_away_from_any_control_does_nothing() {
let mut banner = banner();
banner.set_actions(vec!["Retry".to_string()]);
let seen = action_sink(&mut banner);
let count = dismiss_sink(&mut banner);
banner.handle_event(&Event::mouse_press(4, 24, 1));
assert!(!banner.is_dismissed(), "clicking the message is not a dismissal");
assert!(seen.lock().expect("signal sink poisoned").is_empty());
assert_eq!(*count.lock().expect("signal sink poisoned"), 0);
}
#[test]
fn only_the_left_button_activates_controls() {
let mut banner = banner();
let rect = banner.dismiss_action_rect().expect("a dismiss area exists");
banner.handle_event(&Event::mouse_press(rect.x + 2, rect.y + 2, 3));
assert!(!banner.is_dismissed());
}
#[test]
fn a_non_dismissible_banner_offers_no_dismiss_area() {
let mut banner = banner();
banner.set_actions(vec!["Retry".to_string()]);
assert!(banner.dismiss_action_rect().is_some());
banner.set_dismissible(false);
assert_eq!(banner.dismiss_action_rect(), None);
let action = banner.action_rect(0).expect("the action is still laid out");
assert!(
action.x + action.width as i32 <= banner.geometry().x + banner.geometry().width as i32,
"an action must stay inside the bar"
);
}
#[test]
fn severity_round_trips_through_the_property_contract() {
use crate::widget::capability::properties_trait::{
widget_property_get, widget_property_set,
};
for severity in [
BannerSeverity::Info,
BannerSeverity::Success,
BannerSeverity::Warning,
BannerSeverity::Error,
] {
let mut banner = banner();
let token = severity.token();
widget_property_set(
&mut banner,
"severity",
CapabilityValue::String(token.to_string()),
)
.unwrap_or_else(|_| panic!("{token} must be accepted"));
assert_eq!(banner.severity(), severity);
assert_eq!(
widget_property_get(&banner, "severity"),
Ok(CapabilityValue::String(token.to_string()))
);
}
let mut banner = banner();
assert_eq!(
widget_property_set(
&mut banner,
"severity",
CapabilityValue::String("loud".to_string())
),
Err(CapabilityAccessError::TypeMismatch),
"an unknown token must be refused rather than defaulted"
);
assert_eq!(banner.severity(), BannerSeverity::Info);
}
#[test]
fn properties_round_trip() {
use crate::widget::capability::properties_trait::{
widget_property_get, widget_property_set,
};
let mut banner = banner();
widget_property_set(&mut banner, "text", CapabilityValue::String("Saved".to_string()))
.expect("text");
assert_eq!(
widget_property_get(&banner, "text"),
Ok(CapabilityValue::String("Saved".to_string()))
);
widget_property_set(&mut banner, "dismissible", CapabilityValue::Bool(false))
.expect("dismissible");
assert_eq!(widget_property_get(&banner, "dismissible"), Ok(CapabilityValue::Bool(false)));
banner.dismiss();
assert_eq!(widget_property_get(&banner, "dismissed"), Ok(CapabilityValue::Bool(false)));
assert_eq!(
widget_property_set(&mut banner, "dismissed", CapabilityValue::Bool(true)),
Err(CapabilityAccessError::ReadOnlyProperty)
);
assert_eq!(
widget_property_set(&mut banner, "action_count", CapabilityValue::UInt(3)),
Err(CapabilityAccessError::ReadOnlyProperty)
);
assert_eq!(widget_property_get(&banner, "action_count"), Ok(CapabilityValue::UInt(0)));
}
#[test]
fn a_new_banner_is_an_undismissed_info_banner() {
let banner = banner();
assert_eq!(banner.severity(), BannerSeverity::Info);
assert_eq!(BannerSeverity::default(), BannerSeverity::Info);
assert!(banner.dismissible());
assert!(!banner.is_dismissed());
assert_eq!(banner.text(), "");
assert_eq!(banner.action_count(), 0);
assert!(banner.actions().is_empty());
assert_eq!(banner.action_rect(0), None, "there are no actions to lay out");
}
#[test]
fn set_actions_replaces_the_action_list() {
let mut banner = banner();
banner.set_actions(vec!["A".to_string(), "B".to_string()]);
banner.handle_event(&Event::mouse_move(0, 0));
assert_eq!(banner.action_count(), 2);
banner.set_actions(vec!["C".to_string()]);
assert_eq!(banner.action_count(), 1);
assert_eq!(banner.actions(), ["C".to_string()]);
assert_eq!(banner.action_rect(1), None);
assert!(banner.action_rect(0).is_some());
}
}