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`).