Commit 6197944
10
files
+259
added
-43
deleted
@@ -358,11 +358,13 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
358
358
`TextView` (pole wielolinijkowe), `Popover` (generyczny), `ColumnView` (tabela),
359
359
`Assistant` (kreator kroków), `Scrollbar`/`Adjustment` (przewijanie w obu osiach),
360
360
`ColorChooser` (paleta), `FlowBox` (zawijająca siatka z wyborem), `SpinButton` (pole liczbowe),
361
- `EmojiChooser` (siatka emoji), `NavigationRail` (kompaktowa szyna nawigacji);
361
+ `EmojiChooser` (siatka emoji), `NavigationRail` (kompaktowa szyna nawigacji),
362
+ `Viewport`, `WindowHandle` (obszar chwytu okna);
362
363
aliasy `ScrolledWindow` = `ScrollView`,
363
364
`StackSwitcher` = `ViewSwitcher`, `Notebook` = `TabView`, `ListBox` = `NavList`.
364
365
**Animacje wcielone w widgety**: przejścia `Stack` (slide/fade przez `StackTransition`),
365
- płynny `Revealer` i `Toast` (wjazd + fade) — sterowane z aplikacji przez `advance(dt, …)`.
366
+ płynny `Revealer`, `Toast` (wjazd + fade), `BottomSheet` (wysuwanie), `Pager` (animowane
367
+ przyciąganie do strony) — sterowane z aplikacji przez `advance(dt, …)`.
366
368
- `libluna-widgets::layout`: **dwuprzebiegowy układ** (retencyjne drzewo `measure`/`allocate`) —
367
369
[`Node`] (`VBox`/`HBox`, `Padding`, `Expanded`, `Center`, `Spacer`, `Label`, `Fixed`),
368
370
[`Constraints`](min/max) i [`Layout`](`desired_size`/`fit`). Liście pochłaniają nadmiar
@@ -35,6 +35,8 @@ fn render_pixels(settings: &ThemeSettings, demo: &Demo, text: &TextEngine, size:
35
35
let mut demo = demo.clone();
36
36
demo.tab_stack.advance(1.0, 1.0);
37
37
demo.toast.advance(1.0, 1.0);
38
+ demo.sheet.advance(1.0, 1.0);
39
+ demo.pager.advance(1.0, 1.0);
38
40
let theme = settings.resolve_themed(false);
39
41
let mut scene = Scene::new();
40
42
ui::build_scene(
@@ -78,6 +78,16 @@ fn main() -> Result<(), Box<dyn Error>> {
78
78
frame.request_frame();
79
79
}
80
80
81
+ // Animacja dolnego panelu (wysuwanie) i przyciągania karuzeli.
82
+ if demo.sheet.is_animating() {
83
+ demo.sheet.advance(frame.delta_time(), 0.3);
84
+ frame.request_frame();
85
+ }
86
+ if demo.pager.is_animating() {
87
+ demo.pager.advance(frame.delta_time(), 0.3);
88
+ frame.request_frame();
89
+ }
90
+
81
91
// Motyw po zmianach (np. wybór stylu) — klatka od razu poprawna.
82
92
let theme = settings.resolve_themed(frame.system_dark());
83
93
ui::build_scene(
@@ -662,21 +662,21 @@ pub fn handle_events(
662
662
if demo.sheet.is_open() {
663
663
let area = demo.sheet.content_rect(size, theme);
664
664
if demo.sheet.close_at(size, theme, p) {
665
- demo.sheet.set_open(false);
665
+ demo.sheet.set_open_animated(false);
666
666
demo.sheet_entry.set_focused(false);
667
667
} else if demo.sheet.drag_zone_at(size, theme, p) {
668
668
demo.sheet.begin_drag(p.y);
669
669
} else if demo.sheet.scrim_at(size, theme, p) {
670
- demo.sheet.set_open(false);
670
+ demo.sheet.set_open_animated(false);
671
671
demo.sheet_entry.set_focused(false);
672
672
} else if area.contains(p) {
673
673
if sheet_entry_rect(size, theme).contains(p) {
674
674
demo.sheet_entry.set_focused(true);
675
- } else if demo.pager.click_indicator(
676
- sheet_pages_rect(size, theme),
677
- theme,
678
- p,
679
- ) {
675
+ } else if let Some(i) =
676
+ demo.pager
677
+ .indicator_at(sheet_pages_rect(size, theme), theme, p)
678
+ {
679
+ demo.pager.set_active_animated(i);
680
680
demo.sheet_entry.set_focused(false);
681
681
} else {
682
682
demo.sheet_entry.set_focused(false);
@@ -741,7 +741,7 @@ pub fn handle_events(
741
741
}
742
742
Popover::Menu if entry == ABOUT_ENTRY => {
743
743
// „O aplikacji” otwiera dolny panel.
744
- demo.sheet.set_open(true);
744
+ demo.sheet.set_open_animated(true);
745
745
}
746
746
Popover::Styles => {
747
747
// 0 = „Domyślny”, dalej style z plików.
@@ -842,7 +842,7 @@ pub fn handle_events(
842
842
demo.dragging_sidebar = false;
843
843
demo.volume_dragging = false;
844
844
if demo.pager_dragging {
845
- demo.pager.end_drag();
845
+ demo.pager.end_drag_animated();
846
846
demo.pager_dragging = false;
847
847
}
848
848
if demo.scroll_dragging {
@@ -969,7 +969,10 @@ pub fn handle_events(
969
969
// `v` — przełącz widok (Lista/Siatka)
970
970
0x76 => demo.view_mode = (demo.view_mode + 1) % 2,
971
971
// `o` — pokaż/ukryj dolny panel („O aplikacji”)
972
- 0x6f => demo.sheet.set_open(!demo.sheet.is_open()),
972
+ 0x6f => {
973
+ let open = !demo.sheet.is_open();
974
+ demo.sheet.set_open_animated(open);
975
+ }
973
976
_ => {}
974
977
}
975
978
}
@@ -1072,8 +1075,8 @@ pub fn build_scene(
1072
1075
draw_options(&mut paint, size, &lay, demo);
1073
1076
}
1074
1077
1075
- // Dolny panel (modalny) rysowany na samym wierzchu.
1076
- if demo.sheet.is_open() {
1078
+ // Dolny panel (modalny) rysowany na samym wierzchu (także w trakcie wygaszania).
1079
+ if demo.sheet.progress() > 0.001 {
1077
1080
let mut sheet = demo.sheet.clone();
1078
1081
sheet.set_title(Some(tr("About")));
1079
1082
sheet.paint(&mut paint, size);
@@ -7,7 +7,7 @@
7
7
//! Zawartość panelu rysuje aplikacja w [`BottomSheet::content_rect`].
8
8
9
9
use libluna_core::style::weight;
10
-use libluna_core::{Color, Point, Rect, Size, Theme};
10
+use libluna_core::{Animation, Color, Easing, Point, Rect, Size, Theme};
11
11
12
12
use crate::ctx::PaintCtx;
13
13
use crate::icon;
@@ -28,6 +28,8 @@ pub struct BottomSheet {
28
28
/// Pozycja wskaźnika (y) na początku przeciągania.
29
29
drag_from: Option<f64>,
30
30
hover_close: bool,
31
+ /// Postęp wysuwania (0 = schowany, 1 = w pełni otwarty).
32
+ animation: Animation,
31
33
}
32
34
33
35
impl BottomSheet {
@@ -51,12 +53,39 @@ impl BottomSheet {
51
53
52
54
pub fn set_open(&mut self, open: bool) {
53
55
self.open = open;
56
+ self.animation.jump_to(if open { 1.0 } else { 0.0 });
54
57
if !open {
55
58
self.drag = 0.0;
56
59
self.drag_from = None;
57
60
}
58
61
}
59
62
63
+ /// Otwiera/zamyka panel z animacją wysuwania.
64
+ pub fn set_open_animated(&mut self, open: bool) {
65
+ self.set_open_animated_eased(open, Easing::EaseOut);
66
+ }
67
+
68
+ /// Jak [`BottomSheet::set_open_animated`], z wyborem krzywej.
69
+ pub fn set_open_animated_eased(&mut self, open: bool, easing: Easing) {
70
+ self.open = open;
71
+ self.animation.set_easing(easing);
72
+ self.animation.animate_to(if open { 1.0 } else { 0.0 });
73
+ }
74
+
75
+ /// Krok animacji wysuwania; zwraca `true`, gdy trwa dalej.
76
+ pub fn advance(&mut self, dt: f64, duration: f64) -> bool {
77
+ self.animation.advance(dt, duration)
78
+ }
79
+
80
+ pub fn is_animating(&self) -> bool {
81
+ self.animation.is_animating()
82
+ }
83
+
84
+ /// Postęp wysuwania (0..1).
85
+ pub fn progress(&self) -> f64 {
86
+ self.animation.value()
87
+ }
88
+
60
89
pub fn is_open(&self) -> bool {
61
90
self.open
62
91
}
@@ -70,11 +99,19 @@ impl BottomSheet {
70
99
}
71
100
72
101
/// Prostokąt panelu (przyklejony do dołu, z uwzględnieniem przeciągania).
102
+ ///
103
+ /// To geometria **docelowa** (ustabilizowana) — używana przez trafienia i układ treści.
104
+ /// Wizualne wysuwanie dodaje [`BottomSheet::slide_offset`] tylko w `paint`.
73
105
pub fn sheet_rect(&self, size: Size, theme: &Theme) -> Rect {
74
106
let h = self.height(theme);
75
107
Rect::from_xywh(0.0, size.height - h + self.drag, size.width, h)
76
108
}
77
109
110
+ /// Dodatkowe przesunięcie w dół podczas wysuwania (0, gdy w pełni otwarty).
111
+ fn slide_offset(&self, theme: &Theme) -> f64 {
112
+ (1.0 - self.animation.value().clamp(0.0, 1.0)) * self.height(theme)
113
+ }
114
+
78
115
/// Pełnoekranowe tło przygaszające.
79
116
pub fn scrim_rect(&self, size: Size) -> Rect {
80
117
Rect::from_xywh(0.0, 0.0, size.width, size.height)
@@ -86,24 +123,32 @@ impl BottomSheet {
86
123
Rect::from_xywh(s.x(), s.y(), s.width(), theme.metrics.sheet.header)
87
124
}
88
125
89
- /// Prostokąt „grabbera”.
90
- pub fn grabber_rect(&self, size: Size, theme: &Theme) -> Rect {
91
- let s = self.sheet_rect(size, theme);
126
+ /// Prostokąt „grabbera” (dla danej geometrii panelu).
127
+ fn grabber_in(&self, sheet: Rect, theme: &Theme) -> Rect {
92
128
let m = theme.metrics.sheet;
93
129
Rect::from_xywh(
94
- s.center().x - m.handle_width / 2.0,
95
- s.y() + HANDLE_TOP,
130
+ sheet.center().x - m.handle_width / 2.0,
131
+ sheet.y() + HANDLE_TOP,
96
132
m.handle_width,
97
133
m.handle_height,
98
134
)
99
135
}
100
136
137
+ /// Prostokąt „grabbera”.
138
+ pub fn grabber_rect(&self, size: Size, theme: &Theme) -> Rect {
139
+ self.grabber_in(self.sheet_rect(size, theme), theme)
140
+ }
141
+
142
+ /// Prostokąt przycisku zamknięcia (dla danej geometrii panelu).
143
+ fn close_in(&self, sheet: Rect, theme: &Theme) -> Rect {
144
+ let md = theme.spacing.md;
145
+ let cy = sheet.y() + theme.metrics.sheet.header / 2.0;
146
+ Rect::from_xywh(sheet.right() - md - CLOSE, cy - CLOSE / 2.0, CLOSE, CLOSE)
147
+ }
148
+
101
149
/// Prostokąt przycisku zamknięcia.
102
150
pub fn close_rect(&self, size: Size, theme: &Theme) -> Rect {
103
- let s = self.sheet_rect(size, theme);
104
- let md = theme.spacing.md;
105
- let cy = s.y() + theme.metrics.sheet.header / 2.0;
106
- Rect::from_xywh(s.right() - md - CLOSE, cy - CLOSE / 2.0, CLOSE, CLOSE)
151
+ self.close_in(self.sheet_rect(size, theme), theme)
107
152
}
108
153
109
154
/// Obszar zawartości (poniżej nagłówka).
@@ -150,6 +195,7 @@ impl BottomSheet {
150
195
let dismissed = self.drag > theme.metrics.sheet.dismiss;
151
196
if dismissed {
152
197
self.open = false;
198
+ self.animation.jump_to(0.0);
153
199
}
154
200
self.drag = 0.0;
155
201
self.drag_from = None;
@@ -158,7 +204,8 @@ impl BottomSheet {
158
204
159
205
/// Rysuje przygaszenie i panel (na wierzchu sceny).
160
206
pub fn paint(&self, ctx: &mut PaintCtx, size: Size) {
161
- if !self.open {
207
+ let progress = self.animation.value();
208
+ if progress <= 0.001 {
162
209
return;
163
210
}
164
211
let theme = *ctx.theme;
@@ -167,10 +214,21 @@ impl BottomSheet {
167
214
168
215
ctx.scene.fill_rect(
169
216
self.scrim_rect(size),
170
- Color::rgba(0.0, 0.0, 0.0, m.scrim as f32),
217
+ Color::rgba(
218
+ 0.0,
219
+ 0.0,
220
+ 0.0,
221
+ m.scrim as f32 * progress.clamp(0.0, 1.0) as f32,
222
+ ),
171
223
);
172
224
173
- let sheet = self.sheet_rect(size, &theme);
225
+ let settled = self.sheet_rect(size, &theme);
226
+ let sheet = Rect::from_xywh(
227
+ settled.x(),
228
+ settled.y() + self.slide_offset(&theme),
229
+ settled.width(),
230
+ settled.height(),
231
+ );
174
232
// Panel: zaokrąglone górne narożniki, dolne „przycięte” do krawędzi.
175
233
ctx.scene.fill_rounded_rect(sheet, m.radius, colors.card_bg);
176
234
ctx.scene.fill_rect(
@@ -187,7 +245,7 @@ impl BottomSheet {
187
245
colors.border,
188
246
);
189
247
190
- let g = self.grabber_rect(size, &theme);
248
+ let g = self.grabber_in(sheet, &theme);
191
249
ctx.scene
192
250
.fill_rounded_rect(g, g.height() / 2.0, colors.dim_text.scale_alpha(0.5));
193
251
@@ -202,7 +260,7 @@ impl BottomSheet {
202
260
);
203
261
}
204
262
205
- let c = self.close_rect(size, &theme);
263
+ let c = self.close_in(sheet, &theme);
206
264
if self.hover_close {
207
265
ctx.scene
208
266
.fill_rounded_rect(c, theme.radius.small, colors.shade);
@@ -278,4 +336,20 @@ mod tests {
278
336
assert!(!s.end_drag(&theme));
279
337
assert!(s.is_open());
280
338
}
339
+
340
+ #[test]
341
+ fn animated_open_ramps_and_settles() {
342
+ let theme = Theme::light();
343
+ let mut s = BottomSheet::new();
344
+ s.set_open_animated(true);
345
+ assert!(s.is_animating());
346
+ assert!(s.progress() < 1.0);
347
+ // Geometria (trafienia) jest ustabilizowana niezależnie od postępu.
348
+ assert!((s.sheet_rect(size(), &theme).bottom() - size().height).abs() < 1e-9);
349
+ while s.is_animating() {
350
+ s.advance(0.1, 0.3);
351
+ }
352
+ assert_eq!(s.progress(), 1.0);
353
+ assert!((s.sheet_rect(size(), &theme).bottom() - size().height).abs() < 1e-9);
354
+ }
281
355
}
@@ -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::{Animation, Easing, Point, Rect, Theme};
9
+use libluna_core::{Animation, Easing, Point, Rect, Size, Theme};
10
10
use libluna_render::TextEngine;
11
11
12
12
use crate::ctx::PaintCtx;
@@ -57,6 +57,27 @@ impl Fixed {
57
57
}
58
58
}
59
59
60
+/// Okno na większą treść (`GtkViewport`): mapuje przesuniętą treść na obszar widoku.
61
+#[derive(Debug, Clone, Copy, Default)]
62
+pub struct Viewport;
63
+
64
+impl Viewport {
65
+ /// Prostokąt treści o rozmiarze `content`, przesunięty o `offset`, we współrzędnych `view`.
66
+ pub fn content_rect(view: Rect, content: Size, offset: Point) -> Rect {
67
+ Rect::from_xywh(
68
+ view.x() - offset.x,
69
+ view.y() - offset.y,
70
+ content.width,
71
+ content.height,
72
+ )
73
+ }
74
+
75
+ /// Czy treść nie mieści się w widoku (po którejkolwiek osi).
76
+ pub fn overflows(view: Rect, content: Size) -> bool {
77
+ content.width > view.width() + 1e-9 || content.height > view.height() + 1e-9
78
+ }
79
+}
80
+
60
81
/// Siatka `rows × cols` z równymi komórkami i odstępem z motywu.
61
82
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62
83
pub struct Grid {
@@ -675,6 +696,17 @@ mod tests {
675
696
assert_eq!(p, Rect::from_xywh(11.0, 22.0, 3.0, 4.0));
676
697
}
677
698
699
+ #[test]
700
+ fn viewport_maps_scrolled_content() {
701
+ let view = Rect::from_xywh(0.0, 0.0, 100.0, 80.0);
702
+ let content = Size::new(100.0, 300.0);
703
+ assert!(Viewport::overflows(view, content));
704
+ assert!(!Viewport::overflows(view, Size::new(100.0, 80.0)));
705
+ let r = Viewport::content_rect(view, content, Point::new(0.0, 50.0));
706
+ assert_eq!(r.y(), -50.0);
707
+ assert_eq!(r.height(), 300.0);
708
+ }
709
+
678
710
#[test]
679
711
fn center_box_places_three_parts() {
680
712
let rect = Rect::from_xywh(0.0, 0.0, 300.0, 40.0);
@@ -2,7 +2,7 @@
2
2
//! `BreakpointBin`, `ButtonContent`.
3
3
4
4
use libluna_core::style::weight;
5
-use libluna_core::{Rect, Size, Theme};
5
+use libluna_core::{Point, Rect, Size, Theme};
6
6
use libluna_render::TextEngine;
7
7
8
8
use crate::ctx::PaintCtx;
@@ -282,6 +282,18 @@ impl ButtonContent {
282
282
}
283
283
}
284
284
285
+/// Obszar chwytu okna (`GtkWindowHandle`): rozstrzyga, czy punkt w pasku tytułu służy
286
+/// do **przeciągania okna**, czy trafia w kontrolkę (wtedy nie jest uchwytem).
287
+#[derive(Debug, Clone, Copy, Default)]
288
+pub struct WindowHandle;
289
+
290
+impl WindowHandle {
291
+ /// Czy `p` leży w `bar` i nie trafia w żadną z kontrolek.
292
+ pub fn is_drag_region(bar: Rect, controls: &[Rect], p: Point) -> bool {
293
+ bar.contains(p) && !controls.iter().any(|c| c.contains(p))
294
+ }
295
+}
296
+
285
297
#[cfg(test)]
286
298
mod tests {
287
299
use super::*;
@@ -300,6 +312,27 @@ mod tests {
300
312
assert_eq!(r.content.height(), 600.0 - 46.0 - 28.0);
301
313
}
302
314
315
+ #[test]
316
+ fn window_handle_excludes_controls() {
317
+ let bar = Rect::from_xywh(0.0, 0.0, 800.0, 46.0);
318
+ let controls = [Rect::from_xywh(700.0, 8.0, 90.0, 30.0)];
319
+ assert!(WindowHandle::is_drag_region(
320
+ bar,
321
+ &controls,
322
+ Point::new(400.0, 20.0)
323
+ ));
324
+ assert!(!WindowHandle::is_drag_region(
325
+ bar,
326
+ &controls,
327
+ Point::new(720.0, 20.0)
328
+ ));
329
+ assert!(!WindowHandle::is_drag_region(
330
+ bar,
331
+ &controls,
332
+ Point::new(400.0, 200.0)
333
+ ));
334
+ }
335
+
303
336
#[test]
304
337
fn clamp_centers_and_limits() {
305
338
let clamp = Clamp::new(400.0);
@@ -56,6 +56,7 @@
56
56
//! - Animacje (`libluna-core::motion`): `Easing`/`lerp`/`Animation`; użyte w [`Revealer`],
57
57
//! [`Toast`] i przejściach [`Stack`] ([`StackTransition`]).
58
58
//! - [`FlowBox`], [`SpinButton`], [`EmojiChooser`], [`NavigationRail`] — kolejne widgety libLuny.
59
+//! - [`Viewport`], [`WindowHandle`] — pomocniki okna/obszaru (GTK4).
59
60
//! - Aliasy w duchu GTK4: [`Carousel`] = [`Pager`], [`ScrolledWindow`] = [`ScrollView`],
60
61
//! [`StackSwitcher`] = [`ViewSwitcher`], [`Notebook`] = [`TabView`], [`ListBox`] = [`NavList`].
61
62
//! - [`Carousel`] (alias [`Pager`]), [`ToolbarView`], [`WindowTitle`], [`Clamp`], [`WrapBox`],
@@ -147,6 +148,7 @@ pub use color_chooser::ColorChooser;
147
148
pub use column_view::{Column, ColumnView};
148
149
pub use containers::{
149
150
ActionBar, CenterBox, Fixed, Grid, Paned, PopoverMenuBar, Stack, StackFrame, StackTransition,
151
+ Viewport,
150
152
};
151
153
pub use ctx::PaintCtx;
152
154
pub use dialogs::{
@@ -164,8 +166,8 @@ pub use expander::Expander;
164
166
pub use flow_box::FlowBox;
165
167
pub use header_bar::{ControlsLayout, ControlsSide, HeaderBar, TitleAlign, WindowControl};
166
168
pub use helpers::{
167
- BreakpointBin, ButtonContent, Clamp, ToolbarRects, ToolbarView, WindowTitle, WindowTitleRects,
168
- WrapBox,
169
+ BreakpointBin, ButtonContent, Clamp, ToolbarRects, ToolbarView, WindowHandle, WindowTitle,
170
+ WindowTitleRects, WrapBox,
169
171
};
170
172
pub use image::{Image, ImageIcon};
171
173
pub use item_view::{Item, ItemKind, ItemView, ViewMode};
@@ -6,7 +6,7 @@
6
6
//! ([`Pager::indicator_at`]) oraz przeciąganie ([`Pager::begin_drag`] →
7
7
//! [`Pager::update_drag`] → [`Pager::end_drag`], które „przyciąga” do najbliższej strony).
8
8
9
-use libluna_core::{Insets, Point, Rect, Theme};
9
+use libluna_core::{Animation, Easing, Insets, Point, Rect, Theme};
10
10
11
11
use crate::ctx::PaintCtx;
12
12
@@ -20,15 +20,15 @@ pub struct Pager {
20
20
drag_from: Option<f64>,
21
21
/// Pozycja karuzeli na początku przeciągania.
22
22
drag_pos: f64,
23
+ /// Animowane „przyciąganie” do strony (po puszczeniu / skoku z kropek).
24
+ snap: Animation,
23
25
}
24
26
25
27
impl Pager {
26
28
pub fn new(count: usize) -> Self {
27
29
Self {
28
30
count,
29
- position: 0.0,
30
- drag_from: None,
31
- drag_pos: 0.0,
31
+ ..Self::default()
32
32
}
33
33
}
34
34
@@ -130,6 +130,41 @@ impl Pager {
130
130
active
131
131
}
132
132
133
+ /// Kończy przeciąganie z **animowanym** przyciąganiem do najbliższej strony.
134
+ pub fn end_drag_animated(&mut self) -> usize {
135
+ self.drag_from = None;
136
+ let active = self.active();
137
+ self.snap.jump_to(self.position);
138
+ self.snap.animate_to(active as f64);
139
+ active
140
+ }
141
+
142
+ /// Skok do strony z animacją (np. klik w kropkę).
143
+ pub fn set_active_animated(&mut self, index: usize) {
144
+ let target = (index as f64).clamp(0.0, self.max_position());
145
+ self.snap.jump_to(self.position);
146
+ self.snap.animate_to(target);
147
+ }
148
+
149
+ pub fn is_animating(&self) -> bool {
150
+ self.snap.is_animating()
151
+ }
152
+
153
+ /// Ustawia krzywą wygładzania przyciągania.
154
+ pub fn set_snap_easing(&mut self, easing: Easing) {
155
+ self.snap.set_easing(easing);
156
+ }
157
+
158
+ /// Krok animacji przyciągania; zwraca `true`, gdy trwa dalej.
159
+ pub fn advance(&mut self, dt: f64, duration: f64) -> bool {
160
+ if !self.snap.is_animating() {
161
+ return false;
162
+ }
163
+ let animating = self.snap.advance(dt, duration);
164
+ self.position = self.snap.value().clamp(0.0, self.max_position());
165
+ animating
166
+ }
167
+
133
168
/// Rysuje kropki-wskaźniki.
134
169
pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
135
170
let theme = *ctx.theme;
@@ -202,4 +237,27 @@ mod tests {
202
237
assert_eq!(p.end_drag(), 1);
203
238
assert!((p.position() - 1.0).abs() < 1e-9);
204
239
}
240
+
241
+ #[test]
242
+ fn animated_snap_settles_on_target() {
243
+ let mut p = Pager::new(3);
244
+ p.begin_drag(0.0);
245
+ p.update_drag(-420.0, rect());
246
+ assert_eq!(p.end_drag_animated(), 1);
247
+ assert!(p.is_animating());
248
+ assert!(p.advance(0.5, 1.0));
249
+ assert!(!p.advance(0.5, 1.0));
250
+ assert!((p.position() - 1.0).abs() < 1e-9);
251
+ }
252
+
253
+ #[test]
254
+ fn dot_jump_animates() {
255
+ let mut p = Pager::new(4);
256
+ p.set_active_animated(3);
257
+ assert!(p.is_animating());
258
+ while p.is_animating() {
259
+ p.advance(0.1, 1.0);
260
+ }
261
+ assert_eq!(p.active(), 3);
262
+ }
205
263
}
@@ -56,19 +56,19 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
56
56
„oko”) i `EditableLabel`; kontenery `CenterBox`, `Grid`, `Fixed`, `Paned`, `Stack`,
57
57
`ActionBar`, `PopoverMenuBar`; dalej `MenuButton`, `SearchBar`, `Expander`, `Statusbar`,
58
58
`Calendar`, `Image`, `TextView`, `Popover`, `ColumnView`, `Assistant`, `Scrollbar`/
59
- `Adjustment`, `ColorChooser`, `FlowBox`, `SpinButton`, `EmojiChooser`, `NavigationRail`;
60
- aliasy GTK4 `ScrolledWindow` = `ScrollView`,
59
+ `Adjustment`, `ColorChooser`, `FlowBox`, `SpinButton`, `EmojiChooser`, `NavigationRail`,
60
+ `Viewport`, `WindowHandle`; aliasy GTK4 `ScrolledWindow` = `ScrollView`,
61
61
`StackSwitcher` = `ViewSwitcher`, `Notebook` = `TabView`, `ListBox` = `NavList`.
62
62
Tokeny metryk kontenerów: `Theme::metrics.container`. Animacje: `libluna-core` `motion`
63
- (`Easing`, `lerp`, `Animation`), użyte w `Stack` (przejścia `StackTransition`), `Revealer`
64
- i `Toast` (sterowane `advance(dt, …)`).
63
+ (`Easing`, `lerp`, `Animation`), użyte w `Stack` (przejścia `StackTransition`), `Revealer`,
64
+ `Toast`, `BottomSheet` i `Pager` (sterowane `advance(dt, …)`).
65
65
- `libluna-platform`: realne akcje okna z `Frame` (`close`/`minimize`/`toggle_maximize`).
66
66
**Klatki animacji**: `Frame::delta_time()` + `Frame::request_frame()` (płynne ~60 Hz, gdy trwa
67
67
animacja; bez tego okno odświeża się tylko na zdarzenia).
68
68
- Demo używa widgetów zamiast ręcznego rysowania (pasek boczny, wybór motywu/widoku, menu `☰`,
69
69
przycisk „Opcje”, przełączniki, suwak, wybór języka, pasek tytułu, zakładki `TabBar`/`TabView`).
70
- Powiadomienie (`Toast`) w demo animuje się (wjazd + fade), a zmiana zakładki
71
- to animowane przejście (`Stack` slide w poziomie).
70
+ Powiadomienie (`Toast`) w demo animuje się (wjazd + fade), zmiana zakładki
71
+ to animowane przejście (`Stack` slide), a dolny panel wysuwa się i karuzela „przyciąga”.
72
72
- **Galeria widgetów** headless: `cargo run -p luna-demo --bin gallery` → `docs/images/gallery.png`
73
73
(kontenery, tabela, kalendarz, chooser’y, kreator kroków, pasek stanu z cz. 5–12).
74
74
- Motywy: wariant (light/dark/auto) + akcent, z trwałością. **Component metrics**: rozmiary