🔒 Repository is read-only – file editing is disabled.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
//! `Toast` — krótkie powiadomienie unoszące się nad treścią (nakładka).
//!
//! Widget immediate: trzyma napis, opcjonalną etykietę akcji i stan otwarcia.
//! Aplikacja woła [`Toast::paint`] (na samym wierzchu sceny) oraz [`Toast::action_at`]
//! (klik w akcję — np. „Cofnij”). Czas ukrycia sterowany jest z aplikacji (brak pętli animacji).
use libluna_core::style::{weight, Appearance};
use libluna_core::{Animation, Easing, Point, Rect, Size, Theme};
use libluna_render::TextEngine;
use crate::ctx::PaintCtx;
/// Powiadomienie („toast”).
#[derive(Debug, Clone, Default)]
pub struct Toast {
message: String,
action: Option<String>,
open: bool,
animation: Animation,
}
impl Toast {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
..Self::default()
}
}
/// Ustawia etykietę akcji (np. „Cofnij”).
pub fn action(mut self, label: impl Into<String>) -> Self {
self.action = Some(label.into());
self
}
pub fn set_message(&mut self, message: impl Into<String>) {
self.message = message.into();
}
pub fn set_action(&mut self, action: Option<String>) {
self.action = action;
}
/// Pokazuje powiadomienie (natychmiast).
pub fn show(&mut self) {
self.open = true;
self.animation.jump_to(1.0);
}
/// Pokazuje powiadomienie z animacją (wjazd + rozjaśnienie).
pub fn show_animated(&mut self) {
self.open = true;
self.animation.set_easing(Easing::EaseOut);
self.animation.animate_to(1.0);
}
pub fn set_open(&mut self, open: bool) {
self.open = open;
self.animation.jump_to(if open { 1.0 } else { 0.0 });
}
/// Ustawia stan z animacją (pokazanie/ukrycie).
pub fn set_open_animated(&mut self, open: bool) {
self.open = open;
self.animation.animate_to(if open { 1.0 } else { 0.0 });
}
/// Krok animacji (`dt` i czas `duration` w sekundach); zwraca `true`, gdy trwa dalej.
pub fn advance(&mut self, dt: f64, duration: f64) -> bool {
self.animation.advance(dt, duration)
}
pub fn is_animating(&self) -> bool {
self.animation.is_animating()
}
/// Postęp pokazywania (0..1).
pub fn progress(&self) -> f64 {
self.animation.value()
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn message(&self) -> &str {
&self.message
}
pub fn action_label(&self) -> Option<&str> {
self.action.as_deref()
}
/// Wysokość powiadomienia (z metryk motywu).
pub fn height(&self, theme: &Theme) -> f64 {
theme.metrics.toast.height
}
/// Prostokąt powiadomienia (wyśrodkowany poziomo, odsunięty od dołu).
pub fn rect(&self, size: Size, theme: &Theme, text: &TextEngine) -> Rect {
let m = theme.metrics.toast;
let body = theme.typography.body.size;
let mut w = m.pad_x * 2.0 + text.measure(&self.message, body, weight::NORMAL).width;
if let Some(action) = &self.action {
w += m.gap + text.measure(action, body, weight::MEDIUM).width;
}
Rect::from_xywh(
(size.width - w) / 2.0,
size.height - m.margin_bottom - m.height,
w,
m.height,
)
}
/// Prostokąt akcji (jeśli jest), w prawym końcu powiadomienia.
pub fn action_rect(&self, size: Size, theme: &Theme, text: &TextEngine) -> Option<Rect> {
let action = self.action.as_ref()?;
let t = self.rect(size, theme, text);
let body = theme.typography.body.size;
let pad = theme.spacing.sm;
let w = text.measure(action, body, weight::MEDIUM).width + pad * 2.0;
Some(Rect::from_xywh(
t.right() - theme.metrics.toast.pad_x - w,
t.y(),
w,
t.height(),
))
}
/// Czy punkt trafia w akcję powiadomienia.
pub fn action_at(&self, size: Size, theme: &Theme, text: &TextEngine, p: Point) -> bool {
self.open
&& self
.action_rect(size, theme, text)
.is_some_and(|r| r.contains(p))
}
/// Czy punkt trafia w powiadomienie poza jego akcją (klik zamykający).
pub fn dismiss_at(&self, size: Size, theme: &Theme, text: &TextEngine, p: Point) -> bool {
self.open
&& self.rect(size, theme, text).contains(p)
&& !self
.action_rect(size, theme, text)
.is_some_and(|r| r.contains(p))
}
/// Rysuje powiadomienie (na wierzchu sceny).
pub fn paint(&self, ctx: &mut PaintCtx, size: Size) {
let progress = self.animation.value();
if progress <= 0.001 {
return;
}
let theme = *ctx.theme;
let colors = theme.colors;
let m = theme.metrics.toast;
let settled = self.rect(size, &theme, ctx.text);
// Wjazd od dołu: im mniejszy postęp, tym niżej i mniej widoczne.
let t = Rect::from_xywh(
settled.x(),
settled.y() + (1.0 - progress) * (m.height + m.margin_bottom),
settled.width(),
settled.height(),
);
let alpha = progress.clamp(0.0, 1.0) as f32;
// Powierzchnia o wysokim kontraście: w motywie jasnym ciemna, w ciemnym nieco
// jaśniejsza od tła okna (jak „toast” w libadwaita).
let (bg, fg) = match theme.appearance {
Appearance::Light => (colors.text, colors.view_bg),
Appearance::Dark => (colors.card_bg, colors.text),
};
let bg = bg.scale_alpha(alpha);
let fg = fg.scale_alpha(alpha);
ctx.scene.fill_rounded_rect(t, t.height() / 2.0, bg);
let body = theme.typography.body.size;
ctx.label_left(
t.x() + m.pad_x,
t.center().y,
&self.message,
body,
weight::NORMAL,
fg,
);
if let (Some(action), Some(ar)) = (&self.action, self.action_rect(size, &theme, ctx.text)) {
ctx.label_center(
ar.center().x,
ar.center().y,
action,
body,
weight::MEDIUM,
colors.accent.scale_alpha(alpha),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn size() -> Size {
Size::new(900.0, 800.0)
}
#[test]
fn hidden_toast_never_hits_action() {
let theme = Theme::light();
let text = TextEngine::system();
let toast = Toast::new("Zapisano").action("Cofnij");
let ar = toast.action_rect(size(), &theme, &text).unwrap();
assert!(!toast.action_at(size(), &theme, &text, ar.center()));
}
#[test]
fn shown_toast_hits_action() {
let theme = Theme::light();
let text = TextEngine::system();
let mut toast = Toast::new("Zapisano").action("Cofnij");
toast.show();
let ar = toast.action_rect(size(), &theme, &text).unwrap();
assert!(toast.action_at(size(), &theme, &text, ar.center()));
assert!(!toast.action_at(size(), &theme, &text, Point::new(5.0, 5.0)));
}
#[test]
fn body_dismisses_but_action_does_not() {
let theme = Theme::light();
let text = TextEngine::system();
let mut toast = Toast::new("Saved").action("Undo");
toast.show();
let r = toast.rect(size(), &theme, &text);
// Klik w lewy skraj treści zamyka.
assert!(toast.dismiss_at(size(), &theme, &text, Point::new(r.x() + 6.0, r.center().y)));
// Klik w akcję nie zamyka (obsługiwany osobno).
let ar = toast.action_rect(size(), &theme, &text).unwrap();
assert!(!toast.dismiss_at(size(), &theme, &text, ar.center()));
// Klik poza powiadomieniem nie zamyka.
assert!(!toast.dismiss_at(size(), &theme, &text, Point::new(5.0, 5.0)));
}
#[test]
fn toast_is_centered_and_above_bottom() {
let theme = Theme::light();
let text = TextEngine::system();
let mut toast = Toast::new("Gotowe");
toast.show();
let r = toast.rect(size(), &theme, &text);
assert!((r.center().x - 450.0).abs() < 1e-9);
assert!((r.bottom() - (800.0 - theme.metrics.toast.margin_bottom)).abs() < 1e-9);
assert!(toast.action_rect(size(), &theme, &text).is_none());
}
#[test]
fn show_is_instant_but_animated_show_ramps() {
let mut instant = Toast::new("A");
instant.show();
assert_eq!(instant.progress(), 1.0);
assert!(!instant.is_animating());
let mut animated = Toast::new("B");
animated.show_animated();
assert!(animated.is_animating());
assert!(animated.advance(0.25, 0.5)); // pół drogi
// EaseOut po połowie czasu daje 0.75 postępu wizualnego.
assert!((animated.progress() - 0.75).abs() < 1e-9);
assert!(!animated.advance(0.25, 0.5)); // koniec
assert_eq!(animated.progress(), 1.0);
}
}