Commit 99899a9
8
files
+864
added
-2
deleted
@@ -355,7 +355,9 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
355
355
`SearchBar`, `Expander`, `Statusbar`, `Calendar`, `Image` (ikony wektorowe),
356
356
`TextView` (pole wielolinijkowe), `Popover` (generyczny), `ColumnView` (tabela),
357
357
`Assistant` (kreator kroków), `Scrollbar`/`Adjustment` (przewijanie w obu osiach),
358
- `ColorChooser` (paleta); aliasy `ScrolledWindow` = `ScrollView`,
358
+ `ColorChooser` (paleta), `FlowBox` (zawijająca siatka z wyborem), `SpinButton` (pole liczbowe),
359
+ `EmojiChooser` (siatka emoji), `NavigationRail` (kompaktowa szyna nawigacji);
360
+ aliasy `ScrolledWindow` = `ScrollView`,
359
361
`StackSwitcher` = `ViewSwitcher`, `Notebook` = `TabView`, `ListBox` = `NavList`.
360
362
**Animacje wcielone w widgety**: przejścia `Stack` (slide/fade przez `StackTransition`),
361
363
płynny `Revealer` i `Toast` (wjazd + fade) — sterowane z aplikacji przez `advance(dt, …)`.
362
364
new file mode 100644
@@ -0,0 +1,187 @@
1
+//! `EmojiChooser` — wybór emoji z siatki (GTK4, w duchu libLuny).
2
+//!
3
+//! Prosta, samowystarczalna siatka glifów emoji: wybór jednego z nich. To nie jest pełny
4
+//! `GtkEmojiChooser` (bez kategorii i wyszukiwania) — libLuna daje zwięzłą paletę znaków.
5
+
6
+use libluna_core::{Point, Rect, Theme};
7
+
8
+use crate::ctx::PaintCtx;
9
+
10
+/// Siatka emoji.
11
+#[derive(Debug, Clone, Default)]
12
+pub struct EmojiChooser {
13
+ emojis: Vec<String>,
14
+ columns: usize,
15
+ selected: usize,
16
+ hovered: Option<usize>,
17
+}
18
+
19
+impl EmojiChooser {
20
+ /// Krótka, domyślna paleta emoji (do szybkiego wyboru).
21
+ pub fn default_palette() -> Vec<String> {
22
+ [
23
+ "😀", "😄", "😉", "😍", "🤔", "😎", "🥳", "😴", "👍", "👏", "🙏", "💪", "🔥", "✨",
24
+ "🎉", "❤️", "⭐", "✅", "⚠️", "💡", "📁", "📄", "🔍", "🔒",
25
+ ]
26
+ .into_iter()
27
+ .map(String::from)
28
+ .collect()
29
+ }
30
+
31
+ /// Siatka z listy znaków (4 kolumny, wybrany pierwszy).
32
+ pub fn new(emojis: Vec<String>) -> Self {
33
+ Self {
34
+ emojis,
35
+ columns: 4,
36
+ selected: 0,
37
+ hovered: None,
38
+ }
39
+ }
40
+
41
+ pub fn emojis(&self) -> &[String] {
42
+ &self.emojis
43
+ }
44
+
45
+ pub fn columns(&self) -> usize {
46
+ self.columns
47
+ }
48
+
49
+ pub fn set_columns(&mut self, columns: usize) {
50
+ self.columns = columns.max(1);
51
+ }
52
+
53
+ pub fn selected(&self) -> usize {
54
+ self.selected
55
+ }
56
+
57
+ pub fn selected_emoji(&self) -> Option<&str> {
58
+ self.emojis.get(self.selected).map(String::as_str)
59
+ }
60
+
61
+ pub fn set_selected(&mut self, index: usize) {
62
+ if index < self.emojis.len() {
63
+ self.selected = index;
64
+ }
65
+ }
66
+
67
+ pub fn set_hovered(&mut self, hovered: Option<usize>) {
68
+ self.hovered = hovered.filter(|i| *i < self.emojis.len());
69
+ }
70
+
71
+ /// Prostokąty komórek (kwadraty wypełniające szerokość, wierszami).
72
+ pub fn cells(&self, rect: Rect, theme: &Theme) -> Vec<Rect> {
73
+ if self.emojis.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.emojis
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
+ pub fn index_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<usize> {
96
+ self.cells(rect, theme).iter().position(|c| c.contains(p))
97
+ }
98
+
99
+ /// Obsługuje klik: wybiera emoji. Zwraca `true`, gdy wybór się zmienił.
100
+ pub fn click_at(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
101
+ match self.index_at(rect, theme, p) {
102
+ Some(i) if i != self.selected => {
103
+ self.selected = i;
104
+ true
105
+ }
106
+ _ => false,
107
+ }
108
+ }
109
+
110
+ /// Wysokość potrzebna na całą siatkę.
111
+ pub fn height(&self, width: f64, theme: &Theme) -> f64 {
112
+ if self.emojis.is_empty() {
113
+ return 0.0;
114
+ }
115
+ let cols = self.columns.max(1);
116
+ let gap = theme.spacing.sm;
117
+ let side = ((width - gap * (cols as f64 - 1.0)) / cols as f64).max(0.0);
118
+ let rows = self.emojis.len().div_ceil(cols) as f64;
119
+ rows * side + (rows - 1.0) * gap
120
+ }
121
+
122
+ /// Rysuje siatkę emoji; wybrana dostaje tło akcentu.
123
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
124
+ let theme = *ctx.theme;
125
+ let size = theme.typography.title.size;
126
+ for (i, cell) in self.cells(rect, &theme).iter().enumerate() {
127
+ let inset = cell.deflate(libluna_core::Insets::splat(2.0));
128
+ if i == self.selected {
129
+ ctx.scene
130
+ .fill_rounded_rect(inset, theme.radius.small, theme.colors.accent);
131
+ } else if self.hovered == Some(i) {
132
+ ctx.scene
133
+ .fill_rounded_rect(inset, theme.radius.small, theme.colors.shade);
134
+ }
135
+ ctx.label_center(
136
+ cell.center().x,
137
+ cell.center().y,
138
+ &self.emojis[i],
139
+ size,
140
+ libluna_core::style::weight::NORMAL,
141
+ theme.colors.text,
142
+ );
143
+ }
144
+ }
145
+}
146
+
147
+#[cfg(test)]
148
+mod tests {
149
+ use super::*;
150
+
151
+ fn chooser() -> EmojiChooser {
152
+ EmojiChooser::new(EmojiChooser::default_palette())
153
+ }
154
+
155
+ #[test]
156
+ fn cells_grid_row_major() {
157
+ let theme = Theme::light();
158
+ let c = chooser();
159
+ let rect = Rect::from_xywh(0.0, 0.0, 200.0, 200.0);
160
+ let cells = c.cells(rect, &theme);
161
+ assert_eq!(cells.len(), 24);
162
+ assert_eq!(cells[4].x(), cells[0].x());
163
+ assert!(cells[4].y() > cells[0].y());
164
+ }
165
+
166
+ #[test]
167
+ fn click_selects_emoji() {
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[1].center()));
173
+ assert_eq!(c.selected_emoji(), Some("😄"));
174
+ assert!(!c.click_at(rect, &theme, cells[1].center()));
175
+ assert!(!c.click_at(rect, &theme, Point::new(999.0, 999.0)));
176
+ }
177
+
178
+ #[test]
179
+ fn height_matches_rows() {
180
+ let theme = Theme::light();
181
+ let c = chooser(); // 24 emoji, 4 kolumny → 6 wierszy
182
+ let w = 200.0;
183
+ let side = (w - 3.0 * theme.spacing.sm) / 4.0;
184
+ let expected = 6.0 * side + 5.0 * theme.spacing.sm;
185
+ assert!((c.height(w, &theme) - expected).abs() < 1e-9);
186
+ }
187
+}
0
188
new file mode 100644
@@ -0,0 +1,161 @@
1
+//! `FlowBox` — zawijająca siatka o stałych komórkach z wyborem (GTK4, w duchu libLuny).
2
+//!
3
+//! W odróżnieniu od [`WrapBox`](crate::WrapBox) (dowolne naturalne rozmiary), `FlowBox` ma
4
+//! **jednakowe komórki** i wybiera jedną z nich — przydatne np. do siatki skrótów czy emoji.
5
+//! Zawijanie liczy [`WrapBox`], a `FlowBox` dokłada stan zaznaczenia i tło komórek.
6
+
7
+use libluna_core::{Point, Rect, Size};
8
+
9
+use crate::ctx::PaintCtx;
10
+use crate::helpers::WrapBox;
11
+
12
+/// Zawijająca siatka jednakowych komórek.
13
+#[derive(Debug, Clone, Default)]
14
+pub struct FlowBox {
15
+ cell: Size,
16
+ spacing: f64,
17
+ line_spacing: f64,
18
+ count: usize,
19
+ selected: Option<usize>,
20
+ hovered: Option<usize>,
21
+}
22
+
23
+impl FlowBox {
24
+ /// Siatka o komórkach `cell × cell`.
25
+ pub fn new(cell: f64) -> Self {
26
+ Self {
27
+ cell: Size::new(cell, cell),
28
+ spacing: 8.0,
29
+ line_spacing: 8.0,
30
+ ..Self::default()
31
+ }
32
+ }
33
+
34
+ pub fn spacing(mut self, spacing: f64) -> Self {
35
+ self.spacing = spacing;
36
+ self
37
+ }
38
+
39
+ pub fn line_spacing(mut self, spacing: f64) -> Self {
40
+ self.line_spacing = spacing;
41
+ self
42
+ }
43
+
44
+ pub fn count(&self) -> usize {
45
+ self.count
46
+ }
47
+
48
+ pub fn set_count(&mut self, count: usize) {
49
+ self.count = count;
50
+ if self.selected.is_some_and(|s| s >= count) {
51
+ self.selected = None;
52
+ }
53
+ }
54
+
55
+ pub fn selected(&self) -> Option<usize> {
56
+ self.selected
57
+ }
58
+
59
+ pub fn set_selected(&mut self, index: Option<usize>) {
60
+ self.selected = index.filter(|i| *i < self.count);
61
+ }
62
+
63
+ pub fn set_hovered(&mut self, hovered: Option<usize>) {
64
+ self.hovered = hovered.filter(|i| *i < self.count);
65
+ }
66
+
67
+ fn wrap(&self) -> WrapBox {
68
+ WrapBox::new()
69
+ .spacing(self.spacing)
70
+ .line_spacing(self.line_spacing)
71
+ }
72
+
73
+ /// Prostokąty kolejnych komórek (zawinięte do szerokości `rect`).
74
+ pub fn cells(&self, rect: Rect) -> Vec<Rect> {
75
+ let sizes = vec![self.cell; self.count];
76
+ self.wrap().layout(rect, &sizes)
77
+ }
78
+
79
+ /// Indeks komórki pod punktem `p` (lub `None`).
80
+ pub fn index_at(&self, rect: Rect, p: Point) -> Option<usize> {
81
+ self.cells(rect).iter().position(|c| c.contains(p))
82
+ }
83
+
84
+ /// Obsługuje klik: ustawia wybór. Zwraca `true`, gdy wybór się zmienił.
85
+ pub fn click_at(&mut self, rect: Rect, p: Point) -> bool {
86
+ match self.index_at(rect, p) {
87
+ Some(i) if self.selected != Some(i) => {
88
+ self.selected = Some(i);
89
+ true
90
+ }
91
+ _ => false,
92
+ }
93
+ }
94
+
95
+ /// Wysokość potrzebna na zawinięcie wszystkich komórek w szerokości `width`.
96
+ pub fn height(&self, width: f64) -> f64 {
97
+ let sizes = vec![self.cell; self.count];
98
+ self.wrap().height(width, &sizes)
99
+ }
100
+
101
+ /// Rysuje tło zaznaczonej/podświetlonej komórki (treść rysuje aplikacja).
102
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
103
+ let theme = *ctx.theme;
104
+ for (i, cell) in self.cells(rect).iter().enumerate() {
105
+ if self.selected == Some(i) {
106
+ ctx.scene.fill_rounded_rect(
107
+ *cell,
108
+ theme.radius.medium,
109
+ theme.colors.shade.scale_alpha(1.8),
110
+ );
111
+ ctx.scene
112
+ .stroke_rounded_rect(*cell, theme.radius.medium, 1.0, theme.colors.accent);
113
+ } else if self.hovered == Some(i) {
114
+ ctx.scene
115
+ .fill_rounded_rect(*cell, theme.radius.medium, theme.colors.shade);
116
+ }
117
+ }
118
+ }
119
+}
120
+
121
+#[cfg(test)]
122
+mod tests {
123
+ use super::*;
124
+
125
+ fn rect() -> Rect {
126
+ Rect::from_xywh(0.0, 0.0, 300.0, 200.0)
127
+ }
128
+
129
+ #[test]
130
+ fn cells_wrap_into_lines() {
131
+ let mut f = FlowBox::new(64.0).spacing(10.0).line_spacing(10.0);
132
+ f.set_count(10);
133
+ let cells = f.cells(rect());
134
+ assert_eq!(cells.len(), 10);
135
+ // 300 px: 4 komórki w linii (4*64 + 3*10 = 286) → 10 komórek to 3 linie.
136
+ assert_eq!(cells[0].y(), cells[3].y());
137
+ assert!(cells[4].y() > cells[3].y());
138
+ assert_eq!(f.height(300.0), 3.0 * 64.0 + 2.0 * 10.0);
139
+ }
140
+
141
+ #[test]
142
+ fn click_selects_cell() {
143
+ let mut f = FlowBox::new(50.0);
144
+ f.set_count(6);
145
+ let cells = f.cells(rect());
146
+ assert!(f.click_at(rect(), cells[2].center()));
147
+ assert_eq!(f.selected(), Some(2));
148
+ assert!(!f.click_at(rect(), cells[2].center()));
149
+ assert!(!f.click_at(rect(), Point::new(-5.0, -5.0)));
150
+ }
151
+
152
+ #[test]
153
+ fn selection_clamps_when_count_shrinks() {
154
+ let mut f = FlowBox::new(50.0);
155
+ f.set_count(6);
156
+ f.set_selected(Some(5));
157
+ assert_eq!(f.selected(), Some(5));
158
+ f.set_count(3);
159
+ assert_eq!(f.selected(), None);
160
+ }
161
+}
@@ -20,6 +20,22 @@ pub fn chevron_down(paint: &mut PaintCtx, color: Color, center: Point, size: f64
20
20
);
21
21
}
22
22
23
+/// Strzałka w górę (chevron); środek w `center`, rozpiętość `size`, grubość `width`.
24
+pub fn chevron_up(paint: &mut PaintCtx, color: Color, center: Point, size: f64, width: f64) {
25
+ paint.scene.stroke_line(
26
+ Point::new(center.x - size, center.y + size * 0.5),
27
+ Point::new(center.x, center.y - size * 0.5),
28
+ width,
29
+ color,
30
+ );
31
+ paint.scene.stroke_line(
32
+ Point::new(center.x, center.y - size * 0.5),
33
+ Point::new(center.x + size, center.y + size * 0.5),
34
+ width,
35
+ color,
36
+ );
37
+}
38
+
23
39
/// Strzałka w prawo (chevron); środek w `center`, rozpiętość `size`, grubość `width`.
24
40
pub fn chevron_right(paint: &mut PaintCtx, color: Color, center: Point, size: f64, width: f64) {
25
41
paint.scene.stroke_line(
@@ -55,6 +55,7 @@
55
55
//! - [`Scrollbar`]/[`Adjustment`], [`ColorChooser`] — przewijanie (obie osie) i paleta kolorów.
56
56
//! - Animacje (`libluna-core::motion`): `Easing`/`lerp`/`Animation`; użyte w [`Revealer`],
57
57
//! [`Toast`] i przejściach [`Stack`] ([`StackTransition`]).
58
+//! - [`FlowBox`], [`SpinButton`], [`EmojiChooser`], [`NavigationRail`] — kolejne widgety libLuny.
58
59
//! - Aliasy w duchu GTK4: [`Carousel`] = [`Pager`], [`ScrolledWindow`] = [`ScrollView`],
59
60
//! [`StackSwitcher`] = [`ViewSwitcher`], [`Notebook`] = [`TabView`], [`ListBox`] = [`NavList`].
60
61
//! - [`Carousel`] (alias [`Pager`]), [`ToolbarView`], [`WindowTitle`], [`Clamp`], [`WrapBox`],
@@ -88,9 +89,11 @@ pub mod ctx;
88
89
pub mod dialogs;
89
90
pub mod display;
90
91
pub mod dropdown;
92
+pub mod emoji_chooser;
91
93
pub mod entries;
92
94
pub mod entry;
93
95
pub mod expander;
96
+pub mod flow_box;
94
97
pub mod header_bar;
95
98
pub mod helpers;
96
99
pub mod icon;
@@ -101,6 +104,7 @@ pub mod menu;
101
104
pub mod menu_button;
102
105
pub mod nav_list;
103
106
pub mod navigation;
107
+pub mod navigation_rail;
104
108
pub mod pager;
105
109
pub mod path_bar;
106
110
pub mod popover;
@@ -113,6 +117,7 @@ pub mod search_bar;
113
117
pub mod search_entry;
114
118
pub mod shortcut_label;
115
119
pub mod slider;
120
+pub mod spin_button;
116
121
pub mod spinner;
117
122
pub mod split_button;
118
123
pub mod status_page;
@@ -152,9 +157,11 @@ pub use display::{
152
157
Separator,
153
158
};
154
159
pub use dropdown::DropDown;
160
+pub use emoji_chooser::EmojiChooser;
155
161
pub use entries::{EditableLabel, PasswordEntry};
156
162
pub use entry::{Entry, EntryIcon, TextInput};
157
163
pub use expander::Expander;
164
+pub use flow_box::FlowBox;
158
165
pub use header_bar::{ControlsLayout, ControlsSide, HeaderBar, TitleAlign, WindowControl};
159
166
pub use helpers::{
160
167
BreakpointBin, ButtonContent, Clamp, ToolbarRects, ToolbarView, WindowTitle, WindowTitleRects,
@@ -169,6 +176,7 @@ pub use menu_button::MenuButton;
169
176
pub use nav_list::NavList as ListBox;
170
177
pub use nav_list::{NavEntry, NavList, NavRow};
171
178
pub use navigation::{NavigationSplitView, NavigationView, OverlaySplitView, Sidebar};
179
+pub use navigation_rail::NavigationRail;
172
180
pub use pager::Pager;
173
181
/// `Carousel` — nazwa [`Pager`] w duchu libadwaita.
174
182
pub use pager::Pager as Carousel;
@@ -185,6 +193,7 @@ pub use search_bar::SearchBar;
185
193
pub use search_entry::SearchEntry;
186
194
pub use shortcut_label::ShortcutLabel;
187
195
pub use slider::Slider;
196
+pub use spin_button::{SpinArrow, SpinButton};
188
197
pub use spinner::Spinner;
189
198
pub use split_button::{SplitButton, SplitRects};
190
199
pub use status_page::{StatusIcon, StatusPage, StatusRects};
191
200
new file mode 100644
@@ -0,0 +1,209 @@
1
+//! `NavigationRail` — kompaktowa szyna nawigacji (pionowa lista pozycji z wyborem).
2
+//!
3
+//! Własny widget libLuny (nie klon `GtkXxx`): wąski pasek pozycji — w wersji pełnej z etykietą,
4
+//! a zwinięty (`collapsed`) z pojedynczym znakiem. Wybrana pozycja dostaje pasek-wskaźnik
5
+//! przy lewej krawędzi.
6
+
7
+use libluna_core::style::weight;
8
+use libluna_core::{Point, Rect, Theme};
9
+
10
+use crate::ctx::PaintCtx;
11
+
12
+/// Kompaktowa szyna nawigacji.
13
+#[derive(Debug, Clone, Default)]
14
+pub struct NavigationRail {
15
+ items: Vec<String>,
16
+ selected: usize,
17
+ hovered: Option<usize>,
18
+ collapsed: bool,
19
+}
20
+
21
+impl NavigationRail {
22
+ pub fn new<I, S>(items: I) -> Self
23
+ where
24
+ I: IntoIterator<Item = S>,
25
+ S: Into<String>,
26
+ {
27
+ Self {
28
+ items: items.into_iter().map(Into::into).collect(),
29
+ ..Self::default()
30
+ }
31
+ }
32
+
33
+ /// Zwinięta szyna (tylko inicjały zamiast etykiet).
34
+ pub fn collapsed(mut self, collapsed: bool) -> Self {
35
+ self.collapsed = collapsed;
36
+ self
37
+ }
38
+
39
+ pub fn items(&self) -> &[String] {
40
+ &self.items
41
+ }
42
+
43
+ pub fn is_collapsed(&self) -> bool {
44
+ self.collapsed
45
+ }
46
+
47
+ pub fn selected(&self) -> usize {
48
+ self.selected
49
+ }
50
+
51
+ pub fn selected_label(&self) -> Option<&str> {
52
+ self.items.get(self.selected).map(String::as_str)
53
+ }
54
+
55
+ pub fn set_selected(&mut self, index: usize) {
56
+ if index < self.items.len() {
57
+ self.selected = index;
58
+ }
59
+ }
60
+
61
+ pub fn set_hovered(&mut self, hovered: Option<usize>) {
62
+ self.hovered = hovered.filter(|i| *i < self.items.len());
63
+ }
64
+
65
+ /// Szerokość szyny wynikająca z trybu (zwinięta/pełna).
66
+ pub fn width(&self, theme: &Theme) -> f64 {
67
+ if self.collapsed {
68
+ theme.metrics.nav.item_h
69
+ } else {
70
+ theme.metrics.nav.item_h * 4.0
71
+ }
72
+ }
73
+
74
+ /// Wysokość jednej pozycji.
75
+ pub fn item_height(&self, theme: &Theme) -> f64 {
76
+ theme.metrics.nav.item_h
77
+ }
78
+
79
+ /// Prostokąt pozycji `index`.
80
+ pub fn item_rect(&self, rect: Rect, theme: &Theme, index: usize) -> Rect {
81
+ let h = self.item_height(theme);
82
+ Rect::from_xywh(rect.x(), rect.y() + index as f64 * h, rect.width(), h)
83
+ }
84
+
85
+ /// Indeks pozycji pod punktem `p` (lub `None`).
86
+ pub fn index_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<usize> {
87
+ (0..self.items.len()).find(|&i| self.item_rect(rect, theme, i).contains(p))
88
+ }
89
+
90
+ /// Obsługuje klik: ustawia wybór. Zwraca `true`, gdy wybór się zmienił.
91
+ pub fn click_at(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
92
+ match self.index_at(rect, theme, p) {
93
+ Some(i) if i != self.selected => {
94
+ self.selected = i;
95
+ true
96
+ }
97
+ _ => false,
98
+ }
99
+ }
100
+
101
+ pub fn hover_at(&mut self, rect: Rect, theme: &Theme, p: Option<Point>) {
102
+ self.hovered = p.and_then(|p| self.index_at(rect, theme, p));
103
+ }
104
+
105
+ /// Rysuje szynę: tło, wskaźnik zaznaczenia i etykiety (lub inicjały).
106
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
107
+ let theme = *ctx.theme;
108
+ ctx.scene.fill_rect(rect, theme.colors.sidebar_bg);
109
+ let body = theme.typography.body.size;
110
+ let indicator_w = 3.0;
111
+ for (i, label) in self.items.iter().enumerate() {
112
+ let item = self.item_rect(rect, &theme, i);
113
+ let selected = i == self.selected;
114
+ if selected || self.hovered == Some(i) {
115
+ ctx.scene.fill_rounded_rect(
116
+ item.deflate(libluna_core::Insets::new(4.0, 6.0, 4.0, 6.0)),
117
+ theme.radius.small,
118
+ theme.colors.shade,
119
+ );
120
+ }
121
+ if selected {
122
+ let bar = Rect::from_xywh(
123
+ rect.x() + 2.0,
124
+ item.y() + 8.0,
125
+ indicator_w,
126
+ (item.height() - 16.0).max(0.0),
127
+ );
128
+ ctx.scene.fill_rounded_rect(bar, 1.5, theme.colors.accent);
129
+ }
130
+ let text = if self.collapsed {
131
+ label.chars().next().map(|c| c.to_uppercase().to_string())
132
+ } else {
133
+ Some(label.clone())
134
+ };
135
+ if let Some(text) = text {
136
+ let x = if self.collapsed {
137
+ item.center().x
138
+ } else {
139
+ item.x() + 16.0
140
+ };
141
+ ctx.label_left(
142
+ x,
143
+ item.center().y,
144
+ &text,
145
+ body,
146
+ weight::MEDIUM,
147
+ theme.colors.text,
148
+ );
149
+ }
150
+ }
151
+ }
152
+}
153
+
154
+#[cfg(test)]
155
+mod tests {
156
+ use super::*;
157
+
158
+ fn rail() -> NavigationRail {
159
+ NavigationRail::new(["Przegląd", "Sieć", "System"])
160
+ }
161
+
162
+ fn area() -> Rect {
163
+ Rect::from_xywh(0.0, 0.0, 200.0, 200.0)
164
+ }
165
+
166
+ #[test]
167
+ fn items_stack_vertically() {
168
+ let theme = Theme::light();
169
+ let r = rail();
170
+ let h = r.item_height(&theme);
171
+ let r0 = r.item_rect(area(), &theme, 0);
172
+ let r2 = r.item_rect(area(), &theme, 2);
173
+ assert_eq!(r0.height(), h);
174
+ assert_eq!(r2.y(), 2.0 * h);
175
+ }
176
+
177
+ #[test]
178
+ fn click_selects_item() {
179
+ let theme = Theme::light();
180
+ let mut r = rail();
181
+ let target = r.item_rect(area(), &theme, 1).center();
182
+ assert!(r.click_at(area(), &theme, target));
183
+ assert_eq!(r.selected(), 1);
184
+ assert_eq!(r.selected_label(), Some("Sieć"));
185
+ assert!(!r.click_at(area(), &theme, target));
186
+ }
187
+
188
+ #[test]
189
+ fn width_depends_on_mode() {
190
+ let theme = Theme::light();
191
+ let full = rail();
192
+ let collapsed = rail().collapsed(true);
193
+ assert!(collapsed.width(&theme) < full.width(&theme));
194
+ }
195
+
196
+ #[test]
197
+ fn hover_at_clears_outside() {
198
+ let theme = Theme::light();
199
+ let mut r = rail();
200
+ r.hover_at(
201
+ area(),
202
+ &theme,
203
+ Some(r.item_rect(area(), &theme, 0).center()),
204
+ );
205
+ assert_eq!(r.hovered, Some(0));
206
+ r.hover_at(area(), &theme, Some(Point::new(-10.0, -10.0)));
207
+ assert_eq!(r.hovered, None);
208
+ }
209
+}
0
210
new file mode 100644
@@ -0,0 +1,277 @@
1
+//! `SpinButton` — pole liczbowe z edycją tekstową i przyciskami `▲`/`▼` (GTK4, w duchu libLuny).
2
+//!
3
+//! Standalone wariant liczbowy: tekst edytuje [`Entry`], a strzałki po prawej zwiększają/
4
+//! zmniejszają wartość o krok. Wartość zmienia się dopiero po zatwierdzeniu edycji
5
+//! ([`SpinButton::commit`], np. na `Enter` albo utratę fokusu).
6
+
7
+use libluna_core::{Point, Rect, Theme};
8
+
9
+use crate::ctx::PaintCtx;
10
+use crate::entry::{Entry, TextInput};
11
+use crate::icon;
12
+
13
+/// Strzałka pola liczbowego.
14
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15
+pub enum SpinArrow {
16
+ Up,
17
+ Down,
18
+}
19
+
20
+/// Pole liczbowe.
21
+#[derive(Debug, Clone, Default)]
22
+pub struct SpinButton {
23
+ entry: Entry,
24
+ value: f64,
25
+ min: f64,
26
+ max: f64,
27
+ step: f64,
28
+ digits: usize,
29
+ arrows_hover: Option<SpinArrow>,
30
+}
31
+
32
+impl SpinButton {
33
+ /// Pole z wartością w zakresie `min..max`, krokiem 1 i bez miejsc po przecinku.
34
+ pub fn new(value: f64, min: f64, max: f64) -> Self {
35
+ let value = value.clamp(min, max);
36
+ let mut entry = Entry::new("");
37
+ entry.set_text(format_number(value, 0));
38
+ Self {
39
+ entry,
40
+ value,
41
+ min,
42
+ max,
43
+ step: 1.0,
44
+ digits: 0,
45
+ arrows_hover: None,
46
+ }
47
+ }
48
+
49
+ pub fn step(mut self, step: f64) -> Self {
50
+ self.step = step.abs().max(f64::EPSILON);
51
+ self
52
+ }
53
+
54
+ /// Liczba miejsc po przecinku (formatowanie wartości).
55
+ pub fn digits(mut self, digits: usize) -> Self {
56
+ self.digits = digits;
57
+ self.entry.set_text(format_number(self.value, digits));
58
+ self
59
+ }
60
+
61
+ pub fn value(&self) -> f64 {
62
+ self.value
63
+ }
64
+
65
+ pub fn min(&self) -> f64 {
66
+ self.min
67
+ }
68
+
69
+ pub fn max(&self) -> f64 {
70
+ self.max
71
+ }
72
+
73
+ pub fn is_focused(&self) -> bool {
74
+ self.entry.is_focused()
75
+ }
76
+
77
+ pub fn set_focused(&mut self, focused: bool) {
78
+ self.entry.set_focused(focused);
79
+ }
80
+
81
+ /// Ustawia wartość (przyciętą do zakresu) i Odświeża tekst.
82
+ pub fn set_value(&mut self, value: f64) {
83
+ self.value = value.clamp(self.min, self.max);
84
+ self.entry.set_text(format_number(self.value, self.digits));
85
+ }
86
+
87
+ pub fn inc(&mut self) {
88
+ self.set_value(self.value + self.step);
89
+ }
90
+
91
+ pub fn dec(&mut self) {
92
+ self.set_value(self.value - self.step);
93
+ }
94
+
95
+ /// Zatwierdza edycję tekstową: parsuje wpis i przycina do zakresu.
96
+ pub fn commit(&mut self) {
97
+ match self.entry.text().trim().parse::<f64>() {
98
+ Ok(v) => self.set_value(v),
99
+ Err(_) => self.entry.set_text(format_number(self.value, self.digits)),
100
+ }
101
+ }
102
+
103
+ pub fn height(&self, theme: &Theme) -> f64 {
104
+ theme.metrics.entry.height
105
+ }
106
+
107
+ /// Szerokość kolumny strzałek po prawej.
108
+ pub fn arrows_width(&self, theme: &Theme, rect: Rect) -> f64 {
109
+ theme
110
+ .metrics
111
+ .stepper
112
+ .button
113
+ .clamp(0.0, (rect.width() * 0.5).max(0.0))
114
+ }
115
+
116
+ /// Prostokąt strzałki `Up`/`Down`.
117
+ pub fn arrow_rect(&self, rect: Rect, theme: &Theme, arrow: SpinArrow) -> Rect {
118
+ let w = self.arrows_width(theme, rect);
119
+ let half = rect.height() / 2.0;
120
+ let y = match arrow {
121
+ SpinArrow::Up => rect.y(),
122
+ SpinArrow::Down => rect.y() + half,
123
+ };
124
+ Rect::from_xywh(rect.right() - w, y, w, half)
125
+ }
126
+
127
+ pub fn arrow_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<SpinArrow> {
128
+ [SpinArrow::Up, SpinArrow::Down]
129
+ .into_iter()
130
+ .find(|&a| self.arrow_rect(rect, theme, a).contains(p))
131
+ }
132
+
133
+ pub fn set_arrows_hover(&mut self, hover: Option<SpinArrow>) {
134
+ self.arrows_hover = hover;
135
+ }
136
+
137
+ /// Obsługuje klik w strzałkę: zmienia wartość. Zwraca `true`, gdy trafiono.
138
+ pub fn click_at(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
139
+ match self.arrow_at(rect, theme, p) {
140
+ Some(SpinArrow::Up) => {
141
+ self.inc();
142
+ true
143
+ }
144
+ Some(SpinArrow::Down) => {
145
+ self.dec();
146
+ true
147
+ }
148
+ None => false,
149
+ }
150
+ }
151
+
152
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
153
+ let theme = *ctx.theme;
154
+ let w = self.arrows_width(&theme, rect);
155
+ let mut entry = self.entry.clone();
156
+ entry.set_trailing_space(w + 6.0);
157
+ entry.paint(ctx, rect);
158
+
159
+ let m = theme.metrics.entry;
160
+ for arrow in [SpinArrow::Up, SpinArrow::Down] {
161
+ let ar = self.arrow_rect(rect, &theme, arrow);
162
+ if self.arrows_hover == Some(arrow) {
163
+ ctx.scene.fill_rounded_rect(
164
+ ar,
165
+ theme.radius.small,
166
+ theme.colors.shade.scale_alpha(1.8),
167
+ );
168
+ }
169
+ let c = ar.center();
170
+ let size = m.icon * 0.22;
171
+ match arrow {
172
+ SpinArrow::Up => icon::chevron_up(ctx, theme.colors.dim_text, c, size, 1.8),
173
+ SpinArrow::Down => icon::chevron_down(ctx, theme.colors.dim_text, c, size, 1.8),
174
+ }
175
+ }
176
+ // Cienka kreska oddzielająca strzałki od pola.
177
+ ctx.scene.fill_rect(
178
+ Rect::from_xywh(
179
+ rect.right() - w - 2.0,
180
+ rect.y() + 5.0,
181
+ 1.0,
182
+ (rect.height() - 10.0).max(0.0),
183
+ ),
184
+ theme.colors.border,
185
+ );
186
+ }
187
+}
188
+
189
+impl TextInput for SpinButton {
190
+ fn insert_str(&mut self, s: &str) {
191
+ self.entry.insert_str(s);
192
+ }
193
+ fn insert_char(&mut self, c: char) {
194
+ self.entry.insert_char(c);
195
+ }
196
+ fn backspace(&mut self) {
197
+ self.entry.backspace();
198
+ }
199
+ fn delete_forward(&mut self) {
200
+ self.entry.delete_forward();
201
+ }
202
+ fn move_left(&mut self) {
203
+ self.entry.move_left();
204
+ }
205
+ fn move_right(&mut self) {
206
+ self.entry.move_right();
207
+ }
208
+ fn home(&mut self) {
209
+ self.entry.home();
210
+ }
211
+ fn end(&mut self) {
212
+ self.entry.end();
213
+ }
214
+}
215
+
216
+fn format_number(value: f64, digits: usize) -> String {
217
+ if digits == 0 {
218
+ format!("{}", value.round() as i64)
219
+ } else {
220
+ format!("{value:.digits$}")
221
+ }
222
+}
223
+
224
+#[cfg(test)]
225
+mod tests {
226
+ use super::*;
227
+
228
+ fn rect() -> Rect {
229
+ Rect::from_xywh(0.0, 0.0, 120.0, 34.0)
230
+ }
231
+
232
+ #[test]
233
+ fn value_is_clamped_and_formatted() {
234
+ let mut s = SpinButton::new(5.0, 0.0, 10.0);
235
+ assert_eq!(s.value(), 5.0);
236
+ s.set_value(99.0);
237
+ assert_eq!(s.value(), 10.0);
238
+ s.set_value(-3.0);
239
+ assert_eq!(s.value(), 0.0);
240
+ assert_eq!(s.entry.text(), "0");
241
+ }
242
+
243
+ #[test]
244
+ fn arrows_change_value() {
245
+ let theme = Theme::light();
246
+ let mut s = SpinButton::new(2.0, 0.0, 10.0).step(3.0);
247
+ let up = s.arrow_rect(rect(), &theme, SpinArrow::Up).center();
248
+ let down = s.arrow_rect(rect(), &theme, SpinArrow::Down).center();
249
+ assert!(s.click_at(rect(), &theme, up));
250
+ assert_eq!(s.value(), 5.0);
251
+ assert!(s.click_at(rect(), &theme, down));
252
+ assert_eq!(s.value(), 2.0);
253
+ // Klik w pole (nie w strzałkę) nie zmienia wartości.
254
+ assert!(!s.click_at(rect(), &theme, Point::new(10.0, 17.0)));
255
+ }
256
+
257
+ #[test]
258
+ fn commit_parses_and_clamps() {
259
+ let mut s = SpinButton::new(1.0, 0.0, 10.0);
260
+ s.entry.set_text("7");
261
+ s.commit();
262
+ assert_eq!(s.value(), 7.0);
263
+ s.entry.set_text("bzdury");
264
+ s.commit();
265
+ assert_eq!(s.value(), 7.0); // przywrócone formatowanie
266
+ assert_eq!(s.entry.text(), "7");
267
+ s.entry.set_text("999");
268
+ s.commit();
269
+ assert_eq!(s.value(), 10.0);
270
+ }
271
+
272
+ #[test]
273
+ fn digits_formatting() {
274
+ let s = SpinButton::new(2.5, 0.0, 10.0).digits(2);
275
+ assert_eq!(s.entry.text(), "2.50");
276
+ }
277
+}
@@ -56,7 +56,8 @@ 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`; aliasy GTK4 `ScrolledWindow` = `ScrollView`,
59
+ `Adjustment`, `ColorChooser`, `FlowBox`, `SpinButton`, `EmojiChooser`, `NavigationRail`;
60
+ aliasy GTK4 `ScrolledWindow` = `ScrollView`,
60
61
`StackSwitcher` = `ViewSwitcher`, `Notebook` = `TabView`, `ListBox` = `NavList`.
61
62
Tokeny metryk kontenerów: `Theme::metrics.container`. Animacje: `libluna-core` `motion`
62
63
(`Easing`, `lerp`, `Animation`), użyte w `Stack` (przejścia `StackTransition`), `Revealer`