← luna

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 `☰`,