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

324
331

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