Commit b689289
10
files
+875
added
-8
deleted
@@ -349,8 +349,9 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
349
349
- `libluna-widgets` — **galeria GTK4** (kolejny zestaw): prezentacja `Label`, `Separator`,
350
350
`ProgressBar`, `LevelBar`, `Frame`, `Revealer`, `Overlay`, `AspectFrame`; przyciski
351
351
`ToggleButton`, `LinkButton`, `ColorButton`; pola specjalne `PasswordEntry` (maskowanie +
352
- podgląd „oko”) i `EditableLabel`; kontenery `CenterBox`, `Grid`, `Paned` (przeciągany
353
- separator), `Stack`, `ActionBar`, `PopoverMenuBar`; aliasy `ScrolledWindow` = `ScrollView`
352
+ podgląd „oko”) i `EditableLabel`; kontenery `CenterBox`, `Grid`, `Fixed`, `Paned`
353
+ (przeciągany separator), `Stack`, `ActionBar`, `PopoverMenuBar`; oraz `MenuButton`,
354
+ `SearchBar`, `Expander`, `Statusbar`, `Calendar`; aliasy `ScrolledWindow` = `ScrollView`
354
355
i `StackSwitcher` = `ViewSwitcher`.
355
356
- `libluna-widgets::layout`: **dwuprzebiegowy układ** (retencyjne drzewo `measure`/`allocate`) —
356
357
[`Node`] (`VBox`/`HBox`, `Padding`, `Expanded`, `Center`, `Spacer`, `Label`, `Fixed`),
357
358
new file mode 100644
@@ -0,0 +1,266 @@
1
+//! `Calendar` — kalendarz miesięczny (GTK4): siatka tygodni + wybór dnia.
2
+//!
3
+//! Widget immediate: trzyma rok, miesiąc i wybrany dzień, liczy geometrię dni i obsługuje
4
+//! trafienia. Nazwy dni tygodnia i tytuł miesiąca pochodzą z aplikacji (i18n), bo widgety
5
+//! libLuny nie zależą od warstwy językowej.
6
+
7
+use libluna_core::style::weight;
8
+use libluna_core::{Point, Rect, Theme};
9
+
10
+use crate::ctx::PaintCtx;
11
+
12
+/// Kolumny tygodnia.
13
+const COLS: usize = 7;
14
+/// Stała liczba wierszy dni (miesiąc zajmuje do 6 tygodni) — stabilny układ.
15
+const ROWS: usize = 6;
16
+
17
+/// Kalendarz miesięczny.
18
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19
+pub struct Calendar {
20
+ year: i32,
21
+ month: u32,
22
+ selected: u32,
23
+}
24
+
25
+impl Calendar {
26
+ /// Kalendarz na dany miesiąc (`month` 1..=12), z wybranym dniem 1.
27
+ pub fn new(year: i32, month: u32) -> Self {
28
+ Self {
29
+ year,
30
+ month: month.clamp(1, 12),
31
+ selected: 1,
32
+ }
33
+ }
34
+
35
+ pub fn year(&self) -> i32 {
36
+ self.year
37
+ }
38
+
39
+ pub fn month(&self) -> u32 {
40
+ self.month
41
+ }
42
+
43
+ pub fn selected(&self) -> u32 {
44
+ self.selected
45
+ }
46
+
47
+ /// Wybiera dzień (przycięty do zakresu miesiąca).
48
+ pub fn set_selected(&mut self, day: u32) {
49
+ self.selected = day.clamp(1, self.days_in_month());
50
+ }
51
+
52
+ /// Liczba dni w bieżącym miesiącu.
53
+ pub fn days_in_month(&self) -> u32 {
54
+ days_in_month(self.year, self.month)
55
+ }
56
+
57
+ /// Dzień tygodnia pierwszego dnia miesiąca (`0` = poniedziałek … `6` = niedziela).
58
+ pub fn first_weekday(&self) -> u32 {
59
+ let w = days_from_civil(self.year, self.month, 1);
60
+ ((w + 4).rem_euclid(7) as u32 + 6) % 7
61
+ }
62
+
63
+ /// Przechodzi do poprzedniego miesiąca.
64
+ pub fn prev_month(&mut self) {
65
+ if self.month == 1 {
66
+ self.month = 12;
67
+ self.year -= 1;
68
+ } else {
69
+ self.month -= 1;
70
+ }
71
+ self.set_selected(self.selected);
72
+ }
73
+
74
+ /// Przechodzi do następnego miesiąca.
75
+ pub fn next_month(&mut self) {
76
+ if self.month == 12 {
77
+ self.month = 1;
78
+ self.year += 1;
79
+ } else {
80
+ self.month += 1;
81
+ }
82
+ self.set_selected(self.selected);
83
+ }
84
+
85
+ pub fn title_height(&self, theme: &Theme) -> f64 {
86
+ theme.typography.body.line_height + theme.spacing.sm * 2.0
87
+ }
88
+
89
+ pub fn weekday_height(&self, theme: &Theme) -> f64 {
90
+ theme.typography.caption.line_height + theme.spacing.xs
91
+ }
92
+
93
+ fn grid_rect(&self, rect: Rect, theme: &Theme) -> Rect {
94
+ let top = self.title_height(theme) + self.weekday_height(theme);
95
+ Rect::from_xywh(
96
+ rect.x(),
97
+ rect.y() + top,
98
+ rect.width(),
99
+ (rect.height() - top).max(0.0),
100
+ )
101
+ }
102
+
103
+ /// Prostokąt komórki `(row, col)` w siatce dni.
104
+ pub fn cell_rect(&self, rect: Rect, theme: &Theme, row: usize, col: usize) -> Rect {
105
+ let g = self.grid_rect(rect, theme);
106
+ let cw = g.width() / COLS as f64;
107
+ let ch = g.height() / ROWS as f64;
108
+ Rect::from_xywh(g.x() + col as f64 * cw, g.y() + row as f64 * ch, cw, ch)
109
+ }
110
+
111
+ /// Prostokąt dnia miesiąca.
112
+ pub fn day_rect(&self, rect: Rect, theme: &Theme, day: u32) -> Rect {
113
+ let idx = self.first_weekday() + day - 1;
114
+ let row = (idx / COLS as u32) as usize;
115
+ let col = (idx % COLS as u32) as usize;
116
+ self.cell_rect(rect, theme, row.min(ROWS - 1), col)
117
+ }
118
+
119
+ /// Dzień pod punktem `p` (lub `None`).
120
+ pub fn day_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<u32> {
121
+ (1..=self.days_in_month()).find(|d| self.day_rect(rect, theme, *d).contains(p))
122
+ }
123
+
124
+ /// Rysuje kalendarz: tytuł, nagłówek dni tygodnia (`weekdays` = 7 napisów) i dni.
125
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect, title: &str, weekdays: &[String]) {
126
+ let theme = *ctx.theme;
127
+ let body = theme.typography.body.size;
128
+ let caption = theme.typography.caption.size;
129
+
130
+ // Tytuł miesiąca.
131
+ ctx.label_left(
132
+ rect.x() + theme.spacing.sm,
133
+ rect.y() + self.title_height(&theme) / 2.0,
134
+ title,
135
+ body,
136
+ weight::MEDIUM,
137
+ theme.colors.text,
138
+ );
139
+
140
+ // Nagłówek dni tygodnia.
141
+ let g = self.grid_rect(rect, &theme);
142
+ let wy = rect.y() + self.title_height(&theme) + self.weekday_height(&theme) / 2.0;
143
+ let cw = g.width() / COLS as f64;
144
+ for (i, name) in weekdays.iter().take(COLS).enumerate() {
145
+ ctx.label_center(
146
+ g.x() + (i as f64 + 0.5) * cw,
147
+ wy,
148
+ name,
149
+ caption,
150
+ weight::NORMAL,
151
+ theme.colors.dim_text,
152
+ );
153
+ }
154
+
155
+ // Dni.
156
+ for day in 1..=self.days_in_month() {
157
+ let cell = self.day_rect(rect, &theme, day);
158
+ let c = cell.center();
159
+ let selected = day == self.selected;
160
+ let r = (cell.width().min(cell.height()) / 2.0 - 2.0).max(6.0);
161
+ if selected {
162
+ ctx.scene.fill_rounded_rect(
163
+ Rect::from_xywh(c.x - r, c.y - r, r * 2.0, r * 2.0),
164
+ r,
165
+ theme.colors.accent,
166
+ );
167
+ }
168
+ let fg = if selected {
169
+ theme.colors.accent_fg
170
+ } else {
171
+ theme.colors.text
172
+ };
173
+ ctx.label_center(c.x, c.y, &day.to_string(), caption, weight::NORMAL, fg);
174
+ }
175
+ }
176
+}
177
+
178
+fn is_leap(year: i32) -> bool {
179
+ (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
180
+}
181
+
182
+fn days_in_month(year: i32, month: u32) -> u32 {
183
+ match month {
184
+ 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
185
+ 4 | 6 | 9 | 11 => 30,
186
+ 2 => {
187
+ if is_leap(year) {
188
+ 29
189
+ } else {
190
+ 28
191
+ }
192
+ }
193
+ _ => 30,
194
+ }
195
+}
196
+
197
+/// Liczba dni od 1970-01-01 (algorytm Howarda Hinnanta). 1970-01-01 = 0 (czwartek).
198
+fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
199
+ let y = if m <= 2 { y - 1 } else { y } as i64;
200
+ let m = m as i64;
201
+ let d = d as i64;
202
+ let era = if y >= 0 { y } else { y - 399 } / 400;
203
+ let yoe = y - era * 400;
204
+ let mp = if m > 2 { m - 3 } else { m + 9 };
205
+ let doy = (153 * mp + 2) / 5 + d - 1;
206
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
207
+ era * 146097 + doe - 719468
208
+}
209
+
210
+#[cfg(test)]
211
+mod tests {
212
+ use super::*;
213
+
214
+ #[test]
215
+ fn month_lengths_and_leap_years() {
216
+ assert_eq!(days_in_month(2024, 2), 29);
217
+ assert_eq!(days_in_month(2023, 2), 28);
218
+ assert_eq!(days_in_month(2024, 4), 30);
219
+ assert_eq!(days_in_month(2024, 1), 31);
220
+ }
221
+
222
+ #[test]
223
+ fn first_weekday_is_monday_zero() {
224
+ // 2024-01-01 to poniedziałek.
225
+ assert_eq!(Calendar::new(2024, 1).first_weekday(), 0);
226
+ // 2023-01-01 to niedziela.
227
+ assert_eq!(Calendar::new(2023, 1).first_weekday(), 6);
228
+ }
229
+
230
+ #[test]
231
+ fn month_navigation_wraps_years() {
232
+ let mut c = Calendar::new(2024, 1);
233
+ c.prev_month();
234
+ assert_eq!((c.year(), c.month()), (2023, 12));
235
+ c.next_month();
236
+ assert_eq!((c.year(), c.month()), (2024, 1));
237
+ let mut d = Calendar::new(2024, 12);
238
+ d.next_month();
239
+ assert_eq!((d.year(), d.month()), (2025, 1));
240
+ }
241
+
242
+ #[test]
243
+ fn selected_day_is_clamped() {
244
+ let mut c = Calendar::new(2023, 2);
245
+ c.set_selected(31);
246
+ assert_eq!(c.selected(), 28);
247
+ c.set_selected(0);
248
+ assert_eq!(c.selected(), 1);
249
+ }
250
+
251
+ #[test]
252
+ fn day_cells_hit_testing() {
253
+ let theme = Theme::light();
254
+ let rect = Rect::from_xywh(0.0, 0.0, 350.0, 300.0);
255
+ let c = Calendar::new(2024, 1);
256
+ let jan1 = c.day_rect(rect, &theme, 1);
257
+ assert_eq!(c.day_at(rect, &theme, jan1.center()), Some(1));
258
+ // Punkt w wierszu tytułu nie trafia w żaden dzień.
259
+ assert_eq!(c.day_at(rect, &theme, Point::new(5.0, 5.0)), None);
260
+ // Dni mieszczą się w siatce.
261
+ for day in 1..=c.days_in_month() {
262
+ let r = c.day_rect(rect, &theme, day);
263
+ assert!(r.bottom() <= rect.bottom() + 1e-6);
264
+ }
265
+ }
266
+}
@@ -35,6 +35,28 @@ impl CenterBox {
35
35
}
36
36
}
37
37
38
+/// Kontener o stałym położeniu dzieci (`GtkFixed`): dziecko umieszczane jest
39
+/// we współrzędnych względem kontenera, niezależnie od reszty układu.
40
+#[derive(Debug, Clone, Copy, Default)]
41
+pub struct Fixed;
42
+
43
+impl Fixed {
44
+ /// Prostokąt dziecka o rozmiarze `w × h` w punkcie `(x, y)` względem `rect`.
45
+ pub fn child(rect: Rect, x: f64, y: f64, w: f64, h: f64) -> Rect {
46
+ Rect::from_xywh(rect.x() + x, rect.y() + y, w, h)
47
+ }
48
+
49
+ /// Przesuwa gotowy prostokąt dziecka o przesunięcie względne w `rect`.
50
+ pub fn place(rect: Rect, child: Rect) -> Rect {
51
+ Rect::from_xywh(
52
+ rect.x() + child.x(),
53
+ rect.y() + child.y(),
54
+ child.width(),
55
+ child.height(),
56
+ )
57
+ }
58
+}
59
+
38
60
/// Siatka `rows × cols` z równymi komórkami i odstępem z motywu.
39
61
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40
62
pub struct Grid {
@@ -504,6 +526,15 @@ mod tests {
504
526
Theme::light()
505
527
}
506
528
529
+ #[test]
530
+ fn fixed_offsets_children() {
531
+ let rect = Rect::from_xywh(10.0, 20.0, 100.0, 100.0);
532
+ let c = Fixed::child(rect, 5.0, 7.0, 30.0, 40.0);
533
+ assert_eq!(c, Rect::from_xywh(15.0, 27.0, 30.0, 40.0));
534
+ let p = Fixed::place(rect, Rect::from_xywh(1.0, 2.0, 3.0, 4.0));
535
+ assert_eq!(p, Rect::from_xywh(11.0, 22.0, 3.0, 4.0));
536
+ }
537
+
507
538
#[test]
508
539
fn center_box_places_three_parts() {
509
540
let rect = Rect::from_xywh(0.0, 0.0, 300.0, 40.0);
510
541
new file mode 100644
@@ -0,0 +1,155 @@
1
+//! `Expander` — rozwijana sekcja (GTK4): nagłówek z tytułem i chevronem, pod nim treść.
2
+//!
3
+//! Widget immediate: trzyma stan rozwinięcia i tytuł. Aplikacja rysuje treść w prostokącie
4
+//! [`Expander::content_rect`] (zerowej wysokości, gdy zwinięty).
5
+
6
+use libluna_core::style::weight;
7
+use libluna_core::{Point, Rect, Theme};
8
+
9
+use crate::ctx::PaintCtx;
10
+use crate::icon;
11
+
12
+/// Rozwijana sekcja.
13
+#[derive(Debug, Clone, Default)]
14
+pub struct Expander {
15
+ title: String,
16
+ expanded: bool,
17
+ hovered: bool,
18
+}
19
+
20
+impl Expander {
21
+ pub fn new(title: impl Into<String>) -> Self {
22
+ Self {
23
+ title: title.into(),
24
+ ..Self::default()
25
+ }
26
+ }
27
+
28
+ pub fn expanded(mut self, expanded: bool) -> Self {
29
+ self.expanded = expanded;
30
+ self
31
+ }
32
+
33
+ pub fn title(&self) -> &str {
34
+ &self.title
35
+ }
36
+
37
+ pub fn is_expanded(&self) -> bool {
38
+ self.expanded
39
+ }
40
+
41
+ pub fn set_expanded(&mut self, expanded: bool) {
42
+ self.expanded = expanded;
43
+ }
44
+
45
+ /// Przełącza rozwinięcie; zwraca nowy stan.
46
+ pub fn toggle(&mut self) -> bool {
47
+ self.expanded = !self.expanded;
48
+ self.expanded
49
+ }
50
+
51
+ pub fn set_hovered(&mut self, hovered: bool) {
52
+ self.hovered = hovered;
53
+ }
54
+
55
+ pub fn header_height(&self, theme: &Theme) -> f64 {
56
+ theme.metrics.button.height
57
+ }
58
+
59
+ /// Nagłówek (górna część `rect`).
60
+ pub fn header_rect(&self, rect: Rect, theme: &Theme) -> Rect {
61
+ Rect::from_xywh(rect.x(), rect.y(), rect.width(), self.header_height(theme))
62
+ }
63
+
64
+ /// Treść pod nagłówkiem; zerowej wysokości, gdy zwinięty.
65
+ pub fn content_rect(&self, rect: Rect, theme: &Theme) -> Rect {
66
+ let hdr = self.header_rect(rect, theme);
67
+ let h = if self.expanded {
68
+ (rect.height() - hdr.height()).max(0.0)
69
+ } else {
70
+ 0.0
71
+ };
72
+ Rect::from_xywh(rect.x(), hdr.bottom(), rect.width(), h)
73
+ }
74
+
75
+ /// Kwadrat chevronu po lewej stronie nagłówka.
76
+ pub fn chevron_rect(&self, rect: Rect, theme: &Theme) -> Rect {
77
+ let hdr = self.header_rect(rect, theme);
78
+ let s = theme.metrics.button.chevron * 2.0;
79
+ Rect::from_xywh(hdr.x() + theme.spacing.sm, hdr.center().y - s / 2.0, s, s)
80
+ }
81
+
82
+ /// Obsługuje klik w nagłówek (przełącza rozwinięcie).
83
+ pub fn click_at(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
84
+ if self.header_rect(rect, theme).contains(p) {
85
+ self.toggle();
86
+ true
87
+ } else {
88
+ false
89
+ }
90
+ }
91
+
92
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
93
+ let theme = *ctx.theme;
94
+ let hdr = self.header_rect(rect, &theme);
95
+ if self.hovered {
96
+ ctx.scene.fill_rect(hdr, theme.colors.shade);
97
+ }
98
+ let chev = self.chevron_rect(rect, &theme).center();
99
+ if self.expanded {
100
+ icon::chevron_down(ctx, theme.colors.dim_text, chev, 4.0, 2.0);
101
+ } else {
102
+ icon::chevron_right(ctx, theme.colors.dim_text, chev, 4.0, 2.0);
103
+ }
104
+ let text_x = chev.x + theme.metrics.button.chevron + theme.spacing.sm;
105
+ ctx.label_left(
106
+ text_x,
107
+ hdr.center().y,
108
+ &self.title,
109
+ theme.typography.body.size,
110
+ weight::MEDIUM,
111
+ theme.colors.text,
112
+ );
113
+ }
114
+}
115
+
116
+#[cfg(test)]
117
+mod tests {
118
+ use super::*;
119
+
120
+ fn rect() -> Rect {
121
+ Rect::from_xywh(0.0, 0.0, 300.0, 200.0)
122
+ }
123
+
124
+ #[test]
125
+ fn collapsed_has_no_content() {
126
+ let theme = Theme::light();
127
+ let e = Expander::new("Więcej");
128
+ assert_eq!(e.content_rect(rect(), &theme).height(), 0.0);
129
+ assert_eq!(
130
+ e.header_rect(rect(), &theme).height(),
131
+ e.header_height(&theme)
132
+ );
133
+ }
134
+
135
+ #[test]
136
+ fn expanded_content_fills_below_header() {
137
+ let theme = Theme::light();
138
+ let e = Expander::new("Więcej").expanded(true);
139
+ let c = e.content_rect(rect(), &theme);
140
+ assert!((c.height() - (200.0 - e.header_height(&theme))).abs() < 1e-9);
141
+ assert_eq!(c.y(), e.header_height(&theme));
142
+ }
143
+
144
+ #[test]
145
+ fn click_header_toggles_only_there() {
146
+ let theme = Theme::light();
147
+ let mut e = Expander::new("Sekcja");
148
+ assert!(e.click_at(rect(), &theme, Point::new(150.0, 10.0)));
149
+ assert!(e.is_expanded());
150
+ assert!(!e.click_at(rect(), &theme, Point::new(150.0, 150.0)));
151
+ assert!(e.is_expanded());
152
+ assert!(e.click_at(rect(), &theme, Point::new(150.0, 10.0)));
153
+ assert!(!e.is_expanded());
154
+ }
155
+}
@@ -187,3 +187,16 @@ pub fn eye_off(paint: &mut PaintCtx, color: Color, center: Point, size: f64) {
187
187
color,
188
188
);
189
189
}
190
+
191
+/// Trzy poziome kreski (menu „hamburger”); środek w `center`, szerokość `size`, grubość `width`.
192
+pub fn burger(paint: &mut PaintCtx, color: Color, center: Point, size: f64, width: f64) {
193
+ for i in -1..=1 {
194
+ let y = center.y + i as f64 * size * 0.42;
195
+ paint.scene.stroke_line(
196
+ Point::new(center.x - size, y),
197
+ Point::new(center.x + size, y),
198
+ width,
199
+ color,
200
+ );
201
+ }
202
+}
@@ -47,8 +47,9 @@
47
47
//! [`AspectFrame`] — widgety prezentacji (GTK4 Display).
48
48
//! - [`ToggleButton`], [`LinkButton`], [`ColorButton`] — pozostałe przyciski (GTK4).
49
49
//! - [`PasswordEntry`], [`EditableLabel`] — pola specjalne (GTK4).
50
-//! - [`CenterBox`], [`Grid`], [`Paned`], [`Stack`], [`ActionBar`], [`PopoverMenuBar`] — kontenery GTK4
51
-//! (`Box` = stos drzewa układu: [`Node::hbox`]/[`Node::vbox`]).
50
+//! - [`CenterBox`], [`Grid`], [`Fixed`], [`Paned`], [`Stack`], [`ActionBar`], [`PopoverMenuBar`] —
51
+//! kontenery GTK4 (`Box` = stos drzewa układu: [`Node::hbox`]/[`Node::vbox`]).
52
+//! - [`MenuButton`], [`SearchBar`], [`Expander`], [`Statusbar`], [`Calendar`] — kolejne widgety GTK4.
52
53
//! - [`Carousel`] (alias [`Pager`]), [`ToolbarView`], [`WindowTitle`], [`Clamp`], [`WrapBox`],
53
54
//! [`BreakpointBin`], [`ButtonContent`] — pomocniki.
54
55
//!
@@ -69,6 +70,7 @@ pub mod bin;
69
70
pub mod bottom_sheet;
70
71
pub mod button;
71
72
pub mod buttons;
73
+pub mod calendar;
72
74
pub mod card;
73
75
pub mod check_button;
74
76
pub mod containers;
@@ -78,12 +80,14 @@ pub mod display;
78
80
pub mod dropdown;
79
81
pub mod entries;
80
82
pub mod entry;
83
+pub mod expander;
81
84
pub mod header_bar;
82
85
pub mod helpers;
83
86
pub mod icon;
84
87
pub mod item_view;
85
88
pub mod layout;
86
89
pub mod menu;
90
+pub mod menu_button;
87
91
pub mod nav_list;
88
92
pub mod navigation;
89
93
pub mod pager;
@@ -92,12 +96,14 @@ pub mod preferences;
92
96
pub mod radio_group;
93
97
pub mod rows;
94
98
pub mod scroll_view;
99
+pub mod search_bar;
95
100
pub mod search_entry;
96
101
pub mod shortcut_label;
97
102
pub mod slider;
98
103
pub mod spinner;
99
104
pub mod split_button;
100
105
pub mod status_page;
106
+pub mod statusbar;
101
107
pub mod stepper;
102
108
pub mod switch;
103
109
pub mod tab_bar;
@@ -114,9 +120,10 @@ pub use bin::{Bin, BinAlign};
114
120
pub use bottom_sheet::BottomSheet;
115
121
pub use button::{Button, ButtonVariant};
116
122
pub use buttons::{ColorButton, LinkButton, ToggleButton};
123
+pub use calendar::Calendar;
117
124
pub use card::Card;
118
125
pub use check_button::CheckButton;
119
-pub use containers::{ActionBar, CenterBox, Grid, Paned, PopoverMenuBar, Stack};
126
+pub use containers::{ActionBar, CenterBox, Fixed, Grid, Paned, PopoverMenuBar, Stack};
120
127
pub use ctx::PaintCtx;
121
128
pub use dialogs::{
122
129
AboutDialog, AlertDialog, AlertRects, Dialog, DialogRects, PreferencesDialog, ShortcutsDialog,
@@ -128,6 +135,7 @@ pub use display::{
128
135
pub use dropdown::DropDown;
129
136
pub use entries::{EditableLabel, PasswordEntry};
130
137
pub use entry::{Entry, EntryIcon, TextInput};
138
+pub use expander::Expander;
131
139
pub use header_bar::{ControlsLayout, ControlsSide, HeaderBar, TitleAlign, WindowControl};
132
140
pub use helpers::{
133
141
BreakpointBin, ButtonContent, Clamp, ToolbarRects, ToolbarView, WindowTitle, WindowTitleRects,
@@ -136,6 +144,7 @@ pub use helpers::{
136
144
pub use item_view::{Item, ItemKind, ItemView, ViewMode};
137
145
pub use layout::{Constraints, Layout, LayoutCtx, Node};
138
146
pub use menu::{Menu, MenuEntry, MenuRow};
147
+pub use menu_button::MenuButton;
139
148
pub use nav_list::{NavEntry, NavList, NavRow};
140
149
pub use navigation::{NavigationSplitView, NavigationView, OverlaySplitView, Sidebar};
141
150
pub use pager::Pager;
@@ -148,12 +157,14 @@ pub use rows::{Row, RowRects, RowSuffix};
148
157
pub use scroll_view::ScrollView;
149
158
/// `ScrolledWindow` — nazwa [`ScrollView`] w duchu GTK4.
150
159
pub use scroll_view::ScrollView as ScrolledWindow;
160
+pub use search_bar::SearchBar;
151
161
pub use search_entry::SearchEntry;
152
162
pub use shortcut_label::ShortcutLabel;
153
163
pub use slider::Slider;
154
164
pub use spinner::Spinner;
155
165
pub use split_button::{SplitButton, SplitRects};
156
166
pub use status_page::{StatusIcon, StatusPage, StatusRects};
167
+pub use statusbar::Statusbar;
157
168
pub use stepper::{Stepper, StepperSide};
158
169
pub use switch::Switch;
159
170
pub use tab_bar::{ScrollDir, Tab, TabBar};
160
171
new file mode 100644
@@ -0,0 +1,167 @@
1
+//! `MenuButton` — przycisk otwierający popover (GTK4).
2
+//!
3
+//! Warianty: z etykietą (+ opcjonalny chevron) albo ikoną „hamburger” (`☰`).
4
+//! Widget immediate: trzyma stan otwarcia i wygląda jak płaski przycisk.
5
+
6
+use libluna_core::style::weight;
7
+use libluna_core::{Point, Rect, Theme};
8
+use libluna_render::TextEngine;
9
+
10
+use crate::ctx::PaintCtx;
11
+use crate::icon;
12
+
13
+/// Przycisk otwierający menu/popover.
14
+#[derive(Debug, Clone, Default)]
15
+pub struct MenuButton {
16
+ label: Option<String>,
17
+ burger: bool,
18
+ chevron: bool,
19
+ open: bool,
20
+ hovered: bool,
21
+}
22
+
23
+impl MenuButton {
24
+ /// Przycisk z etykietą (z chevronem rozwijania).
25
+ pub fn new(label: impl Into<String>) -> Self {
26
+ Self {
27
+ label: Some(label.into()),
28
+ chevron: true,
29
+ ..Self::default()
30
+ }
31
+ }
32
+
33
+ /// Przycisk ikonowy „hamburger” (`☰`) — np. menu główne.
34
+ pub fn burger() -> Self {
35
+ Self {
36
+ burger: true,
37
+ ..Self::default()
38
+ }
39
+ }
40
+
41
+ /// Włącza/wyłącza chevron przy etykiecie.
42
+ pub fn chevron(mut self, chevron: bool) -> Self {
43
+ self.chevron = chevron;
44
+ self
45
+ }
46
+
47
+ pub fn label(&self) -> Option<&str> {
48
+ self.label.as_deref()
49
+ }
50
+
51
+ pub fn is_open(&self) -> bool {
52
+ self.open
53
+ }
54
+
55
+ pub fn set_open(&mut self, open: bool) {
56
+ self.open = open;
57
+ }
58
+
59
+ /// Przełącza stan; zwraca nowy stan.
60
+ pub fn toggle(&mut self) -> bool {
61
+ self.open = !self.open;
62
+ self.open
63
+ }
64
+
65
+ pub fn set_hovered(&mut self, hovered: bool) {
66
+ self.hovered = hovered;
67
+ }
68
+
69
+ pub fn height(&self, theme: &Theme) -> f64 {
70
+ theme.metrics.button.height
71
+ }
72
+
73
+ pub fn width(&self, text: &TextEngine, theme: &Theme) -> f64 {
74
+ let m = theme.metrics.button;
75
+ if self.burger || self.label.is_none() {
76
+ return self.height(theme).max(30.0);
77
+ }
78
+ let label = self.label.as_deref().unwrap_or("");
79
+ let body = theme.typography.body.size;
80
+ let mut w = m.pad_x * 2.0 + text.measure(label, body, weight::NORMAL).width;
81
+ if self.chevron {
82
+ w += m.chevron_gap + m.chevron;
83
+ }
84
+ w
85
+ }
86
+
87
+ /// Zwraca `true`, jeśli klik trafił (i przełączył stan).
88
+ pub fn click_at(&mut self, rect: Rect, p: Point) -> bool {
89
+ if rect.contains(p) {
90
+ self.toggle();
91
+ true
92
+ } else {
93
+ false
94
+ }
95
+ }
96
+
97
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
98
+ let theme = *ctx.theme;
99
+ let m = theme.metrics.button;
100
+ if self.hovered || self.open {
101
+ let bg = if self.open {
102
+ theme.colors.shade.scale_alpha(1.8)
103
+ } else {
104
+ theme.colors.shade
105
+ };
106
+ ctx.scene.fill_rounded_rect(rect, theme.radius.small, bg);
107
+ }
108
+ let fg = theme.colors.text;
109
+ let cy = rect.center().y;
110
+
111
+ if self.burger {
112
+ icon::burger(ctx, fg, rect.center(), 7.0, 1.8);
113
+ return;
114
+ }
115
+
116
+ let label = self.label.as_deref().unwrap_or("");
117
+ let body = theme.typography.body.size;
118
+ let label_w = ctx.measure(label, body, weight::NORMAL);
119
+ let content_w = label_w
120
+ + if self.chevron {
121
+ m.chevron_gap + m.chevron
122
+ } else {
123
+ 0.0
124
+ };
125
+ let start_x = rect.center().x - content_w / 2.0;
126
+ ctx.label_left(start_x, cy, label, body, weight::NORMAL, fg);
127
+ if self.chevron {
128
+ let cx = start_x + label_w + m.chevron_gap + m.chevron / 2.0;
129
+ icon::chevron_down(ctx, fg, Point::new(cx, cy), 4.0, 2.0);
130
+ }
131
+ }
132
+}
133
+
134
+#[cfg(test)]
135
+mod tests {
136
+ use super::*;
137
+
138
+ #[test]
139
+ fn burger_is_square_and_small() {
140
+ let theme = Theme::light();
141
+ let text = TextEngine::system();
142
+ let b = MenuButton::burger();
143
+ assert_eq!(b.width(&text, &theme), b.height(&theme).max(30.0));
144
+ }
145
+
146
+ #[test]
147
+ fn labelled_button_grows_with_chevron() {
148
+ let theme = Theme::light();
149
+ let text = TextEngine::system();
150
+ let plain = MenuButton::new("Opcje").chevron(false);
151
+ let with = MenuButton::new("Opcje");
152
+ assert!(with.width(&text, &theme) > plain.width(&text, &theme));
153
+ }
154
+
155
+ #[test]
156
+ fn click_toggles_only_inside() {
157
+ let rect = Rect::from_xywh(0.0, 0.0, 44.0, 34.0);
158
+ let mut b = MenuButton::burger();
159
+ assert!(!b.is_open());
160
+ assert!(b.click_at(rect, rect.center()));
161
+ assert!(b.is_open());
162
+ assert!(!b.click_at(rect, Point::new(200.0, 200.0)));
163
+ assert!(b.is_open());
164
+ assert!(b.click_at(rect, rect.center()));
165
+ assert!(!b.is_open());
166
+ }
167
+}
0
168
new file mode 100644
@@ -0,0 +1,113 @@
1
+//! `SearchBar` — pasek wyszukiwania (GTK4): odsłaniana szuflada z polem wyszukiwania.
2
+//!
3
+//! W modelu immediate aplikacja rysuje [`SearchEntry`](crate::SearchEntry) w prostokącie
4
+//! [`SearchBar::entry_rect`], a widget odpowiada za tryb (widoczny/ukryty), geometrię
5
+//! i tło. Przełączanie trybu: [`SearchBar::toggle`] / [`SearchBar::set_search_mode`].
6
+
7
+use libluna_core::{Point, Rect, Theme};
8
+
9
+use crate::ctx::PaintCtx;
10
+
11
+/// Pasek wyszukiwania.
12
+#[derive(Debug, Clone, Copy, Default)]
13
+pub struct SearchBar {
14
+ mode: bool,
15
+}
16
+
17
+impl SearchBar {
18
+ pub fn new() -> Self {
19
+ Self::default()
20
+ }
21
+
22
+ pub fn with_mode(mode: bool) -> Self {
23
+ Self { mode }
24
+ }
25
+
26
+ pub fn is_search_mode(&self) -> bool {
27
+ self.mode
28
+ }
29
+
30
+ pub fn set_search_mode(&mut self, mode: bool) {
31
+ self.mode = mode;
32
+ }
33
+
34
+ /// Przełącza tryb; zwraca nowy stan.
35
+ pub fn toggle(&mut self) -> bool {
36
+ self.mode = !self.mode;
37
+ self.mode
38
+ }
39
+
40
+ /// Wysokość paska (gdy widoczny).
41
+ pub fn height(&self, theme: &Theme) -> f64 {
42
+ theme.metrics.entry.height + theme.spacing.sm * 2.0
43
+ }
44
+
45
+ /// Obszar paska w `rect` (pas u góry kontenera). Gdy ukryty — o wysokości zerowej.
46
+ pub fn reveal_rect(&self, rect: Rect, theme: &Theme) -> Rect {
47
+ let h = if self.mode { self.height(theme) } else { 0.0 };
48
+ Rect::from_xywh(rect.x(), rect.y(), rect.width(), h)
49
+ }
50
+
51
+ /// Prostokąt pola wyszukiwania (z wewnętrznym marginesem); zerowy, gdy ukryty.
52
+ pub fn entry_rect(&self, rect: Rect, theme: &Theme) -> Rect {
53
+ let bar = self.reveal_rect(rect, theme);
54
+ if bar.height() <= 0.0 {
55
+ return Rect::from_xywh(bar.x(), bar.y(), bar.width(), 0.0);
56
+ }
57
+ let pad = theme.spacing.sm;
58
+ Rect::from_xywh(
59
+ bar.x() + pad,
60
+ bar.y() + pad,
61
+ (bar.width() - 2.0 * pad).max(0.0),
62
+ (bar.height() - 2.0 * pad).max(0.0),
63
+ )
64
+ }
65
+
66
+ /// Czy punkt leży w obszarze paska (np. by nie zaczynać przeciągania okna).
67
+ pub fn hit(&self, rect: Rect, theme: &Theme, p: Point) -> bool {
68
+ self.reveal_rect(rect, theme).contains(p)
69
+ }
70
+
71
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
72
+ if !self.mode {
73
+ return;
74
+ }
75
+ let theme = *ctx.theme;
76
+ let bar = self.reveal_rect(rect, &theme);
77
+ ctx.scene.fill_rect(bar, theme.colors.window_bg);
78
+ let line = Rect::from_xywh(bar.x(), bar.bottom() - 1.0, bar.width(), 1.0);
79
+ ctx.scene.fill_rect(line, theme.colors.border);
80
+ }
81
+}
82
+
83
+#[cfg(test)]
84
+mod tests {
85
+ use super::*;
86
+
87
+ #[test]
88
+ fn hidden_bar_has_zero_height() {
89
+ let theme = Theme::light();
90
+ let rect = Rect::from_xywh(0.0, 0.0, 400.0, 300.0);
91
+ let b = SearchBar::new();
92
+ assert_eq!(b.reveal_rect(rect, &theme).height(), 0.0);
93
+ assert_eq!(b.entry_rect(rect, &theme).height(), 0.0);
94
+ assert!(!b.hit(rect, &theme, Point::new(10.0, 10.0)));
95
+ }
96
+
97
+ #[test]
98
+ fn visible_bar_reveals_entry_with_padding() {
99
+ let theme = Theme::light();
100
+ let rect = Rect::from_xywh(0.0, 0.0, 400.0, 300.0);
101
+ let mut b = SearchBar::new();
102
+ assert!(b.toggle());
103
+ let bar = b.reveal_rect(rect, &theme);
104
+ assert!(
105
+ (bar.height() - (theme.metrics.entry.height + theme.spacing.sm * 2.0)).abs() < 1e-9
106
+ );
107
+ let e = b.entry_rect(rect, &theme);
108
+ assert_eq!(e.x(), theme.spacing.sm);
109
+ assert_eq!(e.width(), 400.0 - 2.0 * theme.spacing.sm);
110
+ assert!(b.hit(rect, &theme, e.center()));
111
+ assert!(!b.hit(rect, &theme, Point::new(10.0, 250.0)));
112
+ }
113
+}
0
114
new file mode 100644
@@ -0,0 +1,109 @@
1
+//! `Statusbar` — pasek stanu (GTK4): komunikat po lewej, informacje po prawej.
2
+//!
3
+//! Widget immediate: trzyma dwa napisy (lewy komunikat i prawy opis), liczy geometrię
4
+//! i rysuje tło z górną krawędzią. Aplikacja wpisuje tam np. „Gotowy” i liczbę elementów.
5
+
6
+use libluna_core::style::weight;
7
+use libluna_core::{Rect, Theme};
8
+
9
+use crate::ctx::PaintCtx;
10
+
11
+/// Pasek stanu.
12
+#[derive(Debug, Clone, Default)]
13
+pub struct Statusbar {
14
+ message: String,
15
+ right: String,
16
+}
17
+
18
+impl Statusbar {
19
+ pub fn new() -> Self {
20
+ Self::default()
21
+ }
22
+
23
+ pub fn message(&self) -> &str {
24
+ &self.message
25
+ }
26
+
27
+ pub fn right(&self) -> &str {
28
+ &self.right
29
+ }
30
+
31
+ pub fn set_message(&mut self, message: impl Into<String>) {
32
+ self.message = message.into();
33
+ }
34
+
35
+ pub fn set_right(&mut self, right: impl Into<String>) {
36
+ self.right = right.into();
37
+ }
38
+
39
+ pub fn height(&self, theme: &Theme) -> f64 {
40
+ theme.typography.caption.line_height + theme.spacing.xs * 2.0
41
+ }
42
+
43
+ /// Prostokąty: `[lewy komunikat, prawy opis]` wewnątrz `rect`.
44
+ pub fn parts(&self, rect: Rect, theme: &Theme) -> [Rect; 2] {
45
+ let pad = theme.spacing.md;
46
+ let h = rect.height();
47
+ let w = (rect.width() - 2.0 * pad).max(0.0) / 2.0;
48
+ [
49
+ Rect::from_xywh(rect.x() + pad, rect.y(), w, h),
50
+ Rect::from_xywh(rect.right() - pad - w, rect.y(), w, h),
51
+ ]
52
+ }
53
+
54
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
55
+ let theme = *ctx.theme;
56
+ ctx.scene.fill_rect(rect, theme.colors.window_bg);
57
+ let line = Rect::from_xywh(rect.x(), rect.y(), rect.width(), 1.0);
58
+ ctx.scene.fill_rect(line, theme.colors.border);
59
+
60
+ let [left, right] = self.parts(rect, &theme);
61
+ let size = theme.typography.caption.size;
62
+ ctx.label_left(
63
+ left.x(),
64
+ left.center().y,
65
+ &self.message,
66
+ size,
67
+ weight::NORMAL,
68
+ theme.colors.dim_text,
69
+ );
70
+ ctx.label_right(
71
+ right.right(),
72
+ right.center().y,
73
+ &self.right,
74
+ size,
75
+ weight::NORMAL,
76
+ theme.colors.dim_text,
77
+ );
78
+ }
79
+}
80
+
81
+#[cfg(test)]
82
+mod tests {
83
+ use super::*;
84
+
85
+ #[test]
86
+ fn height_from_caption_metrics() {
87
+ let theme = Theme::light();
88
+ let s = Statusbar::new();
89
+ assert!(
90
+ (s.height(&theme) - (theme.typography.caption.line_height + theme.spacing.xs * 2.0))
91
+ .abs()
92
+ < 1e-9
93
+ );
94
+ }
95
+
96
+ #[test]
97
+ fn parts_are_padded_start_and_end() {
98
+ let theme = Theme::light();
99
+ let rect = Rect::from_xywh(0.0, 0.0, 400.0, 24.0);
100
+ let mut s = Statusbar::new();
101
+ s.set_message("Gotowy");
102
+ s.set_right("4 elementy");
103
+ assert_eq!(s.message(), "Gotowy");
104
+ assert_eq!(s.right(), "4 elementy");
105
+ let [l, r] = s.parts(rect, &theme);
106
+ assert_eq!(l.x(), theme.spacing.md);
107
+ assert!((r.right() - (400.0 - theme.spacing.md)).abs() < 1e-9);
108
+ }
109
+}
@@ -50,11 +50,12 @@ 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–6): prezentacja `Label`, `Separator`,
53
+- **Galeria GTK4 pokrywana partiami** (partia 5–7): prezentacja `Label`, `Separator`,
54
54
`ProgressBar`, `LevelBar`, `Frame`, `Revealer`, `Overlay`, `AspectFrame`; przyciski
55
55
`ToggleButton`, `LinkButton`, `ColorButton`; pola specjalne `PasswordEntry` (maskowanie +
56
- „oko”) i `EditableLabel`; kontenery `CenterBox`, `Grid`, `Paned`, `Stack`, `ActionBar`,
57
- `PopoverMenuBar`; aliasy GTK4 `ScrolledWindow` = `ScrollView`, `StackSwitcher` = `ViewSwitcher`.
56
+ „oko”) i `EditableLabel`; kontenery `CenterBox`, `Grid`, `Fixed`, `Paned`, `Stack`,
57
+ `ActionBar`, `PopoverMenuBar`; dalej `MenuButton`, `SearchBar`, `Expander`, `Statusbar`,
58
+ `Calendar`; aliasy GTK4 `ScrolledWindow` = `ScrollView`, `StackSwitcher` = `ViewSwitcher`.
58
59
Tokeny metryk kontenerów: `Theme::metrics.container`.
59
60
- `libluna-platform`: realne akcje okna z `Frame` (`close`/`minimize`/`toggle_maximize`).
60
61
- Demo używa widgetów zamiast ręcznego rysowania (pasek boczny, wybór motywu/widoku, menu `☰`,