← luna

Commit b9ef3b4

6
files
+1073
added
-5
deleted
@@ -28,10 +28,10 @@ pub use geometry::{Insets, Point, Rect, Size};
28 28 pub use scale::Scale;
29 29 pub use style::{
30 30 ActionRowMetrics, AvatarMetrics, BannerMetrics, BottomSheetMetrics, ButtonMetrics, CardMetrics,
31 - CheckMetrics, DropDownMetrics, EntryMetrics, HeaderBarMetrics, ItemViewMetrics, MenuMetrics,
32 - Metrics, NavMetrics, PagerMetrics, PathBarMetrics, RadioMetrics, Radius, ScrollViewMetrics,
33 - SegmentedMetrics, ShortcutMetrics, SliderMetrics, Spacing, SpinnerMetrics, SplitMetrics,
34 - StatusPageMetrics, StepperMetrics, SwitchMetrics, TabsMetrics, Theme, ToastMetrics,
35 - TooltipMetrics, Typography,
31 + CheckMetrics, DropDownMetrics, EntryMetrics, GroupMetrics, HeaderBarMetrics, ItemViewMetrics,
32 + MenuMetrics, Metrics, NavMetrics, PagerMetrics, PathBarMetrics, RadioMetrics, Radius,
33 + ScrollViewMetrics, SegmentedMetrics, ShortcutMetrics, SliderMetrics, Spacing, SpinnerMetrics,
34 + SplitMetrics, StatusPageMetrics, StepperMetrics, SwitchMetrics, TabsMetrics, Theme,
35 + ToastMetrics, TooltipMetrics, Typography,
36 36 };
37 37 pub use theme_file::{available as available_themes, StyledTheme, ThemePatch};
@@ -541,6 +541,17 @@ pub struct SplitMetrics {
541 541 pub divider: f64,
542 542 }
543 543
544 +/// Geometria grupy preferencji ([`PreferencesGroup`](crate::style)).
545 +#[derive(Debug, Clone, Copy, PartialEq)]
546 +pub struct GroupMetrics {
547 + /// Wysokość nagłówka grupy (tytuł nad ramką).
548 + pub header: f64,
549 + /// Odstęp między grupami.
550 + pub gap: f64,
551 + /// Wcięcie poziome zawartości grupy.
552 + pub pad_x: f64,
553 +}
554 +
544 555 /// Geometria kontrolek („component metrics”) — rozmiary i paddingi w pikselach logicznych.
545 556 ///
546 557 /// Widgety nie mają już własnych stałych rozmiarów: czytają je z motywu, dzięki czemu
@@ -576,6 +587,7 @@ pub struct Metrics {
576 587 pub avatar: AvatarMetrics,
577 588 pub shortcut: ShortcutMetrics,
578 589 pub split: SplitMetrics,
590 + pub group: GroupMetrics,
579 591 }
580 592
581 593 impl Metrics {
@@ -733,6 +745,11 @@ impl Metrics {
733 745 arrow_w: 30.0,
734 746 divider: 1.0,
735 747 },
748 + group: GroupMetrics {
749 + header: 30.0,
750 + gap: 18.0,
751 + pad_x: 12.0,
752 + },
736 753 }
737 754 }
738 755 }
739 756 new file mode 100644
@@ -0,0 +1,507 @@
1 +//! `Dialog` i warianty — okna modalne (nakładka): zwykłe, alert, o aplikacji, skróty, preferencje.
2 +//!
3 +//! Bazowy [`Dialog`] rysuje przygaszenie, ramkę i nagłówek z zamknięciem, i wyznacza obszar
4 +//! zawartości. [`AlertDialog`], [`AboutDialog`], [`ShortcutsDialog`] i [`PreferencesDialog`]
5 +//! to warianty o różnej zawartości (budują geometrię na bazie [`Dialog`]).
6 +
7 +use libluna_core::style::weight;
8 +use libluna_core::{Color, Insets, Point, Rect, Size, Theme};
9 +use libluna_render::TextEngine;
10 +
11 +use crate::ctx::PaintCtx;
12 +use crate::icon;
13 +use crate::shortcut_label::ShortcutLabel;
14 +
15 +/// Bok obszaru przycisku zamknięcia.
16 +const CLOSE: f64 = 24.0;
17 +
18 +fn centered_panel(size: Size, width: f64, height: f64) -> Rect {
19 + Rect::from_xywh(
20 + (size.width - width) / 2.0,
21 + (size.height - height) / 2.0,
22 + width,
23 + height,
24 + )
25 +}
26 +
27 +/// Prostokąty okna modalnego.
28 +#[derive(Debug, Clone, Copy, PartialEq)]
29 +pub struct DialogRects {
30 + pub panel: Rect,
31 + pub header: Rect,
32 + pub content: Rect,
33 + pub close: Rect,
34 +}
35 +
36 +/// Okno modalne (nakładka).
37 +#[derive(Debug, Clone)]
38 +pub struct Dialog {
39 + title: String,
40 + width: f64,
41 + height: f64,
42 +}
43 +
44 +impl Dialog {
45 + pub fn new(title: impl Into<String>) -> Self {
46 + Self {
47 + title: title.into(),
48 + width: 400.0,
49 + height: 280.0,
50 + }
51 + }
52 +
53 + pub fn size(mut self, width: f64, height: f64) -> Self {
54 + self.width = width;
55 + self.height = height;
56 + self
57 + }
58 +
59 + pub fn set_height(&mut self, height: f64) {
60 + self.height = height;
61 + }
62 +
63 + pub fn title(&self) -> &str {
64 + &self.title
65 + }
66 +
67 + /// Prostokąty okna w danym rozmiarze (panel wyśrodkowany).
68 + pub fn rects(&self, size: Size, theme: &Theme) -> DialogRects {
69 + let panel = centered_panel(size, self.width, self.height);
70 + let header_h = theme.metrics.sheet.header;
71 + let md = theme.spacing.md;
72 + let header = Rect::from_xywh(panel.x(), panel.y(), panel.width(), header_h);
73 + let content = Rect::from_xywh(
74 + panel.x(),
75 + panel.y() + header_h,
76 + panel.width(),
77 + (panel.height() - header_h).max(0.0),
78 + );
79 + let cy = panel.y() + header_h / 2.0;
80 + let close = Rect::from_xywh(panel.right() - md - CLOSE, cy - CLOSE / 2.0, CLOSE, CLOSE);
81 + DialogRects {
82 + panel,
83 + header,
84 + content,
85 + close,
86 + }
87 + }
88 +
89 + /// Całe tło przygaszające.
90 + pub fn scrim_rect(size: Size) -> Rect {
91 + Rect::from_size(size)
92 + }
93 +
94 + /// Czy punkt trafia w tło poza panelem (klik zamykający).
95 + pub fn scrim_at(&self, size: Size, theme: &Theme, p: Point) -> bool {
96 + !self.rects(size, theme).panel.contains(p)
97 + }
98 +
99 + /// Czy punkt trafia w przycisk zamknięcia.
100 + pub fn close_at(&self, size: Size, theme: &Theme, p: Point) -> bool {
101 + self.rects(size, theme).close.contains(p)
102 + }
103 +
104 + /// Rysuje przygaszenie i panel z nagłówkiem; zwraca prostokąty.
105 + pub fn paint(&self, ctx: &mut PaintCtx, size: Size) -> DialogRects {
106 + let theme = *ctx.theme;
107 + let r = self.rects(size, &theme);
108 + ctx.scene.fill_rect(
109 + Self::scrim_rect(size),
110 + Color::rgba(0.0, 0.0, 0.0, theme.metrics.sheet.scrim as f32),
111 + );
112 + ctx.scene
113 + .fill_rounded_rect(r.panel, theme.radius.large, theme.colors.card_bg);
114 + ctx.scene
115 + .stroke_rounded_rect(r.panel, theme.radius.large, 1.0, theme.colors.border);
116 + ctx.label_center(
117 + r.header.center().x,
118 + r.header.center().y,
119 + &self.title,
120 + theme.typography.body.size,
121 + weight::BOLD,
122 + theme.colors.text,
123 + );
124 + icon::close(ctx, theme.colors.dim_text, r.close, 5.0);
125 + r
126 + }
127 +}
128 +
129 +/// Okno alertu: tytuł, treść i przyciski.
130 +#[derive(Debug, Clone)]
131 +pub struct AlertDialog {
132 + dialog: Dialog,
133 + body: String,
134 + buttons: Vec<String>,
135 +}
136 +
137 +/// Prostokąty okna alertu.
138 +#[derive(Debug, Clone, PartialEq)]
139 +pub struct AlertRects {
140 + pub dialog: DialogRects,
141 + pub body: Rect,
142 + pub buttons: Vec<Rect>,
143 +}
144 +
145 +impl AlertDialog {
146 + pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
147 + Self {
148 + dialog: Dialog::new(title).size(380.0, 0.0),
149 + body: body.into(),
150 + buttons: Vec::new(),
151 + }
152 + }
153 +
154 + pub fn button(mut self, label: impl Into<String>) -> Self {
155 + self.buttons.push(label.into());
156 + self
157 + }
158 +
159 + fn height(&self, theme: &Theme) -> f64 {
160 + let pad = theme.spacing.lg;
161 + let header = theme.metrics.sheet.header;
162 + let lines = self.body.split('\n').count().max(1) as f64;
163 + header
164 + + pad
165 + + lines * theme.typography.body.line_height
166 + + pad
167 + + theme.metrics.button.height
168 + + pad
169 + }
170 +
171 + pub fn rects(&self, size: Size, theme: &Theme, text: &TextEngine) -> AlertRects {
172 + let dialog = Dialog {
173 + title: self.dialog.title.clone(),
174 + width: self.dialog.width,
175 + height: self.height(theme),
176 + };
177 + let d = dialog.rects(size, theme);
178 + let pad = theme.spacing.lg;
179 + let inner = d.content.deflate(Insets::new(pad, pad, 0.0, pad));
180 + let lines = self.body.split('\n').count().max(1) as f64;
181 + let body_h = lines * theme.typography.body.line_height;
182 + let body = Rect::from_xywh(inner.x(), inner.y(), inner.width(), body_h);
183 + let buttons = layout_buttons(
184 + &self.buttons,
185 + Rect::from_xywh(
186 + inner.x(),
187 + inner.y() + body_h + pad,
188 + inner.width(),
189 + theme.metrics.button.height,
190 + ),
191 + theme,
192 + text,
193 + );
194 + AlertRects {
195 + dialog: d,
196 + body,
197 + buttons,
198 + }
199 + }
200 +
201 + pub fn action_at(
202 + &self,
203 + size: Size,
204 + theme: &Theme,
205 + text: &TextEngine,
206 + p: Point,
207 + ) -> Option<usize> {
208 + self.rects(size, theme, text)
209 + .buttons
210 + .iter()
211 + .position(|r| r.contains(p))
212 + }
213 +
214 + pub fn paint(&self, ctx: &mut PaintCtx, size: Size) {
215 + let theme = *ctx.theme;
216 + let r = {
217 + let dialog = Dialog {
218 + title: self.dialog.title.clone(),
219 + width: self.dialog.width,
220 + height: self.height(&theme),
221 + };
222 + dialog.paint(ctx, size)
223 + };
224 + let pad = theme.spacing.lg;
225 + let inner = r.content.deflate(Insets::new(pad, pad, 0.0, pad));
226 + for (i, line) in self.body.split('\n').enumerate() {
227 + ctx.label_left(
228 + inner.x(),
229 + inner.y() + i as f64 * theme.typography.body.line_height,
230 + line,
231 + theme.typography.body.size,
232 + weight::NORMAL,
233 + theme.colors.dim_text,
234 + );
235 + }
236 + let rects = self.rects(size, &theme, ctx.text);
237 + for (label, br) in self.buttons.iter().zip(&rects.buttons) {
238 + ctx.scene
239 + .fill_rounded_rect(*br, theme.radius.small, theme.colors.accent);
240 + ctx.label_center(
241 + br.center().x,
242 + br.center().y,
243 + label,
244 + theme.typography.body.size,
245 + weight::MEDIUM,
246 + theme.colors.accent_fg,
247 + );
248 + }
249 + }
250 +}
251 +
252 +fn layout_buttons(labels: &[String], area: Rect, theme: &Theme, text: &TextEngine) -> Vec<Rect> {
253 + let bm = theme.metrics.button;
254 + let mut out = Vec::with_capacity(labels.len());
255 + let mut right = area.right();
256 + for label in labels.iter().rev() {
257 + let w = 2.0 * bm.pad_x
258 + + text
259 + .measure(label, theme.typography.body.size, weight::MEDIUM)
260 + .width;
261 + out.push(Rect::from_xywh(right - w, area.y(), w, area.height()));
262 + right -= w + theme.spacing.md;
263 + }
264 + out.reverse();
265 + out
266 +}
267 +
268 +/// Okno „O aplikacji".
269 +#[derive(Debug, Clone)]
270 +pub struct AboutDialog {
271 + dialog: Dialog,
272 + app_name: String,
273 + version: String,
274 + developer: Option<String>,
275 + website: Option<String>,
276 +}
277 +
278 +impl AboutDialog {
279 + pub fn new(app_name: impl Into<String>, version: impl Into<String>) -> Self {
280 + Self {
281 + dialog: Dialog::new("About").size(380.0, 0.0),
282 + app_name: app_name.into(),
283 + version: version.into(),
284 + developer: None,
285 + website: None,
286 + }
287 + }
288 +
289 + pub fn developer(mut self, developer: impl Into<String>) -> Self {
290 + self.developer = Some(developer.into());
291 + self
292 + }
293 +
294 + pub fn website(mut self, website: impl Into<String>) -> Self {
295 + self.website = Some(website.into());
296 + self
297 + }
298 +
299 + fn height(&self, theme: &Theme) -> f64 {
300 + theme.metrics.sheet.header + theme.spacing.lg * 2.0 + 4.0 * 24.0
301 + }
302 +
303 + pub fn paint(&self, ctx: &mut PaintCtx, size: Size) {
304 + let theme = *ctx.theme;
305 + let dialog = Dialog {
306 + title: self.dialog.title.clone(),
307 + width: self.dialog.width,
308 + height: self.height(&theme),
309 + };
310 + let r = dialog.paint(ctx, size);
311 + let cx = r.content.center().x;
312 + let mut y = r.content.y() + theme.spacing.lg;
313 + let line = 24.0;
314 + ctx.label_center(
315 + cx,
316 + y,
317 + &self.app_name,
318 + theme.typography.title.size,
319 + weight::BOLD,
320 + theme.colors.text,
321 + );
322 + y += line;
323 + ctx.label_center(
324 + cx,
325 + y,
326 + &self.version,
327 + theme.typography.body.size,
328 + weight::NORMAL,
329 + theme.colors.dim_text,
330 + );
331 + y += line;
332 + if let Some(dev) = &self.developer {
333 + ctx.label_center(
334 + cx,
335 + y,
336 + dev,
337 + theme.typography.caption.size,
338 + weight::NORMAL,
339 + theme.colors.dim_text,
340 + );
341 + y += line;
342 + }
343 + if let Some(site) = &self.website {
344 + ctx.label_center(
345 + cx,
346 + y,
347 + site,
348 + theme.typography.caption.size,
349 + weight::NORMAL,
350 + theme.colors.accent,
351 + );
352 + }
353 + }
354 +}
355 +
356 +/// Okno ze skrótami klawiszowymi.
357 +#[derive(Debug, Clone)]
358 +pub struct ShortcutsDialog {
359 + dialog: Dialog,
360 + entries: Vec<(String, Vec<String>)>,
361 +}
362 +
363 +impl ShortcutsDialog {
364 + pub fn new() -> Self {
365 + Self {
366 + dialog: Dialog::new("Keyboard Shortcuts").size(420.0, 0.0),
367 + entries: Vec::new(),
368 + }
369 + }
370 +
371 + /// Dodaje pozycję: etykietę akcji + klawisze (np. `["Ctrl","N"]`).
372 + pub fn entry<I, S>(mut self, label: impl Into<String>, keys: I) -> Self
373 + where
374 + I: IntoIterator<Item = S>,
375 + S: Into<String>,
376 + {
377 + self.entries
378 + .push((label.into(), keys.into_iter().map(Into::into).collect()));
379 + self
380 + }
381 +
382 + fn row_h(theme: &Theme) -> f64 {
383 + theme.metrics.menu.item_h
384 + }
385 +
386 + fn height(&self, theme: &Theme) -> f64 {
387 + theme.metrics.sheet.header
388 + + theme.spacing.lg * 2.0
389 + + self.entries.len() as f64 * Self::row_h(theme)
390 + }
391 +
392 + pub fn paint(&self, ctx: &mut PaintCtx, size: Size) {
393 + let theme = *ctx.theme;
394 + let dialog = Dialog {
395 + title: self.dialog.title.clone(),
396 + width: self.dialog.width,
397 + height: self.height(&theme),
398 + };
399 + let r = dialog.paint(ctx, size);
400 + let pad = theme.spacing.lg;
401 + let inner = r.content.deflate(Insets::splat(pad));
402 + let row_h = Self::row_h(&theme);
403 + for (i, (label, keys)) in self.entries.iter().enumerate() {
404 + let row = Rect::from_xywh(
405 + inner.x(),
406 + inner.y() + i as f64 * row_h,
407 + inner.width(),
408 + row_h,
409 + );
410 + ctx.label_left(
411 + row.x(),
412 + row.center().y,
413 + label,
414 + theme.typography.body.size,
415 + weight::NORMAL,
416 + theme.colors.text,
417 + );
418 + let sc = ShortcutLabel::new(keys.clone());
419 + let w = sc.width(&theme, ctx.text);
420 + let rect = Rect::from_xywh(row.right() - w, row.y(), w, row.height());
421 + sc.paint(ctx, rect);
422 + }
423 + }
424 +}
425 +
426 +impl Default for ShortcutsDialog {
427 + fn default() -> Self {
428 + Self::new()
429 + }
430 +}
431 +
432 +/// Okno preferencji: [`Dialog`] z obszarem zawartości na [`PreferencesPage`](crate::PreferencesPage).
433 +#[derive(Debug, Clone)]
434 +pub struct PreferencesDialog {
435 + dialog: Dialog,
436 +}
437 +
438 +impl PreferencesDialog {
439 + pub fn new(title: impl Into<String>, height: f64) -> Self {
440 + Self {
441 + dialog: Dialog::new(title).size(460.0, height),
442 + }
443 + }
444 +
445 + /// Prostokąty (m.in. `content` na [`PreferencesPage`](crate::PreferencesPage)).
446 + pub fn rects(&self, size: Size, theme: &Theme) -> DialogRects {
447 + self.dialog.rects(size, theme)
448 + }
449 +
450 + pub fn scrim_at(&self, size: Size, theme: &Theme, p: Point) -> bool {
451 + self.dialog.scrim_at(size, theme, p)
452 + }
453 +
454 + pub fn close_at(&self, size: Size, theme: &Theme, p: Point) -> bool {
455 + self.dialog.close_at(size, theme, p)
456 + }
457 +
458 + pub fn paint(&self, ctx: &mut PaintCtx, size: Size) -> DialogRects {
459 + self.dialog.paint(ctx, size)
460 + }
461 +}
462 +
463 +#[cfg(test)]
464 +mod tests {
465 + use super::*;
466 +
467 + fn size() -> Size {
468 + Size::new(900.0, 700.0)
469 + }
470 +
471 + #[test]
472 + fn dialog_centers_and_close_hits() {
473 + let theme = Theme::light();
474 + let d = Dialog::new("Test").size(400.0, 300.0);
475 + let r = d.rects(size(), &theme);
476 + assert!((r.panel.center().x - size().width / 2.0).abs() < 1e-9);
477 + assert!(d.close_at(size(), &theme, r.close.center()));
478 + assert!(d.scrim_at(size(), &theme, Point::new(5.0, 5.0)));
479 + assert!(!d.scrim_at(size(), &theme, r.panel.center()));
480 + }
481 +
482 + #[test]
483 + fn alert_actions_are_right_aligned() {
484 + let theme = Theme::light();
485 + let text = TextEngine::system();
486 + let alert = AlertDialog::new("Błąd?", "Nie udało się zapisać.")
487 + .button("Anuluj")
488 + .button("Zapisz");
489 + let r = alert.rects(size(), &theme, &text);
490 + assert_eq!(r.buttons.len(), 2);
491 + assert!(r.buttons[0].right() <= r.buttons[1].x());
492 + assert_eq!(
493 + alert.action_at(size(), &theme, &text, r.buttons[1].center()),
494 + Some(1)
495 + );
496 + assert!(r.dialog.content.y() > r.dialog.panel.y());
497 + }
498 +
499 + #[test]
500 + fn preferences_dialog_content_below_header() {
501 + let theme = Theme::light();
502 + let d = PreferencesDialog::new("Preferences", 400.0);
503 + let r = d.rects(size(), &theme);
504 + assert!(r.content.y() > r.panel.y());
505 + assert_eq!(r.panel.height(), 400.0);
506 + }
507 +}
@@ -39,6 +39,9 @@
39 39 //! - [`ToggleGroup`] — grupa przełączników (wielokrotny wybór).
40 40 //! - [`SplitButton`] — przycisk dzielony (akcja + strzałka).
41 41 //! - [`Bin`] — kontener jednego dziecka (wcięcie + wyrównanie).
42 +//! - [`Row`] — wiersz listy (switch/combo/expander/entry/spin…) — rodzina „boxed list”.
43 +//! - [`PreferencesGroup`] / [`PreferencesPage`] — grupa i strona ustawień.
44 +//! - [`Dialog`] (+ [`AlertDialog`], [`AboutDialog`], [`ShortcutsDialog`], [`PreferencesDialog`]) — okna modalne.
42 45 //!
43 46 //! Układ (retencyjne drzewo, `measure`/`allocate`): [`layout`] ([`Node`], [`Layout`], [`Constraints`]).
44 47 //!
@@ -59,6 +62,7 @@ pub mod button;
59 62 pub mod card;
60 63 pub mod check_button;
61 64 pub mod ctx;
65 +pub mod dialogs;
62 66 pub mod dropdown;
63 67 pub mod entry;
64 68 pub mod header_bar;
@@ -69,7 +73,9 @@ pub mod menu;
69 73 pub mod nav_list;
70 74 pub mod pager;
71 75 pub mod path_bar;
76 +pub mod preferences;
72 77 pub mod radio_group;
78 +pub mod rows;
73 79 pub mod scroll_view;
74 80 pub mod search_entry;
75 81 pub mod shortcut_label;
@@ -95,6 +101,9 @@ pub use button::{Button, ButtonVariant};
95 101 pub use card::Card;
96 102 pub use check_button::CheckButton;
97 103 pub use ctx::PaintCtx;
104 +pub use dialogs::{
105 + AboutDialog, AlertDialog, AlertRects, Dialog, DialogRects, PreferencesDialog, ShortcutsDialog,
106 +};
98 107 pub use dropdown::DropDown;
99 108 pub use entry::{Entry, EntryIcon, TextInput};
100 109 pub use header_bar::{ControlsLayout, ControlsSide, HeaderBar, TitleAlign, WindowControl};
@@ -104,7 +113,9 @@ pub use menu::{Menu, MenuEntry, MenuRow};
104 113 pub use nav_list::{NavEntry, NavList, NavRow};
105 114 pub use pager::Pager;
106 115 pub use path_bar::PathBar;
116 +pub use preferences::{PreferencesGroup, PreferencesPage};
107 117 pub use radio_group::RadioGroup;
118 +pub use rows::{Row, RowRects, RowSuffix};
108 119 pub use scroll_view::ScrollView;
109 120 pub use search_entry::SearchEntry;
110 121 pub use shortcut_label::ShortcutLabel;
111 122 new file mode 100644
@@ -0,0 +1,178 @@
1 +//! `PreferencesGroup` / `PreferencesPage` — grupa ustawień i strona z grup.
2 +//!
3 +//! `PreferencesGroup` rysuje tytuł nad ramką (jak „karta”) i zwraca obszar zawartości na wiersze
4 +//! ([`Row`](crate::Row)). `PreferencesPage` układa grupy pionowo z odstępem i (opcjonalnie)
5 +//! przewija całość — aplikacja rysuje wiersze w zwróconych obszarach.
6 +
7 +use libluna_core::style::weight;
8 +use libluna_core::{Rect, Theme};
9 +
10 +use crate::ctx::PaintCtx;
11 +
12 +/// Grupa ustawień (tytuł + ramka z wierszami).
13 +#[derive(Debug, Clone)]
14 +pub struct PreferencesGroup {
15 + title: String,
16 +}
17 +
18 +impl PreferencesGroup {
19 + pub fn new(title: impl Into<String>) -> Self {
20 + Self {
21 + title: title.into(),
22 + }
23 + }
24 +
25 + pub fn title(&self) -> &str {
26 + &self.title
27 + }
28 +
29 + /// Wysokość nagłówka grupy (z metryk).
30 + pub fn header_height(&self, theme: &Theme) -> f64 {
31 + theme.metrics.group.header
32 + }
33 +
34 + /// Prostokąt wiersza `index` wewnątrz obszaru zawartości grupy.
35 + pub fn row_rect(content: Rect, index: usize, row_h: f64) -> Rect {
36 + Rect::from_xywh(
37 + content.x(),
38 + content.y() + index as f64 * row_h,
39 + content.width(),
40 + row_h,
41 + )
42 + }
43 +
44 + /// Rysuje tytuł i ramkę grupy; zwraca obszar zawartości (na wiersze).
45 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) -> Rect {
46 + let theme = *ctx.theme;
47 + let header = theme.metrics.group.header;
48 + ctx.label_left(
49 + rect.x() + theme.metrics.group.pad_x,
50 + rect.y() + header / 2.0,
51 + &self.title,
52 + theme.typography.caption.size,
53 + weight::BOLD,
54 + theme.colors.dim_text,
55 + );
56 + let box_ = Rect::from_xywh(
57 + rect.x(),
58 + rect.y() + header,
59 + rect.width(),
60 + (rect.height() - header).max(0.0),
61 + );
62 + ctx.scene
63 + .fill_rounded_rect(box_, theme.radius.large, theme.colors.card_bg);
64 + ctx.scene
65 + .stroke_rounded_rect(box_, theme.radius.large, 1.0, theme.colors.border);
66 + box_
67 + }
68 +}
69 +
70 +/// Strona ustawień: pionowy stos grup.
71 +#[derive(Debug, Clone, Default)]
72 +pub struct PreferencesPage {
73 + groups: Vec<(PreferencesGroup, usize)>,
74 +}
75 +
76 +impl PreferencesPage {
77 + pub fn new() -> Self {
78 + Self::default()
79 + }
80 +
81 + /// Dodaje grupę z liczbą wierszy.
82 + pub fn add(mut self, group: PreferencesGroup, rows: usize) -> Self {
83 + self.groups.push((group, rows));
84 + self
85 + }
86 +
87 + pub fn is_empty(&self) -> bool {
88 + self.groups.is_empty()
89 + }
90 +
91 + fn group_height(&self, rows: usize, row_h: f64, theme: &Theme) -> f64 {
92 + theme.metrics.group.header + rows as f64 * row_h
93 + }
94 +
95 + /// Wysokość całej zawartości (z odstępami między grupami).
96 + pub fn content_height(&self, row_h: f64, theme: &Theme) -> f64 {
97 + let gap = theme.metrics.group.gap;
98 + let n = self.groups.len();
99 + if n == 0 {
100 + return 0.0;
101 + }
102 + let sum: f64 = self
103 + .groups
104 + .iter()
105 + .map(|(_, rows)| self.group_height(*rows, row_h, theme))
106 + .sum();
107 + sum + gap * (n as f64 - 1.0)
108 + }
109 +
110 + /// Prostokąty ramek grup (bez nagłówków) w prostokącie `rect`.
111 + pub fn group_rects(&self, rect: Rect, row_h: f64, theme: &Theme) -> Vec<Rect> {
112 + let gap = theme.metrics.group.gap;
113 + let mut y = rect.y();
114 + let mut out = Vec::with_capacity(self.groups.len());
115 + for (_, rows) in &self.groups {
116 + let h = self.group_height(*rows, row_h, theme);
117 + out.push(Rect::from_xywh(rect.x(), y, rect.width(), h));
118 + y += h + gap;
119 + }
120 + out
121 + }
122 +
123 + /// Rysuje wszystkie grupy; zwraca obszary zawartości (na wiersze), w kolejności grup.
124 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect, row_h: f64) -> Vec<Rect> {
125 + let theme = *ctx.theme;
126 + let gap = theme.metrics.group.gap;
127 + let mut out = Vec::with_capacity(self.groups.len());
128 + let mut y = rect.y();
129 + for (group, rows) in &self.groups {
130 + let h = self.group_height(*rows, row_h, &theme);
131 + let grect = Rect::from_xywh(rect.x(), y, rect.width(), h);
132 + out.push(group.paint(ctx, grect));
133 + y += h + gap;
134 + }
135 + out
136 + }
137 +}
138 +
139 +#[cfg(test)]
140 +mod tests {
141 + use super::*;
142 +
143 + #[test]
144 + fn group_paint_returns_content_below_header() {
145 + let theme = Theme::light();
146 + let text = libluna_render::TextEngine::system();
147 + let mut scene = libluna_render::Scene::new();
148 + let mut ctx = PaintCtx {
149 + scene: &mut scene,
150 + theme: &theme,
151 + text: &text,
152 + };
153 + let group = PreferencesGroup::new("Wygląd");
154 + let rect = Rect::from_xywh(0.0, 0.0, 300.0, 200.0);
155 + let content = group.paint(&mut ctx, rect);
156 + assert_eq!(content.y(), rect.y() + theme.metrics.group.header);
157 + assert_eq!(content.width(), rect.width());
158 + }
159 +
160 + #[test]
161 + fn page_stacks_groups_with_gap() {
162 + let theme = Theme::light();
163 + let page = PreferencesPage::new()
164 + .add(PreferencesGroup::new("A"), 3)
165 + .add(PreferencesGroup::new("B"), 2);
166 + let rect = Rect::from_xywh(0.0, 0.0, 300.0, 500.0);
167 + let rects = page.group_rects(rect, 56.0, &theme);
168 + assert_eq!(rects.len(), 2);
169 + let a_h = theme.metrics.group.header + 3.0 * 56.0;
170 + assert!((rects[1].y() - (a_h + theme.metrics.group.gap)).abs() < 1e-9);
171 + assert!(
172 + (page.content_height(56.0, &theme)
173 + - (a_h + theme.metrics.group.header + 2.0 * 56.0 + theme.metrics.group.gap))
174 + .abs()
175 + < 1e-9
176 + );
177 + }
178 +}
0 179 new file mode 100644
@@ -0,0 +1,355 @@
1 +//! `Row` — wiersz listy („boxed list”): tytuł/podtytuł z lewej, kontrolka z prawej.
2 +//!
3 +//! Jeden elastyczny widget pokrywa rodzinę wierszy znaną z libadwaita — konstruktory
4 +//! [`Row::switch`], [`Row::combo`], [`Row::expander`], [`Row::entry`], [`Row::password`],
5 +//! [`Row::spin`], [`Row::button`], [`Row::nav`] i [`Row::action`] różnią się tylko kontrolką
6 +//! (suffixem). Wiersz rysuje tytuł/podtytuł oraz **proste** suffixy (przełącznik, wartość,
7 +//! chevron, przycisk); dla suffixu [`RowSuffix::Custom`] aplikacja rysuje własną kontrolkę
8 +//! we [`Row::suffix_rect`].
9 +
10 +use libluna_core::style::weight;
11 +use libluna_core::{Insets, Point, Rect, Theme};
12 +use libluna_render::TextEngine;
13 +
14 +use crate::ctx::PaintCtx;
15 +use crate::icon;
16 +use crate::switch::Switch;
17 +
18 +/// Kontrolka po prawej stronie wiersza.
19 +#[derive(Debug, Clone, PartialEq)]
20 +pub enum RowSuffix {
21 + /// Bez kontrolki.
22 + None,
23 + /// Przełącznik wł./wył. (rysowany przez wiersz).
24 + Switch { on: bool },
25 + /// Wartość + chevron (jak wybór z listy).
26 + Value(String),
27 + /// Strzałka nawigacji (chevron w prawo).
28 + Chevron,
29 + /// Chevron rozwijania (w prawo gdy zwinięte, w dół gdy rozwinięte).
30 + Expander { expanded: bool },
31 + /// Płaski przycisk z etykietą.
32 + Button(String),
33 + /// Kontrolka rysowana przez aplikację (Entry/Slider/Stepper…) o zadanej szerokości.
34 + Custom(f64),
35 +}
36 +
37 +/// Prostokąty wiersza.
38 +#[derive(Debug, Clone, Copy, PartialEq)]
39 +pub struct RowRects {
40 + pub title: Rect,
41 + pub subtitle: Option<Rect>,
42 + pub suffix: Rect,
43 +}
44 +
45 +/// Wiersz listy.
46 +#[derive(Debug, Clone)]
47 +pub struct Row {
48 + title: String,
49 + subtitle: Option<String>,
50 + suffix: RowSuffix,
51 +}
52 +
53 +/// Bok obszaru prostych suffixów (chevron).
54 +const ICON_AREA: f64 = 20.0;
55 +
56 +impl Row {
57 + fn with_suffix(title: String, subtitle: Option<String>, suffix: RowSuffix) -> Self {
58 + Self {
59 + title,
60 + subtitle,
61 + suffix,
62 + }
63 + }
64 +
65 + /// Wiersz akcji (bez kontrolki) — jak [`ActionRow`](crate::ActionRow).
66 + pub fn action(title: impl Into<String>) -> Self {
67 + Self::with_suffix(title.into(), None, RowSuffix::None)
68 + }
69 +
70 + /// Wiersz z przełącznikiem.
71 + pub fn switch(title: impl Into<String>, on: bool) -> Self {
72 + Self::with_suffix(title.into(), None, RowSuffix::Switch { on })
73 + }
74 +
75 + /// Wiersz z wyborem (wartość + chevron).
76 + pub fn combo(title: impl Into<String>, value: impl Into<String>) -> Self {
77 + Self::with_suffix(title.into(), None, RowSuffix::Value(value.into()))
78 + }
79 +
80 + /// Wiersz nawigacyjny (chevron w prawo).
81 + pub fn nav(title: impl Into<String>) -> Self {
82 + Self::with_suffix(title.into(), None, RowSuffix::Chevron)
83 + }
84 +
85 + /// Wiersz rozwijany.
86 + pub fn expander(title: impl Into<String>, expanded: bool) -> Self {
87 + Self::with_suffix(title.into(), None, RowSuffix::Expander { expanded })
88 + }
89 +
90 + /// Wiersz z płaskim przyciskiem.
91 + pub fn button(title: impl Into<String>, label: impl Into<String>) -> Self {
92 + Self::with_suffix(title.into(), None, RowSuffix::Button(label.into()))
93 + }
94 +
95 + /// Wiersz z polem tekstowym (aplikacja rysuje `Entry` w obszarze suffixu).
96 + pub fn entry(title: impl Into<String>) -> Self {
97 + Self::with_suffix(title.into(), None, RowSuffix::Custom(200.0))
98 + }
99 +
100 + /// Wiersz z polem hasła (jak [`Row::entry`], maskowane przez aplikację).
101 + pub fn password(title: impl Into<String>) -> Self {
102 + Self::with_suffix(title.into(), None, RowSuffix::Custom(200.0))
103 + }
104 +
105 + /// Wiersz z krokrowcem (aplikacja rysuje `Stepper` w obszarze suffixu).
106 + pub fn spin(title: impl Into<String>) -> Self {
107 + Self::with_suffix(title.into(), None, RowSuffix::Custom(120.0))
108 + }
109 +
110 + /// Wiersz z suwakiem (aplikacja rysuje `Slider` w obszarze suffixu).
111 + pub fn slider(title: impl Into<String>) -> Self {
112 + Self::with_suffix(title.into(), None, RowSuffix::Custom(180.0))
113 + }
114 +
115 + pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
116 + self.subtitle = Some(subtitle.into());
117 + self
118 + }
119 +
120 + pub fn suffix(mut self, suffix: RowSuffix) -> Self {
121 + self.suffix = suffix;
122 + self
123 + }
124 +
125 + pub fn title(&self) -> &str {
126 + &self.title
127 + }
128 +
129 + pub fn subtitle_text(&self) -> Option<&str> {
130 + self.subtitle.as_deref()
131 + }
132 +
133 + pub fn suffix_kind(&self) -> &RowSuffix {
134 + &self.suffix
135 + }
136 +
137 + pub fn is_switch_on(&self) -> bool {
138 + matches!(self.suffix, RowSuffix::Switch { on: true })
139 + }
140 +
141 + pub fn is_expanded(&self) -> bool {
142 + matches!(self.suffix, RowSuffix::Expander { expanded: true })
143 + }
144 +
145 + /// Prostokąt kontrolki (suffixu), wyrównany do prawej i wyśrodkowany pionowo.
146 + pub fn suffix_rect(&self, rect: Rect, theme: &Theme, text: &TextEngine) -> Rect {
147 + let pad = theme.metrics.row.pad_x;
148 + let cy = rect.center().y;
149 + let body = theme.typography.body.size;
150 + match &self.suffix {
151 + RowSuffix::None => Rect::from_xywh(rect.right() - pad, cy, 0.0, 0.0),
152 + RowSuffix::Switch { .. } => {
153 + let m = theme.metrics.switch;
154 + Rect::from_xywh(
155 + rect.right() - pad - m.width,
156 + cy - m.height / 2.0,
157 + m.width,
158 + m.height,
159 + )
160 + }
161 + RowSuffix::Value(value) => {
162 + let bm = theme.metrics.button;
163 + let w =
164 + text.measure(value, body, weight::NORMAL).width + bm.chevron_gap + bm.chevron;
165 + Rect::from_xywh(rect.right() - pad - w, cy - 12.0, w, 24.0)
166 + }
167 + RowSuffix::Chevron | RowSuffix::Expander { .. } => Rect::from_xywh(
168 + rect.right() - pad - ICON_AREA,
169 + cy - ICON_AREA / 2.0,
170 + ICON_AREA,
171 + ICON_AREA,
172 + ),
173 + RowSuffix::Button(label) => {
174 + let bm = theme.metrics.button;
175 + let w = 2.0 * bm.pad_x + text.measure(label, body, weight::MEDIUM).width;
176 + Rect::from_xywh(rect.right() - pad - w, cy - bm.height / 2.0, w, bm.height)
177 + }
178 + RowSuffix::Custom(w) => {
179 + Rect::from_xywh(rect.right() - pad - w, rect.y(), *w, rect.height())
180 + }
181 + }
182 + }
183 +
184 + /// Prostokąty: tytuł, ewentualny podtytuł i kontrolka.
185 + pub fn rects(&self, rect: Rect, theme: &Theme, text: &TextEngine) -> RowRects {
186 + let pad = theme.metrics.row.pad_x;
187 + let suffix = self.suffix_rect(rect, theme, text);
188 + let left = rect.x() + pad;
189 + let width = (suffix.x() - left - theme.metrics.row.gap).max(0.0);
190 + let pad_y = 8.0;
191 + let (title, subtitle) = match self.subtitle.as_deref() {
192 + Some(_) => (
193 + Rect::from_xywh(left, rect.y() + pad_y, width, rect.height() / 2.0 - pad_y),
194 + Some(Rect::from_xywh(
195 + left,
196 + rect.center().y,
197 + width,
198 + rect.height() / 2.0 - pad_y,
199 + )),
200 + ),
201 + None => (Rect::from_xywh(left, rect.y(), width, rect.height()), None),
202 + };
203 + RowRects {
204 + title,
205 + subtitle,
206 + suffix,
207 + }
208 + }
209 +
210 + /// Rysuje tytuł/podtytuł i prosty suffix; zwraca prostokąty.
211 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) -> RowRects {
212 + let theme = *ctx.theme;
213 + let colors = theme.colors;
214 + let r = self.rects(rect, &theme, ctx.text);
215 +
216 + match &self.subtitle {
217 + Some(subtitle) => {
218 + ctx.label_left(
219 + r.title.x(),
220 + r.title.center().y,
221 + &self.title,
222 + theme.typography.body.size,
223 + weight::NORMAL,
224 + colors.text,
225 + );
226 + if let Some(sr) = r.subtitle {
227 + ctx.label_left(
228 + sr.x(),
229 + sr.center().y,
230 + subtitle,
231 + theme.typography.caption.size,
232 + weight::NORMAL,
233 + colors.dim_text,
234 + );
235 + }
236 + }
237 + None => ctx.label_left(
238 + r.title.x(),
239 + r.title.center().y,
240 + &self.title,
241 + theme.typography.body.size,
242 + weight::NORMAL,
243 + colors.text,
244 + ),
245 + }
246 +
247 + let body = theme.typography.body.size;
248 + match &self.suffix {
249 + RowSuffix::None => {}
250 + RowSuffix::Switch { on } => {
251 + Switch::new(*on).paint(ctx, r.suffix);
252 + }
253 + RowSuffix::Value(value) => {
254 + let bm = theme.metrics.button;
255 + let text_right = r.suffix.right() - bm.chevron - bm.chevron_gap;
256 + ctx.label_right(
257 + text_right,
258 + r.suffix.center().y,
259 + value,
260 + body,
261 + weight::NORMAL,
262 + colors.dim_text,
263 + );
264 + icon::chevron_down(
265 + ctx,
266 + colors.dim_text,
267 + Point::new(r.suffix.right() - bm.chevron / 2.0, r.suffix.center().y),
268 + 4.0,
269 + 2.0,
270 + );
271 + }
272 + RowSuffix::Chevron => {
273 + icon::chevron_right(ctx, colors.dim_text, r.suffix.center(), 4.0, 2.0);
274 + }
275 + RowSuffix::Expander { expanded } => {
276 + if *expanded {
277 + icon::chevron_down(ctx, colors.dim_text, r.suffix.center(), 4.0, 2.0);
278 + } else {
279 + icon::chevron_right(ctx, colors.dim_text, r.suffix.center(), 4.0, 2.0);
280 + }
281 + }
282 + RowSuffix::Button(label) => {
283 + ctx.scene
284 + .fill_rounded_rect(r.suffix, theme.radius.small, colors.shade);
285 + ctx.label_center(
286 + r.suffix.center().x,
287 + r.suffix.center().y,
288 + label,
289 + body,
290 + weight::MEDIUM,
291 + colors.text,
292 + );
293 + }
294 + RowSuffix::Custom(_) => {}
295 + }
296 + r
297 + }
298 +
299 + /// Czy punkt trafia w obszar kontrolki (z tolerancją).
300 + pub fn suffix_at(&self, rect: Rect, theme: &Theme, text: &TextEngine, p: Point) -> bool {
301 + if matches!(self.suffix, RowSuffix::None) {
302 + return false;
303 + }
304 + self.suffix_rect(rect, theme, text)
305 + .inflate(Insets::splat(4.0))
306 + .contains(p)
307 + }
308 +}
309 +
310 +#[cfg(test)]
311 +mod tests {
312 + use super::*;
313 +
314 + fn rect() -> Rect {
315 + Rect::from_xywh(0.0, 0.0, 360.0, 56.0)
316 + }
317 +
318 + #[test]
319 + fn switch_row_suffix_hit() {
320 + let theme = Theme::light();
321 + let text = TextEngine::system();
322 + let row = Row::switch("Motyw", true);
323 + assert!(row.is_switch_on());
324 + let r = row.rects(rect(), &theme, &text);
325 + assert!(row.suffix_at(rect(), &theme, &text, r.suffix.center()));
326 + assert!(!row.suffix_at(rect(), &theme, &text, Point::new(5.0, 5.0)));
327 + assert!(r.suffix.right() <= rect().right());
328 + assert!(r.title.right() <= r.suffix.x());
329 + }
330 +
331 + #[test]
332 + fn constructors_map_to_suffix_kinds() {
333 + assert_eq!(
334 + Row::combo("Język", "Polski").suffix_kind(),
335 + &RowSuffix::Value("Polski".into())
336 + );
337 + assert!(Row::expander("Więcej", true).is_expanded());
338 + assert!(matches!(
339 + Row::entry("Nazwa").suffix_kind(),
340 + RowSuffix::Custom(_)
341 + ));
342 + assert!(matches!(
343 + Row::nav("O nas").suffix_kind(),
344 + RowSuffix::Chevron
345 + ));
346 + }
347 +
348 + #[test]
349 + fn action_row_has_no_suffix_hit() {
350 + let theme = Theme::light();
351 + let text = TextEngine::system();
352 + let row = Row::action("Tylko tekst");
353 + assert!(!row.suffix_at(rect(), &theme, &text, rect().center()));
354 + }
355 +}