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
+}