use crate::core::{Color, Font, HorizontalAlignment, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::coercion::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};
pub struct Snackbar {
base: BaseWidget,
message: String,
action_label: Option<String>,
visible: bool,
progress: Option<f32>,
pub action_triggered: Signal1<String>,
pub dismissed: Signal1<()>,
}
impl Snackbar {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::StatusBar, geometry, "Snackbar"),
message: String::new(),
action_label: None,
visible: false,
progress: None,
action_triggered: Signal1::new(),
dismissed: Signal1::new(),
}
}
pub fn is_visible(&self) -> bool {
self.visible
}
pub fn message(&self) -> &str {
&self.message
}
pub fn show(&mut self, message: impl Into<String>) {
self.message = message.into();
self.action_label = None;
self.visible = true;
self.base.request_redraw();
}
pub fn show_with_action(
&mut self,
message: impl Into<String>,
action_label: impl Into<String>,
) {
self.message = message.into();
self.action_label = Some(action_label.into());
self.visible = true;
self.base.request_redraw();
}
pub fn dismiss(&mut self) {
if self.visible {
self.visible = false;
self.dismissed.emit(());
self.base.request_redraw();
}
}
pub fn set_progress(&mut self, progress: Option<f32>) {
self.progress = progress.map(|value| value.clamp(0.0, 1.0));
self.base.request_redraw();
}
pub fn action_label(&self) -> Option<&str> {
self.action_label.as_deref()
}
fn trigger_action(&mut self) -> bool {
if !self.visible {
return false;
}
let Some(label) = self.action_label.clone() else {
return false;
};
self.action_triggered.emit(label);
true
}
fn action_rect(&self) -> Option<Rect> {
self.action_label.as_ref()?;
let rect = self.geometry();
Some(Rect::new(rect.x + rect.width as i32 - 90, rect.y + rect.height as i32 - 28, 76, 20))
}
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 Snackbar {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn as_draw_mut(&mut self) -> Option<&mut dyn crate::widget::Draw> {
Some(self)
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(300, 48)
}
impl_widget_property_hooks!();
}
impl WidgetProperties for Snackbar {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"message" => Ok(CapabilityValue::String(self.message().to_string())),
"action_label" => match self.action_label() {
Some(label) => Ok(CapabilityValue::String(label.to_string())),
None => Ok(CapabilityValue::Null),
},
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"message" => {
self.show(expect_string(value)?);
Ok(())
}
"action_label" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["message", "action_label", BASE_PROPERTY_NAMES]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"dismiss" => {
self.dismiss();
Ok(())
}
"show" | "show_with_action" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl EventHandler for Snackbar {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() || !self.visible {
return;
}
match event {
Event::MousePress { pos, button: 1 } => {
if let Some(rect) = self.action_rect() {
if Self::point_in_rect(*pos, rect) {
let _ = self.trigger_action();
}
}
}
Event::KeyPress { key, modifiers: _ } => match *key {
13 => {
let _ = self.trigger_action();
}
27 => self.dismiss(),
_ => {}
},
_ => {}
}
}
}
impl Draw for Snackbar {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
let style = self.base.style().clone();
let theme = crate::style::resolved_theme_style("snackbar");
let resolved = style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
.unwrap_or(Color::rgb(248, 250, 253));
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.unwrap_or_else(|| resolved.blend(&Color::BLACK, 0.15));
let text_color = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(Color::rgb(0, 0, 0));
let background = resolved.blend(&text_color, 0.08);
let bar_background = background.blend(&text_color, 0.85);
let bar_border = bar_background.blend(&text_color, 0.18);
let bar_text = bar_background.blend(&background, 0.92);
let action_background = crate::style::resolved_theme_style("button")
.and_then(|button| button.background_color)
.unwrap_or_else(|| background.blend(&text_color, 0.55));
let action_border = action_background.blend(&text_color, 0.18);
let action_text = action_background.blend(&background, 0.92);
let track = bar_background.blend(&text_color, 0.12);
let progress_fill = crate::style::semantic_color(crate::style::SemanticColor::Success)
.map(|token| token.blend(&bar_background, 0.2))
.unwrap_or_else(|| bar_background.blend(&background, 0.5));
context.fill_rect(rect, background);
context.draw_rect(rect, border);
if !self.visible {
return;
}
let bar = Rect::new(
rect.x + 8,
rect.y + rect.height as i32 - 34,
rect.width.saturating_sub(16),
26,
);
context.fill_rect(bar, bar_background);
context.draw_rect(bar, bar_border);
let message_font = Font::default();
let message_h = context.measure_text("M", &message_font).height;
context.draw_text(
Point::new(bar.x + 10, bar.y + (bar.height as i32 - message_h as i32) / 2),
&self.message,
&message_font,
bar_text,
HorizontalAlignment::Left,
);
if let Some(action_rect) = self.action_rect() {
context.fill_rect(action_rect, action_background);
context.draw_rect(action_rect, action_border);
if let Some(label) = &self.action_label {
context.draw_text_fitted(
Rect::new(
action_rect.x + 4,
action_rect.y + (action_rect.height as i32 - message_h as i32) / 2,
action_rect.width.saturating_sub(8),
message_h,
),
label,
&message_font,
action_text,
HorizontalAlignment::Center,
);
}
}
if let Some(progress) = self.progress {
let progress_bar = Rect::new(bar.x, bar.y + bar.height as i32 - 3, bar.width, 3);
context.fill_rect(progress_bar, track);
let fill = (progress_bar.width as f32 * progress).round() as u32;
if fill > 0 {
context.fill_rect(
Rect::new(progress_bar.x, progress_bar.y, fill, progress_bar.height),
progress_fill,
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
#[test]
fn show_and_dismiss_change_visibility() {
let mut bar = Snackbar::new(Rect::new(0, 0, 420, 120));
assert!(!bar.is_visible());
bar.show("Saved successfully");
assert!(bar.is_visible());
assert_eq!(bar.message(), "Saved successfully");
bar.dismiss();
assert!(!bar.is_visible());
}
#[test]
fn enter_key_triggers_action_signal() {
let mut bar = Snackbar::new(Rect::new(0, 0, 420, 120));
bar.show_with_action("Retry deployment", "Retry");
let actions = Arc::new(Mutex::new(Vec::<String>::new()));
let sink = actions.clone();
bar.action_triggered.connect(move |label| {
if let Ok(mut guard) = sink.lock() {
guard.push(label.as_ref().clone());
}
});
bar.handle_event(&Event::key_press(13, 0));
let got = actions.lock().ok().map(|guard| guard.clone()).unwrap_or_default();
assert_eq!(got, vec!["Retry".to_string()]);
}
#[test]
fn esc_key_dismisses() {
let mut bar = Snackbar::new(Rect::new(0, 0, 420, 120));
bar.show_with_action("Sync pending", "Open");
assert!(bar.is_visible());
bar.handle_event(&Event::key_press(27, 0));
assert!(!bar.is_visible());
}
}