Commit 3721027
17
files
+832
added
-12
deleted
@@ -89,7 +89,7 @@ libluna/
89
89
libluna-i18n/ # wielojęzyczność: każdy język w pliku i18n/<kod>.lang + przełączanie w runtime
90
90
libluna-render/ # graf sceny + backend software (tiny-skia + fontdue)
91
91
libluna-platform/ # Wayland: xdg-shell (CSD), wl_shm, wejście, pętla zdarzeń, motyw w Frame
92
- libluna-widgets/ # widgety: ViewSwitcher, NavList, Menu, Button, Switch, Slider, DropDown, HeaderBar, TabBar/TabView, BottomSheet, Pager, PathBar, SearchEntry, ItemView, ScrollView
92
+ libluna-widgets/ # widgety: ViewSwitcher, NavList, Menu, Button, Switch, Slider, DropDown, HeaderBar, TabBar/TabView, BottomSheet, Pager, PathBar, SearchEntry, ItemView, ScrollView, Stepper, CheckButton, RadioGroup
93
93
apps/
94
94
luna-demo/ # demo aplikacji środowiska Luna; też render offline do PNG
95
95
templates/
@@ -192,7 +192,9 @@ cargo run -p libluna --example minimal
192
192
193
193
- **Mysz** — kliknij ikonę **`◧`** w HeaderBarze, aby zwinąć/rozwinąć pasek boczny
194
194
(ikona zmienia się w `▭`), **`☰`** aby otworzyć menu (zamyka się klikiem poza panelem),
195
- lub kontrolkę **języka** (`DropDown`). Z menu **`☰` → „O aplikacji”** otwiera się **dolny panel**
195
+ lub kontrolkę **języka** (`DropDown`). Przycisk **„Opcje ▾”** otwiera **popover opcji**
196
+ (`Stepper` rozmiaru ikon, `RadioGroup` sortowania, `CheckButton` ukrytych plików).
197
+ Z menu **`☰` → „O aplikacji”** otwiera się **dolny panel**
196
198
(`BottomSheet` z karuzelą stron `Pager`), zamykany klikiem w tło, przyciskiem `✕` lub
197
199
przeciągnięciem w dół. W karcie **Pliki** kliknij element **okruszków** (nawigacja „w górę”),
198
200
**pole wyszukiwania** (fokus) lub **plik/katalog** (zaznaczenie); przełącznik `Lista | Siatka`
@@ -224,7 +226,7 @@ w `~/.config/luna/theme.conf`.
224
226
Rozmiary i paddingi kontrolek nie są już stałymi w widgetach, lecz **tokenami**
225
227
(`Theme::metrics`) podzielonymi na sekcje per widget: `headerbar`, `button`, `switch`,
226
228
`slider`, `dropdown`, `menu`, `nav`, `segmented`, `tabs`, `sheet`, `pager`, `pathbar`, `entry`,
227
-`itemview`, `scrollview`. Dzięki temu wygląd steruje **także
229
+`itemview`, `scrollview`, `stepper`, `check`, `radio`. Dzięki temu wygląd steruje **także
228
230
geometrią**, nie tylko kolorami — np. `theme.metrics.switch.width` albo
229
231
`theme.metrics.headerbar.height`. Widgety czytają te wartości w `paint`/`layout`, więc
230
232
przyszły, instalowany styl może zmienić np. wysokość paska tytułu czy rozmiar przełącznika
@@ -298,6 +300,7 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
298
300
`PathBar` (okruszki z separatorem `›`, wskaźnikiem `▾` i zwijaniem do `…`), `SearchEntry`
299
301
(pole wyszukiwania: lupka, kursor, czyszczenie), `ItemView` (pliki/katalogi w siatce lub
300
302
liście, z zaznaczaniem i przewijaniem) i `ScrollView` (pionowe przewijanie: rolka + „kciuk”)
303
+ oraz `Stepper`, `CheckButton` i `RadioGroup` (krokowiec, pole wyboru, wybór jednokrotny)
301
304
oraz wspólny `PaintCtx`.
302
305
- `luna-demo`: `HeaderBar` jako widget (tytuł + `[—][□][✕]` po prawej + treść: `◧`, `+`,
303
306
„Opcje ▾”, `☰`); **popover `☰`**, chowany i rozciągany (drag) panel boczny (`NavList`),
@@ -186,6 +186,19 @@ fn main() -> Result<(), Box<dyn Error>> {
186
186
frames.push(render_pixels(&settings, &demo, &text, size));
187
187
labels.push("10. Wybór języka (DropDown)".into());
188
188
189
+ // 11. Popover „Opcje” (rozmiar ikon / sortowanie / ukryte pliki).
190
+ click!(Point::new(450.0, 300.0)); // zamknij popover języka
191
+ assert!(demo.popover.is_none(), "popover się nie zamknął");
192
+ let c = layout!().options_button.center();
193
+ click!(c);
194
+ assert_eq!(
195
+ demo.popover,
196
+ Some(Popover::Options),
197
+ "popover opcji się nie otworzył"
198
+ );
199
+ frames.push(render_pixels(&settings, &demo, &text, size));
200
+ labels.push("11. Popover \u{201e}Opcje\u{201d}".into());
201
+
189
202
// Zapis klatek.
190
203
for (i, pixels) in frames.iter().enumerate() {
191
204
let path = format!("docs/images/demo-step-{}.png", i + 1);
@@ -11,9 +11,10 @@ use libluna_core::{
11
11
use libluna_i18n::{tr, Locale};
12
12
use libluna_render::TextEngine;
13
13
use libluna_widgets::{
14
- BottomSheet, Button, ControlsLayout, ControlsSide, DropDown, HeaderBar, Item, ItemView, Menu,
15
- MenuEntry, NavEntry, NavList, Pager, PaintCtx, PathBar, ScrollView, SearchEntry, Slider,
16
- Switch, Tab, TabBar, TabView, ViewMode, ViewSwitcher, WindowControl,
14
+ BottomSheet, Button, CheckButton, ControlsLayout, ControlsSide, DropDown, HeaderBar, Item,
15
+ ItemView, Menu, MenuEntry, NavEntry, NavList, Pager, PaintCtx, PathBar, RadioGroup, ScrollView,
16
+ SearchEntry, Slider, Stepper, StepperSide, Switch, Tab, TabBar, TabView, ViewMode,
17
+ ViewSwitcher, WindowControl,
17
18
};
18
19
19
20
const STATUS_H: f64 = 28.0;
@@ -36,6 +37,8 @@ pub enum Popover {
36
37
Menu,
37
38
/// Lista wyboru języka pod kontrolką języka.
38
39
Language,
40
+ /// Popover opcji widoku pod przyciskiem „Opcje ▾”.
41
+ Options,
39
42
}
40
43
41
44
/// Stan aplikacji demo, który nie należy do warstwy platformy ani do widgetów.
@@ -81,6 +84,13 @@ pub struct Demo {
81
84
/// Przewijanie listy plików i stan przeciągania paska.
82
85
pub files_scroll: ScrollView,
83
86
pub scroll_dragging: bool,
87
+ /// Stan popovera „Opcje” (rozmiar ikon, sortowanie, ukryte pliki) i podświetlenia.
88
+ pub icon_size: i64,
89
+ pub sort: usize,
90
+ pub show_hidden: bool,
91
+ pub options_stepper_hover: Option<StepperSide>,
92
+ pub options_radio_hover: Option<usize>,
93
+ pub options_check_hover: bool,
84
94
}
85
95
86
96
impl Default for Demo {
@@ -136,6 +146,12 @@ impl Default for Demo {
136
146
items_hover: None,
137
147
files_scroll: ScrollView::new(),
138
148
scroll_dragging: false,
149
+ icon_size: 32,
150
+ sort: 0,
151
+ show_hidden: false,
152
+ options_stepper_hover: None,
153
+ options_radio_hover: None,
154
+ options_check_hover: false,
139
155
}
140
156
}
141
157
}
@@ -301,6 +317,8 @@ pub fn popover_menu(
301
317
let w = menu.width(text, theme);
302
318
Some((menu.place(lay.language, w, viewport, theme), menu))
303
319
}
320
+ // Popover „Opcje” rysowany osobno (własny układ).
321
+ Popover::Options => None,
304
322
}
305
323
}
306
324
@@ -567,6 +585,27 @@ pub fn handle_events(
567
585
continue;
568
586
}
569
587
588
+ // Popover „Opcje” obsługuje własne kontrolki.
589
+ if demo.popover == Some(Popover::Options) {
590
+ let ol = options_layout(size, theme, &lay);
591
+ let mut stepper =
592
+ Stepper::new(demo.icon_size, ICON_MIN, ICON_MAX).step(8);
593
+ let mut radios = sort_options();
594
+ radios.set_selected(demo.sort);
595
+ let mut check = CheckButton::new(tr("Show hidden files"));
596
+ check.set_checked(demo.show_hidden);
597
+ if stepper.click_at(ol.stepper, theme, p) {
598
+ demo.icon_size = stepper.value();
599
+ } else if radios.click_at(ol.radios, p, theme) {
600
+ demo.sort = radios.selected();
601
+ } else if check.click_at(ol.check, p) {
602
+ demo.show_hidden = check.checked();
603
+ } else if !ol.panel.contains(p) {
604
+ demo.popover = None;
605
+ }
606
+ continue;
607
+ }
608
+
570
609
// Gdy popover otwarty, klik wybiera pozycję albo zamyka.
571
610
if demo.popover.is_some() {
572
611
if let (Some((panel, menu)), Some(kind)) = (&popover, demo.popover) {
@@ -600,6 +639,8 @@ pub fn handle_events(
600
639
}
601
640
} else if lay.burger.contains(p) {
602
641
demo.popover = Some(Popover::Menu);
642
+ } else if lay.options_button.contains(p) {
643
+ demo.popover = Some(Popover::Options);
603
644
} else if lay.language.contains(p) {
604
645
demo.popover = Some(Popover::Language);
605
646
} else if lay.sidebar_handle.contains(p) {
@@ -678,6 +719,14 @@ pub fn handle_events(
678
719
}
679
720
continue;
680
721
}
722
+ if demo.popover == Some(Popover::Options) {
723
+ let ol = options_layout(size, theme, &lay);
724
+ demo.options_stepper_hover = Stepper::new(demo.icon_size, ICON_MIN, ICON_MAX)
725
+ .side_at(ol.stepper, theme, position);
726
+ demo.options_radio_hover = sort_options().index_at(ol.radios, position, theme);
727
+ demo.options_check_hover = CheckButton::new("").hit(ol.check, position);
728
+ continue;
729
+ }
681
730
if demo.dragging_sidebar && demo.sidebar_visible {
682
731
demo.sidebar_width = position.x.clamp(MIN_SIDEBAR_W, MAX_SIDEBAR_W);
683
732
}
@@ -852,6 +901,11 @@ pub fn build_scene(
852
901
menu.paint(&mut paint, panel);
853
902
}
854
903
904
+ // Popover „Opcje” (rozmiar ikon / sortowanie / ukryte pliki).
905
+ if demo.popover == Some(Popover::Options) {
906
+ draw_options(&mut paint, size, &lay, demo);
907
+ }
908
+
855
909
// Dolny panel (modalny) rysowany na samym wierzchu.
856
910
if demo.sheet.is_open() {
857
911
let mut sheet = demo.sheet.clone();
@@ -1241,3 +1295,148 @@ fn draw_sidebar_icon(paint: &mut PaintCtx, color: Color, box_: Rect, filled: boo
1241
1295
paint.scene.fill_rounded_rect(inner, 2.0, color);
1242
1296
}
1243
1297
}
1298
+
1299
+// --- Popover „Opcje” (rozmiar ikon, sortowanie, ukryte pliki) ---
1300
+
1301
+const ICON_MIN: i64 = 16;
1302
+const ICON_MAX: i64 = 64;
1303
+const OPTIONS_W: f64 = 260.0;
1304
+
1305
+/// Rozkład popovera opcji.
1306
+pub struct OptionsLayout {
1307
+ pub panel: Rect,
1308
+ pub stepper: Rect,
1309
+ pub radios: Rect,
1310
+ pub check: Rect,
1311
+ pub icon_label_cy: f64,
1312
+ pub sort_label_cy: f64,
1313
+ pub sep1_y: f64,
1314
+ pub sep2_y: f64,
1315
+}
1316
+
1317
+fn sort_options() -> RadioGroup {
1318
+ RadioGroup::new([tr("Name"), tr("Date modified"), tr("Size")])
1319
+}
1320
+
1321
+/// Liczy rozkład popovera opcji (pod przyciskiem „Opcje ▾”).
1322
+pub fn options_layout(size: Size, theme: &Theme, lay: &Layout) -> OptionsLayout {
1323
+ let md = theme.spacing.md;
1324
+ let stepper_h = theme.metrics.stepper.height;
1325
+ let stepper_w = theme.metrics.stepper.width;
1326
+ let radio_h = 3.0 * theme.metrics.radio.row_h;
1327
+ let check_h = theme.metrics.check.size.max(24.0);
1328
+ let row = stepper_h;
1329
+ let cap = 20.0;
1330
+ let sep = 1.0;
1331
+
1332
+ let h = md + row + md + sep + md + cap + radio_h + md + sep + md + check_h + md;
1333
+ let anchor = lay.options_button;
1334
+ let mut x = anchor.x();
1335
+ if x + OPTIONS_W > size.width - 6.0 {
1336
+ x = size.width - 6.0 - OPTIONS_W;
1337
+ }
1338
+ x = x.max(6.0);
1339
+ let mut y = anchor.bottom() + 6.0;
1340
+ if y + h > size.height - 6.0 {
1341
+ y = anchor.y() - h - 6.0;
1342
+ }
1343
+ y = y.max(6.0);
1344
+ let panel = Rect::from_xywh(x, y, OPTIONS_W, h);
1345
+
1346
+ let icon_label_cy = panel.y() + md + row / 2.0;
1347
+ let stepper = Rect::from_xywh(
1348
+ panel.right() - md - stepper_w,
1349
+ panel.y() + md + (row - stepper_h) / 2.0,
1350
+ stepper_w,
1351
+ stepper_h,
1352
+ );
1353
+ let sep1_y = panel.y() + md + row + md;
1354
+ let sort_label_cy = sep1_y + md + cap / 2.0;
1355
+ let radios = Rect::from_xywh(
1356
+ panel.x() + md,
1357
+ sep1_y + md + cap,
1358
+ panel.width() - 2.0 * md,
1359
+ radio_h,
1360
+ );
1361
+ let sep2_y = sep1_y + md + cap + radio_h + md;
1362
+ let check = Rect::from_xywh(
1363
+ panel.x() + md,
1364
+ sep2_y + md,
1365
+ panel.width() - 2.0 * md,
1366
+ check_h,
1367
+ );
1368
+ OptionsLayout {
1369
+ panel,
1370
+ stepper,
1371
+ radios,
1372
+ check,
1373
+ icon_label_cy,
1374
+ sort_label_cy,
1375
+ sep1_y,
1376
+ sep2_y,
1377
+ }
1378
+}
1379
+
1380
+fn draw_options(paint: &mut PaintCtx, size: Size, lay: &Layout, demo: &Demo) {
1381
+ let theme = *paint.theme;
1382
+ let md = theme.spacing.md;
1383
+ let body = theme.typography.body.size;
1384
+ let ol = options_layout(size, &theme, lay);
1385
+
1386
+ paint
1387
+ .scene
1388
+ .fill_rounded_rect(ol.panel, theme.radius.medium, theme.colors.card_bg);
1389
+ paint
1390
+ .scene
1391
+ .stroke_rounded_rect(ol.panel, theme.radius.medium, 1.0, theme.colors.border);
1392
+
1393
+ paint.label_left(
1394
+ ol.panel.x() + md,
1395
+ ol.icon_label_cy,
1396
+ &tr("Icon size"),
1397
+ body,
1398
+ weight::NORMAL,
1399
+ theme.colors.text,
1400
+ );
1401
+ let mut stepper = Stepper::new(demo.icon_size, ICON_MIN, ICON_MAX).step(8);
1402
+ stepper.set_hovered(demo.options_stepper_hover);
1403
+ stepper.paint(paint, ol.stepper);
1404
+
1405
+ paint.scene.fill_rect(
1406
+ Rect::from_xywh(
1407
+ ol.panel.x() + md,
1408
+ ol.sep1_y,
1409
+ (ol.panel.width() - 2.0 * md).max(0.0),
1410
+ 1.0,
1411
+ ),
1412
+ theme.colors.border,
1413
+ );
1414
+
1415
+ paint.label_left(
1416
+ ol.panel.x() + md,
1417
+ ol.sort_label_cy,
1418
+ &tr("Sort by"),
1419
+ body,
1420
+ weight::NORMAL,
1421
+ theme.colors.text,
1422
+ );
1423
+ let mut radios = sort_options();
1424
+ radios.set_selected(demo.sort);
1425
+ radios.set_hovered(demo.options_radio_hover);
1426
+ radios.paint(paint, ol.radios);
1427
+
1428
+ paint.scene.fill_rect(
1429
+ Rect::from_xywh(
1430
+ ol.panel.x() + md,
1431
+ ol.sep2_y,
1432
+ (ol.panel.width() - 2.0 * md).max(0.0),
1433
+ 1.0,
1434
+ ),
1435
+ theme.colors.border,
1436
+ );
1437
+
1438
+ let mut check = CheckButton::new(tr("Show hidden files"));
1439
+ check.set_checked(demo.show_hidden);
1440
+ check.set_hovered(demo.options_check_hover);
1441
+ check.paint(paint, ol.check);
1442
+}
@@ -6,7 +6,7 @@ use libluna_core::{
6
6
ScrollEvent, Size, ThemePreference, ThemeSettings,
7
7
};
8
8
use libluna_render::TextEngine;
9
-use libluna_widgets::ViewSwitcher;
9
+use libluna_widgets::{RadioGroup, ViewSwitcher};
10
10
use luna_demo::ui::{self, make_nav, Demo, Popover};
11
11
12
12
fn size() -> Size {
@@ -181,6 +181,28 @@ fn scrolling_files_moves_offset() {
181
181
assert!(demo.items.scroll() > 0.0);
182
182
}
183
183
184
+#[test]
185
+fn options_popover_selects_sort() {
186
+ let mut settings = ThemeSettings::default();
187
+ let mut demo = Demo::default();
188
+
189
+ // Otwórz popover „Opcje”.
190
+ let btn = layout(&settings, &demo).options_button.center();
191
+ click(&mut settings, &mut demo, btn);
192
+ assert_eq!(demo.popover, Some(Popover::Options));
193
+
194
+ // Klik w 3. opcję sortowania (Rozmiar).
195
+ let theme = settings.resolve(false);
196
+ let ol = ui::options_layout(size(), &theme, &layout(&settings, &demo));
197
+ let row = RadioGroup::new(["a", "b", "c"])
198
+ .row_rect(ol.radios, 2, &theme)
199
+ .center();
200
+ click(&mut settings, &mut demo, row);
201
+ assert_eq!(demo.sort, 2);
202
+ // Popover zostaje otwarty po kliknięciu wewnątrz.
203
+ assert_eq!(demo.popover, Some(Popover::Options));
204
+}
205
+
184
206
#[test]
185
207
fn clicking_sidebar_icon_toggles_and_back() {
186
208
let mut settings = ThemeSettings::default();
@@ -26,8 +26,8 @@ pub use event::{
26
26
pub use geometry::{Insets, Point, Rect, Size};
27
27
pub use scale::Scale;
28
28
pub use style::{
29
- BottomSheetMetrics, ButtonMetrics, DropDownMetrics, EntryMetrics, HeaderBarMetrics,
30
- ItemViewMetrics, MenuMetrics, Metrics, NavMetrics, PagerMetrics, PathBarMetrics, Radius,
31
- ScrollViewMetrics, SegmentedMetrics, SliderMetrics, Spacing, SwitchMetrics, TabsMetrics, Theme,
32
- Typography,
29
+ BottomSheetMetrics, ButtonMetrics, CheckMetrics, DropDownMetrics, EntryMetrics,
30
+ HeaderBarMetrics, ItemViewMetrics, MenuMetrics, Metrics, NavMetrics, PagerMetrics,
31
+ PathBarMetrics, RadioMetrics, Radius, ScrollViewMetrics, SegmentedMetrics, SliderMetrics,
32
+ Spacing, StepperMetrics, SwitchMetrics, TabsMetrics, Theme, Typography,
33
33
};
@@ -398,6 +398,37 @@ pub struct ScrollViewMetrics {
398
398
pub margin: f64,
399
399
}
400
400
401
+/// Geometria krokowca ([`Stepper`](crate::style)).
402
+#[derive(Debug, Clone, Copy, PartialEq)]
403
+pub struct StepperMetrics {
404
+ /// Wysokość kontrolki.
405
+ pub height: f64,
406
+ /// Domyślna szerokość.
407
+ pub width: f64,
408
+ /// Bok przycisku `−`/`+`.
409
+ pub button: f64,
410
+}
411
+
412
+/// Geometria pola wyboru ([`CheckButton`](crate::style)).
413
+#[derive(Debug, Clone, Copy, PartialEq)]
414
+pub struct CheckMetrics {
415
+ /// Bok kwadratu (checkbox).
416
+ pub size: f64,
417
+ /// Odstęp kwadratu od etykiety.
418
+ pub gap: f64,
419
+}
420
+
421
+/// Geometria grupy opcji ([`RadioGroup`](crate::style)).
422
+#[derive(Debug, Clone, Copy, PartialEq)]
423
+pub struct RadioMetrics {
424
+ /// Średnica kółka.
425
+ pub dot: f64,
426
+ /// Odstęp kółka od etykiety.
427
+ pub gap: f64,
428
+ /// Wysokość wiersza opcji.
429
+ pub row_h: f64,
430
+}
431
+
401
432
/// Geometria kontrolek („component metrics”) — rozmiary i paddingi w pikselach logicznych.
402
433
///
403
434
/// Widgety nie mają już własnych stałych rozmiarów: czytają je z motywu, dzięki czemu
@@ -420,6 +451,9 @@ pub struct Metrics {
420
451
pub entry: EntryMetrics,
421
452
pub itemview: ItemViewMetrics,
422
453
pub scrollview: ScrollViewMetrics,
454
+ pub stepper: StepperMetrics,
455
+ pub check: CheckMetrics,
456
+ pub radio: RadioMetrics,
423
457
}
424
458
425
459
impl Metrics {
@@ -514,6 +548,20 @@ impl Metrics {
514
548
min_thumb: 24.0,
515
549
margin: 2.0,
516
550
},
551
+ stepper: StepperMetrics {
552
+ height: 34.0,
553
+ width: 120.0,
554
+ button: 34.0,
555
+ },
556
+ check: CheckMetrics {
557
+ size: 20.0,
558
+ gap: 10.0,
559
+ },
560
+ radio: RadioMetrics {
561
+ dot: 20.0,
562
+ gap: 10.0,
563
+ row_h: 30.0,
564
+ },
517
565
}
518
566
}
519
567
}
@@ -45,3 +45,9 @@ Files = Dateien
45
45
Report = Bericht
46
46
Notes = Notizen
47
47
Budget = Budget
48
+Icon size = Symbolgröße
49
+Sort by = Sortieren nach
50
+Name = Name
51
+Date modified = Änderungsdatum
52
+Size = Größe
53
+Show hidden files = Versteckte Dateien anzeigen
@@ -46,3 +46,9 @@ Files = Files
46
46
Report = Report
47
47
Notes = Notes
48
48
Budget = Budget
49
+Icon size = Icon size
50
+Sort by = Sort by
51
+Name = Name
52
+Date modified = Date modified
53
+Size = Size
54
+Show hidden files = Show hidden files
@@ -45,3 +45,9 @@ Files = Pliki
45
45
Report = Raport
46
46
Notes = Notatki
47
47
Budget = Budżet
48
+Icon size = Rozmiar ikon
49
+Sort by = Sortuj według
50
+Name = Nazwy
51
+Date modified = Daty modyfikacji
52
+Size = Rozmiar
53
+Show hidden files = Pokazuj ukryte pliki
48
54
new file mode 100644
@@ -0,0 +1,133 @@
1
+//! `CheckButton` — pole wyboru (checkbox) z etykietą.
2
+//!
3
+//! Widget immediate: trzyma stan zaznaczenia. Aplikacja woła [`CheckButton::paint`] oraz
4
+//! [`CheckButton::click_at`].
5
+
6
+use libluna_core::style::weight;
7
+use libluna_core::{Point, Rect, Theme};
8
+
9
+use crate::ctx::PaintCtx;
10
+
11
+/// Pole wyboru.
12
+#[derive(Debug, Clone, Default)]
13
+pub struct CheckButton {
14
+ label: String,
15
+ checked: bool,
16
+ hovered: bool,
17
+}
18
+
19
+impl CheckButton {
20
+ pub fn new(label: impl Into<String>) -> Self {
21
+ Self {
22
+ label: label.into(),
23
+ ..Self::default()
24
+ }
25
+ }
26
+
27
+ pub fn label(&self) -> &str {
28
+ &self.label
29
+ }
30
+
31
+ pub fn checked(&self) -> bool {
32
+ self.checked
33
+ }
34
+
35
+ pub fn set_checked(&mut self, checked: bool) {
36
+ self.checked = checked;
37
+ }
38
+
39
+ pub fn set_hovered(&mut self, hovered: bool) {
40
+ self.hovered = hovered;
41
+ }
42
+
43
+ /// Prostokąt kwadratu (checkbox) po lewej stronie wiersza.
44
+ pub fn box_rect(&self, rect: Rect, theme: &Theme) -> Rect {
45
+ let size = theme.metrics.check.size;
46
+ Rect::from_xywh(rect.x(), rect.center().y - size / 2.0, size, size)
47
+ }
48
+
49
+ pub fn hit(&self, rect: Rect, p: Point) -> bool {
50
+ rect.contains(p)
51
+ }
52
+
53
+ /// Przełącza zaznaczenie, jeśli kliknięto w obszar. Zwraca `true` przy trafieniu.
54
+ pub fn click_at(&mut self, rect: Rect, p: Point) -> bool {
55
+ if rect.contains(p) {
56
+ self.checked = !self.checked;
57
+ true
58
+ } else {
59
+ false
60
+ }
61
+ }
62
+
63
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
64
+ let theme = *ctx.theme;
65
+ let colors = theme.colors;
66
+ let radius = theme.radius.small;
67
+ let b = self.box_rect(rect, &theme);
68
+
69
+ if self.checked {
70
+ ctx.scene.fill_rounded_rect(b, radius, colors.accent);
71
+ // „Ptaszek”.
72
+ let s = b.width();
73
+ ctx.scene.stroke_line(
74
+ Point::new(b.x() + s * 0.24, b.y() + s * 0.54),
75
+ Point::new(b.x() + s * 0.43, b.y() + s * 0.72),
76
+ 2.0,
77
+ colors.accent_fg,
78
+ );
79
+ ctx.scene.stroke_line(
80
+ Point::new(b.x() + s * 0.43, b.y() + s * 0.72),
81
+ Point::new(b.x() + s * 0.78, b.y() + s * 0.28),
82
+ 2.0,
83
+ colors.accent_fg,
84
+ );
85
+ } else {
86
+ let fill = if self.hovered {
87
+ colors.shade.scale_alpha(1.8)
88
+ } else {
89
+ colors.shade
90
+ };
91
+ ctx.scene.fill_rounded_rect(b, radius, fill);
92
+ ctx.scene.stroke_rounded_rect(b, radius, 1.0, colors.border);
93
+ }
94
+
95
+ ctx.label_left(
96
+ b.right() + theme.metrics.check.gap,
97
+ rect.center().y,
98
+ &self.label,
99
+ theme.typography.body.size,
100
+ weight::NORMAL,
101
+ colors.text,
102
+ );
103
+ }
104
+}
105
+
106
+#[cfg(test)]
107
+mod tests {
108
+ use super::*;
109
+
110
+ fn rect() -> Rect {
111
+ Rect::from_xywh(0.0, 0.0, 200.0, 34.0)
112
+ }
113
+
114
+ #[test]
115
+ fn click_toggles_only_inside() {
116
+ let mut c = CheckButton::new("Pokazuj ukryte");
117
+ assert!(!c.checked());
118
+ assert!(c.click_at(rect(), Point::new(10.0, 17.0)));
119
+ assert!(c.checked());
120
+ assert!(!c.click_at(rect(), Point::new(500.0, 17.0)));
121
+ assert!(c.checked());
122
+ }
123
+
124
+ #[test]
125
+ fn box_is_left_aligned_and_centered() {
126
+ let theme = Theme::light();
127
+ let c = CheckButton::new("X");
128
+ let b = c.box_rect(rect(), &theme);
129
+ assert_eq!(b.x(), rect().x());
130
+ assert!((b.center().y - rect().center().y).abs() < 1e-9);
131
+ assert!((b.width() - theme.metrics.check.size).abs() < 1e-9);
132
+ }
133
+}
@@ -23,6 +23,9 @@
23
23
//! - [`SearchEntry`] — pole tekstowe wyszukiwania (lupka, kursor, czyszczenie).
24
24
//! - [`ItemView`] — widok elementów (pliki/katalogi) w siatce lub liście.
25
25
//! - [`ScrollView`] — przewijanie pionowe (rolka + „kciuk”).
26
+//! - [`Stepper`] — krokowiec (`−` wartość `+`).
27
+//! - [`CheckButton`] — pole wyboru (checkbox) z etykietą.
28
+//! - [`RadioGroup`] — lista opcji z pojedynczym wyborem.
26
29
//!
27
30
//! ```no_run
28
31
//! use libluna_widgets::{PaintCtx, ViewSwitcher};
@@ -34,6 +37,7 @@
34
37
35
38
pub mod bottom_sheet;
36
39
pub mod button;
40
+pub mod check_button;
37
41
pub mod ctx;
38
42
pub mod dropdown;
39
43
pub mod header_bar;
@@ -43,9 +47,11 @@ pub mod menu;
43
47
pub mod nav_list;
44
48
pub mod pager;
45
49
pub mod path_bar;
50
+pub mod radio_group;
46
51
pub mod scroll_view;
47
52
pub mod search_entry;
48
53
pub mod slider;
54
+pub mod stepper;
49
55
pub mod switch;
50
56
pub mod tab_bar;
51
57
pub mod tab_view;
@@ -53,6 +59,7 @@ pub mod view_switcher;
53
59
54
60
pub use bottom_sheet::BottomSheet;
55
61
pub use button::{Button, ButtonVariant};
62
+pub use check_button::CheckButton;
56
63
pub use ctx::PaintCtx;
57
64
pub use dropdown::DropDown;
58
65
pub use header_bar::{ControlsLayout, ControlsSide, HeaderBar, TitleAlign, WindowControl};
@@ -61,9 +68,11 @@ pub use menu::{Menu, MenuEntry, MenuRow};
61
68
pub use nav_list::{NavEntry, NavList, NavRow};
62
69
pub use pager::Pager;
63
70
pub use path_bar::PathBar;
71
+pub use radio_group::RadioGroup;
64
72
pub use scroll_view::ScrollView;
65
73
pub use search_entry::SearchEntry;
66
74
pub use slider::Slider;
75
+pub use stepper::{Stepper, StepperSide};
67
76
pub use switch::Switch;
68
77
pub use tab_bar::{Tab, TabBar};
69
78
pub use tab_view::TabView;
70
79
new file mode 100644
@@ -0,0 +1,164 @@
1
+//! `RadioGroup` — lista opcji z pojedynczym wyborem (radiobuttony).
2
+//!
3
+//! Widget immediate: trzyma opcje i indeks wybranej. Aplikacja woła [`RadioGroup::paint`]
4
+//! oraz [`RadioGroup::index_at`] / [`RadioGroup::click_at`].
5
+
6
+use libluna_core::style::weight;
7
+use libluna_core::{Point, Rect, Theme};
8
+
9
+use crate::ctx::PaintCtx;
10
+
11
+/// Grupa opcji (jeden wybór).
12
+#[derive(Debug, Clone, Default)]
13
+pub struct RadioGroup {
14
+ options: Vec<String>,
15
+ selected: usize,
16
+ hovered: Option<usize>,
17
+}
18
+
19
+impl RadioGroup {
20
+ pub fn new<I, S>(options: I) -> Self
21
+ where
22
+ I: IntoIterator<Item = S>,
23
+ S: Into<String>,
24
+ {
25
+ Self {
26
+ options: options.into_iter().map(Into::into).collect(),
27
+ selected: 0,
28
+ hovered: None,
29
+ }
30
+ }
31
+
32
+ pub fn options(&self) -> &[String] {
33
+ &self.options
34
+ }
35
+
36
+ pub fn len(&self) -> usize {
37
+ self.options.len()
38
+ }
39
+
40
+ pub fn is_empty(&self) -> bool {
41
+ self.options.is_empty()
42
+ }
43
+
44
+ pub fn selected(&self) -> usize {
45
+ self.selected
46
+ }
47
+
48
+ pub fn set_selected(&mut self, index: usize) {
49
+ if !self.options.is_empty() {
50
+ self.selected = index.min(self.options.len() - 1);
51
+ }
52
+ }
53
+
54
+ pub fn set_hovered(&mut self, hovered: Option<usize>) {
55
+ self.hovered = hovered;
56
+ }
57
+
58
+ /// Wysokość całej grupy.
59
+ pub fn height(&self, theme: &Theme) -> f64 {
60
+ self.options.len() as f64 * theme.metrics.radio.row_h
61
+ }
62
+
63
+ /// Prostokąt wiersza opcji `i`.
64
+ pub fn row_rect(&self, rect: Rect, i: usize, theme: &Theme) -> Rect {
65
+ Rect::from_xywh(
66
+ rect.x(),
67
+ rect.y() + i as f64 * theme.metrics.radio.row_h,
68
+ rect.width(),
69
+ theme.metrics.radio.row_h,
70
+ )
71
+ }
72
+
73
+ /// Indeks opcji pod punktem `p`.
74
+ pub fn index_at(&self, rect: Rect, p: Point, theme: &Theme) -> Option<usize> {
75
+ (0..self.options.len()).find(|i| self.row_rect(rect, *i, theme).contains(p))
76
+ }
77
+
78
+ /// Wybiera opcję pod punktem `p`. Zwraca `true` przy trafieniu.
79
+ pub fn click_at(&mut self, rect: Rect, p: Point, theme: &Theme) -> bool {
80
+ match self.index_at(rect, p, theme) {
81
+ Some(i) => {
82
+ self.selected = i;
83
+ true
84
+ }
85
+ None => false,
86
+ }
87
+ }
88
+
89
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
90
+ let theme = *ctx.theme;
91
+ let colors = theme.colors;
92
+ let d = theme.metrics.radio.dot;
93
+ let gap = theme.metrics.radio.gap;
94
+
95
+ for (i, label) in self.options.iter().enumerate() {
96
+ let row = self.row_rect(rect, i, &theme);
97
+ let dot = Rect::from_xywh(rect.x(), row.center().y - d / 2.0, d, d);
98
+ let selected = i == self.selected;
99
+
100
+ if self.hovered == Some(i) && !selected {
101
+ let halo = dot.inflate(libluna_core::Insets::splat(4.0));
102
+ ctx.scene
103
+ .fill_rounded_rect(halo, halo.width() / 2.0, colors.shade);
104
+ }
105
+
106
+ let ring = if selected {
107
+ colors.accent
108
+ } else {
109
+ colors.border
110
+ };
111
+ ctx.scene.stroke_rounded_rect(dot, d / 2.0, 1.5, ring);
112
+ if selected {
113
+ let inner = dot.deflate(libluna_core::Insets::splat(d * 0.3));
114
+ ctx.scene
115
+ .fill_rounded_rect(inner, inner.width() / 2.0, colors.accent);
116
+ }
117
+
118
+ ctx.label_left(
119
+ dot.right() + gap,
120
+ row.center().y,
121
+ label,
122
+ theme.typography.body.size,
123
+ if selected {
124
+ weight::MEDIUM
125
+ } else {
126
+ weight::NORMAL
127
+ },
128
+ colors.text,
129
+ );
130
+ }
131
+ }
132
+}
133
+
134
+#[cfg(test)]
135
+mod tests {
136
+ use super::*;
137
+
138
+ fn rect() -> Rect {
139
+ Rect::from_xywh(0.0, 0.0, 200.0, 120.0)
140
+ }
141
+
142
+ #[test]
143
+ fn rows_stack_and_hit() {
144
+ let theme = Theme::light();
145
+ let g = RadioGroup::new(["Nazwy", "Daty", "Rozmiar"]);
146
+ assert_eq!(g.len(), 3);
147
+ let r0 = g.row_rect(rect(), 0, &theme);
148
+ let r2 = g.row_rect(rect(), 2, &theme);
149
+ assert!(r2.y() > r0.y());
150
+ assert_eq!(g.index_at(rect(), r2.center(), &theme), Some(2));
151
+ assert_eq!(g.index_at(rect(), Point::new(500.0, 5.0), &theme), None);
152
+ }
153
+
154
+ #[test]
155
+ fn click_selects_and_clamps() {
156
+ let theme = Theme::light();
157
+ let mut g = RadioGroup::new(["A", "B", "C"]);
158
+ let row1 = g.row_rect(rect(), 1, &theme).center();
159
+ assert!(g.click_at(rect(), row1, &theme));
160
+ assert_eq!(g.selected(), 1);
161
+ g.set_selected(99);
162
+ assert_eq!(g.selected(), 2);
163
+ }
164
+}
0
165
new file mode 100644
@@ -0,0 +1,204 @@
1
+//! `Stepper` — krokowy wybór liczby: `[ − ] wartość [ + ]`.
2
+//!
3
+//! Widget immediate: trzyma wartość w zakresie `[min, max]` i krok. Aplikacja woła
4
+//! [`Stepper::paint`] oraz [`Stepper::click_at`] (albo [`Stepper::inc`] / [`Stepper::dec`]).
5
+
6
+use libluna_core::style::weight;
7
+use libluna_core::{Point, Rect, Theme};
8
+
9
+use crate::ctx::PaintCtx;
10
+
11
+/// Która strona krokowca.
12
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13
+pub enum StepperSide {
14
+ Minus,
15
+ Plus,
16
+}
17
+
18
+/// Krokowiec.
19
+#[derive(Debug, Clone, Copy)]
20
+pub struct Stepper {
21
+ value: i64,
22
+ min: i64,
23
+ max: i64,
24
+ step: i64,
25
+ hovered: Option<StepperSide>,
26
+}
27
+
28
+impl Stepper {
29
+ pub fn new(value: i64, min: i64, max: i64) -> Self {
30
+ Self {
31
+ value: value.clamp(min, max),
32
+ min,
33
+ max,
34
+ step: 1,
35
+ hovered: None,
36
+ }
37
+ }
38
+
39
+ pub fn step(mut self, step: i64) -> Self {
40
+ self.step = step.max(1);
41
+ self
42
+ }
43
+
44
+ pub fn value(&self) -> i64 {
45
+ self.value
46
+ }
47
+
48
+ pub fn min(&self) -> i64 {
49
+ self.min
50
+ }
51
+
52
+ pub fn max(&self) -> i64 {
53
+ self.max
54
+ }
55
+
56
+ pub fn set_value(&mut self, value: i64) {
57
+ self.value = value.clamp(self.min, self.max);
58
+ }
59
+
60
+ pub fn inc(&mut self) {
61
+ self.set_value(self.value + self.step);
62
+ }
63
+
64
+ pub fn dec(&mut self) {
65
+ self.set_value(self.value - self.step);
66
+ }
67
+
68
+ pub fn set_hovered(&mut self, side: Option<StepperSide>) {
69
+ self.hovered = side;
70
+ }
71
+
72
+ pub fn height(&self, theme: &Theme) -> f64 {
73
+ theme.metrics.stepper.height
74
+ }
75
+
76
+ pub fn width(&self, theme: &Theme) -> f64 {
77
+ theme.metrics.stepper.width
78
+ }
79
+
80
+ /// Prostokąt przycisku `−`/`+`.
81
+ pub fn side_rect(&self, rect: Rect, theme: &Theme, side: StepperSide) -> Rect {
82
+ let b = theme.metrics.stepper.button.clamp(0.0, rect.height());
83
+ let y = rect.center().y - b / 2.0;
84
+ match side {
85
+ StepperSide::Minus => Rect::from_xywh(rect.x(), y, b, b),
86
+ StepperSide::Plus => Rect::from_xywh(rect.right() - b, y, b, b),
87
+ }
88
+ }
89
+
90
+ /// Która strona jest pod punktem `p`.
91
+ pub fn side_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<StepperSide> {
92
+ [StepperSide::Minus, StepperSide::Plus]
93
+ .into_iter()
94
+ .find(|&side| self.side_rect(rect, theme, side).contains(p))
95
+ }
96
+
97
+ /// Obsługuje klik: zmienia wartość. Zwraca `true`, jeśli trafiono w przycisk.
98
+ pub fn click_at(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
99
+ match self.side_at(rect, theme, p) {
100
+ Some(StepperSide::Minus) => {
101
+ self.dec();
102
+ true
103
+ }
104
+ Some(StepperSide::Plus) => {
105
+ self.inc();
106
+ true
107
+ }
108
+ None => false,
109
+ }
110
+ }
111
+
112
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
113
+ let theme = *ctx.theme;
114
+ let colors = theme.colors;
115
+ let radius = theme.radius.small;
116
+ ctx.scene.fill_rounded_rect(rect, radius, colors.shade);
117
+
118
+ let b = theme.metrics.stepper.button.clamp(0.0, rect.height());
119
+ let arm = b * 0.16;
120
+ for side in [StepperSide::Minus, StepperSide::Plus] {
121
+ let sr = self.side_rect(rect, &theme, side);
122
+ if self.hovered == Some(side) {
123
+ ctx.scene
124
+ .fill_rounded_rect(sr, radius, colors.shade.scale_alpha(1.8));
125
+ }
126
+ let c = sr.center();
127
+ match side {
128
+ StepperSide::Minus => {
129
+ ctx.scene.stroke_line(
130
+ Point::new(c.x - arm, c.y),
131
+ Point::new(c.x + arm, c.y),
132
+ 2.0,
133
+ colors.text,
134
+ );
135
+ }
136
+ StepperSide::Plus => {
137
+ ctx.scene.stroke_line(
138
+ Point::new(c.x - arm, c.y),
139
+ Point::new(c.x + arm, c.y),
140
+ 2.0,
141
+ colors.text,
142
+ );
143
+ ctx.scene.stroke_line(
144
+ Point::new(c.x, c.y - arm),
145
+ Point::new(c.x, c.y + arm),
146
+ 2.0,
147
+ colors.text,
148
+ );
149
+ }
150
+ }
151
+ }
152
+
153
+ ctx.label_center(
154
+ rect.center().x,
155
+ rect.center().y,
156
+ &self.value.to_string(),
157
+ theme.typography.body.size,
158
+ weight::MEDIUM,
159
+ colors.text,
160
+ );
161
+ }
162
+}
163
+
164
+#[cfg(test)]
165
+mod tests {
166
+ use super::*;
167
+
168
+ fn rect() -> Rect {
169
+ Rect::from_xywh(0.0, 0.0, 120.0, 34.0)
170
+ }
171
+
172
+ #[test]
173
+ fn value_is_clamped() {
174
+ let mut s = Stepper::new(5, 0, 10);
175
+ s.set_value(99);
176
+ assert_eq!(s.value(), 10);
177
+ s.set_value(-5);
178
+ assert_eq!(s.value(), 0);
179
+ }
180
+
181
+ #[test]
182
+ fn click_on_sides_changes_value() {
183
+ let theme = Theme::light();
184
+ let mut s = Stepper::new(5, 0, 10);
185
+ let plus = s.side_rect(rect(), &theme, StepperSide::Plus).center();
186
+ let minus = s.side_rect(rect(), &theme, StepperSide::Minus).center();
187
+ assert!(s.click_at(rect(), &theme, plus));
188
+ assert_eq!(s.value(), 6);
189
+ assert!(s.click_at(rect(), &theme, minus));
190
+ assert_eq!(s.value(), 5);
191
+ // Środek nie zmienia wartości.
192
+ assert!(!s.click_at(rect(), &theme, rect().center()));
193
+ }
194
+
195
+ #[test]
196
+ fn step_and_bounds() {
197
+ let mut s = Stepper::new(32, 16, 64).step(8);
198
+ s.inc();
199
+ assert_eq!(s.value(), 40);
200
+ s.set_value(64);
201
+ s.inc();
202
+ assert_eq!(s.value(), 64);
203
+ }
204
+}
0
205
Binary files a/docs/images/demo-montage.png and b/docs/images/demo-montage.png differ
1
206
new file mode 100644
2
207
Binary files /dev/null and b/docs/images/demo-step-11.png differ
@@ -38,7 +38,8 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
38
38
(rozwijany przez `Menu`), `HeaderBar` (pasek tytułu z kontrolkami okna), `TabBar`/`TabView`
39
39
(zakładki: pasek + model stron), `BottomSheet` (dolny panel modalny), `Pager` (karuzela stron),
40
40
`PathBar` (okruszki), `SearchEntry` (pole wyszukiwania), `ItemView` (pliki/katalogi: siatka
41
- lub lista) i `ScrollView` (przewijanie), oraz wspólny `PaintCtx`; testy hit-testingu bez okna.
41
+ lub lista) i `ScrollView` (przewijanie), oraz `Stepper`, `CheckButton` i `RadioGroup`
42
+ (krokowiec, checkbox, wybór jednokrotny); wspólny `PaintCtx`; testy hit-testingu bez okna.
42
43
- `libluna-platform`: realne akcje okna z `Frame` (`close`/`minimize`/`toggle_maximize`).
43
44
- Demo używa widgetów zamiast ręcznego rysowania (pasek boczny, wybór motywu/widoku, menu `☰`,
44
45
przycisk „Opcje”, przełączniki, suwak, wybór języka, pasek tytułu, zakładki `TabBar`/`TabView`).
@@ -44,3 +44,9 @@ Files = Fichiers
44
44
Report = Rapport
45
45
Notes = Notes
46
46
Budget = Budget
47
+Icon size = Taille des icônes
48
+Sort by = Trier par
49
+Name = Nom
50
+Date modified = Date de modification
51
+Size = Taille
52
+Show hidden files = Afficher les fichiers cachés