← luna

Commit 4889526

8
files
+434
added
-38
deleted
@@ -313,7 +313,7 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
313 313 **ustawienia wyglądu** (`ThemePreference`, `Accent`, `ThemeSettings` z trwałością
314 314 i rozwiązywaniem `light/dark`), a także **style instalowane z plików** (`theme_file`:
315 315 `ThemePatch`, skanowanie katalogów `available_themes()`). Animacje: `motion` (`Easing`,
316 - `lerp`) — matematyka ruchu bez stanu.
316 + `lerp`, `Animation`) — matematyka ruchu i animowany skalar, bez stanu w widgetach.
317 317 - `libluna-i18n`: `Locale` (z `LANG`/`LC_*`), `Catalog`, `Translations`, globalne `tr`/`set_locale`,
318 318 makro `t!` z podstawianiem argumentów, **języki z osobnych plików** `i18n/<kod>.lang`
319 319 (wbudowane) oraz **doładowywane w runtime** z katalogów; `languages()` i `language_name()`.
@@ -357,6 +357,8 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
357 357 `Assistant` (kreator kroków), `Scrollbar`/`Adjustment` (przewijanie w obu osiach),
358 358 `ColorChooser` (paleta); aliasy `ScrolledWindow` = `ScrollView`,
359 359 `StackSwitcher` = `ViewSwitcher`, `Notebook` = `TabView`, `ListBox` = `NavList`.
360 + **Animacje wcielone w widgety**: przejścia `Stack` (slide/fade przez `StackTransition`),
361 + płynny `Revealer` i `Toast` (wjazd + fade) — sterowane z aplikacji przez `advance(dt, …)`.
360 362 - `libluna-widgets::layout`: **dwuprzebiegowy układ** (retencyjne drzewo `measure`/`allocate`) —
361 363 [`Node`] (`VBox`/`HBox`, `Padding`, `Expanded`, `Center`, `Spacer`, `Label`, `Fixed`),
362 364 [`Constraints`](min/max) i [`Layout`](`desired_size`/`fit`). Liście pochłaniają nadmiar
@@ -27,7 +27,7 @@ pub use event::{
27 27 ButtonState, Event, KeyEvent, Modifiers, PointerButton, PointerEvent, ScrollEvent, WindowEvent,
28 28 };
29 29 pub use geometry::{Insets, Point, Rect, Size};
30 -pub use motion::{lerp, Easing};
30 +pub use motion::{lerp, Animation, Easing};
31 31 pub use scale::Scale;
32 32 pub use style::{
33 33 ActionRowMetrics, AvatarMetrics, BannerMetrics, BarMetrics, BottomSheetMetrics, ButtonMetrics,
@@ -48,6 +48,92 @@ pub fn lerp(from: f64, to: f64, t: f64) -> f64 {
48 48 from + (to - from) * t.clamp(0.0, 1.0)
49 49 }
50 50
51 +/// Animowany skalar: płynnie przechodzi do celu `to` w zadanym czasie.
52 +///
53 +/// Sterowany z aplikacji: woła się [`Animation::advance`] o `dt` (sekundy) i bieżący czas
54 +/// trwania, a wartość odczytuje przez [`Animation::value`]. Zero zależności od pętli zdarzeń.
55 +#[derive(Debug, Clone, Copy, PartialEq)]
56 +pub struct Animation {
57 + from: f64,
58 + to: f64,
59 + progress: f64,
60 + easing: Easing,
61 +}
62 +
63 +impl Default for Animation {
64 + fn default() -> Self {
65 + Self {
66 + from: 0.0,
67 + to: 0.0,
68 + progress: 1.0,
69 + easing: Easing::EaseInOut,
70 + }
71 + }
72 +}
73 +
74 +impl Animation {
75 + /// Animacja z podaną krzywą (stan początkowy: 0, bez ruchu).
76 + pub fn new(easing: Easing) -> Self {
77 + Self {
78 + easing,
79 + ..Self::default()
80 + }
81 + }
82 +
83 + /// Bieżąca (wyświetlana) wartość.
84 + pub fn value(&self) -> f64 {
85 + self.easing.lerp(self.from, self.to, self.progress)
86 + }
87 +
88 + /// Wartość docelowa.
89 + pub fn target(&self) -> f64 {
90 + self.to
91 + }
92 +
93 + pub fn progress(&self) -> f64 {
94 + self.progress
95 + }
96 +
97 + pub fn easing(&self) -> Easing {
98 + self.easing
99 + }
100 +
101 + pub fn set_easing(&mut self, easing: Easing) {
102 + self.easing = easing;
103 + }
104 +
105 + /// Rozpoczyna płynne przejście do `to` (od bieżącej wartości).
106 + pub fn animate_to(&mut self, to: f64) {
107 + self.from = self.value();
108 + self.to = to;
109 + self.progress = 0.0;
110 + }
111 +
112 + /// Ustawia wartość natychmiast (bez animacji).
113 + pub fn jump_to(&mut self, value: f64) {
114 + self.from = value;
115 + self.to = value;
116 + self.progress = 1.0;
117 + }
118 +
119 + pub fn is_animating(&self) -> bool {
120 + self.progress < 1.0
121 + }
122 +
123 + /// Postęp animacji z krokiem `dt` przy czasie `duration`; zwraca `true`, gdy trwa dalej.
124 + pub fn advance(&mut self, dt: f64, duration: f64) -> bool {
125 + if !self.is_animating() {
126 + return false;
127 + }
128 + if duration <= 0.0 {
129 + self.progress = 1.0;
130 + } else {
131 + self.progress = (self.progress + dt / duration).min(1.0);
132 + }
133 + self.is_animating()
134 + }
135 +}
136 +
51 137 #[cfg(test)]
52 138 mod tests {
53 139 use super::*;
@@ -84,4 +170,37 @@ mod tests {
84 170 assert_eq!(lerp(0.0, 100.0, 0.25), 25.0);
85 171 assert_eq!(Easing::Linear.lerp(10.0, 20.0, 0.5), 15.0);
86 172 }
173 +
174 + #[test]
175 + fn animation_reaches_target_and_stops() {
176 + let mut a = Animation::new(Easing::Linear);
177 + assert!(!a.is_animating());
178 + assert_eq!(a.value(), 0.0);
179 + a.animate_to(1.0);
180 + assert!(a.is_animating());
181 + assert!(a.advance(0.5, 1.0)); // połowa czasu
182 + assert!((a.value() - 0.5).abs() < 1e-9);
183 + assert!(!a.advance(0.5, 1.0)); // koniec
184 + assert_eq!(a.value(), 1.0);
185 + assert!(!a.is_animating());
186 + }
187 +
188 + #[test]
189 + fn animation_jump_is_instant() {
190 + let mut a = Animation::new(Easing::EaseInOut);
191 + a.jump_to(0.75);
192 + assert_eq!(a.value(), 0.75);
193 + assert!(!a.is_animating());
194 + a.animate_to(0.0);
195 + assert_eq!(a.value(), 0.75); // start z bieżącej wartości
196 + }
197 +
198 + #[test]
199 + fn animation_zero_duration_completes() {
200 + let mut a = Animation::new(Easing::Linear);
201 + a.animate_to(5.0);
202 + // Zerowy czas trwania: pierwszy krok domyka animację.
203 + assert!(!a.advance(0.0, 0.0));
204 + assert_eq!(a.value(), 5.0);
205 + }
87 206 }
@@ -6,7 +6,7 @@
6 6 //! te ze stanem (Paned, Stack, PopoverMenuBar) rysują się i obsługują trafienia.
7 7
8 8 use libluna_core::style::weight;
9 -use libluna_core::{Point, Rect, Theme};
9 +use libluna_core::{Animation, Easing, Point, Rect, Theme};
10 10 use libluna_render::TextEngine;
11 11
12 12 use crate::ctx::PaintCtx;
@@ -308,14 +308,41 @@ impl Paned {
308 308 }
309 309 }
310 310
311 +/// Sposób przejścia między stronami [`Stack`].
312 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
313 +pub enum StackTransition {
314 + /// Bez animacji (natychmiastowa zmiana).
315 + #[default]
316 + None,
317 + /// Wsuwanie w poziomie.
318 + SlideHorizontal,
319 + /// Wsuwanie w pionie.
320 + SlideVertical,
321 + /// Przenikanie (crossfade).
322 + Fade,
323 +}
324 +
325 +/// Pojedyncza klatka strony w trakcie przejścia: prostokąt i krycie (0..1).
326 +#[derive(Debug, Clone, Copy, PartialEq)]
327 +pub struct StackFrame {
328 + pub rect: Rect,
329 + pub alpha: f64,
330 +}
331 +
311 332 /// Stos stron: wiele dzieci, jedno widoczne (bez zakładek).
312 333 ///
313 334 /// W odróżnieniu od [`TabView`](crate::TabView) nie ma paska zakładek — przełączaniem
314 -/// steruje aplikacja (np. przez [`ViewSwitcher`](crate::ViewSwitcher)).
335 +/// steruje aplikacja (np. przez [`ViewSwitcher`](crate::ViewSwitcher)). Opcjonalnie zmiana
336 +/// strony może być animowana ([`StackTransition`]); postęp animacji sterowany jest
337 +/// z aplikacji przez [`Stack::advance`], a klatki wychodzącej/przychodzącej daje
338 +/// [`Stack::frames`].
315 339 #[derive(Debug, Clone, Default)]
316 340 pub struct Stack {
317 341 count: usize,
318 342 visible: usize,
343 + previous: Option<usize>,
344 + transition: StackTransition,
345 + progress: Animation,
319 346 }
320 347
321 348 impl Stack {
@@ -324,7 +351,11 @@ impl Stack {
324 351 }
325 352
326 353 pub fn with_count(count: usize) -> Self {
327 - Self { count, visible: 0 }
354 + Self {
355 + count,
356 + visible: 0,
357 + ..Self::default()
358 + }
328 359 }
329 360
330 361 pub fn count(&self) -> usize {
@@ -335,10 +366,44 @@ impl Stack {
335 366 self.visible
336 367 }
337 368
369 + /// Indeks strony wychodzącej w trakcie animacji przejścia.
370 + pub fn previous_index(&self) -> Option<usize> {
371 + self.previous
372 + }
373 +
374 + /// Ustawia sposób przejścia między stronami.
375 + pub fn set_transition(&mut self, transition: StackTransition) {
376 + self.transition = transition;
377 + }
378 +
379 + pub fn transition(&self) -> StackTransition {
380 + self.transition
381 + }
382 +
383 + /// Ustawia krzywą wygładzania przejścia.
384 + pub fn set_transition_easing(&mut self, easing: Easing) {
385 + self.progress.set_easing(easing);
386 + }
387 +
388 + pub fn is_animating(&self) -> bool {
389 + self.previous.is_some() && self.progress.is_animating()
390 + }
391 +
392 + /// Ustawia widoczną stronę; przy włączonym przejściu startuje animację.
338 393 pub fn set_visible_index(&mut self, index: usize) {
339 - if self.count > 0 {
340 - self.visible = index.min(self.count - 1);
394 + if self.count == 0 {
395 + return;
396 + }
397 + let index = index.min(self.count - 1);
398 + if index == self.visible {
399 + return;
400 + }
401 + if self.transition != StackTransition::None {
402 + self.previous = Some(self.visible);
403 + self.progress.jump_to(0.0);
404 + self.progress.animate_to(1.0);
341 405 }
406 + self.visible = index;
342 407 }
343 408
344 409 pub fn is_visible(&self, index: usize) -> bool {
@@ -357,18 +422,93 @@ impl Stack {
357 422 if self.visible >= self.count && self.count > 0 {
358 423 self.visible = self.count - 1;
359 424 }
425 + self.previous = None;
360 426 }
361 427 }
362 428
363 429 pub fn next(&mut self) {
364 430 if self.count > 0 {
365 - self.visible = (self.visible + 1) % self.count;
431 + self.set_visible_index((self.visible + 1) % self.count);
366 432 }
367 433 }
368 434
369 435 pub fn prev(&mut self) {
370 436 if self.count > 0 {
371 - self.visible = (self.visible + self.count - 1) % self.count;
437 + self.set_visible_index((self.visible + self.count - 1) % self.count);
438 + }
439 + }
440 +
441 + /// Krok animacji przejścia; po jej zakończeniu czyści stronę wychodzącą.
442 + /// Zwraca `true`, gdy animacja trwa dalej.
443 + pub fn advance(&mut self, dt: f64, duration: f64) -> bool {
444 + if self.previous.is_none() {
445 + return false;
446 + }
447 + let animating = self.progress.advance(dt, duration);
448 + if !animating {
449 + self.previous = None;
450 + }
451 + animating
452 + }
453 +
454 + /// Klatki `(wychodząca, przychodząca)` dla bieżącego postępu przejścia.
455 + pub fn frames(&self, rect: Rect) -> (StackFrame, StackFrame) {
456 + let full = |alpha: f64| StackFrame { rect, alpha };
457 + if self.previous.is_none() {
458 + return (full(0.0), full(1.0));
459 + }
460 + let t = self.progress.value().clamp(0.0, 1.0);
461 + match self.transition {
462 + StackTransition::None => (full(0.0), full(1.0)),
463 + StackTransition::Fade => (full(1.0 - t), full(t)),
464 + StackTransition::SlideHorizontal => {
465 + let out = Rect::from_xywh(
466 + rect.x() - rect.width() * t,
467 + rect.y(),
468 + rect.width(),
469 + rect.height(),
470 + );
471 + let inc = Rect::from_xywh(
472 + rect.x() + rect.width() * (1.0 - t),
473 + rect.y(),
474 + rect.width(),
475 + rect.height(),
476 + );
477 + (
478 + StackFrame {
479 + rect: out,
480 + alpha: 1.0,
481 + },
482 + StackFrame {
483 + rect: inc,
484 + alpha: 1.0,
485 + },
486 + )
487 + }
488 + StackTransition::SlideVertical => {
489 + let out = Rect::from_xywh(
490 + rect.x(),
491 + rect.y() - rect.height() * t,
492 + rect.width(),
493 + rect.height(),
494 + );
495 + let inc = Rect::from_xywh(
496 + rect.x(),
497 + rect.y() + rect.height() * (1.0 - t),
498 + rect.width(),
499 + rect.height(),
500 + );
501 + (
502 + StackFrame {
503 + rect: out,
504 + alpha: 1.0,
505 + },
506 + StackFrame {
507 + rect: inc,
508 + alpha: 1.0,
509 + },
510 + )
511 + }
372 512 }
373 513 }
374 514 }
@@ -618,6 +758,47 @@ mod tests {
618 758 assert_eq!(s.visible_index(), 1);
619 759 }
620 760
761 + #[test]
762 + fn stack_without_transition_hides_outgoing() {
763 + let s = Stack::with_count(2);
764 + let rect = Rect::from_xywh(0.0, 0.0, 100.0, 50.0);
765 + let (out, inc) = s.frames(rect);
766 + assert_eq!(out.alpha, 0.0);
767 + assert_eq!(inc.alpha, 1.0);
768 + assert!(!s.is_animating());
769 + }
770 +
771 + #[test]
772 + fn stack_slide_moves_frames() {
773 + let rect = Rect::from_xywh(0.0, 0.0, 100.0, 50.0);
774 + let mut s = Stack::with_count(2);
775 + s.set_transition(StackTransition::SlideHorizontal);
776 + s.set_visible_index(1);
777 + assert!(s.is_animating());
778 + assert_eq!(s.previous_index(), Some(0));
779 + let (out, inc) = s.frames(rect);
780 + assert_eq!(out.rect.x(), 0.0);
781 + assert_eq!(inc.rect.x(), 100.0);
782 + assert!(!s.advance(1.0, 1.0));
783 + assert!(!s.is_animating());
784 + assert_eq!(s.frames(rect).1.rect.x(), 0.0);
785 + }
786 +
787 + #[test]
788 + fn stack_fade_crossfades() {
789 + let rect = Rect::from_xywh(0.0, 0.0, 100.0, 50.0);
790 + let mut s = Stack::with_count(2);
791 + s.set_transition(StackTransition::Fade);
792 + s.set_visible_index(1);
793 + let (out, inc) = s.frames(rect);
794 + assert_eq!(out.alpha, 1.0);
795 + assert_eq!(inc.alpha, 0.0);
796 + assert!(s.advance(0.5, 1.0)); // EaseInOut w połowie = 0.5
797 + let (out2, inc2) = s.frames(rect);
798 + assert!((out2.alpha - 0.5).abs() < 1e-9);
799 + assert!((inc2.alpha - 0.5).abs() < 1e-9);
800 + }
801 +
621 802 #[test]
622 803 fn action_bar_parts_and_height() {
623 804 let theme = theme();
@@ -2,7 +2,7 @@
2 2 //! `Revealer`, `Overlay`, `AspectFrame`.
3 3
4 4 use libluna_core::style::weight;
5 -use libluna_core::{Rect, Size, Theme};
5 +use libluna_core::{Animation, Easing, Rect, Size, Theme};
6 6 use libluna_render::TextEngine;
7 7
8 8 use crate::bin::BinAlign;
@@ -290,20 +290,20 @@ impl Frame {
290 290 }
291 291 }
292 292
293 -/// Odkrywanie/ukrywanie zawartości (bez animacji — ułamek zadaje aplikacja).
293 +/// Odkrywanie/ukrywanie zawartości; ułamek można zadać wprost albo animować [`Revealer::advance`].
294 294 #[derive(Debug, Clone, Copy)]
295 295 pub struct Revealer {
296 296 open: bool,
297 - fraction: f64,
298 297 orientation: Orientation,
298 + animation: Animation,
299 299 }
300 300
301 301 impl Default for Revealer {
302 302 fn default() -> Self {
303 303 Self {
304 304 open: false,
305 - fraction: 0.0,
306 305 orientation: Orientation::Vertical,
306 + animation: Animation::default(),
307 307 }
308 308 }
309 309 }
@@ -318,35 +318,54 @@ impl Revealer {
318 318 self
319 319 }
320 320
321 + /// Ustawia stan natychmiast (bez animacji).
321 322 pub fn set_open(&mut self, open: bool) {
322 323 self.open = open;
323 - self.fraction = if open { 1.0 } else { 0.0 };
324 + self.animation.jump_to(if open { 1.0 } else { 0.0 });
324 325 }
325 326
327 + /// Ustawia ułamek odsłonięcia natychmiast.
326 328 pub fn set_fraction(&mut self, fraction: f64) {
327 - self.fraction = fraction.clamp(0.0, 1.0);
328 - self.open = self.fraction > 0.0;
329 + let fraction = fraction.clamp(0.0, 1.0);
330 + self.animation.jump_to(fraction);
331 + self.open = fraction > 0.0;
329 332 }
330 333
331 334 pub fn is_open(&self) -> bool {
332 335 self.open
333 336 }
334 337
338 + /// Bieżący ułamek odsłonięcia (0..1).
339 + pub fn fraction(&self) -> f64 {
340 + self.animation.value()
341 + }
342 +
343 + /// Rozpoczyna płynne odsłanianie/ukrywanie z zadaną krzywą.
344 + pub fn animate_open(&mut self, open: bool, easing: Easing) {
345 + self.open = open;
346 + self.animation.set_easing(easing);
347 + self.animation.animate_to(if open { 1.0 } else { 0.0 });
348 + }
349 +
350 + /// Krok animacji (`dt` i czas `duration` w sekundach); zwraca `true`, gdy trwa dalej.
351 + pub fn advance(&mut self, dt: f64, duration: f64) -> bool {
352 + self.animation.advance(dt, duration)
353 + }
354 +
355 + pub fn is_animating(&self) -> bool {
356 + self.animation.is_animating()
357 + }
358 +
335 359 /// Widoczny prostokąt zawartości (przycięty wg ułamka).
336 360 pub fn reveal_rect(&self, rect: Rect) -> Rect {
361 + let f = self.fraction();
337 362 match self.orientation {
338 - Orientation::Vertical => Rect::from_xywh(
339 - rect.x(),
340 - rect.y(),
341 - rect.width(),
342 - rect.height() * self.fraction,
343 - ),
344 - Orientation::Horizontal => Rect::from_xywh(
345 - rect.x(),
346 - rect.y(),
347 - rect.width() * self.fraction,
348 - rect.height(),
349 - ),
363 + Orientation::Vertical => {
364 + Rect::from_xywh(rect.x(), rect.y(), rect.width(), rect.height() * f)
365 + }
366 + Orientation::Horizontal => {
367 + Rect::from_xywh(rect.x(), rect.y(), rect.width() * f, rect.height())
368 + }
350 369 }
351 370 }
352 371 }
@@ -470,6 +489,19 @@ mod tests {
470 489 assert_eq!(r.reveal_rect(rect()).height(), 0.0);
471 490 }
472 491
492 + #[test]
493 + fn revealer_animates_open() {
494 + let mut r = Revealer::new();
495 + r.animate_open(true, Easing::Linear);
496 + assert!(r.is_animating());
497 + assert!(r.advance(0.5, 1.0));
498 + assert!((r.fraction() - 0.5).abs() < 1e-9);
499 + assert!(!r.advance(0.5, 1.0));
500 + assert_eq!(r.fraction(), 1.0);
501 + assert!(!r.is_animating());
502 + assert_eq!(r.reveal_rect(rect()).height(), 40.0);
503 + }
504 +
473 505 #[test]
474 506 fn aspect_frame_fits_ratio() {
475 507 let a = AspectFrame::new(2.0); // szersze niż wyższe
@@ -53,6 +53,8 @@
53 53 //! - [`Image`], [`TextView`] — obrazek (ikony wektorowe) i pole wielolinijkowe (GTK4).
54 54 //! - [`Popover`], [`ColumnView`], [`Assistant`] — generyczny popover, tabela i kreator kroków (GTK4).
55 55 //! - [`Scrollbar`]/[`Adjustment`], [`ColorChooser`] — przewijanie (obie osie) i paleta kolorów.
56 +//! - Animacje (`libluna-core::motion`): `Easing`/`lerp`/`Animation`; użyte w [`Revealer`],
57 +//! [`Toast`] i przejściach [`Stack`] ([`StackTransition`]).
56 58 //! - Aliasy w duchu GTK4: [`Carousel`] = [`Pager`], [`ScrolledWindow`] = [`ScrollView`],
57 59 //! [`StackSwitcher`] = [`ViewSwitcher`], [`Notebook`] = [`TabView`], [`ListBox`] = [`NavList`].
58 60 //! - [`Carousel`] (alias [`Pager`]), [`ToolbarView`], [`WindowTitle`], [`Clamp`], [`WrapBox`],
@@ -138,7 +140,9 @@ pub use card::Card;
138 140 pub use check_button::CheckButton;
139 141 pub use color_chooser::ColorChooser;
140 142 pub use column_view::{Column, ColumnView};
141 -pub use containers::{ActionBar, CenterBox, Fixed, Grid, Paned, PopoverMenuBar, Stack};
143 +pub use containers::{
144 + ActionBar, CenterBox, Fixed, Grid, Paned, PopoverMenuBar, Stack, StackFrame, StackTransition,
145 +};
142 146 pub use ctx::PaintCtx;
143 147 pub use dialogs::{
144 148 AboutDialog, AlertDialog, AlertRects, Dialog, DialogRects, PreferencesDialog, ShortcutsDialog,
@@ -5,7 +5,7 @@
5 5 //! (klik w akcję — np. „Cofnij”). Czas ukrycia sterowany jest z aplikacji (brak pętli animacji).
6 6
7 7 use libluna_core::style::{weight, Appearance};
8 -use libluna_core::{Point, Rect, Size, Theme};
8 +use libluna_core::{Animation, Easing, Point, Rect, Size, Theme};
9 9 use libluna_render::TextEngine;
10 10
11 11 use crate::ctx::PaintCtx;
@@ -16,14 +16,14 @@ pub struct Toast {
16 16 message: String,
17 17 action: Option<String>,
18 18 open: bool,
19 + animation: Animation,
19 20 }
20 21
21 22 impl Toast {
22 23 pub fn new(message: impl Into<String>) -> Self {
23 24 Self {
24 25 message: message.into(),
25 - action: None,
26 - open: false,
26 + ..Self::default()
27 27 }
28 28 }
29 29
@@ -41,13 +41,42 @@ impl Toast {
41 41 self.action = action;
42 42 }
43 43
44 - /// Pokazuje powiadomienie.
44 + /// Pokazuje powiadomienie (natychmiast).
45 45 pub fn show(&mut self) {
46 46 self.open = true;
47 + self.animation.jump_to(1.0);
48 + }
49 +
50 + /// Pokazuje powiadomienie z animacją (wjazd + rozjaśnienie).
51 + pub fn show_animated(&mut self) {
52 + self.open = true;
53 + self.animation.set_easing(Easing::EaseOut);
54 + self.animation.animate_to(1.0);
47 55 }
48 56
49 57 pub fn set_open(&mut self, open: bool) {
50 58 self.open = open;
59 + self.animation.jump_to(if open { 1.0 } else { 0.0 });
60 + }
61 +
62 + /// Ustawia stan z animacją (pokazanie/ukrycie).
63 + pub fn set_open_animated(&mut self, open: bool) {
64 + self.open = open;
65 + self.animation.animate_to(if open { 1.0 } else { 0.0 });
66 + }
67 +
68 + /// Krok animacji (`dt` i czas `duration` w sekundach); zwraca `true`, gdy trwa dalej.
69 + pub fn advance(&mut self, dt: f64, duration: f64) -> bool {
70 + self.animation.advance(dt, duration)
71 + }
72 +
73 + pub fn is_animating(&self) -> bool {
74 + self.animation.is_animating()
75 + }
76 +
77 + /// Postęp pokazywania (0..1).
78 + pub fn progress(&self) -> f64 {
79 + self.animation.value()
51 80 }
52 81
53 82 pub fn is_open(&self) -> bool {
@@ -117,12 +146,22 @@ impl Toast {
117 146
118 147 /// Rysuje powiadomienie (na wierzchu sceny).
119 148 pub fn paint(&self, ctx: &mut PaintCtx, size: Size) {
120 - if !self.open {
149 + let progress = self.animation.value();
150 + if progress <= 0.001 {
121 151 return;
122 152 }
123 153 let theme = *ctx.theme;
124 154 let colors = theme.colors;
125 - let t = self.rect(size, &theme, ctx.text);
155 + let m = theme.metrics.toast;
156 + let settled = self.rect(size, &theme, ctx.text);
157 + // Wjazd od dołu: im mniejszy postęp, tym niżej i mniej widoczne.
158 + let t = Rect::from_xywh(
159 + settled.x(),
160 + settled.y() + (1.0 - progress) * (m.height + m.margin_bottom),
161 + settled.width(),
162 + settled.height(),
163 + );
164 + let alpha = progress.clamp(0.0, 1.0) as f32;
126 165
127 166 // Powierzchnia o wysokim kontraście: w motywie jasnym ciemna, w ciemnym nieco
128 167 // jaśniejsza od tła okna (jak „toast” w libadwaita).
@@ -130,11 +169,12 @@ impl Toast {
130 169 Appearance::Light => (colors.text, colors.view_bg),
131 170 Appearance::Dark => (colors.card_bg, colors.text),
132 171 };
172 + let bg = bg.scale_alpha(alpha);
173 + let fg = fg.scale_alpha(alpha);
133 174
134 175 ctx.scene.fill_rounded_rect(t, t.height() / 2.0, bg);
135 176
136 177 let body = theme.typography.body.size;
137 - let m = theme.metrics.toast;
138 178 ctx.label_left(
139 179 t.x() + m.pad_x,
140 180 t.center().y,
@@ -151,7 +191,7 @@ impl Toast {
151 191 action,
152 192 body,
153 193 weight::MEDIUM,
154 - colors.accent,
194 + colors.accent.scale_alpha(alpha),
155 195 );
156 196 }
157 197 }
@@ -212,4 +252,21 @@ mod tests {
212 252 assert!((r.bottom() - (800.0 - theme.metrics.toast.margin_bottom)).abs() < 1e-9);
213 253 assert!(toast.action_rect(size(), &theme, &text).is_none());
214 254 }
255 +
256 + #[test]
257 + fn show_is_instant_but_animated_show_ramps() {
258 + let mut instant = Toast::new("A");
259 + instant.show();
260 + assert_eq!(instant.progress(), 1.0);
261 + assert!(!instant.is_animating());
262 +
263 + let mut animated = Toast::new("B");
264 + animated.show_animated();
265 + assert!(animated.is_animating());
266 + assert!(animated.advance(0.25, 0.5)); // pół drogi
267 + // EaseOut po połowie czasu daje 0.75 postępu wizualnego.
268 + assert!((animated.progress() - 0.75).abs() < 1e-9);
269 + assert!(!animated.advance(0.25, 0.5)); // koniec
270 + assert_eq!(animated.progress(), 1.0);
271 + }
215 272 }
@@ -59,7 +59,8 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
59 59 `Adjustment`, `ColorChooser`; aliasy GTK4 `ScrolledWindow` = `ScrollView`,
60 60 `StackSwitcher` = `ViewSwitcher`, `Notebook` = `TabView`, `ListBox` = `NavList`.
61 61 Tokeny metryk kontenerów: `Theme::metrics.container`. Animacje: `libluna-core` `motion`
62 - (`Easing`, `lerp`).
62 + (`Easing`, `lerp`, `Animation`), użyte w `Stack` (przejścia `StackTransition`), `Revealer`
63 + i `Toast` (sterowane `advance(dt, …)`).
63 64 - `libluna-platform`: realne akcje okna z `Frame` (`close`/`minimize`/`toggle_maximize`).
64 65 - Demo używa widgetów zamiast ręcznego rysowania (pasek boczny, wybór motywu/widoku, menu `☰`,
65 66 przycisk „Opcje”, przełączniki, suwak, wybór języka, pasek tytułu, zakładki `TabBar`/`TabView`).