rust_widgets 2.5.2

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! `ToastStack` — the container that queues toasts and lays them out as rows.

use super::item::{ToastItem, ToastLevel};
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::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};

/// Toast stack with keyboard/mouse activation and dismiss.
pub struct ToastStack {
    base: BaseWidget,
    toasts: Vec<ToastItem>,
    selected_index: Option<usize>,
    row_height: u32,
    /// Emitted when toast is activated.
    pub toast_activated: Signal1<String>,
    /// Emitted when toast is dismissed.
    pub toast_dismissed: Signal1<String>,
}

impl ToastStack {
    /// Creates empty toast stack.
    pub fn new(geometry: Rect) -> Self {
        Self {
            base: BaseWidget::new(WidgetKind::PopupWindow, geometry, "ToastStack"),
            toasts: Vec::new(),
            selected_index: None,
            row_height: 30,
            toast_activated: Signal1::new(),
            toast_dismissed: Signal1::new(),
        }
    }

    /// Returns toast items.
    pub fn toasts(&self) -> &[ToastItem] {
        &self.toasts
    }

    /// Adds a toast to the stack.
    pub fn push(&mut self, item: ToastItem) {
        self.toasts.push(item);
        self.selected_index = Some(self.toasts.len() - 1);
        self.base.request_layout();
        self.base.request_redraw();
    }

    /// Clears all toasts.
    pub fn clear(&mut self) {
        self.toasts.clear();
        self.selected_index = None;
        self.base.request_layout();
        self.base.request_redraw();
    }

    /// Returns selected toast id.
    pub fn selected_id(&self) -> Option<&str> {
        let index = self.selected_index?;
        self.toasts.get(index).map(|item| item.id.as_str())
    }

    /// Selects toast by index.
    pub fn select_index(&mut self, index: usize) -> bool {
        if index >= self.toasts.len() {
            return false;
        }
        self.selected_index = Some(index);
        self.base.request_redraw();
        true
    }

    /// Activates selected toast.
    pub fn activate_selected(&mut self) -> bool {
        let Some(index) = self.selected_index else {
            return false;
        };
        let Some(item) = self.toasts.get(index) else {
            return false;
        };
        self.toast_activated.emit(item.id.clone());
        true
    }

    /// Dismisses selected toast.
    pub fn dismiss_selected(&mut self) -> bool {
        let Some(index) = self.selected_index else {
            return false;
        };
        if index >= self.toasts.len() {
            return false;
        }

        let id = self.toasts[index].id.clone();
        self.toasts.remove(index);
        self.toast_dismissed.emit(id);

        if self.toasts.is_empty() {
            self.selected_index = None;
        } else if index >= self.toasts.len() {
            self.selected_index = Some(self.toasts.len() - 1);
        }

        self.base.request_layout();
        self.base.request_redraw();
        true
    }

    fn row_at(&self, pos: Point) -> Option<usize> {
        let rect = self.geometry();
        if pos.x < rect.x
            || pos.x >= rect.x + rect.width as i32
            || pos.y < rect.y
            || pos.y >= rect.y + rect.height as i32
        {
            return None;
        }

        if self.toasts.is_empty() {
            return None;
        }

        let bottom = rect.y + rect.height as i32;
        for index in 0..self.toasts.len() {
            let top = bottom - ((index + 1) as i32 * self.row_height as i32);
            if pos.y >= top && pos.y < top + self.row_height as i32 {
                return Some(self.toasts.len() - 1 - index);
            }
        }
        None
    }
}

impl Widget for ToastStack {
    fn base(&self) -> &BaseWidget {
        &self.base
    }

    fn base_mut(&mut self) -> &mut BaseWidget {
        &mut self.base
    }

    fn size_hint(&self) -> crate::core::Size {
        crate::core::Size::new(300, 48)
    }
    impl_draw_bridge!();
    impl_widget_property_hooks!();
}

/// `ToastStack`'s property contract.
///
/// `toast_count` and `selected_id` describe the live stack. `row_height` is the
/// one writable name here: the stack lays its toasts out bottom-up from the
/// geometry it was given, so its row height must stay adjustable when a host
/// changes density.
impl WidgetProperties for ToastStack {
    fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
        match name {
            "toast_count" => Ok(CapabilityValue::UInt(self.toasts().len() as u64)),
            "selected_id" => match self.selected_id() {
                Some(id) => Ok(CapabilityValue::String(id.to_string())),
                None => Ok(CapabilityValue::Null),
            },
            "row_height" => Ok(CapabilityValue::UInt(self.row_height 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.row_height = height.max(1);
                    self.base.request_layout();
                    self.base.request_redraw();
                    Ok(())
                }
                _ => Err(CapabilityAccessError::TypeMismatch),
            },
            "toast_count" | "selected_id" => Err(CapabilityAccessError::ReadOnlyProperty),
            _ => base_property_set(self, name, value),
        }
    }

    fn property_names(&self) -> &'static [&'static str] {
        property_names_of!["toast_count", "selected_id", "row_height", BASE_PROPERTY_NAMES]
    }

    /// Runs one of the commands `toast_stack` publishes.
    ///
    /// `clear`, `activate_selected` and `dismiss_selected` are payload-free and
    /// map onto the widget's real methods; the two `*_selected` actions report
    /// `OutOfRange` when no toast is selected. `push` needs a whole `ToastItem`,
    /// and `select_index` an index, so those are answered as needing a payload.
    fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
        match name {
            "clear" => {
                self.clear();
                Ok(())
            }
            "activate_selected" => {
                if self.activate_selected() {
                    Ok(())
                } else {
                    Err(CapabilityAccessError::OutOfRange)
                }
            }
            "dismiss_selected" => {
                if self.dismiss_selected() {
                    Ok(())
                } else {
                    Err(CapabilityAccessError::OutOfRange)
                }
            }
            "push" | "select_index" => Err(CapabilityAccessError::OutOfRange),
            _ => Err(CapabilityAccessError::UnknownCommand),
        }
    }
}

impl EventHandler for ToastStack {
    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 let Some(index) = self.row_at(*pos) {
                    let _ = self.select_index(index);
                    let _ = self.activate_selected();
                }
            }
            Event::KeyPress { key, modifiers: _ } => match *key {
                38 => {
                    if let Some(index) = self.selected_index {
                        if index > 0 {
                            let _ = self.select_index(index - 1);
                        }
                    }
                }
                40 => {
                    if let Some(index) = self.selected_index {
                        if index + 1 < self.toasts.len() {
                            let _ = self.select_index(index + 1);
                        }
                    }
                }
                13 => {
                    let _ = self.activate_selected();
                }
                46 => {
                    let _ = self.dismiss_selected();
                }
                // Unknown key; ignore
                _ => {}
            },
            // Other events are not relevant for this widget
            _ => {}
        }
    }
}

impl Draw for ToastStack {
    fn draw(&mut self, context: &mut RenderContext) {
        let rect = self.geometry();

        // Chrome colours resolve explicit style first, then the theme's resolved
        // style for this control, and only then a literal. The theme step is what
        // makes an appearance switch visible; previously every colour below was a
        // hardcoded literal, so light and dark rendered identically.
        //
        // `resolved_theme_style` takes and releases the global manager's lock
        // internally, so no guard is held across the draw (the mutex is not
        // re-entrant).
        let style = self.base.style().clone();
        let theme = crate::style::resolved_theme_style("toast_stack");
        // `toast_stack` is not a control kind in the role table, so it classifies as
        // `Surface`, whose background is `theme.colors.background` — byte-identical
        // to the window behind it. The stack's own fill is therefore a step toward
        // the foreground, so it reads as a surface of its own.
        let resolved = style
            .background_color
            .or_else(|| theme.as_ref().and_then(|t| t.background_color))
            .unwrap_or(Color::rgb(250, 251, 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);
        // Row chrome is derived from the resolved pair: the selected row reads as
        // tinted toward the text colour, an ordinary row more faintly.
        let selected_background = background.blend(&text_color, 0.14);
        let row_background = background.blend(&text_color, 0.05);
        let row_border = background.blend(&text_color, 0.2);

        context.fill_rect(rect, background);
        context.draw_rect(rect, border);

        let bottom = rect.y + rect.height as i32;
        for (index, item) in self.toasts.iter().enumerate() {
            let visual_order = self.toasts.len() - 1 - index;
            let y = bottom - ((visual_order + 1) as i32 * self.row_height as i32);
            if y < rect.y {
                continue;
            }

            let row = Rect::new(
                rect.x + 4,
                y + 2,
                rect.width.saturating_sub(8),
                self.row_height.saturating_sub(4),
            );
            let bg = if self.selected_index == Some(index) {
                selected_background
            } else {
                row_background
            };
            context.fill_rect(row, bg);
            context.draw_rect(row, row_border);

            // The badge is a *state* indicator, so it reads the theme's semantic
            // tokens rather than a literal colour per level.
            let badge = crate::style::semantic_color(match item.level {
                ToastLevel::Info => crate::style::SemanticColor::Info,
                ToastLevel::Success => crate::style::SemanticColor::Success,
                ToastLevel::Warning => crate::style::SemanticColor::Warning,
                ToastLevel::Error => crate::style::SemanticColor::Error,
            })
            .map(|token| token.blend(&bg, 0.15))
            .unwrap_or_else(|| bg.blend(&text_color, 0.6));
            context.fill_rect(Rect::new(row.x + 6, row.y + 9, 8, 8), badge);
            context.draw_text(
                Point::new(row.x + 20, row.y + 17),
                &item.message,
                &Font::default(),
                text_color,
                HorizontalAlignment::Left,
            );
        }
    }
}