use crate::core::{Color, HorizontalAlignment, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::{GenericSignal, Signal1};
use crate::style::{MotionSlot, PropertyDriver};
use crate::widget::capability::coercion::{expect_string, expect_usize};
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::metrics::role_font;
use crate::widget::{BaseWidget, Draw, IconItemError, IconSource, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
pub struct MaterialSnackbar {
base: BaseWidget,
message: String,
action_text: String,
pub action_pressed: GenericSignal,
pub dismissed: GenericSignal,
duration_ms: u64,
elapsed_ms: u64,
slide: PropertyDriver,
closing: bool,
action_rect: Option<Rect>,
pressed_action: bool,
}
impl MaterialSnackbar {
pub fn new(geometry: Rect) -> Self {
let mut base = BaseWidget::new(WidgetKind::MaterialSnackbar, geometry, "MaterialSnackbar");
base.hide();
Self {
base,
message: String::new(),
action_text: String::new(),
action_pressed: GenericSignal::new(),
dismissed: GenericSignal::new(),
duration_ms: 4_000,
elapsed_ms: 0,
slide: PropertyDriver::at(0.0, MotionSlot::Normal),
closing: false,
action_rect: None,
pressed_action: false,
}
}
pub fn set_message(&mut self, message: &str) {
if self.message == message {
return;
}
self.message = message.to_string();
self.base.request_redraw();
}
pub fn message(&self) -> &str {
&self.message
}
pub fn set_action_text(&mut self, text: &str) {
if self.action_text == text {
return;
}
self.action_text = text.to_string();
self.action_rect = None;
self.pressed_action = false;
self.base.request_redraw();
}
pub fn action_text(&self) -> &str {
&self.action_text
}
pub fn show(&mut self) {
if !self.base.is_visible() {
self.base.show();
self.elapsed_ms = 0;
self.closing = false;
self.slide.jump_to(0.0);
self.slide.set_target(1.0);
self.base.request_redraw();
}
}
pub fn dismiss(&mut self) {
if self.base.is_visible() {
self.base.hide();
self.closing = false;
self.slide.jump_to(0.0);
self.dismissed.emit();
self.base.request_redraw();
}
}
pub fn dismiss_animated(&mut self) {
if self.base.is_visible() && !self.closing {
self.closing = true;
self.slide.set_target(0.0);
self.base.request_redraw();
}
}
pub fn duration_ms(&self) -> u64 {
self.duration_ms
}
pub fn set_duration_ms(&mut self, duration_ms: u64) {
if self.duration_ms == duration_ms {
return;
}
self.duration_ms = duration_ms;
self.elapsed_ms = 0;
}
pub fn slide_progress(&self) -> f32 {
self.slide.value()
}
pub fn is_visible(&self) -> bool {
self.base.is_visible()
}
}
impl Widget for MaterialSnackbar {
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(
crate::widget::metrics::dimensions::CUPERTINO_BAR_DEFAULT_WIDTH,
crate::widget::metrics::dimensions::CUPERTINO_BAR_DEFAULT_HEIGHT,
)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn tick(&mut self, delta_ms: u32) -> bool {
let mut changed = false;
if self.base.is_visible() && !self.closing && self.duration_ms > 0 {
self.elapsed_ms = self.elapsed_ms.saturating_add(u64::from(delta_ms));
if self.elapsed_ms >= self.duration_ms {
self.dismiss();
return true;
}
changed = true;
}
let moving = self.slide.tick(delta_ms);
changed |= moving;
if self.closing && !self.slide.is_moving() && self.slide.value() <= 0.001 {
self.base.hide();
self.closing = false;
self.dismissed.emit();
changed = true;
}
if changed {
self.base.request_redraw();
}
changed
}
fn is_animating(&self) -> bool {
self.slide.is_moving() || (self.base.is_visible() && !self.closing && self.duration_ms > 0)
}
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"action_pressed" => Some(EventSignalRef::unit("action_pressed", &self.action_pressed)),
"dismissed" => Some(EventSignalRef::unit("dismissed", &self.dismissed)),
_ => None,
}
}
}
impl WidgetProperties for MaterialSnackbar {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"message" => Ok(CapabilityValue::String(self.message().to_string())),
"action_text" => Ok(CapabilityValue::String(self.action_text().to_string())),
"duration_ms" => Ok(CapabilityValue::UInt(self.duration_ms())),
"slide_progress" => Ok(CapabilityValue::Float(self.slide_progress() as f64)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"message" => {
self.set_message(&expect_string(value)?);
Ok(())
}
"action_text" => {
self.set_action_text(&expect_string(value)?);
Ok(())
}
"duration_ms" => {
let duration = match value {
CapabilityValue::UInt(ms) => ms,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_duration_ms(duration);
Ok(())
}
"slide_progress" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"message",
"action_text",
"duration_ms",
"slide_progress",
BASE_PROPERTY_NAMES
]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"show" => {
self.show();
Ok(())
}
"dismiss" => {
self.dismiss();
Ok(())
}
"set_message" | "set_action_text" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl Draw for MaterialSnackbar {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
let style = self.base.style();
let theme = crate::style::resolved_theme_style_shared("material_snackbar");
let (window_fill, foreground, secondary, primary) = {
let manager = crate::style::theme_manager();
match manager.current_theme() {
Some(active) => (
active.colors.background,
active.colors.foreground,
active.colors.secondary,
active.colors.primary,
),
None => (
Color::rgb(240, 240, 240),
Color::BLACK,
Color::rgb(158, 158, 158),
Color::rgb(33, 150, 243),
),
}
};
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(foreground);
let pill = match style.background_color {
Some(resolved) if resolved != window_fill => resolved,
_ => window_fill.blend(&ink, 0.72),
};
let label = pill.contrast_color();
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.filter(|resolved| *resolved != pill)
.unwrap_or_else(|| pill.blend(&secondary, 0.45));
let bar_height = 48u32;
let slide = if self.base.is_visible() { self.slide.value().clamp(0.0, 1.0) } else { 1.0 };
let bar_y = rect.y + rect.height as i32 - bar_height as i32 - 16
+ ((1.0 - slide) * (bar_height + 16) as f32) as i32;
let bar_rect = Rect::new(rect.x + 12, bar_y, rect.width.saturating_sub(24), bar_height);
let fill = if self.base.is_visible() { pill } else { pill.with_alpha(96) };
context.fill_rounded_rect(bar_rect, bar_height / 2, fill);
context.draw_rounded_rect_stroke(bar_rect, bar_height / 2, border, 1);
if !self.base.is_visible() {
return;
}
if !self.message.is_empty() {
let font = role_font(style, 14.0);
let metrics = context.measure_text(&self.message, &font);
let text_x = bar_rect.x + 16;
let text_y = bar_rect.y + (bar_height as i32 - metrics.height as i32) / 2;
context.draw_text(
crate::core::Point::new(text_x, text_y),
&self.message,
&font,
label,
HorizontalAlignment::Left,
);
}
if !self.action_text.is_empty() {
let action_font = role_font(style, 14.0).with_bold(true);
let metrics = context.measure_text(&self.action_text, &action_font);
let action_x = bar_rect.x + bar_rect.width as i32 - metrics.width as i32 - 16;
self.action_rect =
Some(Rect::new(action_x - 8, bar_rect.y, metrics.width + 16, bar_height));
let action_y = bar_rect.y + (bar_height as i32 - metrics.height as i32) / 2;
context.draw_text(
crate::core::Point::new(action_x, action_y),
&self.action_text,
&action_font,
primary,
HorizontalAlignment::Left,
);
} else {
self.action_rect = None;
}
}
}
impl EventHandler for MaterialSnackbar {
fn handle_event(&mut self, event: &Event) {
if !self.base.is_visible() {
return;
}
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button, .. } => {
if *button != 1 {
return;
}
let rect = self.geometry();
let bar_height = 48u32;
let bar_y = rect.y + rect.height as i32 - bar_height as i32 - 16;
let bar_rect =
Rect::new(rect.x + 12, bar_y, rect.width.saturating_sub(24), bar_height);
if !bar_rect.contains_point(*pos) {
return;
}
let action = self.action_rect.unwrap_or_else(|| {
let font = role_font(self.style(), 14.0).with_bold(true);
let width =
crate::widget::metrics::estimate_text_width(&self.action_text, &font, 1.0);
Rect::new(
bar_rect.right() - width as i32 - 24,
bar_rect.y,
width + 16,
bar_height,
)
});
self.pressed_action = action.contains_point(*pos);
self.action_rect = Some(action);
if !self.pressed_action {
self.dismiss();
}
}
Event::MouseRelease { pos, button } => {
if *button != 1 {
return;
}
let within = self.action_rect.is_some_and(|action| action.contains_point(*pos));
if self.pressed_action && within {
self.action_pressed.emit();
self.base.request_redraw();
}
self.pressed_action = false;
}
_ => {
self.base.handle_event(event);
}
}
}
}
#[cfg(test)]
mod snackbar_tests {
use super::*;
#[test]
fn a_snackbar_dismisses_after_its_duration() {
let mut snackbar = MaterialSnackbar::new(Rect::new(0, 0, 375, 160));
snackbar.set_message("Saved");
assert_eq!(snackbar.duration_ms(), 4_000);
snackbar.show();
assert!(snackbar.is_visible());
assert!(snackbar.is_animating(), "a visible snackbar owes its timeout frame");
assert!((snackbar.slide_progress() - 0.0).abs() < f32::EPSILON);
assert!(snackbar.tick(16), "the slide moved");
assert!(snackbar.slide_progress() > 0.0);
let dismissed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let d = dismissed.clone();
snackbar.dismissed.connect(move || {
d.store(true, std::sync::atomic::Ordering::SeqCst);
});
assert!(snackbar.tick(5_000));
assert!(!snackbar.is_visible(), "the timeout hides the bar");
assert!(dismissed.load(std::sync::atomic::Ordering::SeqCst));
}
#[test]
fn zero_duration_is_permanent_and_the_exit_slides_out() {
let mut snackbar = MaterialSnackbar::new(Rect::new(0, 0, 375, 160));
snackbar.set_duration_ms(0);
snackbar.show();
for _ in 0..100 {
snackbar.tick(100);
}
assert!(snackbar.is_visible(), "duration 0 must not auto-dismiss");
assert!((snackbar.slide_progress() - 1.0).abs() < 1e-3);
snackbar.dismiss_animated();
assert!(snackbar.is_visible(), "the exit starts while still on screen");
for _ in 0..100 {
snackbar.tick(16);
}
assert!(!snackbar.is_visible());
assert_eq!(snackbar.slide_progress(), 0.0);
}
}
#[derive(Clone)]
pub struct RailItem {
pub icon: String,
pub label: String,
pub badge: Option<u32>,
}
impl RailItem {
pub fn new(icon: &str, label: &str) -> Self {
Self { icon: icon.to_string(), label: label.to_string(), badge: None }
}
pub fn with_badge(mut self, badge: u32) -> Self {
self.badge = Some(badge);
self
}
}
const RAIL_MAX_WIDTH: u32 = 80;
const RAIL_ITEM_HEIGHT: u32 = 72;
const RAIL_HEADER_HEIGHT: u32 = 56;
pub struct MaterialNavigationRail {
base: BaseWidget,
items: Vec<RailItem>,
item_icon_sources: Vec<Option<IconSource>>,
selected_index: usize,
pub current_changed: Signal1<usize>,
header_widget: Option<crate::compat::Box<dyn Widget>>,
}
impl MaterialNavigationRail {
pub fn new(geometry: crate::core::Rect) -> Self {
let base =
BaseWidget::new(WidgetKind::MaterialNavigationRail, geometry, "MaterialNavigationRail");
Self {
base,
items: Vec::new(),
item_icon_sources: Vec::new(),
selected_index: 0,
current_changed: Signal1::new(),
header_widget: None,
}
}
pub fn selected_changed(&self) -> &Signal1<usize> {
&self.current_changed
}
pub fn add_item(&mut self, icon: &str, label: &str) {
self.items.push(RailItem::new(icon, label));
self.item_icon_sources.push(None);
self.base.request_redraw();
}
pub fn set_item_icon_source(
&mut self,
index: usize,
source: Option<IconSource>,
) -> Result<(), IconItemError> {
let len = self.items.len();
let Some(slot) = self.item_icon_sources.get_mut(index) else {
return Err(IconItemError::IndexOutOfBounds { index, len });
};
*slot = source;
self.base.request_redraw();
Ok(())
}
pub fn set_selected(&mut self, index: usize) {
if self.items.is_empty() {
self.selected_index = 0;
return;
}
let clamped = index.min(self.items.len() - 1);
if clamped != self.selected_index {
self.selected_index = clamped;
self.current_changed.emit(clamped);
self.base.request_redraw();
}
}
pub fn selected(&self) -> usize {
self.selected_index
}
pub fn item_count(&self) -> usize {
self.items.len()
}
pub fn clear_items(&mut self) {
self.items.clear();
self.item_icon_sources.clear();
self.selected_index = 0;
self.base.request_redraw();
}
pub fn remove_item(&mut self, index: usize) -> bool {
if index >= self.items.len() {
return false;
}
self.items.remove(index);
self.item_icon_sources.remove(index);
if self.items.is_empty() {
self.selected_index = 0;
} else if index < self.selected_index {
self.selected_index -= 1;
} else {
self.selected_index = self.selected_index.min(self.items.len() - 1);
}
self.base.request_layout();
self.base.request_redraw();
true
}
pub fn set_item_label(&mut self, index: usize, label: &str) -> bool {
let Some(item) = self.items.get_mut(index) else {
return false;
};
if item.label == label {
return true;
}
item.label = label.to_string();
self.base.request_layout();
self.base.request_redraw();
true
}
pub fn set_item_icon(&mut self, index: usize, icon: &str) -> bool {
let Some(item) = self.items.get_mut(index) else {
return false;
};
if item.icon == icon {
return true;
}
item.icon = icon.to_string();
self.base.request_redraw();
true
}
pub fn set_item_badge(&mut self, index: usize, badge: Option<u32>) -> bool {
let Some(item) = self.items.get_mut(index) else {
return false;
};
if item.badge == badge {
return true;
}
item.badge = badge;
self.base.request_redraw();
true
}
pub fn set_header_widget(&mut self, widget: Option<crate::compat::Box<dyn Widget>>) {
let rect = self.geometry();
let width = rect.width.min(RAIL_MAX_WIDTH);
self.header_widget = widget;
if let Some(widget) = self.header_widget.as_mut() {
widget.set_geometry(crate::core::Rect::new(rect.x, rect.y, width, RAIL_HEADER_HEIGHT));
}
self.base.request_layout();
self.base.request_redraw();
}
pub fn header_widget_mut(&mut self) -> Option<&mut (dyn Widget + '_)> {
self.header_widget.as_deref_mut()
}
fn items_top(&self) -> i32 {
self.geometry().y + if self.header_widget.is_some() { RAIL_HEADER_HEIGHT as i32 } else { 0 }
}
}
impl Widget for MaterialNavigationRail {
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(
crate::widget::metrics::dimensions::CUPERTINO_TAB_BAR_DEFAULT_WIDTH,
crate::widget::metrics::dimensions::CUPERTINO_TAB_BAR_DEFAULT_HEIGHT,
)
}
fn kind(&self) -> WidgetKind {
WidgetKind::MaterialNavigationRail
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"current_changed" => {
Some(EventSignalRef::mapped("current_changed", &self.current_changed, |v| {
CapabilityValue::UInt(*v as u64)
}))
}
_ => None,
}
}
}
impl WidgetProperties for MaterialNavigationRail {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"selected_index" => Ok(CapabilityValue::UInt(self.selected() as u64)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"selected_index" => {
self.set_selected(expect_usize(value)?);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["selected_index", BASE_PROPERTY_NAMES]
}
}
impl Draw for MaterialNavigationRail {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
let style = self.base.style();
let theme = crate::style::resolved_theme_style_shared("material_navigation_rail");
let (window_fill, foreground, secondary, primary) = {
let manager = crate::style::theme_manager();
match manager.current_theme() {
Some(active) => (
active.colors.background,
active.colors.foreground,
active.colors.secondary,
active.colors.primary,
),
None => (
Color::rgb(240, 240, 240),
Color::BLACK,
Color::rgb(158, 158, 158),
Color::rgb(33, 150, 243),
),
}
};
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(foreground);
let rail = match style.background_color {
Some(resolved) if resolved != window_fill => resolved,
_ => window_fill.blend(&ink, 0.08),
};
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.filter(|resolved| *resolved != rail)
.unwrap_or_else(|| rail.blend(&secondary, 0.45));
let rail_width = rect.width.min(RAIL_MAX_WIDTH);
context.fill_rect(Rect::new(rect.x, rect.y, rail_width, rect.height), rail);
context.draw_line(
Point::new(rect.x + rail_width as i32 - 1, rect.y),
Point::new(rect.x + rail_width as i32 - 1, rect.y + rect.height as i32),
border,
);
if self.items.is_empty() && self.header_widget.is_none() {
return;
}
if self.header_widget.is_some() {
let header = Rect::new(rect.x, rect.y, rail_width, RAIL_HEADER_HEIGHT.min(rect.height));
if let Some(widget) = self.header_widget.as_mut() {
widget.set_geometry(header);
if let Some(drawable) = crate::widget::draw_bridge::draw_of(widget.as_mut()) {
drawable.draw(context);
}
}
context.draw_line(
Point::new(header.x, header.bottom() - 1),
Point::new(header.right() - 1, header.bottom() - 1),
border,
);
}
let item_height = RAIL_ITEM_HEIGHT;
let icon_font = role_font(style, 14.0);
let label_font = role_font(style, 12.0);
let items_top = self.items_top();
for (i, item) in self.items.iter().enumerate() {
let item_y = items_top + (i as u32 * item_height) as i32;
let is_selected = i == self.selected_index;
if is_selected {
context.fill_rect(
crate::core::Rect::new(rect.x, item_y + 8, 4, item_height.saturating_sub(16)),
primary,
);
}
if is_selected {
context.fill_rounded_rect(
crate::core::Rect::new(
rect.x + 8,
item_y + 8,
rail_width.saturating_sub(16),
item_height.saturating_sub(16),
),
8,
primary.with_alpha(25),
);
}
let icon_color = if is_selected { primary } else { secondary };
let typed_source = self.item_icon_sources.get(i).copied().flatten();
let icon_side = 24u32;
let icon_rect = Rect::new(
rect.x + (rail_width as i32 - icon_side as i32) / 2,
item_y + 16,
icon_side,
icon_side,
);
if let Some(source) = typed_source {
if let Err(error) =
crate::widget::paint_icon(context, source.into(), icon_rect, icon_color)
{
log::warn!("navigation rail item {i} icon failed: {error:?}");
}
} else if !crate::widget::draw_icon_named(context, icon_rect, icon_color, &item.icon) {
context.draw_text_fitted_cow(
icon_rect,
&item.icon,
&icon_font,
icon_color,
HorizontalAlignment::Center,
);
}
if let Some(badge) = item.badge {
let mut text = String::new();
{
use core::fmt::Write as _;
let _ = write!(text, "{badge}");
}
let metrics = context.measure_text(&text, &label_font);
let width = (metrics.width + 8).max(14);
let height = metrics.height.max(12);
let badge_rect = Rect::new(
rect.x + (rail_width as i32 + icon_side as i32) / 2 - width as i32 + 2,
item_y + 10,
width,
height,
);
context.fill_rounded_rect(badge_rect, height / 2, primary);
context.draw_text_fitted_cow(
badge_rect,
&text,
&label_font,
primary.contrast_color(),
HorizontalAlignment::Center,
);
}
let label_color = if is_selected { primary } else { secondary };
let label_metrics = context.measure_text(&item.label, &label_font);
let label_band = Rect::new(
rect.x + 4,
item_y + 44,
rail_width.saturating_sub(8),
label_metrics.height.max(1),
);
context.draw_text_fitted_cow(
label_band,
&item.label,
&label_font,
label_color,
HorizontalAlignment::Center,
);
}
}
}
impl EventHandler for MaterialNavigationRail {
fn handle_event(&mut self, event: &Event) {
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button, .. } | Event::MouseRelease { pos, button } => {
if *button != 1 {
return;
}
let rect = self.geometry();
if self.items.is_empty() {
return;
}
let item_height = RAIL_ITEM_HEIGHT;
let items_top = self.items_top();
for i in 0..self.items.len() {
let item_y = items_top + (i as u32 * item_height) as i32;
let item_rect = crate::core::Rect::new(
rect.x,
item_y,
rect.width.min(RAIL_MAX_WIDTH),
item_height,
);
if item_rect.contains_point(*pos) {
if let Event::MouseRelease { .. } = event {
self.set_selected(i);
}
return;
}
}
}
_ => {
self.base.handle_event(event);
}
}
}
}