← luna

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`