← luna

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