← luna

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