Commit 554d2a4
7
files
+682
added
-6
deleted
@@ -312,7 +312,8 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
312
312
motywu (`Spacing`, `Radius`, `Typography`), **metryki kontrolek** (`Metrics`) oraz
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
- `ThemePatch`, skanowanie katalogów `available_themes()`).
315
+ `ThemePatch`, skanowanie katalogów `available_themes()`). Animacje: `motion` (`Easing`,
316
+ `lerp`) — matematyka ruchu bez stanu.
316
317
- `libluna-i18n`: `Locale` (z `LANG`/`LC_*`), `Catalog`, `Translations`, globalne `tr`/`set_locale`,
317
318
makro `t!` z podstawianiem argumentów, **języki z osobnych plików** `i18n/<kod>.lang`
318
319
(wbudowane) oraz **doładowywane w runtime** z katalogów; `languages()` i `language_name()`.
@@ -353,7 +354,8 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
353
354
(przeciągany separator), `Stack`, `ActionBar`, `PopoverMenuBar`; oraz `MenuButton`,
354
355
`SearchBar`, `Expander`, `Statusbar`, `Calendar`, `Image` (ikony wektorowe),
355
356
`TextView` (pole wielolinijkowe), `Popover` (generyczny), `ColumnView` (tabela),
356
- `Assistant` (kreator kroków); aliasy `ScrolledWindow` = `ScrollView`,
357
+ `Assistant` (kreator kroków), `Scrollbar`/`Adjustment` (przewijanie w obu osiach),
358
+ `ColorChooser` (paleta); aliasy `ScrolledWindow` = `ScrollView`,
357
359
`StackSwitcher` = `ViewSwitcher`, `Notebook` = `TabView`, `ListBox` = `NavList`.
358
360
- `libluna-widgets::layout`: **dwuprzebiegowy układ** (retencyjne drzewo `measure`/`allocate`) —
359
361
[`Node`] (`VBox`/`HBox`, `Padding`, `Expanded`, `Center`, `Spacer`, `Label`, `Fixed`),
@@ -6,6 +6,7 @@
6
6
//! - [`color`] — [`Color`] w przestrzeni sRGB z kanałem alfa.
7
7
//! - [`scale`] — [`Scale`] do przeliczania pikseli logicznych na fizyczne.
8
8
//! - [`event`] — [`Event`] i towarzyszące typy wejścia.
9
+//! - [`motion`] — krzywe [`Easing`] i interpolacja [`lerp`] (matematyka ruchu).
9
10
//! - [`style`] — tokeny wyglądu ([`style::Theme`], spacing, radius, typografia).
10
11
//!
11
12
//! Moduł nie zależy od Waylanda ani od konkretnego rasteryzatora — jest wspólnym
@@ -15,6 +16,7 @@ pub mod appearance;
15
16
pub mod color;
16
17
pub mod event;
17
18
pub mod geometry;
19
+pub mod motion;
18
20
pub mod scale;
19
21
pub mod style;
20
22
pub mod theme_file;
@@ -25,6 +27,7 @@ pub use event::{
25
27
ButtonState, Event, KeyEvent, Modifiers, PointerButton, PointerEvent, ScrollEvent, WindowEvent,
26
28
};
27
29
pub use geometry::{Insets, Point, Rect, Size};
30
+pub use motion::{lerp, Easing};
28
31
pub use scale::Scale;
29
32
pub use style::{
30
33
ActionRowMetrics, AvatarMetrics, BannerMetrics, BarMetrics, BottomSheetMetrics, ButtonMetrics,
31
34
new file mode 100644
@@ -0,0 +1,87 @@
1
+//! `motion` — animacja i interpolacja (własny, lekki mechanizm libLuny).
2
+//!
3
+//! To nie jest klon `GtkStack`/`AdwAnimation`: libLuna daje tylko **matematykę ruchu**
4
+//! (krzywe [`Easing`] i interpolację [`lerp`]), a stan animacji trzyma aplikacja — zgodnie
5
+//! z modelem immediate. Dzięki temu przejścia w `Stack`, odsłanianie `Revealer` czy
6
+//! pojawianie się `Toast` płynnie sterują się prostą postępu `t` (0..1).
7
+
8
+/// Krzywa wygładzania (easing) przebiegu animacji.
9
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10
+pub enum Easing {
11
+ /// Liniowo (bez wygładzania).
12
+ Linear,
13
+ /// Wolny start (przyspiesza).
14
+ #[default]
15
+ EaseIn,
16
+ /// Szybki start (zwalnia pod koniec).
17
+ EaseOut,
18
+ /// Wolny start i koniec (najbardziej „naturalny”).
19
+ EaseInOut,
20
+}
21
+
22
+impl Easing {
23
+ /// Mapuje postęp liniowy `t` (0..1) na wygładzoną wartość (0..1).
24
+ pub fn apply(self, t: f64) -> f64 {
25
+ let t = t.clamp(0.0, 1.0);
26
+ match self {
27
+ Easing::Linear => t,
28
+ Easing::EaseIn => t * t,
29
+ Easing::EaseOut => 1.0 - (1.0 - t) * (1.0 - t),
30
+ Easing::EaseInOut => {
31
+ if t < 0.5 {
32
+ 2.0 * t * t
33
+ } else {
34
+ 1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
35
+ }
36
+ }
37
+ }
38
+ }
39
+
40
+ /// Interpoluje `from` → `to` dla postępu `t` (z wygładzeniem tej krzywej).
41
+ pub fn lerp(self, from: f64, to: f64, t: f64) -> f64 {
42
+ from + (to - from) * self.apply(t)
43
+ }
44
+}
45
+
46
+/// Interpolacja liniowa `from` → `to` (bez wygładzania).
47
+pub fn lerp(from: f64, to: f64, t: f64) -> f64 {
48
+ from + (to - from) * t.clamp(0.0, 1.0)
49
+}
50
+
51
+#[cfg(test)]
52
+mod tests {
53
+ use super::*;
54
+
55
+ #[test]
56
+ fn endpoints_are_preserved() {
57
+ for e in [
58
+ Easing::Linear,
59
+ Easing::EaseIn,
60
+ Easing::EaseOut,
61
+ Easing::EaseInOut,
62
+ ] {
63
+ assert!((e.apply(0.0)).abs() < 1e-9);
64
+ assert!((e.apply(1.0) - 1.0).abs() < 1e-9);
65
+ }
66
+ }
67
+
68
+ #[test]
69
+ fn ease_in_lags_and_ease_out_leads_at_midpoint() {
70
+ assert!(Easing::EaseIn.apply(0.5) < 0.5);
71
+ assert!(Easing::EaseOut.apply(0.5) > 0.5);
72
+ assert!((Easing::EaseInOut.apply(0.5) - 0.5).abs() < 1e-9);
73
+ }
74
+
75
+ #[test]
76
+ fn progress_is_clamped() {
77
+ assert_eq!(Easing::Linear.apply(-1.0), 0.0);
78
+ assert_eq!(Easing::Linear.apply(2.0), 1.0);
79
+ assert_eq!(lerp(10.0, 20.0, 5.0), 20.0);
80
+ }
81
+
82
+ #[test]
83
+ fn lerp_moves_between_values() {
84
+ assert_eq!(lerp(0.0, 100.0, 0.25), 25.0);
85
+ assert_eq!(Easing::Linear.lerp(10.0, 20.0, 0.5), 15.0);
86
+ }
87
+}
0
88
new file mode 100644
@@ -0,0 +1,197 @@
1
+//! `ColorChooser` — wybór koloru z palety (GTK4, w duchu libLuny).
2
+//!
3
+//! To nie jest pełny `GtkColorChooser` z kolem barw — libLuna daje zwięzłą **paletę
4
+//! próbek** (`swatch`): kwadratowa siatka kolorów z wyborem. Idealna do wyboru akcentu
5
+//! w ustawieniach.
6
+
7
+use libluna_core::{Color, Point, Rect, Theme};
8
+
9
+use crate::ctx::PaintCtx;
10
+
11
+/// Paleta próbek kolorów.
12
+#[derive(Debug, Clone, Default)]
13
+pub struct ColorChooser {
14
+ colors: Vec<Color>,
15
+ selected: usize,
16
+ columns: usize,
17
+}
18
+
19
+impl ColorChooser {
20
+ /// Paleta domyślna (osiem wyrazistych kolorów w duchu libadwaita).
21
+ pub fn default_palette() -> Vec<Color> {
22
+ vec![
23
+ Color::from_rgba8(0x35, 0x84, 0xe4, 0xff),
24
+ Color::from_rgba8(0x91, 0x41, 0xac, 0xff),
25
+ Color::from_rgba8(0xc0, 0x1c, 0x28, 0xff),
26
+ Color::from_rgba8(0xe5, 0xa5, 0x0a, 0xff),
27
+ Color::from_rgba8(0x2e, 0xc2, 0x7e, 0xff),
28
+ Color::from_rgba8(0x1a, 0x5f, 0xb4, 0xff),
29
+ Color::from_rgba8(0x5e, 0x5c, 0x64, 0xff),
30
+ Color::from_rgba8(0xe0, 0x1b, 0x24, 0xff),
31
+ ]
32
+ }
33
+
34
+ /// Paleta z listy kolorów (wybrany pierwszy, 4 kolumny).
35
+ pub fn new(colors: Vec<Color>) -> Self {
36
+ Self {
37
+ colors,
38
+ selected: 0,
39
+ columns: 4,
40
+ }
41
+ }
42
+
43
+ pub fn colors(&self) -> &[Color] {
44
+ &self.colors
45
+ }
46
+
47
+ pub fn columns(&self) -> usize {
48
+ self.columns
49
+ }
50
+
51
+ /// Ustawia liczbę kolumn siatki.
52
+ pub fn set_columns(&mut self, columns: usize) {
53
+ self.columns = columns.max(1);
54
+ }
55
+
56
+ pub fn selected(&self) -> usize {
57
+ self.selected
58
+ }
59
+
60
+ pub fn selected_color(&self) -> Option<Color> {
61
+ self.colors.get(self.selected).copied()
62
+ }
63
+
64
+ /// Ustawia wybraną próbkę (poza zakresem jest ignorowane).
65
+ pub fn set_selected(&mut self, index: usize) {
66
+ if index < self.colors.len() {
67
+ self.selected = index;
68
+ }
69
+ }
70
+
71
+ /// Prostokąty próbek (kwadraty wypełniające szerokość, wierszami).
72
+ pub fn cells(&self, rect: Rect, theme: &Theme) -> Vec<Rect> {
73
+ if self.colors.is_empty() {
74
+ return Vec::new();
75
+ }
76
+ let cols = self.columns.max(1);
77
+ let gap = theme.spacing.sm;
78
+ let side = ((rect.width() - gap * (cols as f64 - 1.0)) / cols as f64).max(0.0);
79
+ self.colors
80
+ .iter()
81
+ .enumerate()
82
+ .map(|(i, _)| {
83
+ let row = i / cols;
84
+ let col = i % cols;
85
+ Rect::from_xywh(
86
+ rect.x() + col as f64 * (side + gap),
87
+ rect.y() + row as f64 * (side + gap),
88
+ side,
89
+ side,
90
+ )
91
+ })
92
+ .collect()
93
+ }
94
+
95
+ /// Indeks próbki pod punktem `p` (lub `None`).
96
+ pub fn index_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<usize> {
97
+ self.cells(rect, theme).iter().position(|c| c.contains(p))
98
+ }
99
+
100
+ /// Obsługuje klik: wybiera próbkę. Zwraca `true`, gdy wybór się zmienił.
101
+ pub fn click_at(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
102
+ match self.index_at(rect, theme, p) {
103
+ Some(i) if i != self.selected => {
104
+ self.selected = i;
105
+ true
106
+ }
107
+ _ => false,
108
+ }
109
+ }
110
+
111
+ /// Wysokość potrzebna na całą siatkę.
112
+ pub fn height(&self, rect_width: f64, theme: &Theme) -> f64 {
113
+ if self.colors.is_empty() {
114
+ return 0.0;
115
+ }
116
+ let cols = self.columns.max(1);
117
+ let gap = theme.spacing.sm;
118
+ let side = ((rect_width - gap * (cols as f64 - 1.0)) / cols as f64).max(0.0);
119
+ let rows = self.colors.len().div_ceil(cols) as f64;
120
+ rows * side + (rows - 1.0) * gap
121
+ }
122
+
123
+ /// Rysuje siatkę próbek; wybrana dostaje obwódkę akcentu.
124
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
125
+ let theme = *ctx.theme;
126
+ let cells = self.cells(rect, &theme);
127
+ for (i, cell) in cells.iter().enumerate() {
128
+ let swatch = cell.deflate(libluna_core::Insets::splat(2.0));
129
+ ctx.scene
130
+ .fill_rounded_rect(swatch, theme.radius.small, self.colors[i]);
131
+ if i == self.selected {
132
+ ctx.scene
133
+ .stroke_rounded_rect(swatch, theme.radius.small, 2.0, theme.colors.accent);
134
+ } else {
135
+ ctx.scene
136
+ .stroke_rounded_rect(swatch, theme.radius.small, 1.0, theme.colors.border);
137
+ }
138
+ }
139
+ }
140
+}
141
+
142
+#[cfg(test)]
143
+mod tests {
144
+ use super::*;
145
+
146
+ fn chooser() -> ColorChooser {
147
+ ColorChooser::new(ColorChooser::default_palette())
148
+ }
149
+
150
+ #[test]
151
+ fn cells_fill_width_row_major() {
152
+ let theme = Theme::light();
153
+ let c = chooser();
154
+ let rect = Rect::from_xywh(0.0, 0.0, 200.0, 200.0);
155
+ let cells = c.cells(rect, &theme);
156
+ assert_eq!(cells.len(), 8);
157
+ let gap = theme.spacing.sm;
158
+ let side = (200.0 - 3.0 * gap) / 4.0;
159
+ assert!((cells[0].width() - side).abs() < 1e-9);
160
+ // Piąta próbka zaczyna drugi wiersz.
161
+ assert_eq!(cells[4].x(), 0.0);
162
+ assert!(cells[4].y() > cells[0].y());
163
+ assert!((cells[3].right() - 200.0).abs() < 1e-9);
164
+ }
165
+
166
+ #[test]
167
+ fn click_selects_and_reports_change() {
168
+ let theme = Theme::light();
169
+ let rect = Rect::from_xywh(0.0, 0.0, 200.0, 200.0);
170
+ let mut c = chooser();
171
+ let cells = c.cells(rect, &theme);
172
+ assert!(c.click_at(rect, &theme, cells[2].center()));
173
+ assert_eq!(c.selected(), 2);
174
+ assert_eq!(c.selected_color(), Some(c.colors()[2]));
175
+ // Ponowny klik w tę samą próbkę nic nie zmienia.
176
+ assert!(!c.click_at(rect, &theme, cells[2].center()));
177
+ // Klik poza siatką nie zmienia wyboru.
178
+ assert!(!c.click_at(rect, &theme, Point::new(999.0, 999.0)));
179
+ }
180
+
181
+ #[test]
182
+ fn height_matches_grid_rows() {
183
+ let theme = Theme::light();
184
+ let c = chooser(); // 8 kolorów, 4 kolumny → 2 wiersze
185
+ let w = 200.0;
186
+ let side = (w - 3.0 * theme.spacing.sm) / 4.0;
187
+ let expected = 2.0 * side + theme.spacing.sm;
188
+ assert!((c.height(w, &theme) - expected).abs() < 1e-9);
189
+ }
190
+
191
+ #[test]
192
+ fn selection_out_of_range_is_ignored() {
193
+ let mut c = chooser();
194
+ c.set_selected(99);
195
+ assert_eq!(c.selected(), 0);
196
+ }
197
+}
@@ -52,6 +52,7 @@
52
52
//! - [`MenuButton`], [`SearchBar`], [`Expander`], [`Statusbar`], [`Calendar`] — kolejne widgety GTK4.
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
+//! - [`Scrollbar`]/[`Adjustment`], [`ColorChooser`] — przewijanie (obie osie) i paleta kolorów.
55
56
//! - Aliasy w duchu GTK4: [`Carousel`] = [`Pager`], [`ScrolledWindow`] = [`ScrollView`],
56
57
//! [`StackSwitcher`] = [`ViewSwitcher`], [`Notebook`] = [`TabView`], [`ListBox`] = [`NavList`].
57
58
//! - [`Carousel`] (alias [`Pager`]), [`ToolbarView`], [`WindowTitle`], [`Clamp`], [`WrapBox`],
@@ -78,6 +79,7 @@ pub mod buttons;
78
79
pub mod calendar;
79
80
pub mod card;
80
81
pub mod check_button;
82
+pub mod color_chooser;
81
83
pub mod column_view;
82
84
pub mod containers;
83
85
pub mod ctx;
@@ -104,6 +106,7 @@ pub mod preferences;
104
106
pub mod radio_group;
105
107
pub mod rows;
106
108
pub mod scroll_view;
109
+pub mod scrollbar;
107
110
pub mod search_bar;
108
111
pub mod search_entry;
109
112
pub mod shortcut_label;
@@ -133,6 +136,7 @@ pub use buttons::{ColorButton, LinkButton, ToggleButton};
133
136
pub use calendar::Calendar;
134
137
pub use card::Card;
135
138
pub use check_button::CheckButton;
139
+pub use color_chooser::ColorChooser;
136
140
pub use column_view::{Column, ColumnView};
137
141
pub use containers::{ActionBar, CenterBox, Fixed, Grid, Paned, PopoverMenuBar, Stack};
138
142
pub use ctx::PaintCtx;
@@ -172,6 +176,7 @@ pub use rows::{Row, RowRects, RowSuffix};
172
176
pub use scroll_view::ScrollView;
173
177
/// `ScrolledWindow` — nazwa [`ScrollView`] w duchu GTK4.
174
178
pub use scroll_view::ScrollView as ScrolledWindow;
179
+pub use scrollbar::{Adjustment, Scrollbar};
175
180
pub use search_bar::SearchBar;
176
181
pub use search_entry::SearchEntry;
177
182
pub use shortcut_label::ShortcutLabel;
178
183
new file mode 100644
@@ -0,0 +1,380 @@
1
+//! `Scrollbar` + `Adjustment` — pasek przewijania i model zakresu (GTK4, w duchu libLuny).
2
+//!
3
+//! [`Adjustment`] to mały model zakresu (`lower`..`upper` z oknem `page`), a [`Scrollbar`]
4
+//! go prezentuje: liczy geometrię „kciuka”, obsługuje przeciąganie i rolkę. Działa w obu
5
+//! orientacjach, więc uzupełnia pionowy [`ScrollView`](crate::ScrollView) o wariant poziomy.
6
+
7
+use libluna_core::{Insets, Point, Rect, Theme};
8
+
9
+use crate::ctx::PaintCtx;
10
+use crate::display::Orientation;
11
+
12
+/// Model zakresu przewijania (`lower`..`upper`, widoczne okno `page`).
13
+#[derive(Debug, Clone, Copy, PartialEq, Default)]
14
+pub struct Adjustment {
15
+ value: f64,
16
+ lower: f64,
17
+ upper: f64,
18
+ page: f64,
19
+}
20
+
21
+impl Adjustment {
22
+ /// Zakres `lower..upper` z oknem `page`, ustawiony na `lower`.
23
+ pub fn new(lower: f64, upper: f64, page: f64) -> Self {
24
+ Self {
25
+ value: lower,
26
+ lower,
27
+ upper,
28
+ page: page.max(0.0),
29
+ }
30
+ }
31
+
32
+ pub fn value(&self) -> f64 {
33
+ self.value
34
+ }
35
+
36
+ pub fn lower(&self) -> f64 {
37
+ self.lower
38
+ }
39
+
40
+ pub fn upper(&self) -> f64 {
41
+ self.upper
42
+ }
43
+
44
+ pub fn page_size(&self) -> f64 {
45
+ self.page
46
+ }
47
+
48
+ /// Maksymalna wartość (`upper - page`, nie mniej niż `lower`).
49
+ pub fn max_value(&self) -> f64 {
50
+ (self.upper - self.page).max(self.lower)
51
+ }
52
+
53
+ /// Czy jest co przewijać.
54
+ pub fn is_scrollable(&self) -> bool {
55
+ self.max_value() > self.lower + 1e-9
56
+ }
57
+
58
+ /// Ustawia wartość (przyciętą do zakresu).
59
+ pub fn set_value(&mut self, value: f64) {
60
+ self.value = value.clamp(self.lower, self.max_value());
61
+ }
62
+
63
+ /// Zmienia zakres (wartość przycinana do nowego zakresu).
64
+ pub fn set_bounds(&mut self, lower: f64, upper: f64, page: f64) {
65
+ self.lower = lower;
66
+ self.upper = upper;
67
+ self.page = page.max(0.0);
68
+ self.set_value(self.value);
69
+ }
70
+
71
+ /// Postęp przewinięcia w zakresie 0..1.
72
+ pub fn fraction(&self) -> f64 {
73
+ let span = self.max_value() - self.lower;
74
+ if span > 0.0 {
75
+ (self.value - self.lower) / span
76
+ } else {
77
+ 0.0
78
+ }
79
+ }
80
+
81
+ /// Ustawia przewinięcie z postępu 0..1.
82
+ pub fn set_fraction(&mut self, fraction: f64) {
83
+ let span = self.max_value() - self.lower;
84
+ self.set_value(self.lower + span * fraction.clamp(0.0, 1.0));
85
+ }
86
+
87
+ /// Przewija o `delta` jednostek (przycinane).
88
+ pub fn scroll_by(&mut self, delta: f64) {
89
+ self.set_value(self.value + delta);
90
+ }
91
+}
92
+
93
+/// Pasek przewijania (pionowy lub poziomy).
94
+#[derive(Debug, Clone, Copy, Default)]
95
+pub struct Scrollbar {
96
+ orientation: Orientation,
97
+ adjustment: Adjustment,
98
+ hovered: bool,
99
+ dragging: bool,
100
+ drag_grab: Option<f64>,
101
+}
102
+
103
+impl Scrollbar {
104
+ pub fn new(orientation: Orientation) -> Self {
105
+ Self {
106
+ orientation,
107
+ ..Self::default()
108
+ }
109
+ }
110
+
111
+ pub fn orientation(&self) -> Orientation {
112
+ self.orientation
113
+ }
114
+
115
+ pub fn adjustment(&self) -> &Adjustment {
116
+ &self.adjustment
117
+ }
118
+
119
+ pub fn adjustment_mut(&mut self) -> &mut Adjustment {
120
+ &mut self.adjustment
121
+ }
122
+
123
+ pub fn set_adjustment(&mut self, adjustment: Adjustment) {
124
+ self.adjustment = adjustment;
125
+ }
126
+
127
+ pub fn set_hovered(&mut self, hovered: bool) {
128
+ self.hovered = hovered;
129
+ }
130
+
131
+ pub fn is_dragging(&self) -> bool {
132
+ self.dragging
133
+ }
134
+
135
+ fn track_length(&self, viewport: Rect) -> f64 {
136
+ match self.orientation {
137
+ Orientation::Vertical => viewport.height(),
138
+ Orientation::Horizontal => viewport.width(),
139
+ }
140
+ }
141
+
142
+ /// Tor paska wewnątrz `viewport`.
143
+ pub fn track_rect(&self, viewport: Rect, theme: &Theme) -> Rect {
144
+ let m = theme.metrics.scrollview;
145
+ match self.orientation {
146
+ Orientation::Vertical => Rect::from_xywh(
147
+ viewport.right() - m.margin - m.thickness,
148
+ viewport.y(),
149
+ m.thickness,
150
+ viewport.height(),
151
+ ),
152
+ Orientation::Horizontal => Rect::from_xywh(
153
+ viewport.x(),
154
+ viewport.bottom() - m.margin - m.thickness,
155
+ viewport.width(),
156
+ m.thickness,
157
+ ),
158
+ }
159
+ }
160
+
161
+ /// Prostokąt „kciuka” (albo `None`, gdy nie ma czego przewijać).
162
+ pub fn thumb_rect(&self, viewport: Rect, theme: &Theme) -> Option<Rect> {
163
+ if !self.adjustment.is_scrollable() {
164
+ return None;
165
+ }
166
+ let m = theme.metrics.scrollview;
167
+ let track = self.track_length(viewport);
168
+ let content = (self.adjustment.upper() - self.adjustment.lower()).max(1e-9);
169
+ let frac = (self.adjustment.page_size() / content).clamp(0.0, 1.0);
170
+ let thumb_len = (track * frac).clamp(m.min_thumb.min(track), track);
171
+ let t = self.adjustment.fraction();
172
+ match self.orientation {
173
+ Orientation::Vertical => {
174
+ let y = viewport.y() + t * (track - thumb_len);
175
+ Some(Rect::from_xywh(
176
+ viewport.right() - m.margin - m.thickness,
177
+ y,
178
+ m.thickness,
179
+ thumb_len,
180
+ ))
181
+ }
182
+ Orientation::Horizontal => {
183
+ let x = viewport.x() + t * (track - thumb_len);
184
+ Some(Rect::from_xywh(
185
+ x,
186
+ viewport.bottom() - m.margin - m.thickness,
187
+ thumb_len,
188
+ m.thickness,
189
+ ))
190
+ }
191
+ }
192
+ }
193
+
194
+ fn hit_pad(&self, theme: &Theme) -> Insets {
195
+ let t = theme.metrics.scrollview.thickness;
196
+ match self.orientation {
197
+ Orientation::Vertical => Insets::new(0.0, t, 0.0, 4.0),
198
+ Orientation::Horizontal => Insets::new(0.0, 4.0, 0.0, t),
199
+ }
200
+ }
201
+
202
+ /// Czy punkt trafia w „kciuk”.
203
+ pub fn thumb_at(&self, viewport: Rect, theme: &Theme, p: Point) -> bool {
204
+ self.thumb_rect(viewport, theme)
205
+ .is_some_and(|t| t.inflate(self.hit_pad(theme)).contains(p))
206
+ }
207
+
208
+ /// Przewija o `pixels` pikseli toru; zwraca `true`, gdy da się przewijać.
209
+ pub fn on_scroll(&mut self, pixels: f64, viewport: Rect) -> bool {
210
+ if !self.adjustment.is_scrollable() {
211
+ return false;
212
+ }
213
+ let track = self.track_length(viewport);
214
+ if track <= 0.0 {
215
+ return false;
216
+ }
217
+ let frac = pixels / track;
218
+ self.adjustment
219
+ .set_fraction(self.adjustment.fraction() + frac);
220
+ true
221
+ }
222
+
223
+ /// Rozpoczyna przeciąganie „kciuka” (albo skok w tor).
224
+ pub fn begin_drag(&mut self, p: Point, viewport: Rect, theme: &Theme) -> bool {
225
+ if !self.adjustment.is_scrollable() {
226
+ return false;
227
+ }
228
+ if let Some(thumb) = self.thumb_rect(viewport, theme) {
229
+ let (along, thumb_start, thumb_len) = match self.orientation {
230
+ Orientation::Vertical => (p.y, thumb.y(), thumb.height()),
231
+ Orientation::Horizontal => (p.x, thumb.x(), thumb.width()),
232
+ };
233
+ if thumb.inflate(self.hit_pad(theme)).contains(p) {
234
+ self.drag_grab = Some(along - thumb_start);
235
+ self.dragging = true;
236
+ return true;
237
+ }
238
+ // Klik w tor poza kciukiem: wyśrodkuj kciuk i zacznij przeciąganie.
239
+ if self.track_rect(viewport, theme).contains(p) {
240
+ let t = (along - thumb_start - thumb_len / 2.0).max(0.0);
241
+ let track = self.track_length(viewport);
242
+ let denom = (track - thumb_len).max(1e-9);
243
+ self.adjustment.set_fraction(t / denom);
244
+ self.drag_grab = Some(thumb_len / 2.0);
245
+ self.dragging = true;
246
+ return true;
247
+ }
248
+ }
249
+ false
250
+ }
251
+
252
+ /// Aktualizuje przeciąganie „kciuka”.
253
+ pub fn drag_to(&mut self, p: Point, viewport: Rect, theme: &Theme) {
254
+ if !self.dragging {
255
+ return;
256
+ }
257
+ let Some(thumb) = self.thumb_rect(viewport, theme) else {
258
+ return;
259
+ };
260
+ let track = self.track_length(viewport);
261
+ let thumb_len = match self.orientation {
262
+ Orientation::Vertical => thumb.height(),
263
+ Orientation::Horizontal => thumb.width(),
264
+ };
265
+ let (along, start) = match self.orientation {
266
+ Orientation::Vertical => (p.y, viewport.y()),
267
+ Orientation::Horizontal => (p.x, viewport.x()),
268
+ };
269
+ let grab = self.drag_grab.unwrap_or(0.0);
270
+ let denom = (track - thumb_len).max(1e-9);
271
+ self.adjustment
272
+ .set_fraction(((along - grab - start) / denom).clamp(0.0, 1.0));
273
+ }
274
+
275
+ /// Kończy przeciąganie.
276
+ pub fn end_drag(&mut self) {
277
+ self.dragging = false;
278
+ self.drag_grab = None;
279
+ }
280
+
281
+ /// Rysuje „kciuk” (jeśli jest co przewijać).
282
+ pub fn paint(&self, ctx: &mut PaintCtx, viewport: Rect) {
283
+ let theme = *ctx.theme;
284
+ if let Some(t) = self.thumb_rect(viewport, &theme) {
285
+ let alpha = if self.dragging {
286
+ 0.7
287
+ } else if self.hovered {
288
+ 0.55
289
+ } else {
290
+ 0.45
291
+ };
292
+ let radius = match self.orientation {
293
+ Orientation::Vertical => t.width() / 2.0,
294
+ Orientation::Horizontal => t.height() / 2.0,
295
+ };
296
+ ctx.scene
297
+ .fill_rounded_rect(t, radius, theme.colors.dim_text.scale_alpha(alpha));
298
+ }
299
+ }
300
+}
301
+
302
+#[cfg(test)]
303
+mod tests {
304
+ use super::*;
305
+
306
+ fn viewport() -> Rect {
307
+ Rect::from_xywh(0.0, 0.0, 200.0, 100.0)
308
+ }
309
+
310
+ #[test]
311
+ fn adjustment_fraction_and_clamping() {
312
+ let mut a = Adjustment::new(0.0, 100.0, 25.0);
313
+ assert_eq!(a.max_value(), 75.0);
314
+ a.set_value(999.0);
315
+ assert_eq!(a.value(), 75.0);
316
+ assert_eq!(a.fraction(), 1.0);
317
+ a.set_fraction(0.5);
318
+ assert_eq!(a.value(), 37.5);
319
+ a.set_value(-10.0);
320
+ assert_eq!(a.value(), 0.0);
321
+ }
322
+
323
+ #[test]
324
+ fn not_scrollable_when_page_covers_range() {
325
+ let a = Adjustment::new(0.0, 100.0, 120.0);
326
+ assert!(!a.is_scrollable());
327
+ let theme = Theme::light();
328
+ let mut s = Scrollbar::new(Orientation::Vertical);
329
+ s.set_adjustment(a);
330
+ assert!(s.thumb_rect(viewport(), &theme).is_none());
331
+ assert!(!s.on_scroll(50.0, viewport()));
332
+ }
333
+
334
+ #[test]
335
+ fn thumb_length_tracks_page_size() {
336
+ let theme = Theme::light();
337
+ let mut s = Scrollbar::new(Orientation::Vertical);
338
+ s.set_adjustment(Adjustment::new(0.0, 200.0, 100.0));
339
+ let t = s.thumb_rect(viewport(), &theme).unwrap();
340
+ assert!((t.height() - 50.0).abs() < 1e-9);
341
+ // Na końcu kciuk dojeżdża do dołu toru.
342
+ s.adjustment_mut().set_fraction(1.0);
343
+ let t2 = s.thumb_rect(viewport(), &theme).unwrap();
344
+ assert!((t2.bottom() - viewport().bottom()).abs() < 1e-9);
345
+ }
346
+
347
+ #[test]
348
+ fn horizontal_thumb_moves_along_x() {
349
+ let theme = Theme::light();
350
+ let mut s = Scrollbar::new(Orientation::Horizontal);
351
+ s.set_adjustment(Adjustment::new(0.0, 200.0, 100.0));
352
+ let t = s.thumb_rect(viewport(), &theme).unwrap();
353
+ assert!(t.x() >= viewport().x());
354
+ s.adjustment_mut().set_fraction(1.0);
355
+ let t2 = s.thumb_rect(viewport(), &theme).unwrap();
356
+ assert!((t2.right() - viewport().right()).abs() < 1e-9);
357
+ }
358
+
359
+ #[test]
360
+ fn drag_moves_to_end() {
361
+ let theme = Theme::light();
362
+ let mut s = Scrollbar::new(Orientation::Vertical);
363
+ s.set_adjustment(Adjustment::new(0.0, 200.0, 100.0));
364
+ let t = s.thumb_rect(viewport(), &theme).unwrap();
365
+ assert!(s.begin_drag(t.center(), viewport(), &theme));
366
+ assert!(s.is_dragging());
367
+ s.drag_to(Point::new(t.center().x, 500.0), viewport(), &theme);
368
+ assert_eq!(s.adjustment().fraction(), 1.0);
369
+ s.end_drag();
370
+ assert!(!s.is_dragging());
371
+ }
372
+
373
+ #[test]
374
+ fn scroll_pixels_change_fraction() {
375
+ let mut s = Scrollbar::new(Orientation::Vertical);
376
+ s.set_adjustment(Adjustment::new(0.0, 200.0, 100.0));
377
+ assert!(s.on_scroll(25.0, viewport())); // 25/100 toru → +0.25
378
+ assert!((s.adjustment().fraction() - 0.25).abs() < 1e-9);
379
+ }
380
+}
@@ -50,14 +50,16 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
50
50
`PreferencesPage`; `Dialog` + `AlertDialog`/`AboutDialog`/`ShortcutsDialog`/`PreferencesDialog`;
51
51
`NavigationView`/`NavigationSplitView`/`OverlaySplitView`/`Sidebar` (`Carousel` = `Pager`);
52
52
`ToolbarView`, `WindowTitle`, `Clamp`, `WrapBox`, `BreakpointBin`, `ButtonContent`.
53
-- **Galeria GTK4 pokrywana partiami** (partia 5–9): prezentacja `Label`, `Separator`,
53
+- **Galeria GTK4 pokrywana partiami** (partia 5–10): prezentacja `Label`, `Separator`,
54
54
`ProgressBar`, `LevelBar`, `Frame`, `Revealer`, `Overlay`, `AspectFrame`; przyciski
55
55
`ToggleButton`, `LinkButton`, `ColorButton`; pola specjalne `PasswordEntry` (maskowanie +
56
56
„oko”) i `EditableLabel`; kontenery `CenterBox`, `Grid`, `Fixed`, `Paned`, `Stack`,
57
57
`ActionBar`, `PopoverMenuBar`; dalej `MenuButton`, `SearchBar`, `Expander`, `Statusbar`,
58
- `Calendar`, `Image`, `TextView`, `Popover`, `ColumnView`, `Assistant`; aliasy GTK4
59
- `ScrolledWindow` = `ScrollView`, `StackSwitcher` = `ViewSwitcher`, `Notebook` = `TabView`,
60
- `ListBox` = `NavList`. Tokeny metryk kontenerów: `Theme::metrics.container`.
58
+ `Calendar`, `Image`, `TextView`, `Popover`, `ColumnView`, `Assistant`, `Scrollbar`/
59
+ `Adjustment`, `ColorChooser`; aliasy GTK4 `ScrolledWindow` = `ScrollView`,
60
+ `StackSwitcher` = `ViewSwitcher`, `Notebook` = `TabView`, `ListBox` = `NavList`.
61
+ Tokeny metryk kontenerów: `Theme::metrics.container`. Animacje: `libluna-core` `motion`
62
+ (`Easing`, `lerp`).
61
63
- `libluna-platform`: realne akcje okna z `Frame` (`close`/`minimize`/`toggle_maximize`).
62
64
- Demo używa widgetów zamiast ręcznego rysowania (pasek boczny, wybór motywu/widoku, menu `☰`,
63
65
przycisk „Opcje”, przełączniki, suwak, wybór języka, pasek tytułu, zakładki `TabBar`/`TabView`).