← luna

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