← luna

Commit d636603

31
files
+738
added
-24
deleted
@@ -198,7 +198,10 @@ cargo run -p libluna --example minimal
198 198 (`BottomSheet` z karuzelą stron `Pager`), zamykany klikiem w tło, przyciskiem `✕` lub
199 199 przeciągnięciem w dół. W karcie **Pliki** kliknij element **okruszków** (nawigacja „w górę”),
200 200 **pole wyszukiwania** (fokus) lub **plik/katalog** (zaznaczenie); przełącznik `Lista | Siatka`
201 - zmienia tryb `ItemView`, a **rolka/przeciąganie paska** przewija listę. Klikaj segmenty `Jasny | Ciemny | Auto`, kropki
201 + zmienia tryb `ItemView`, a **rolka/przeciąganie paska** przewija listę. Wpisany tekst w polu
202 + **wyszukiwania filtruje** elementy; gdy nic nie pasuje, pojawia się ekran stanu (`StatusPage`)
203 + z przyciskiem **„Wyczyść”**. Zmiana **koloru akcentu** pokazuje `Toast` z akcją **„Cofnij”**,
204 + która przywraca poprzedni akcent. Klikaj segmenty `Jasny | Ciemny | Auto`, kropki
202 205 **koloru akcentu**, przełączniki OPCJA A / **Pasek dolny** / **Pasek boczny** i **przeciągaj**
203 206 suwak głośności oraz krawędź paska bocznego (150–460 px). Kontrolki okna `[—][□][✕]` działają
204 207 (minimalizacja/maksymalizacja/zamknięcie przez warstwę platformy).
@@ -226,7 +229,7 @@ w `~/.config/luna/theme.conf`.
226 229 Rozmiary i paddingi kontrolek nie są już stałymi w widgetach, lecz **tokenami**
227 230 (`Theme::metrics`) podzielonymi na sekcje per widget: `headerbar`, `button`, `switch`,
228 231 `slider`, `dropdown`, `menu`, `nav`, `segmented`, `tabs`, `sheet`, `pager`, `pathbar`, `entry`,
229 -`itemview`, `scrollview`, `stepper`, `check`, `radio`, `card`, `row`. Dzięki temu wygląd steruje **także
232 +`itemview`, `scrollview`, `stepper`, `check`, `radio`, `card`, `row`, `toast`, `status`. Dzięki temu wygląd steruje **także
230 233 geometrią**, nie tylko kolorami — np. `theme.metrics.switch.width` albo
231 234 `theme.metrics.headerbar.height`. Widgety czytają te wartości w `paint`/`layout`, więc
232 235 przyszły, instalowany styl może zmienić np. wysokość paska tytułu czy rozmiar przełącznika
@@ -301,14 +304,18 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
301 304 (pole wyszukiwania: lupka, kursor, czyszczenie), `ItemView` (pliki/katalogi w siatce lub
302 305 liście, z zaznaczaniem i przewijaniem) i `ScrollView` (pionowe przewijanie: rolka + „kciuk”)
303 306 oraz `Stepper`, `CheckButton` i `RadioGroup` (krokowiec, pole wyboru, wybór jednokrotny),
304 - a także `Card` (karta z nagłówkiem) i `ActionRow` (wiersz ustawień) oraz wspólny `PaintCtx`.
307 + a także `Card` (karta z nagłówkiem), `ActionRow` (wiersz ustawień), `Toast` (powiadomienie
308 + nakładkowe z akcją, np. „Cofnij”) oraz `StatusPage` (ekran stanu: ikona, tytuł, opis, akcja —
309 + używany jako „pusty stan” listy) i wspólny `PaintCtx`.
305 310 - `luna-demo`: `HeaderBar` jako widget (tytuł + `[—][□][✕]` po prawej + treść: `◧`, `+`,
306 311 „Opcje ▾”, `☰`); karty i wiersze ustawień rysowane widgetami (`Card`, `ActionRow`);
307 312 **popover `☰`**, chowany i rozciągany (drag) panel boczny (`NavList`),
308 313 chowany **pasek dolny**, wybór motywu/widoku (`ViewSwitcher`), `Switch`, `Slider`, `DropDown`,
309 314 **zakładki** (`TabBar` + `TabView`) oraz **dolny panel** (`BottomSheet` z karuzelą `Pager`,
310 315 otwierany z menu `☰` → „O aplikacji”), a także kartę **Pliki** (`PathBar` + `SearchEntry` +
311 - `ItemView` — przełącznik **Widok: Lista | Siatka** zmienia tryb widoku elementów).
316 + `ItemView` — przełącznik **Widok: Lista | Siatka** zmienia tryb widoku elementów; wyszukiwanie
317 + **filtruje** elementy, a brak wyników pokazuje `StatusPage` „Brak wyników” z akcją „Wyczyść”).
318 + Zmiana **koloru akcentu** wyświetla `Toast` z akcją **„Cofnij”**.
312 319 - Rozmiary i paddingi kontrolek są **tokenami** (`Theme::metrics`), więc geometria jest tak samo
313 320 „stylowalna” jak kolory — nie ma już stałych typów w widgetach (`HeaderBar::HEIGHT`,
314 321 `Switch::WIDTH`, `Button::HEIGHT`…).
@@ -323,6 +330,11 @@ z pliku doładowanego w runtime:
323 330 ![Ustawienia — ciemny, DE, fioletowy](docs/images/settings-de-dark.png)
324 331 ![Ustawienia — francuski z pliku](docs/images/settings-fr.png)
325 332
333 +Przebieg interakcji (headless `simulate`) — m.in. menu `☰`, dolny panel, powiadomienie (`Toast`)
334 +karta 12 i ekran stanu („Brak wyników”) klatka 14:
335 +
336 +![Interakcje demo](docs/images/demo-montage.png)
337 +
326 338 ## Motywacja i kontekst
327 339
328 340 Środowisko **Luna** (kompozytor **Selene**) ma docelowo zastąpić GTK4/libadwaita w aplikacjach
@@ -9,7 +9,7 @@
9 9 use std::error::Error;
10 10
11 11 use libluna_core::{
12 - Accent, ButtonState, Event, Modifiers, Point, PointerButton, PointerEvent, Size,
12 + Accent, ButtonState, Event, KeyEvent, Modifiers, Point, PointerButton, PointerEvent, Size,
13 13 ThemePreference, ThemeSettings,
14 14 };
15 15 use libluna_i18n::{set_locale, Locale};
@@ -199,6 +199,72 @@ fn main() -> Result<(), Box<dyn Error>> {
199 199 frames.push(render_pixels(&settings, &demo, &text, size));
200 200 labels.push("11. Popover \u{201e}Opcje\u{201d}".into());
201 201
202 + // 12. Klik popovera (zamknięcie) i zmiana akcentu → powiadomienie (Toast) z „Cofnij”.
203 + click!(Point::new(450.0, 300.0));
204 + let c = layout!().accents[1].center();
205 + click!(c);
206 + assert_eq!(
207 + settings.accent(),
208 + Accent::Teal,
209 + "akcent nie ustawił się na Teal"
210 + );
211 + assert!(demo.toast.is_open(), "powiadomienie się nie pokazało");
212 + frames.push(render_pixels(&settings, &demo, &text, size));
213 + labels.push("12. Powiadomienie (Toast)".into());
214 +
215 + // 13. Klik „Cofnij” w powiadomieniu → powrót akcentu, powiadomienie znika.
216 + let theme = settings.resolve(false);
217 + let undo = demo
218 + .toast
219 + .action_rect(size, &theme, &text)
220 + .expect("akcja powiadomienia")
221 + .center();
222 + click!(undo);
223 + assert_eq!(
224 + settings.accent(),
225 + Accent::Purple,
226 + "akcent nie wrócił po „Cofnij”"
227 + );
228 + assert!(!demo.toast.is_open(), "powiadomienie nie zniknęło");
229 + frames.push(render_pixels(&settings, &demo, &text, size));
230 + labels.push("13. Cofnięto akcent".into());
231 +
232 + // 14. Filtr bez wyników → ekran stanu (StatusPage) z akcją „Wyczyść”.
233 + let c = layout!().search.center();
234 + click!(c);
235 + assert!(demo.search.is_focused(), "pole wyszukiwania bez fokusu");
236 + for ch in ['x', 'y', 'z'] {
237 + let key = Event::Key(KeyEvent {
238 + keysym: ch as u32,
239 + text: Some(ch.to_string()),
240 + state: ButtonState::Pressed,
241 + modifiers: Modifiers::NONE,
242 + repeat: false,
243 + });
244 + let theme = settings.resolve(false);
245 + ui::handle_events(&mut settings, &[key], size, &theme, &text, &mut demo);
246 + }
247 + assert!(
248 + ui::empty_state(&demo).is_some(),
249 + "brak ekranu stanu dla braku wyników"
250 + );
251 + frames.push(render_pixels(&settings, &demo, &text, size));
252 + labels.push("14. Brak wyników (StatusPage)".into());
253 +
254 + // 15. Klik „Wyczyść” w ekranie stanu → lista wraca.
255 + let theme = settings.resolve(false);
256 + let clear = ui::empty_state(&demo)
257 + .expect("ekran stanu")
258 + .rects(layout!().items, &theme)
259 + .action
260 + .expect("akcja ekranu stanu")
261 + .center();
262 + click!(clear);
263 + assert!(demo.search.is_empty(), "filtr nie został wyczyszczony");
264 + assert!(ui::empty_state(&demo).is_none(), "ekran stanu nie zniknął");
265 + frames.push(render_pixels(&settings, &demo, &text, size));
266 + labels.push("15. Filtr wyczyszczony".into());
267 +
202 268 // Zapis klatek.
203 269 for (i, pixels) in frames.iter().enumerate() {
204 270 let path = format!("docs/images/demo-step-{}.png", i + 1);
@@ -13,8 +13,8 @@ use libluna_render::TextEngine;
13 13 use libluna_widgets::{
14 14 ActionRow, BottomSheet, Button, Card, CheckButton, ControlsLayout, ControlsSide, DropDown,
15 15 HeaderBar, Item, ItemView, Menu, MenuEntry, NavEntry, NavList, Pager, PaintCtx, PathBar,
16 - RadioGroup, ScrollView, SearchEntry, Slider, Stepper, StepperSide, Switch, Tab, TabBar,
17 - TabView, ViewMode, ViewSwitcher, WindowControl,
16 + RadioGroup, ScrollView, SearchEntry, Slider, StatusPage, Stepper, StepperSide, Switch, Tab,
17 + TabBar, TabView, Toast, ViewMode, ViewSwitcher, WindowControl,
18 18 };
19 19
20 20 const STATUS_H: f64 = 28.0;
@@ -93,6 +93,9 @@ pub struct Demo {
93 93 pub options_stepper_hover: Option<StepperSide>,
94 94 pub options_radio_hover: Option<usize>,
95 95 pub options_check_hover: bool,
96 + /// Powiadomienie (nakładka) oraz akcent sprzed ostatniej zmiany (do „Cofnij”).
97 + pub toast: Toast,
98 + pub prev_accent: Accent,
96 99 }
97 100
98 101 impl Default for Demo {
@@ -154,6 +157,8 @@ impl Default for Demo {
154 157 options_stepper_hover: None,
155 158 options_radio_hover: None,
156 159 options_check_hover: false,
160 + toast: Toast::new(""),
161 + prev_accent: Accent::default(),
157 162 }
158 163 }
159 164 }
@@ -244,13 +249,30 @@ pub fn path_bar(demo: &Demo) -> PathBar {
244 249 bar
245 250 }
246 251
252 +/// Indeksy elementów z `demo.items`, które przechodzą obecny filtr wyszukiwania.
253 +pub fn visible_indices(demo: &Demo) -> Vec<usize> {
254 + let query = demo.search.text().trim().to_lowercase();
255 + let all = demo.items.items();
256 + if query.is_empty() {
257 + return (0..all.len()).collect();
258 + }
259 + all.iter()
260 + .enumerate()
261 + .filter(|(_, it)| tr(&it.label).to_lowercase().contains(&query))
262 + .map(|(i, _)| i)
263 + .collect()
264 +}
265 +
247 266 /// Widok elementów demo z przetłumaczonymi etykietami (etykiety to klucze i18n).
267 +///
268 +/// Wykorzystuje filtr wyszukiwania, a zaznaczenie (indeks w pełnej liście) mapuje
269 +/// na widoczną pozycję.
248 270 pub fn item_view(demo: &Demo) -> ItemView {
249 - let items: Vec<Item> = demo
250 - .items
251 - .items()
271 + let visible = visible_indices(demo);
272 + let items: Vec<Item> = visible
252 273 .iter()
253 - .map(|it| {
274 + .map(|&i| {
275 + let it = &demo.items.items()[i];
254 276 let mut item = Item::new(tr(&it.label)).kind(it.kind);
255 277 if let Some(detail) = &it.detail {
256 278 item = item.detail(detail.clone());
@@ -260,12 +282,33 @@ pub fn item_view(demo: &Demo) -> ItemView {
260 282 .collect();
261 283 let mut view = ItemView::new(items);
262 284 view.set_mode(demo.items.mode());
263 - view.set_selected(demo.items.selected());
285 + view.set_selected(
286 + demo.items
287 + .selected()
288 + .and_then(|s| visible.iter().position(|&i| i == s)),
289 + );
264 290 view.set_hovered(demo.items_hover);
265 291 view.set_scroll(demo.items.scroll());
266 292 view
267 293 }
268 294
295 +/// Ekran stanu dla listy plików: brak wyników wyszukiwania albo pusty katalog.
296 +///
297 +/// W demo obszar listy jest niski, więc używamy wariantu kompaktowego (tytuł + akcja).
298 +pub fn empty_state(demo: &Demo) -> Option<StatusPage> {
299 + let query = demo.search.text().trim();
300 + if !query.is_empty() {
301 + if visible_indices(demo).is_empty() {
302 + return Some(StatusPage::new(tr("No results")).action(tr("Clear")));
303 + }
304 + None
305 + } else if demo.items.is_empty() {
306 + Some(StatusPage::new(tr("Nothing here")))
307 + } else {
308 + None
309 + }
310 +}
311 +
269 312 /// Kody języków i kontrolka wyboru (nazwy natywne).
270 313 pub fn language_dropdown(codes: &[String]) -> DropDown {
271 314 let names: Vec<String> = codes
@@ -554,6 +597,7 @@ pub fn handle_events(
554 597 let tabs_bar = tab_bar(demo);
555 598 let path = path_bar(demo);
556 599 let items = item_view(demo);
600 + let visible = visible_indices(demo);
557 601 let nav = make_nav();
558 602 let slider = Slider::new(demo.volume);
559 603 let popover = popover_menu(size, theme, text, &lay, demo);
@@ -561,7 +605,7 @@ pub fn handle_events(
561 605 let header = header_bar();
562 606
563 607 demo.files_scroll
564 - .set_content_height(demo.items.content_height(lay.items, theme));
608 + .set_content_height(items.content_height(lay.items, theme));
565 609
566 610 for event in events {
567 611 match event {
@@ -587,6 +631,13 @@ pub fn handle_events(
587 631 continue;
588 632 }
589 633
634 + // Akcja powiadomienia (nakładka) ma pierwszeństwo nad treścią.
635 + if demo.toast.is_open() && demo.toast.action_at(size, theme, text, p) {
636 + settings.set_accent(demo.prev_accent);
637 + demo.toast.set_open(false);
638 + continue;
639 + }
640 +
590 641 // Popover „Opcje” obsługuje własne kontrolki.
591 642 if demo.popover == Some(Popover::Options) {
592 643 let ol = options_layout(size, theme, &lay);
@@ -656,10 +707,13 @@ pub fn handle_events(
656 707 } else {
657 708 ViewMode::List
658 709 });
710 + let mut view = item_view(demo);
711 + view.set_scroll(demo.items.scroll());
712 + view.clamp_scroll(lay.items, theme);
713 + demo.items.set_scroll(view.scroll());
659 714 demo.files_scroll
660 - .set_content_height(demo.items.content_height(lay.items, theme));
661 - demo.items.clamp_scroll(lay.items, theme);
662 - demo.files_scroll.set_offset(demo.items.scroll(), lay.items);
715 + .set_content_height(view.content_height(lay.items, theme));
716 + demo.files_scroll.set_offset(view.scroll(), lay.items);
663 717 } else if let Some(i) = tabs_bar.index_at(lay.tabs, text, theme, p) {
664 718 demo.tabs.select(i);
665 719 } else if demo.search.contains(lay.search, p) {
@@ -669,10 +723,15 @@ pub fn handle_events(
669 723 demo.search.set_focused(true);
670 724 } else if let Some(i) = path.index_at(lay.path, text, theme, p) {
671 725 demo.path.truncate(i + 1);
726 + } else if let Some(page) = empty_state(demo) {
727 + // Akcja pustego stanu (np. „Wyczyść” w braku wyników).
728 + if page.action_at(lay.items, theme, p) {
729 + demo.search.clear();
730 + }
672 731 } else if demo.files_scroll.begin_drag(p, lay.items, theme) {
673 732 demo.scroll_dragging = true;
674 733 } else if let Some(i) = items.index_at(lay.items, p, theme) {
675 - demo.items.set_selected(Some(i));
734 + demo.items.set_selected(visible.get(i).copied());
676 735 } else if let Some(i) = nav.index_at(lay.sidebar, p, theme) {
677 736 demo.nav_selected = Some(i);
678 737 } else if lay.option_a.contains(p) {
@@ -687,7 +746,7 @@ pub fn handle_events(
687 746 } else {
688 747 for (i, r) in lay.accents.iter().enumerate() {
689 748 if r.contains(p) {
690 - settings.set_accent(Accent::ALL[i]);
749 + apply_accent(settings, demo, Accent::ALL[i]);
691 750 }
692 751 }
693 752 }
@@ -805,7 +864,7 @@ pub fn handle_events(
805 864 .iter()
806 865 .position(|a| *a == settings.accent())
807 866 .unwrap_or(0);
808 - settings.set_accent(Accent::ALL[(idx + 1) % Accent::ALL.len()]);
867 + apply_accent(settings, demo, Accent::ALL[(idx + 1) % Accent::ALL.len()]);
809 868 }
810 869 // `g` — następny język
811 870 0x67 => cycle_language(),
@@ -825,6 +884,17 @@ pub fn handle_events(
825 884 }
826 885 }
827 886
887 +/// Zmienia akcent (o ile inny), zapamiętuje poprzedni i pokazuje powiadomienie z „Cofnij”.
888 +fn apply_accent(settings: &mut ThemeSettings, demo: &mut Demo, accent: Accent) {
889 + if settings.accent() == accent {
890 + return;
891 + }
892 + demo.prev_accent = settings.accent();
893 + settings.set_accent(accent);
894 + demo.toast = Toast::new(tr("Accent changed")).action(tr("Undo"));
895 + demo.toast.show();
896 +}
897 +
828 898 fn cycle_language() {
829 899 let languages = libluna_i18n::languages();
830 900 if languages.is_empty() {
@@ -915,6 +985,11 @@ pub fn build_scene(
915 985 sheet.paint(&mut paint, size);
916 986 draw_sheet_content(&mut paint, demo, size);
917 987 }
988 +
989 + // Powiadomienie („toast”) nad wszystkim.
990 + if demo.toast.is_open() {
991 + demo.toast.paint(&mut paint, size);
992 + }
918 993 }
919 994
920 995 /// Zawartość dolnego panelu: karuzela stron (`Pager`) z kropkami-wskaźnikami.
@@ -1128,8 +1203,13 @@ fn draw_content(
1128 1203
1129 1204 item_view(demo).paint(paint, lay.items);
1130 1205
1206 + // Pusty stan (brak wyników / pusty katalog) zamiast listy.
1207 + if let Some(page) = empty_state(demo) {
1208 + page.paint(paint, lay.items);
1209 + }
1210 +
1131 1211 let mut scrollbar = demo.files_scroll;
1132 - scrollbar.set_content_height(demo.items.content_height(lay.items, &theme));
1212 + scrollbar.set_content_height(item_view(demo).content_height(lay.items, &theme));
1133 1213 scrollbar.paint_scrollbar(paint, lay.items);
1134 1214 }
1135 1215
@@ -309,3 +309,69 @@ fn language_dropdown_opens_and_selects() {
309 309 click(&mut settings, &mut demo, last);
310 310 assert!(demo.popover.is_none());
311 311 }
312 +
313 +#[test]
314 +fn accent_change_shows_toast_and_undo_reverts() {
315 + let mut settings = ThemeSettings::default();
316 + let mut demo = Demo::default();
317 +
318 + // Zmiana akcentu pokazuje powiadomienie.
319 + let dot = layout(&settings, &demo).accents[2].center();
320 + click(&mut settings, &mut demo, dot);
321 + assert_eq!(settings.accent(), Accent::ALL[2]);
322 + assert!(demo.toast.is_open());
323 +
324 + // Klik „Cofnij” przywraca poprzedni akcent i chowa powiadomienie.
325 + let theme = settings.resolve(false);
326 + let text = TextEngine::system();
327 + let undo = demo
328 + .toast
329 + .action_rect(size(), &theme, &text)
330 + .expect("akcja powiadomienia")
331 + .center();
332 + click(&mut settings, &mut demo, undo);
333 + assert_eq!(settings.accent(), Accent::default());
334 + assert!(!demo.toast.is_open());
335 +}
336 +
337 +#[test]
338 +fn search_without_results_shows_empty_state_that_clears() {
339 + let mut settings = ThemeSettings::default();
340 + let mut demo = Demo::default();
341 + demo.search.set_text("zzz");
342 +
343 + let theme = settings.resolve(false);
344 + let action = ui::empty_state(&demo)
345 + .expect("ekran stanu dla braku wyników")
346 + .rects(layout(&settings, &demo).items, &theme)
347 + .action
348 + .expect("przycisk akcji")
349 + .center();
350 + click(&mut settings, &mut demo, action);
351 + assert!(demo.search.is_empty());
352 + assert!(ui::empty_state(&demo).is_none());
353 +}
354 +
355 +#[test]
356 +fn search_filters_items_and_selection_maps_to_full_list() {
357 + let mut settings = ThemeSettings::default();
358 + let mut demo = Demo::default();
359 + demo.search.set_text("proj");
360 +
361 + let visible = ui::visible_indices(&demo);
362 + assert_eq!(
363 + visible.len(),
364 + 1,
365 + "filtr powinien zawęzić listę do jednego elementu"
366 + );
367 + let view = ui::item_view(&demo);
368 + assert_eq!(view.len(), 1);
369 +
370 + // Klik widocznego elementu zaznacza go w pełnej liście.
371 + let theme = settings.resolve(false);
372 + let slot = view
373 + .slot_rect(layout(&settings, &demo).items, 0, &theme)
374 + .center();
375 + click(&mut settings, &mut demo, slot);
376 + assert_eq!(demo.items.selected(), Some(visible[0]));
377 +}
@@ -29,6 +29,6 @@ pub use style::{
29 29 ActionRowMetrics, BottomSheetMetrics, ButtonMetrics, CardMetrics, CheckMetrics,
30 30 DropDownMetrics, EntryMetrics, HeaderBarMetrics, ItemViewMetrics, MenuMetrics, Metrics,
31 31 NavMetrics, PagerMetrics, PathBarMetrics, RadioMetrics, Radius, ScrollViewMetrics,
32 - SegmentedMetrics, SliderMetrics, Spacing, StepperMetrics, SwitchMetrics, TabsMetrics, Theme,
33 - Typography,
32 + SegmentedMetrics, SliderMetrics, Spacing, StatusPageMetrics, StepperMetrics, SwitchMetrics,
33 + TabsMetrics, Theme, ToastMetrics, Typography,
34 34 };
@@ -447,6 +447,32 @@ pub struct ActionRowMetrics {
447 447 pub gap: f64,
448 448 }
449 449
450 +/// Geometria powiadomienia ([`Toast`](crate::style)).
451 +#[derive(Debug, Clone, Copy, PartialEq)]
452 +pub struct ToastMetrics {
453 + /// Odstęp od dolnej krawędzi okna.
454 + pub margin_bottom: f64,
455 + /// Padding poziomy (wewnątrz).
456 + pub pad_x: f64,
457 + /// Odstęp między tekstem a akcją.
458 + pub gap: f64,
459 + /// Wysokość powiadomienia.
460 + pub height: f64,
461 +}
462 +
463 +/// Geometria strony stanu ([`StatusPage`](crate::style)).
464 +#[derive(Debug, Clone, Copy, PartialEq)]
465 +pub struct StatusPageMetrics {
466 + /// Bok ikony.
467 + pub icon: f64,
468 + /// Odstęp pionowy między elementami.
469 + pub gap: f64,
470 + /// Szerokość przycisku akcji.
471 + pub button_w: f64,
472 + /// Wysokość przycisku akcji.
473 + pub button_h: f64,
474 +}
475 +
450 476 /// Geometria kontrolek („component metrics”) — rozmiary i paddingi w pikselach logicznych.
451 477 ///
452 478 /// Widgety nie mają już własnych stałych rozmiarów: czytają je z motywu, dzięki czemu
@@ -474,6 +500,8 @@ pub struct Metrics {
474 500 pub radio: RadioMetrics,
475 501 pub card: CardMetrics,
476 502 pub row: ActionRowMetrics,
503 + pub toast: ToastMetrics,
504 + pub status: StatusPageMetrics,
477 505 }
478 506
479 507 impl Metrics {
@@ -590,6 +618,18 @@ impl Metrics {
590 618 pad_x: 12.0,
591 619 gap: 8.0,
592 620 },
621 + toast: ToastMetrics {
622 + margin_bottom: 40.0,
623 + pad_x: 16.0,
624 + gap: 16.0,
625 + height: 40.0,
626 + },
627 + status: StatusPageMetrics {
628 + icon: 64.0,
629 + gap: 12.0,
630 + button_w: 160.0,
631 + button_h: 34.0,
632 + },
593 633 }
594 634 }
595 635 }
@@ -51,3 +51,10 @@ Name = Name
51 51 Date modified = Änderungsdatum
52 52 Size = Größe
53 53 Show hidden files = Versteckte Dateien anzeigen
54 +Accent changed = Akzent geändert
55 +Undo = Rückgängig
56 +No results = Keine Ergebnisse
57 +Try a different search term = Anderen Suchbegriff versuchen
58 +Clear = Leeren
59 +Nothing here = Nichts hier
60 +This folder is empty = Dieser Ordner ist leer
@@ -52,3 +52,10 @@ Name = Name
52 52 Date modified = Date modified
53 53 Size = Size
54 54 Show hidden files = Show hidden files
55 +Accent changed = Accent changed
56 +Undo = Undo
57 +No results = No results
58 +Try a different search term = Try a different search term
59 +Clear = Clear
60 +Nothing here = Nothing here
61 +This folder is empty = This folder is empty
@@ -51,3 +51,10 @@ Name = Nazwy
51 51 Date modified = Daty modyfikacji
52 52 Size = Rozmiar
53 53 Show hidden files = Pokazuj ukryte pliki
54 +Accent changed = Zmieniono akcent
55 +Undo = Cofnij
56 +No results = Brak wyników
57 +Try a different search term = Spróbuj innego hasła
58 +Clear = Wyczyść
59 +Nothing here = Nic tu nie ma
60 +This folder is empty = Ten folder jest pusty
@@ -28,6 +28,8 @@
28 28 //! - [`RadioGroup`] — lista opcji z pojedynczym wyborem.
29 29 //! - [`Card`] — karta z nagłówkiem i obszarem treści.
30 30 //! - [`ActionRow`] — wiersz ustawień (tytuł/podtytuł + obszar na kontrolkę).
31 +//! - [`Toast`] — krótkie powiadomienie (nakładka) z opcjonalną akcją.
32 +//! - [`StatusPage`] — ekran stanu (pusty/powitalny): ikona, tytuł, opis, akcja.
31 33 //!
32 34 //! ```no_run
33 35 //! use libluna_widgets::{PaintCtx, ViewSwitcher};
@@ -55,10 +57,12 @@ pub mod radio_group;
55 57 pub mod scroll_view;
56 58 pub mod search_entry;
57 59 pub mod slider;
60 +pub mod status_page;
58 61 pub mod stepper;
59 62 pub mod switch;
60 63 pub mod tab_bar;
61 64 pub mod tab_view;
65 +pub mod toast;
62 66 pub mod view_switcher;
63 67
64 68 pub use action_row::ActionRow;
@@ -78,8 +82,10 @@ pub use radio_group::RadioGroup;
78 82 pub use scroll_view::ScrollView;
79 83 pub use search_entry::SearchEntry;
80 84 pub use slider::Slider;
85 +pub use status_page::{StatusIcon, StatusPage, StatusRects};
81 86 pub use stepper::{Stepper, StepperSide};
82 87 pub use switch::Switch;
83 88 pub use tab_bar::{Tab, TabBar};
84 89 pub use tab_view::TabView;
90 +pub use toast::Toast;
85 91 pub use view_switcher::ViewSwitcher;
86 92 new file mode 100644
@@ -0,0 +1,224 @@
1 +//! `StatusPage` — ekran stanu (pusty/powitalny): ikona, tytuł, opis i opcjonalna akcja.
2 +//!
3 +//! Widget immediate: układa wyśrodkowany blok w zadanym obszarze. Aplikacja woła
4 +//! [`StatusPage::paint`] oraz [`StatusPage::action_at`] (klik w przycisk akcji).
5 +
6 +use libluna_core::style::weight;
7 +use libluna_core::{Point, Rect, Theme};
8 +
9 +use crate::ctx::PaintCtx;
10 +use crate::icon;
11 +
12 +/// Ikona ekranu stanu (rysowana wektorowo).
13 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14 +pub enum StatusIcon {
15 + /// Katalog (np. „pusty folder”).
16 + Folder,
17 + /// Plik.
18 + File,
19 + /// Wyszukiwanie (np. „brak wyników”).
20 + Search,
21 +}
22 +
23 +/// Prostokąty elementów ekranu stanu.
24 +#[derive(Debug, Clone, Copy, PartialEq)]
25 +pub struct StatusRects {
26 + pub icon: Option<Rect>,
27 + pub title: Rect,
28 + pub description: Option<Rect>,
29 + pub action: Option<Rect>,
30 +}
31 +
32 +/// Ekran stanu.
33 +#[derive(Debug, Clone, Default)]
34 +pub struct StatusPage {
35 + icon: Option<StatusIcon>,
36 + title: String,
37 + description: Option<String>,
38 + action: Option<String>,
39 +}
40 +
41 +impl StatusPage {
42 + pub fn new(title: impl Into<String>) -> Self {
43 + Self {
44 + icon: None,
45 + title: title.into(),
46 + description: None,
47 + action: None,
48 + }
49 + }
50 +
51 + pub fn icon(mut self, icon: StatusIcon) -> Self {
52 + self.icon = Some(icon);
53 + self
54 + }
55 +
56 + pub fn description(mut self, description: impl Into<String>) -> Self {
57 + self.description = Some(description.into());
58 + self
59 + }
60 +
61 + pub fn action(mut self, label: impl Into<String>) -> Self {
62 + self.action = Some(label.into());
63 + self
64 + }
65 +
66 + pub fn set_title(&mut self, title: impl Into<String>) {
67 + self.title = title.into();
68 + }
69 +
70 + pub fn has_action(&self) -> bool {
71 + self.action.is_some()
72 + }
73 +
74 + /// Wyśrodkowany układ elementów w obszarze `area`.
75 + pub fn rects(&self, area: Rect, theme: &Theme) -> StatusRects {
76 + let m = theme.metrics.status;
77 + let title_lh = theme.typography.heading.line_height;
78 + let desc_lh = theme.typography.body.line_height;
79 +
80 + let icon_h = if self.icon.is_some() { m.icon } else { 0.0 };
81 + let mut total = title_lh;
82 + if icon_h > 0.0 {
83 + total += icon_h + m.gap;
84 + }
85 + if self.description.is_some() {
86 + total += m.gap + desc_lh;
87 + }
88 + if self.action.is_some() {
89 + total += m.gap + m.button_h;
90 + }
91 +
92 + let cx = area.center().x;
93 + let mut y = area.center().y - total / 2.0;
94 +
95 + let icon_rect = self.icon.map(|_| {
96 + let r = Rect::from_xywh(cx - m.icon / 2.0, y, m.icon, m.icon);
97 + y += m.icon + m.gap;
98 + r
99 + });
100 +
101 + let title = Rect::from_xywh(area.x(), y, area.width(), title_lh);
102 + y += title_lh;
103 +
104 + let description = self.description.as_ref().map(|_| {
105 + y += m.gap;
106 + let r = Rect::from_xywh(area.x(), y, area.width(), desc_lh);
107 + y += desc_lh;
108 + r
109 + });
110 +
111 + let action = self.action.as_ref().map(|_| {
112 + y += m.gap;
113 + Rect::from_xywh(cx - m.button_w / 2.0, y, m.button_w, m.button_h)
114 + });
115 +
116 + StatusRects {
117 + icon: icon_rect,
118 + title,
119 + description,
120 + action,
121 + }
122 + }
123 +
124 + /// Czy punkt trafia w przycisk akcji.
125 + pub fn action_at(&self, area: Rect, theme: &Theme, p: Point) -> bool {
126 + self.rects(area, theme)
127 + .action
128 + .is_some_and(|r| r.contains(p))
129 + }
130 +
131 + /// Rysuje ekran stanu w obszarze `area`.
132 + pub fn paint(&self, ctx: &mut PaintCtx, area: Rect) {
133 + let theme = *ctx.theme;
134 + let colors = theme.colors;
135 + let r = self.rects(area, &theme);
136 +
137 + ctx.scene.push_clip(area);
138 +
139 + if let (Some(kind), Some(rect)) = (self.icon, r.icon) {
140 + let accent = colors.dim_text;
141 + match kind {
142 + StatusIcon::Folder => icon::folder(ctx, accent, rect),
143 + StatusIcon::File => icon::file(ctx, accent, rect),
144 + StatusIcon::Search => icon::search(ctx, accent, rect.center(), rect.width()),
145 + }
146 + }
147 +
148 + ctx.label_center(
149 + r.title.center().x,
150 + r.title.center().y,
151 + &self.title,
152 + theme.typography.heading.size,
153 + weight::BOLD,
154 + colors.text,
155 + );
156 +
157 + if let (Some(description), Some(rect)) = (&self.description, r.description) {
158 + ctx.label_center(
159 + rect.center().x,
160 + rect.center().y,
161 + description,
162 + theme.typography.body.size,
163 + weight::NORMAL,
164 + colors.dim_text,
165 + );
166 + }
167 +
168 + if let (Some(label), Some(rect)) = (&self.action, r.action) {
169 + let radius = theme.radius.small;
170 + ctx.scene.fill_rounded_rect(rect, radius, colors.accent);
171 + ctx.label_center(
172 + rect.center().x,
173 + rect.center().y,
174 + label,
175 + theme.typography.body.size,
176 + weight::MEDIUM,
177 + colors.accent_fg,
178 + );
179 + }
180 +
181 + ctx.scene.pop_clip();
182 + }
183 +}
184 +
185 +#[cfg(test)]
186 +mod tests {
187 + use super::*;
188 +
189 + #[test]
190 + fn block_is_vertically_centered() {
191 + let theme = Theme::light();
192 + let page = StatusPage::new("Pusto")
193 + .icon(StatusIcon::Folder)
194 + .description("Brak plików");
195 + let area = Rect::from_xywh(0.0, 0.0, 400.0, 300.0);
196 + let r = page.rects(area, &theme);
197 + // Ikona jest najwyżej, opis najniżej, a całość wyśrodkowana pionowo.
198 + assert!(r.icon.unwrap().y() < r.title.y());
199 + assert!(r.title.y() < r.description.unwrap().y());
200 + let top = r.icon.unwrap().y();
201 + let bottom = r.description.unwrap().bottom();
202 + assert!(((top + bottom) / 2.0 - 150.0).abs() < 1e-9);
203 + }
204 +
205 + #[test]
206 + fn action_hit_only_when_present() {
207 + let theme = Theme::light();
208 + let area = Rect::from_xywh(0.0, 0.0, 400.0, 300.0);
209 + let with = StatusPage::new("Pusto").action("Nowy");
210 + let ar = with.rects(area, &theme).action.unwrap();
211 + assert!(with.action_at(area, &theme, ar.center()));
212 + let without = StatusPage::new("Pusto");
213 + assert!(!without.action_at(area, &theme, area.center()));
214 + }
215 +
216 + #[test]
217 + fn title_is_horizontally_centered() {
218 + let theme = Theme::light();
219 + let page = StatusPage::new("Pusto");
220 + let area = Rect::from_xywh(20.0, 0.0, 400.0, 300.0);
221 + let r = page.rects(area, &theme);
222 + assert!((r.title.center().x - area.center().x).abs() < 1e-9);
223 + }
224 +}
0 225 new file mode 100644
@@ -0,0 +1,190 @@
1 +//! `Toast` — krótkie powiadomienie unoszące się nad treścią (nakładka).
2 +//!
3 +//! Widget immediate: trzyma napis, opcjonalną etykietę akcji i stan otwarcia.
4 +//! Aplikacja woła [`Toast::paint`] (na samym wierzchu sceny) oraz [`Toast::action_at`]
5 +//! (klik w akcję — np. „Cofnij”). Czas ukrycia sterowany jest z aplikacji (brak pętli animacji).
6 +
7 +use libluna_core::style::{weight, Appearance};
8 +use libluna_core::{Point, Rect, Size, Theme};
9 +use libluna_render::TextEngine;
10 +
11 +use crate::ctx::PaintCtx;
12 +
13 +/// Powiadomienie („toast”).
14 +#[derive(Debug, Clone, Default)]
15 +pub struct Toast {
16 + message: String,
17 + action: Option<String>,
18 + open: bool,
19 +}
20 +
21 +impl Toast {
22 + pub fn new(message: impl Into<String>) -> Self {
23 + Self {
24 + message: message.into(),
25 + action: None,
26 + open: false,
27 + }
28 + }
29 +
30 + /// Ustawia etykietę akcji (np. „Cofnij”).
31 + pub fn action(mut self, label: impl Into<String>) -> Self {
32 + self.action = Some(label.into());
33 + self
34 + }
35 +
36 + pub fn set_message(&mut self, message: impl Into<String>) {
37 + self.message = message.into();
38 + }
39 +
40 + pub fn set_action(&mut self, action: Option<String>) {
41 + self.action = action;
42 + }
43 +
44 + /// Pokazuje powiadomienie.
45 + pub fn show(&mut self) {
46 + self.open = true;
47 + }
48 +
49 + pub fn set_open(&mut self, open: bool) {
50 + self.open = open;
51 + }
52 +
53 + pub fn is_open(&self) -> bool {
54 + self.open
55 + }
56 +
57 + pub fn message(&self) -> &str {
58 + &self.message
59 + }
60 +
61 + pub fn action_label(&self) -> Option<&str> {
62 + self.action.as_deref()
63 + }
64 +
65 + /// Wysokość powiadomienia (z metryk motywu).
66 + pub fn height(&self, theme: &Theme) -> f64 {
67 + theme.metrics.toast.height
68 + }
69 +
70 + /// Prostokąt powiadomienia (wyśrodkowany poziomo, odsunięty od dołu).
71 + pub fn rect(&self, size: Size, theme: &Theme, text: &TextEngine) -> Rect {
72 + let m = theme.metrics.toast;
73 + let body = theme.typography.body.size;
74 + let mut w = m.pad_x * 2.0 + text.measure(&self.message, body, weight::NORMAL).width;
75 + if let Some(action) = &self.action {
76 + w += m.gap + text.measure(action, body, weight::MEDIUM).width;
77 + }
78 + Rect::from_xywh(
79 + (size.width - w) / 2.0,
80 + size.height - m.margin_bottom - m.height,
81 + w,
82 + m.height,
83 + )
84 + }
85 +
86 + /// Prostokąt akcji (jeśli jest), w prawym końcu powiadomienia.
87 + pub fn action_rect(&self, size: Size, theme: &Theme, text: &TextEngine) -> Option<Rect> {
88 + let action = self.action.as_ref()?;
89 + let t = self.rect(size, theme, text);
90 + let body = theme.typography.body.size;
91 + let pad = theme.spacing.sm;
92 + let w = text.measure(action, body, weight::MEDIUM).width + pad * 2.0;
93 + Some(Rect::from_xywh(
94 + t.right() - theme.metrics.toast.pad_x - w,
95 + t.y(),
96 + w,
97 + t.height(),
98 + ))
99 + }
100 +
101 + /// Czy punkt trafia w akcję powiadomienia.
102 + pub fn action_at(&self, size: Size, theme: &Theme, text: &TextEngine, p: Point) -> bool {
103 + self.open
104 + && self
105 + .action_rect(size, theme, text)
106 + .is_some_and(|r| r.contains(p))
107 + }
108 +
109 + /// Rysuje powiadomienie (na wierzchu sceny).
110 + pub fn paint(&self, ctx: &mut PaintCtx, size: Size) {
111 + if !self.open {
112 + return;
113 + }
114 + let theme = *ctx.theme;
115 + let colors = theme.colors;
116 + let t = self.rect(size, &theme, ctx.text);
117 +
118 + // Powierzchnia o wysokim kontraście: w motywie jasnym ciemna, w ciemnym nieco
119 + // jaśniejsza od tła okna (jak „toast” w libadwaita).
120 + let (bg, fg) = match theme.appearance {
121 + Appearance::Light => (colors.text, colors.view_bg),
122 + Appearance::Dark => (colors.card_bg, colors.text),
123 + };
124 +
125 + ctx.scene.fill_rounded_rect(t, t.height() / 2.0, bg);
126 +
127 + let body = theme.typography.body.size;
128 + let m = theme.metrics.toast;
129 + ctx.label_left(
130 + t.x() + m.pad_x,
131 + t.center().y,
132 + &self.message,
133 + body,
134 + weight::NORMAL,
135 + fg,
136 + );
137 +
138 + if let (Some(action), Some(ar)) = (&self.action, self.action_rect(size, &theme, ctx.text)) {
139 + ctx.label_center(
140 + ar.center().x,
141 + ar.center().y,
142 + action,
143 + body,
144 + weight::MEDIUM,
145 + colors.accent,
146 + );
147 + }
148 + }
149 +}
150 +
151 +#[cfg(test)]
152 +mod tests {
153 + use super::*;
154 +
155 + fn size() -> Size {
156 + Size::new(900.0, 800.0)
157 + }
158 +
159 + #[test]
160 + fn hidden_toast_never_hits_action() {
161 + let theme = Theme::light();
162 + let text = TextEngine::system();
163 + let toast = Toast::new("Zapisano").action("Cofnij");
164 + let ar = toast.action_rect(size(), &theme, &text).unwrap();
165 + assert!(!toast.action_at(size(), &theme, &text, ar.center()));
166 + }
167 +
168 + #[test]
169 + fn shown_toast_hits_action() {
170 + let theme = Theme::light();
171 + let text = TextEngine::system();
172 + let mut toast = Toast::new("Zapisano").action("Cofnij");
173 + toast.show();
174 + let ar = toast.action_rect(size(), &theme, &text).unwrap();
175 + assert!(toast.action_at(size(), &theme, &text, ar.center()));
176 + assert!(!toast.action_at(size(), &theme, &text, Point::new(5.0, 5.0)));
177 + }
178 +
179 + #[test]
180 + fn toast_is_centered_and_above_bottom() {
181 + let theme = Theme::light();
182 + let text = TextEngine::system();
183 + let mut toast = Toast::new("Gotowe");
184 + toast.show();
185 + let r = toast.rect(size(), &theme, &text);
186 + assert!((r.center().x - 450.0).abs() < 1e-9);
187 + assert!((r.bottom() - (800.0 - theme.metrics.toast.margin_bottom)).abs() < 1e-9);
188 + assert!(toast.action_rect(size(), &theme, &text).is_none());
189 + }
190 +}
0 191 Binary files a/docs/images/demo-montage.png and b/docs/images/demo-montage.png differ
1 192 Binary files a/docs/images/demo-step-10.png and b/docs/images/demo-step-10.png differ
2 193 Binary files a/docs/images/demo-step-11.png and b/docs/images/demo-step-11.png differ
3 194 new file mode 100644
4 195 Binary files /dev/null and b/docs/images/demo-step-12.png differ
5 196 new file mode 100644
6 197 Binary files /dev/null and b/docs/images/demo-step-13.png differ
7 198 new file mode 100644
8 199 Binary files /dev/null and b/docs/images/demo-step-14.png differ
9 200 new file mode 100644
10 201 Binary files /dev/null and b/docs/images/demo-step-15.png differ
11 202 Binary files a/docs/images/demo-step-3.png and b/docs/images/demo-step-3.png differ
12 203 Binary files a/docs/images/demo-step-4.png and b/docs/images/demo-step-4.png differ
13 204 Binary files a/docs/images/demo-step-5.png and b/docs/images/demo-step-5.png differ
14 205 Binary files a/docs/images/demo-step-6.png and b/docs/images/demo-step-6.png differ
15 206 Binary files a/docs/images/demo-step-7.png and b/docs/images/demo-step-7.png differ
16 207 Binary files a/docs/images/demo-step-8.png and b/docs/images/demo-step-8.png differ
17 208 Binary files a/docs/images/demo-step-9.png and b/docs/images/demo-step-9.png differ
18 209 Binary files a/docs/images/settings-dark.png and b/docs/images/settings-dark.png differ
19 210 Binary files a/docs/images/settings-de-dark.png and b/docs/images/settings-de-dark.png differ
20 211 Binary files a/docs/images/settings-fr.png and b/docs/images/settings-fr.png differ
@@ -40,7 +40,8 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
40 40 `PathBar` (okruszki), `SearchEntry` (pole wyszukiwania), `ItemView` (pliki/katalogi: siatka
41 41 lub lista) i `ScrollView` (przewijanie), oraz `Stepper`, `CheckButton` i `RadioGroup`
42 42 (krokowiec, checkbox, wybór jednokrotny); do tego `Card` i `ActionRow` (karty i wiersze
43 - ustawień); wspólny `PaintCtx`; testy hit-testingu bez okna.
43 + ustawień), `Toast` (powiadomienie nakładkowe z akcją) i `StatusPage` (ekran stanu: ikona,
44 + tytuł, opis, akcja — np. „pusty stan” listy); wspólny `PaintCtx`; testy hit-testingu bez okna.
44 45 - `libluna-platform`: realne akcje okna z `Frame` (`close`/`minimize`/`toggle_maximize`).
45 46 - Demo używa widgetów zamiast ręcznego rysowania (pasek boczny, wybór motywu/widoku, menu `☰`,
46 47 przycisk „Opcje”, przełączniki, suwak, wybór języka, pasek tytułu, zakładki `TabBar`/`TabView`).
@@ -108,8 +109,9 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
108 109 | DropDown | `libluna-widgets` | Settings |
109 110 | Button (pill/suggested/flat) | `libluna-widgets` | Settings |
110 111 | Stepper / Check / Radio | `libluna-widgets` | Files (popover) |
112 +| Toast | `libluna-widgets` | Settings, Files |
111 113 | StatusBar / bottom info | `libluna-widgets` | obie |
112 -| StatusPage | `libluna-adwaita` | (docelowo) |
114 +| StatusPage (pusty stan) | `libluna-widgets` | Files |
113 115
114 116 ## Kryterium „gotowca” (Definition of Done dla MVP)
115 117 Odtworzenie **obu makiet** (Settings i Files) wyłącznie na libLunie, bez GTK4, z jasnym i
@@ -50,3 +50,10 @@ Name = Nom
50 50 Date modified = Date de modification
51 51 Size = Taille
52 52 Show hidden files = Afficher les fichiers cachés
53 +Accent changed = Accent modifié
54 +Undo = Annuler
55 +No results = Aucun résultat
56 +Try a different search term = Essayez un autre terme
57 +Clear = Effacer
58 +Nothing here = Rien ici
59 +This folder is empty = Ce dossier est vide