← luna

Commit 9980997

22
files
+635
added
-177
deleted
@@ -195,13 +195,14 @@ cargo run -p libluna --example minimal
195 195 lub kontrolkę **języka** (`DropDown`). Przycisk **„Opcje ▾”** otwiera **popover opcji**
196 196 (`Stepper` rozmiaru ikon, `RadioGroup` sortowania, `CheckButton` ukrytych plików).
197 197 Z menu **`☰` → „O aplikacji”** otwiera się **dolny panel**
198 - (`BottomSheet` z karuzelą stron `Pager`), zamykany klikiem w tło, przyciskiem `✕` lub
198 + (`BottomSheet` z karuzelą stron `Pager` i polem tekstowym `Entry` — klik = fokus, wpisywanie),
199 + zamykany klikiem w tło, przyciskiem `✕` lub
199 200 przeciągnięciem w dół. W karcie **Pliki** kliknij element **okruszków** (nawigacja „w górę”),
200 201 **pole wyszukiwania** (fokus) lub **plik/katalog** (zaznaczenie); przełącznik `Lista | Siatka`
201 202 zmienia tryb `ItemView`, a **rolka/przeciąganie paska** przewija listę. Wpisany tekst w polu
202 203 **wyszukiwania filtruje** elementy; gdy nic nie pasuje, pojawia się ekran stanu (`StatusPage`)
203 204 z przyciskiem **„Wyczyść”**. Zmiana **koloru akcentu** pokazuje `Toast` z akcją **„Cofnij”**,
204 - która przywraca poprzedni akcent. Klikaj segmenty `Jasny | Ciemny | Auto`, kropki
205 + która przywraca poprzedni akcent (klik w treść powiadomienia zamyka je bez cofania). Klikaj segmenty `Jasny | Ciemny | Auto`, kropki
205 206 **koloru akcentu**, przełączniki OPCJA A / **Pasek dolny** / **Pasek boczny** i **przeciągaj**
206 207 suwak głośności oraz krawędź paska bocznego (150–460 px). Kontrolki okna `[—][□][✕]` działają
207 208 (minimalizacja/maksymalizacja/zamknięcie przez warstwę platformy).
@@ -300,8 +301,9 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
300 301 `HeaderBar` (pasek tytułu: tytuł, kontrolki okna, obszar na treść), `TabBar`/`TabView`
301 302 (pasek zakładek i model stron: wybór, dodawanie, zamykanie), `BottomSheet` (dolny panel
302 303 modalny z „grabberem”, przeciąganiem i zamykaniem), `Pager` (karuzela stron z kropkami),
303 - `PathBar` (okruszki z separatorem `›`, wskaźnikiem `▾` i zwijaniem do `…`), `SearchEntry`
304 - (pole wyszukiwania: lupka, kursor, czyszczenie), `ItemView` (pliki/katalogi w siatce lub
304 + `PathBar` (okruszki z separatorem `›`, wskaźnikiem `▾` i zwijaniem do `…`), `Entry`
305 + (pole tekstowe: kursor, edycja, opcjonalna ikona wiodąca i czyszczenie) — z `SearchEntry`
306 + jako jego wyszukiwarkową nakładką (lupka + czyszczenie), `ItemView` (pliki/katalogi w siatce lub
305 307 liście, z zaznaczaniem i przewijaniem) i `ScrollView` (pionowe przewijanie: rolka + „kciuk”)
306 308 oraz `Stepper`, `CheckButton` i `RadioGroup` (krokowiec, pole wyboru, wybór jednokrotny),
307 309 a także `Card` (karta z nagłówkiem), `ActionRow` (wiersz ustawień), `Toast` (powiadomienie
@@ -110,6 +110,12 @@ fn main() -> Result<(), Box<dyn Error>> {
110 110 frames.push(render_pixels(&settings, &demo, &text, size));
111 111 labels.push("3. Accent: Purple".into());
112 112
113 + // Zamknięcie powiadomienia klikiem w jego treść (akcent zostaje).
114 + let theme = settings.resolve(false);
115 + let toast = demo.toast.rect(size, &theme, &text);
116 + click!(Point::new(toast.x() + 8.0, toast.center().y));
117 + assert!(!demo.toast.is_open(), "toast did not dismiss");
118 +
113 119 // 4. Klik pozycji „Sieć" w menu nawigacji (indeks elementu 5).
114 120 let theme = settings.resolve(false);
115 121 let c = make_nav().rows(layout!().sidebar, &theme)[5].rect.center();
@@ -174,8 +180,26 @@ fn main() -> Result<(), Box<dyn Error>> {
174 180 .expect("menu is open");
175 181 click!(about);
176 182 assert!(demo.sheet.is_open(), "bottom sheet did not open");
183 +
184 + // Pole `Entry` w panelu: fokus i wpisanie tekstu.
185 + let entry = ui::sheet_entry_rect(size, &settings.resolve(false));
186 + click!(entry.center());
187 + assert!(demo.sheet_entry.is_focused(), "entry not focused");
188 + for ch in ['A', 'd', 'a'] {
189 + let key = Event::Key(KeyEvent {
190 + keysym: ch as u32,
191 + text: Some(ch.to_string()),
192 + state: ButtonState::Pressed,
193 + modifiers: Modifiers::NONE,
194 + repeat: false,
195 + });
196 + let theme = settings.resolve(false);
197 + ui::handle_events(&mut settings, &[key], size, &theme, &text, &mut demo);
198 + }
199 + assert_eq!(demo.sheet_entry.text(), "Ada", "entry text mismatch");
200 +
177 201 frames.push(render_pixels(&settings, &demo, &text, size));
178 - labels.push("9. Bottom sheet \u{201c}About\u{201d}".into());
202 + labels.push("9. Bottom sheet \u{201c}About\u{201d} + Entry".into());
179 203
180 204 // 10. Zamknięcie panelu (klik w tło) i rozwinięcie listy języków (DropDown + Menu).
181 205 click!(Point::new(120.0, 300.0));
@@ -289,12 +313,14 @@ fn main() -> Result<(), Box<dyn Error>> {
289 313 quality: FilterQuality::Bilinear,
290 314 ..Default::default()
291 315 };
292 - let scale = Transform::from_scale(cell_w as f32 / W as f32, cell_h as f32 / H as f32);
293 316 for (i, pixels) in frames.iter().enumerate() {
294 317 let src = Pixmap::from_vec(pixels.clone(), IntSize::from_wh(W, H).unwrap()).unwrap();
295 318 let cx = (i % cols) as u32 * cell_w;
296 319 let cy = (i / cols) as u32 * cell_h;
297 - montage.draw_pixmap(cx as i32, cy as i32, src.as_ref(), &paint, scale, None);
320 + // Skalowanie i przesunięcie w jednej transformacji (inaczej komórki lądują w rogu).
321 + let t = Transform::from_scale(cell_w as f32 / W as f32, cell_h as f32 / H as f32)
322 + .post_translate(cx as f32, cy as f32);
323 + montage.draw_pixmap(0, 0, src.as_ref(), &paint, t, None);
298 324 }
299 325 montage.save_png("docs/images/demo-montage.png")?;
300 326 println!("saved docs/images/demo-montage.png");
@@ -12,9 +12,9 @@ use libluna_i18n::{tr, Locale};
12 12 use libluna_render::TextEngine;
13 13 use libluna_widgets::{
14 14 ActionRow, BottomSheet, Button, Card, CheckButton, ControlsLayout, ControlsSide, DropDown,
15 - HeaderBar, Item, ItemView, Menu, MenuEntry, NavEntry, NavList, Pager, PaintCtx, PathBar,
15 + Entry, HeaderBar, Item, ItemView, Menu, MenuEntry, NavEntry, NavList, Pager, PaintCtx, PathBar,
16 16 RadioGroup, ScrollView, SearchEntry, Slider, StatusPage, Stepper, StepperSide, Switch, Tab,
17 - TabBar, TabView, Toast, ViewMode, ViewSwitcher, WindowControl,
17 + TabBar, TabView, TextInput, Toast, ViewMode, ViewSwitcher, WindowControl,
18 18 };
19 19
20 20 const STATUS_H: f64 = 28.0;
@@ -76,6 +76,8 @@ pub struct Demo {
76 76 pub sheet: BottomSheet,
77 77 pub pager: Pager,
78 78 pub pager_dragging: bool,
79 + /// Pole tekstowe w dolny panelu (demonstracja `Entry`).
80 + pub sheet_entry: Entry,
79 81 /// Pasek ścieżki (składniki to klucze i18n) i pole wyszukiwania.
80 82 pub path: PathBar,
81 83 pub path_hover: Option<usize>,
@@ -133,6 +135,7 @@ impl Default for Demo {
133 135 sheet: BottomSheet::new(),
134 136 pager: Pager::new(3),
135 137 pager_dragging: false,
138 + sheet_entry: Entry::new(""),
136 139 path: PathBar::new(["Home", "Documents", "Projects"]),
137 140 path_hover: None,
138 141 search: SearchEntry::new("Search"),
@@ -618,24 +621,42 @@ pub fn handle_events(
618 621 let area = demo.sheet.content_rect(size, theme);
619 622 if demo.sheet.close_at(size, theme, p) {
620 623 demo.sheet.set_open(false);
624 + demo.sheet_entry.set_focused(false);
621 625 } else if demo.sheet.drag_zone_at(size, theme, p) {
622 626 demo.sheet.begin_drag(p.y);
623 627 } else if demo.sheet.scrim_at(size, theme, p) {
624 628 demo.sheet.set_open(false);
625 - } else if area.contains(p)
626 - && !demo.pager.click_indicator(area, theme, p)
627 - {
628 - demo.pager_dragging = true;
629 - demo.pager.begin_drag(p.x);
629 + demo.sheet_entry.set_focused(false);
630 + } else if area.contains(p) {
631 + if sheet_entry_rect(size, theme).contains(p) {
632 + demo.sheet_entry.set_focused(true);
633 + } else if demo.pager.click_indicator(
634 + sheet_pages_rect(size, theme),
635 + theme,
636 + p,
637 + ) {
638 + demo.sheet_entry.set_focused(false);
639 + } else {
640 + demo.sheet_entry.set_focused(false);
641 + demo.pager_dragging = true;
642 + demo.pager.begin_drag(p.x);
643 + }
630 644 }
631 645 continue;
632 646 }
633 647
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;
648 + // Powiadomienie (nakładka) ma pierwszeństwo nad treścią.
649 + if demo.toast.is_open() {
650 + if demo.toast.action_at(size, theme, text, p) {
651 + settings.set_accent(demo.prev_accent);
652 + demo.toast.set_open(false);
653 + continue;
654 + }
655 + // Klik w treść powiadomienia je zamyka (akcent zostaje).
656 + if demo.toast.dismiss_at(size, theme, text, p) {
657 + demo.toast.set_open(false);
658 + continue;
659 + }
639 660 }
640 661
641 662 // Popover „Opcje” obsługuje własne kontrolki.
@@ -845,7 +866,14 @@ pub fn handle_events(
845 866 }
846 867 Event::Key(ke) if ke.state == ButtonState::Pressed => {
847 868 // Gdy pole wyszukiwania ma fokus, klawisze idą do niego.
848 - if demo.search.is_focused() && handle_search_key(&mut demo.search, ke) {
869 + if demo.search.is_focused() && handle_text_key(&mut demo.search, ke) {
870 + continue;
871 + }
872 + // Pole `Entry` w dolnym panelu ma własny fokus.
873 + if demo.sheet.is_open()
874 + && demo.sheet_entry.is_focused()
875 + && handle_text_key(&mut demo.sheet_entry, ke)
876 + {
849 877 continue;
850 878 }
851 879 match ke.keysym {
@@ -906,10 +934,10 @@ fn cycle_language() {
906 934 libluna_i18n::set_locale(Locale::new(&next));
907 935 }
908 936
909 -/// Kieruje pojedyncze zdarzenie klawiatury do pola wyszukiwania.
937 +/// Kieruje pojedyncze zdarzenie klawiatury do pola tekstowego.
910 938 ///
911 939 /// Zwraca `true`, jeśli klawisz został zużyty (wtedy skróty demo są pomijane).
912 -fn handle_search_key(entry: &mut SearchEntry, ke: &KeyEvent) -> bool {
940 +fn handle_text_key<T: TextInput>(entry: &mut T, ke: &KeyEvent) -> bool {
913 941 // Tekst złożony przez platformę (obsługuje też znaki spoza ASCII).
914 942 if let Some(t) = &ke.text {
915 943 if !t.is_empty() && !t.chars().all(char::is_control) {
@@ -992,28 +1020,61 @@ pub fn build_scene(
992 1020 }
993 1021 }
994 1022
995 -/// Zawartość dolnego panelu: karuzela stron (`Pager`) z kropkami-wskaźnikami.
996 -fn draw_sheet_content(paint: &mut PaintCtx, demo: &Demo, size: Size) {
997 - let theme = *paint.theme;
998 - let content = demo.sheet.content_rect(size, &theme);
999 - let pager = &demo.pager;
1023 +/// Obszar zawartości dolnego panelu (poniżej jego nagłówka).
1024 +fn sheet_content_rect(size: Size, theme: &Theme) -> Rect {
1025 + let m = theme.metrics.sheet;
1026 + Rect::from_xywh(
1027 + 0.0,
1028 + size.height - m.height + m.header,
1029 + size.width,
1030 + (m.height - m.header).max(0.0),
1031 + )
1032 +}
1000 1033
1001 - // Obszar stron (nad kropkami).
1002 - let dots_h = theme.metrics.pager.dot + theme.metrics.pager.pad;
1003 - let pages = Rect::from_xywh(
1034 +/// Prostokąt pola tekstowego w dolnym panelu (demonstracja `Entry`).
1035 +pub fn sheet_entry_rect(size: Size, theme: &Theme) -> Rect {
1036 + let content = sheet_content_rect(size, theme);
1037 + let pad = theme.spacing.md;
1038 + Rect::from_xywh(
1039 + content.x() + pad,
1040 + content.y() + pad,
1041 + (content.width() - 2.0 * pad).max(0.0),
1042 + theme.metrics.entry.height,
1043 + )
1044 +}
1045 +
1046 +/// Obszar stron karuzeli w dolnym panelu (poniżej pola tekstowego).
1047 +fn sheet_pages_rect(size: Size, theme: &Theme) -> Rect {
1048 + let content = sheet_content_rect(size, theme);
1049 + let pad = theme.spacing.md;
1050 + let top = content.y() + pad + theme.metrics.entry.height + pad;
1051 + Rect::from_xywh(
1004 1052 content.x(),
1005 - content.y(),
1053 + top,
1006 1054 content.width(),
1007 - (content.height() - dots_h).max(0.0),
1008 - );
1055 + (content.bottom() - top).max(0.0),
1056 + )
1057 +}
1009 1058
1059 +/// Zawartość dolnego panelu: pole `Entry` (demonstracja) + karuzela stron (`Pager`).
1060 +fn draw_sheet_content(paint: &mut PaintCtx, demo: &Demo, size: Size) {
1061 + let theme = *paint.theme;
1062 +
1063 + // Pole tekstowe (`Entry`) na górze panelu.
1064 + let mut entry = demo.sheet_entry.clone();
1065 + entry.set_placeholder(tr("Your name"));
1066 + entry.paint(paint, sheet_entry_rect(size, &theme));
1067 +
1068 + // Strony karuzeli poniżej.
1069 + let pages = sheet_pages_rect(size, &theme);
1070 + let pager = &demo.pager;
1010 1071 paint.scene.push_clip(pages);
1011 1072 for i in 0..pager.count() {
1012 1073 draw_about_page(paint, pager.page_rect(pages, i), i);
1013 1074 }
1014 1075 paint.scene.pop_clip();
1015 1076
1016 - pager.paint(paint, content);
1077 + pager.paint(paint, pages);
1017 1078 }
1018 1079
1019 1080 /// Pojedyncza strona karuzeli w panelu „O aplikacji”.
@@ -375,3 +375,58 @@ fn search_filters_items_and_selection_maps_to_full_list() {
375 375 click(&mut settings, &mut demo, slot);
376 376 assert_eq!(demo.items.selected(), Some(visible[0]));
377 377 }
378 +
379 +#[test]
380 +fn sheet_entry_focus_and_typing() {
381 + let mut settings = ThemeSettings::default();
382 + let mut demo = Demo::default();
383 +
384 + // Otwórz dolny panel: menu ☰ → „O aplikacji”.
385 + let burger = layout(&settings, &demo).burger.center();
386 + click(&mut settings, &mut demo, burger);
387 + let theme = settings.resolve(false);
388 + let text = TextEngine::system();
389 + let about = ui::popover_menu(size(), &theme, &text, &layout(&settings, &demo), &demo)
390 + .map(|(panel, menu)| menu.rows(panel, &theme)[4].rect.center())
391 + .expect("about entry");
392 + click(&mut settings, &mut demo, about);
393 + assert!(demo.sheet.is_open());
394 +
395 + // Klik pola nadaje fokus; wpisany znak trafia do `Entry`.
396 + let entry = ui::sheet_entry_rect(size(), &theme).center();
397 + click(&mut settings, &mut demo, entry);
398 + assert!(demo.sheet_entry.is_focused());
399 +
400 + let key = Event::Key(KeyEvent {
401 + keysym: 0x41,
402 + text: Some("A".to_owned()),
403 + state: ButtonState::Pressed,
404 + modifiers: Modifiers::NONE,
405 + repeat: false,
406 + });
407 + ui::handle_events(&mut settings, &[key], size(), &theme, &text, &mut demo);
408 + assert_eq!(demo.sheet_entry.text(), "A");
409 +}
410 +
411 +#[test]
412 +fn clicking_toast_body_dismisses_without_reverting() {
413 + let mut settings = ThemeSettings::default();
414 + let mut demo = Demo::default();
415 +
416 + let dot = layout(&settings, &demo).accents[1].center();
417 + click(&mut settings, &mut demo, dot);
418 + assert!(demo.toast.is_open());
419 + let accent_after = settings.accent();
420 +
421 + // Klik w treść powiadomienia zamyka je, ale nie cofa akcentu.
422 + let theme = settings.resolve(false);
423 + let text = TextEngine::system();
424 + let r = demo.toast.rect(size(), &theme, &text);
425 + click(
426 + &mut settings,
427 + &mut demo,
428 + Point::new(r.x() + 6.0, r.center().y),
429 + );
430 + assert!(!demo.toast.is_open());
431 + assert_eq!(settings.accent(), accent_after);
432 +}
@@ -51,6 +51,7 @@ Name = Name
51 51 Date modified = Änderungsdatum
52 52 Size = Größe
53 53 Show hidden files = Versteckte Dateien anzeigen
54 +Your name = Dein Name
54 55 Accent changed = Akzent geändert
55 56 Undo = Rückgängig
56 57 No results = Keine Ergebnisse
@@ -52,6 +52,7 @@ Name = Name
52 52 Date modified = Date modified
53 53 Size = Size
54 54 Show hidden files = Show hidden files
55 +Your name = Your name
55 56 Accent changed = Accent changed
56 57 Undo = Undo
57 58 No results = No results
@@ -51,6 +51,7 @@ Name = Nazwy
51 51 Date modified = Daty modyfikacji
52 52 Size = Rozmiar
53 53 Show hidden files = Pokazuj ukryte pliki
54 +Your name = Imię
54 55 Accent changed = Zmieniono akcent
55 56 Undo = Cofnij
56 57 No results = Brak wyników
57 58 new file mode 100644
@@ -0,0 +1,367 @@
1 +//! `Entry` — pole tekstowe (jednowierszowe) z kursorem, opcjonalną ikoną wiodącą i czyszczeniem.
2 +//!
3 +//! Widget immediate: trzyma treść i pozycję kursora (indeks bajtowy, zawsze na granicy znaku).
4 +//! Aplikacja dostarcza zdarzenia klawiatury jako wywołania metod ([`Entry::insert_str`],
5 +//! [`Entry::backspace`], [`Entry::move_left`]…) i sama decyduje o fokusie ([`Entry::set_focused`]).
6 +//!
7 +//! [`SearchEntry`](crate::SearchEntry) to cienka nakładka: `Entry` z ikoną lupy i czyszczeniem.
8 +
9 +use libluna_core::style::weight;
10 +use libluna_core::{Point, Rect, Theme};
11 +
12 +use crate::ctx::PaintCtx;
13 +use crate::icon;
14 +
15 +/// Ikona wiodąca pola (przed tekstem).
16 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17 +pub enum EntryIcon {
18 + /// Lupka (pola wyszukiwania).
19 + Search,
20 +}
21 +
22 +/// Pole tekstowe.
23 +#[derive(Debug, Clone, Default)]
24 +pub struct Entry {
25 + text: String,
26 + /// Pozycja kursora jako indeks bajtowy (zawsze na granicy znaku).
27 + cursor: usize,
28 + placeholder: String,
29 + leading: Option<EntryIcon>,
30 + clearable: bool,
31 + focused: bool,
32 + hover_clear: bool,
33 +}
34 +
35 +impl Entry {
36 + pub fn new(placeholder: impl Into<String>) -> Self {
37 + Self {
38 + placeholder: placeholder.into(),
39 + ..Self::default()
40 + }
41 + }
42 +
43 + /// Dodaje ikonę wiodącą (np. lupkę).
44 + pub fn leading(mut self, icon: EntryIcon) -> Self {
45 + self.leading = Some(icon);
46 + self
47 + }
48 +
49 + /// Włącza przycisk czyszczenia („×”) widoczny, gdy pole niepuste.
50 + pub fn clearable(mut self, clearable: bool) -> Self {
51 + self.clearable = clearable;
52 + self
53 + }
54 +
55 + pub fn text(&self) -> &str {
56 + &self.text
57 + }
58 +
59 + pub fn is_empty(&self) -> bool {
60 + self.text.is_empty()
61 + }
62 +
63 + pub fn cursor(&self) -> usize {
64 + self.cursor
65 + }
66 +
67 + pub fn is_focused(&self) -> bool {
68 + self.focused
69 + }
70 +
71 + pub fn set_text(&mut self, text: impl Into<String>) {
72 + self.text = text.into();
73 + self.cursor = self.text.len();
74 + }
75 +
76 + pub fn set_focused(&mut self, focused: bool) {
77 + self.focused = focused;
78 + }
79 +
80 + pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
81 + self.placeholder = placeholder.into();
82 + }
83 +
84 + pub fn set_hover_clear(&mut self, hovered: bool) {
85 + self.hover_clear = hovered;
86 + }
87 +
88 + pub fn clear(&mut self) {
89 + self.text.clear();
90 + self.cursor = 0;
91 + }
92 +
93 + /// Wstawia tekst w miejscu kursora.
94 + pub fn insert_str(&mut self, s: &str) {
95 + self.text.insert_str(self.cursor, s);
96 + self.cursor += s.len();
97 + }
98 +
99 + pub fn insert_char(&mut self, c: char) {
100 + let mut buf = [0u8; 4];
101 + self.insert_str(c.encode_utf8(&mut buf));
102 + }
103 +
104 + /// Usuwa znak przed kursorem.
105 + pub fn backspace(&mut self) {
106 + if self.cursor > 0 {
107 + let prev = prev_boundary(&self.text, self.cursor);
108 + self.text.replace_range(prev..self.cursor, "");
109 + self.cursor = prev;
110 + }
111 + }
112 +
113 + /// Usuwa znak za kursorem.
114 + pub fn delete_forward(&mut self) {
115 + if self.cursor < self.text.len() {
116 + let next = next_boundary(&self.text, self.cursor);
117 + self.text.replace_range(self.cursor..next, "");
118 + }
119 + }
120 +
121 + pub fn move_left(&mut self) {
122 + self.cursor = prev_boundary(&self.text, self.cursor);
123 + }
124 +
125 + pub fn move_right(&mut self) {
126 + self.cursor = next_boundary(&self.text, self.cursor);
127 + }
128 +
129 + pub fn home(&mut self) {
130 + self.cursor = 0;
131 + }
132 +
133 + pub fn end(&mut self) {
134 + self.cursor = self.text.len();
135 + }
136 +
137 + pub fn height(&self, theme: &Theme) -> f64 {
138 + theme.metrics.entry.height
139 + }
140 +
141 + pub fn contains(&self, rect: Rect, p: Point) -> bool {
142 + rect.contains(p)
143 + }
144 +
145 + /// Prostokąt przycisku czyszczenia („×”).
146 + pub fn clear_rect(&self, rect: Rect, theme: &Theme) -> Rect {
147 + let m = theme.metrics.entry;
148 + Rect::from_xywh(
149 + rect.right() - m.pad_x - m.icon,
150 + rect.center().y - m.icon / 2.0,
151 + m.icon,
152 + m.icon,
153 + )
154 + }
155 +
156 + /// Czy punkt trafia w przycisk czyszczenia (tylko gdy pole czyszczalne i niepuste).
157 + pub fn clear_at(&self, rect: Rect, theme: &Theme, p: Point) -> bool {
158 + self.clearable && !self.text.is_empty() && self.clear_rect(rect, theme).contains(p)
159 + }
160 +
161 + /// Pozycja (x), od której zaczyna się tekst (za ikoną wiodącą, jeśli jest).
162 + fn text_x(&self, rect: Rect, theme: &Theme) -> f64 {
163 + let m = theme.metrics.entry;
164 + match self.leading {
165 + Some(_) => rect.x() + m.pad_x + m.icon + 8.0,
166 + None => rect.x() + m.pad_x,
167 + }
168 + }
169 +
170 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
171 + let theme = *ctx.theme;
172 + let m = theme.metrics.entry;
173 + let colors = theme.colors;
174 + let radius = theme.radius.medium;
175 +
176 + ctx.scene.fill_rounded_rect(rect, radius, colors.shade);
177 + let (border, width) = if self.focused {
178 + (colors.accent, 2.0)
179 + } else {
180 + (colors.border, 1.0)
181 + };
182 + ctx.scene.stroke_rounded_rect(rect, radius, width, border);
183 +
184 + let cy = rect.center().y;
185 + if let Some(icon_kind) = self.leading {
186 + match icon_kind {
187 + EntryIcon::Search => icon::search(
188 + ctx,
189 + colors.dim_text,
190 + Point::new(rect.x() + m.pad_x + m.icon / 2.0, cy),
191 + m.icon,
192 + ),
193 + }
194 + }
195 + let text_x = self.text_x(rect, &theme);
196 +
197 + if self.text.is_empty() {
198 + ctx.label_left(
199 + text_x,
200 + cy,
201 + &self.placeholder,
202 + theme.typography.body.size,
203 + weight::NORMAL,
204 + colors.dim_text,
205 + );
206 + } else {
207 + let clear_space = if self.clearable { m.icon + 8.0 } else { 0.0 };
208 + let text_right = rect.right() - m.pad_x - clear_space;
209 + ctx.scene.push_clip(Rect::from_xywh(
210 + text_x,
211 + rect.y(),
212 + (text_right - text_x).max(0.0),
213 + rect.height(),
214 + ));
215 + ctx.label_left(
216 + text_x,
217 + cy,
218 + &self.text,
219 + theme.typography.body.size,
220 + weight::NORMAL,
221 + colors.text,
222 + );
223 + if self.focused {
224 + let cx = text_x
225 + + ctx.measure(
226 + &self.text[..self.cursor],
227 + theme.typography.body.size,
228 + weight::NORMAL,
229 + );
230 + ctx.scene.fill_rect(
231 + Rect::from_xywh(cx, rect.y() + 6.0, 1.5, (rect.height() - 12.0).max(0.0)),
232 + colors.text,
233 + );
234 + }
235 + ctx.scene.pop_clip();
236 + }
237 +
238 + if self.clearable && !self.text.is_empty() {
239 + let cb = self.clear_rect(rect, &theme);
240 + if self.hover_clear {
241 + ctx.scene
242 + .fill_rounded_rect(cb, cb.width() / 2.0, colors.shade.scale_alpha(1.8));
243 + }
244 + icon::close(ctx, colors.dim_text, cb, 4.0);
245 + }
246 + }
247 +}
248 +
249 +/// Wspólny interfejs edycji tekstu dla pól ([`Entry`], [`SearchEntry`](crate::SearchEntry)).
250 +///
251 +/// Pozwala aplikacji obsłużyć zdarzenia klawiatury jedną funkcją, niezależnie od tego,
252 +/// czy pole jest „gołym” `Entry`, czy wyspecjalizowanym wariantem.
253 +pub trait TextInput {
254 + fn insert_str(&mut self, s: &str);
255 + fn insert_char(&mut self, c: char);
256 + fn backspace(&mut self);
257 + fn delete_forward(&mut self);
258 + fn move_left(&mut self);
259 + fn move_right(&mut self);
260 + fn home(&mut self);
261 + fn end(&mut self);
262 +}
263 +
264 +impl TextInput for Entry {
265 + fn insert_str(&mut self, s: &str) {
266 + Entry::insert_str(self, s);
267 + }
268 + fn insert_char(&mut self, c: char) {
269 + Entry::insert_char(self, c);
270 + }
271 + fn backspace(&mut self) {
272 + Entry::backspace(self);
273 + }
274 + fn delete_forward(&mut self) {
275 + Entry::delete_forward(self);
276 + }
277 + fn move_left(&mut self) {
278 + Entry::move_left(self);
279 + }
280 + fn move_right(&mut self) {
281 + Entry::move_right(self);
282 + }
283 + fn home(&mut self) {
284 + Entry::home(self);
285 + }
286 + fn end(&mut self) {
287 + Entry::end(self);
288 + }
289 +}
290 +
291 +fn prev_boundary(s: &str, i: usize) -> usize {
292 + s[..i]
293 + .char_indices()
294 + .next_back()
295 + .map(|(j, _)| j)
296 + .unwrap_or(0)
297 +}
298 +
299 +fn next_boundary(s: &str, i: usize) -> usize {
300 + s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(i)
301 +}
302 +
303 +#[cfg(test)]
304 +mod tests {
305 + use super::*;
306 +
307 + #[test]
308 + fn typing_and_cursor() {
309 + let mut e = Entry::new("Nazwa");
310 + e.insert_str("abc");
311 + assert_eq!(e.text(), "abc");
312 + assert_eq!(e.cursor(), 3);
313 + e.move_left();
314 + e.insert_char('X');
315 + assert_eq!(e.text(), "abXc");
316 + e.backspace();
317 + assert_eq!(e.text(), "abc");
318 + assert_eq!(e.cursor(), 2);
319 + e.delete_forward();
320 + assert_eq!(e.text(), "ab");
321 + e.home();
322 + e.delete_forward();
323 + assert_eq!(e.text(), "b");
324 + }
325 +
326 + #[test]
327 + fn multibyte_is_char_aware() {
328 + let mut e = Entry::new("");
329 + e.insert_str("ząb");
330 + e.backspace();
331 + assert_eq!(e.text(), "zą");
332 + e.move_left();
333 + e.backspace();
334 + assert_eq!(e.text(), "ą");
335 + }
336 +
337 + #[test]
338 + fn clear_resets_state() {
339 + let mut e = Entry::new("");
340 + e.insert_str("x");
341 + e.clear();
342 + assert!(e.is_empty());
343 + assert_eq!(e.cursor(), 0);
344 + }
345 +
346 + #[test]
347 + fn clear_hit_requires_clearable_and_text() {
348 + let theme = Theme::light();
349 + let rect = Rect::from_xywh(0.0, 0.0, 200.0, 34.0);
350 + let mut plain = Entry::new("");
351 + plain.insert_str("abc");
352 + let c = plain.clear_rect(rect, &theme).center();
353 + // Pole nieczyszczalne: brak trafienia nawet z tekstem.
354 + assert!(!plain.clear_at(rect, &theme, c));
355 +
356 + let mut e = Entry::new("").clearable(true);
357 + assert!(!e.clear_at(rect, &theme, c));
358 + e.insert_str("abc");
359 + assert!(e.clear_at(rect, &theme, c));
360 + }
361 +
362 + #[test]
363 + fn height_from_metrics() {
364 + let theme = Theme::light();
365 + assert_eq!(Entry::new("").height(&theme), theme.metrics.entry.height);
366 + }
367 +}
@@ -20,6 +20,7 @@
20 20 //! - [`BottomSheet`] — dolny panel (modalny) z „grabberem” i zamykaniem.
21 21 //! - [`Pager`] — karuzela stron (przewijanie w poziomie + kropki-wskaźniki).
22 22 //! - [`PathBar`] — pasek ścieżki (okruszki) z separatorami i zwijaniem w wielokropek.
23 +//! - [`Entry`] — pole tekstowe (kursor, edycja, opcjonalna ikona i czyszczenie).
23 24 //! - [`SearchEntry`] — pole tekstowe wyszukiwania (lupka, kursor, czyszczenie).
24 25 //! - [`ItemView`] — widok elementów (pliki/katalogi) w siatce lub liście.
25 26 //! - [`ScrollView`] — przewijanie pionowe (rolka + „kciuk”).
@@ -46,6 +47,7 @@ pub mod card;
46 47 pub mod check_button;
47 48 pub mod ctx;
48 49 pub mod dropdown;
50 +pub mod entry;
49 51 pub mod header_bar;
50 52 pub mod icon;
51 53 pub mod item_view;
@@ -72,6 +74,7 @@ pub use card::Card;
72 74 pub use check_button::CheckButton;
73 75 pub use ctx::PaintCtx;
74 76 pub use dropdown::DropDown;
77 +pub use entry::{Entry, EntryIcon, TextInput};
75 78 pub use header_bar::{ControlsLayout, ControlsSide, HeaderBar, TitleAlign, WindowControl};
76 79 pub use item_view::{Item, ItemKind, ItemView, ViewMode};
77 80 pub use menu::{Menu, MenuEntry, MenuRow};
@@ -1,229 +1,142 @@
1 -//! `SearchEntry` — pole tekstowe wyszukiwania (lupka, tekst, kursor, „×” czyszczenia).
1 +//! `SearchEntry` — pole wyszukiwania (lupka, tekst, kursor, „×” czyszczenia).
2 2 //!
3 -//! Widget immediate: trzyma treść i pozycję kursora. Aplikacja dostarcza zdarzenia klawiatury
4 -//! jako wywołania metod ([`SearchEntry::insert_str`], [`SearchEntry::backspace`],
5 -//! [`SearchEntry::move_left`]…), a sama decyduje o fokusie ([`SearchEntry::set_focused`]).
3 +//! Cienka nakładka na [`Entry`]: pole tekstowe z ikoną lupy i przyciskiem czyszczenia.
4 +//! Cała logika edycji żyje w [`Entry`]; tutaj delegujemy, zachowując dawny interfejs.
6 5
7 -use libluna_core::style::weight;
8 6 use libluna_core::{Point, Rect, Theme};
9 7
10 8 use crate::ctx::PaintCtx;
11 -use crate::icon;
9 +use crate::entry::{Entry, EntryIcon};
12 10
13 11 /// Pole wyszukiwania.
14 12 #[derive(Debug, Clone, Default)]
15 13 pub struct SearchEntry {
16 - text: String,
17 - /// Pozycja kursora jako indeks bajtowy (zawsze na granicy znaku).
18 - cursor: usize,
19 - placeholder: String,
20 - focused: bool,
21 - hover_clear: bool,
14 + entry: Entry,
22 15 }
23 16
24 17 impl SearchEntry {
25 18 pub fn new(placeholder: impl Into<String>) -> Self {
26 19 Self {
27 - placeholder: placeholder.into(),
28 - ..Self::default()
20 + entry: Entry::new(placeholder)
21 + .leading(EntryIcon::Search)
22 + .clearable(true),
29 23 }
30 24 }
31 25
32 26 pub fn text(&self) -> &str {
33 - &self.text
27 + self.entry.text()
34 28 }
35 29
36 30 pub fn is_empty(&self) -> bool {
37 - self.text.is_empty()
31 + self.entry.is_empty()
38 32 }
39 33
40 34 pub fn cursor(&self) -> usize {
41 - self.cursor
35 + self.entry.cursor()
42 36 }
43 37
44 38 pub fn is_focused(&self) -> bool {
45 - self.focused
39 + self.entry.is_focused()
46 40 }
47 41
48 42 pub fn set_text(&mut self, text: impl Into<String>) {
49 - self.text = text.into();
50 - self.cursor = self.text.len();
43 + self.entry.set_text(text);
51 44 }
52 45
53 46 pub fn set_focused(&mut self, focused: bool) {
54 - self.focused = focused;
47 + self.entry.set_focused(focused);
55 48 }
56 49
57 50 pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
58 - self.placeholder = placeholder.into();
51 + self.entry.set_placeholder(placeholder);
59 52 }
60 53
61 54 pub fn set_hover_clear(&mut self, hovered: bool) {
62 - self.hover_clear = hovered;
55 + self.entry.set_hover_clear(hovered);
63 56 }
64 57
65 58 pub fn clear(&mut self) {
66 - self.text.clear();
67 - self.cursor = 0;
59 + self.entry.clear();
68 60 }
69 61
70 - /// Wstawia tekst w miejscu kursora.
71 62 pub fn insert_str(&mut self, s: &str) {
72 - self.text.insert_str(self.cursor, s);
73 - self.cursor += s.len();
63 + self.entry.insert_str(s);
74 64 }
75 65
76 66 pub fn insert_char(&mut self, c: char) {
77 - let mut buf = [0u8; 4];
78 - self.insert_str(c.encode_utf8(&mut buf));
67 + self.entry.insert_char(c);
79 68 }
80 69
81 - /// Usuwa znak przed kursorem.
82 70 pub fn backspace(&mut self) {
83 - if self.cursor > 0 {
84 - let prev = prev_boundary(&self.text, self.cursor);
85 - self.text.replace_range(prev..self.cursor, "");
86 - self.cursor = prev;
87 - }
71 + self.entry.backspace();
88 72 }
89 73
90 - /// Usuwa znak za kursorem.
91 74 pub fn delete_forward(&mut self) {
92 - if self.cursor < self.text.len() {
93 - let next = next_boundary(&self.text, self.cursor);
94 - self.text.replace_range(self.cursor..next, "");
95 - }
75 + self.entry.delete_forward();
96 76 }
97 77
98 78 pub fn move_left(&mut self) {
99 - self.cursor = prev_boundary(&self.text, self.cursor);
79 + self.entry.move_left();
100 80 }
101 81
102 82 pub fn move_right(&mut self) {
103 - self.cursor = next_boundary(&self.text, self.cursor);
83 + self.entry.move_right();
104 84 }
105 85
106 86 pub fn home(&mut self) {
107 - self.cursor = 0;
87 + self.entry.home();
108 88 }
109 89
110 90 pub fn end(&mut self) {
111 - self.cursor = self.text.len();
91 + self.entry.end();
112 92 }
113 93
114 94 pub fn height(&self, theme: &Theme) -> f64 {
115 - theme.metrics.entry.height
95 + self.entry.height(theme)
116 96 }
117 97
118 98 pub fn contains(&self, rect: Rect, p: Point) -> bool {
119 - rect.contains(p)
99 + self.entry.contains(rect, p)
120 100 }
121 101
122 - /// Prostokąt przycisku czyszczenia („×”).
123 102 pub fn clear_rect(&self, rect: Rect, theme: &Theme) -> Rect {
124 - let m = theme.metrics.entry;
125 - Rect::from_xywh(
126 - rect.right() - m.pad_x - m.icon,
127 - rect.center().y - m.icon / 2.0,
128 - m.icon,
129 - m.icon,
130 - )
103 + self.entry.clear_rect(rect, theme)
131 104 }
132 105
133 - /// Czy punkt trafia w przycisk czyszczenia (tylko gdy pole niepuste).
134 106 pub fn clear_at(&self, rect: Rect, theme: &Theme, p: Point) -> bool {
135 - !self.text.is_empty() && self.clear_rect(rect, theme).contains(p)
107 + self.entry.clear_at(rect, theme, p)
136 108 }
137 109
138 110 pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
139 - let theme = *ctx.theme;
140 - let m = theme.metrics.entry;
141 - let colors = theme.colors;
142 - let radius = theme.radius.medium;
143 -
144 - ctx.scene.fill_rounded_rect(rect, radius, colors.shade);
145 - let (border, width) = if self.focused {
146 - (colors.accent, 2.0)
147 - } else {
148 - (colors.border, 1.0)
149 - };
150 - ctx.scene.stroke_rounded_rect(rect, radius, width, border);
151 -
152 - let cy = rect.center().y;
153 - icon::search(
154 - ctx,
155 - colors.dim_text,
156 - Point::new(rect.x() + m.pad_x + m.icon / 2.0, cy),
157 - m.icon,
158 - );
159 - let text_x = rect.x() + m.pad_x + m.icon + 8.0;
160 -
161 - if self.text.is_empty() {
162 - ctx.label_left(
163 - text_x,
164 - cy,
165 - &self.placeholder,
166 - theme.typography.body.size,
167 - weight::NORMAL,
168 - colors.dim_text,
169 - );
170 - } else {
171 - let clear_space = if self.text.is_empty() {
172 - 0.0
173 - } else {
174 - m.icon + 8.0
175 - };
176 - let text_right = rect.right() - m.pad_x - clear_space;
177 - ctx.scene.push_clip(Rect::from_xywh(
178 - text_x,
179 - rect.y(),
180 - (text_right - text_x).max(0.0),
181 - rect.height(),
182 - ));
183 - ctx.label_left(
184 - text_x,
185 - cy,
186 - &self.text,
187 - theme.typography.body.size,
188 - weight::NORMAL,
189 - colors.text,
190 - );
191 - if self.focused {
192 - let cx = text_x
193 - + ctx.measure(
194 - &self.text[..self.cursor],
195 - theme.typography.body.size,
196 - weight::NORMAL,
197 - );
198 - ctx.scene.fill_rect(
199 - Rect::from_xywh(cx, rect.y() + 6.0, 1.5, (rect.height() - 12.0).max(0.0)),
200 - colors.text,
201 - );
202 - }
203 - ctx.scene.pop_clip();
204 - }
205 -
206 - if !self.text.is_empty() {
207 - let cb = self.clear_rect(rect, &theme);
208 - if self.hover_clear {
209 - ctx.scene
210 - .fill_rounded_rect(cb, cb.width() / 2.0, colors.shade.scale_alpha(1.8));
211 - }
212 - icon::close(ctx, colors.dim_text, cb, 4.0);
213 - }
111 + self.entry.paint(ctx, rect);
214 112 }
215 113 }
216 114
217 -fn prev_boundary(s: &str, i: usize) -> usize {
218 - s[..i]
219 - .char_indices()
220 - .next_back()
221 - .map(|(j, _)| j)
222 - .unwrap_or(0)
223 -}
224 -
225 -fn next_boundary(s: &str, i: usize) -> usize {
226 - s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(i)
115 +impl crate::entry::TextInput for SearchEntry {
116 + fn insert_str(&mut self, s: &str) {
117 + SearchEntry::insert_str(self, s);
118 + }
119 + fn insert_char(&mut self, c: char) {
120 + SearchEntry::insert_char(self, c);
121 + }
122 + fn backspace(&mut self) {
123 + SearchEntry::backspace(self);
124 + }
125 + fn delete_forward(&mut self) {
126 + SearchEntry::delete_forward(self);
127 + }
128 + fn move_left(&mut self) {
129 + SearchEntry::move_left(self);
130 + }
131 + fn move_right(&mut self) {
132 + SearchEntry::move_right(self);
133 + }
134 + fn home(&mut self) {
135 + SearchEntry::home(self);
136 + }
137 + fn end(&mut self) {
138 + SearchEntry::end(self);
139 + }
227 140 }
228 141
229 142 #[cfg(test)]
@@ -106,6 +106,15 @@ impl Toast {
106 106 .is_some_and(|r| r.contains(p))
107 107 }
108 108
109 + /// Czy punkt trafia w powiadomienie poza jego akcją (klik zamykający).
110 + pub fn dismiss_at(&self, size: Size, theme: &Theme, text: &TextEngine, p: Point) -> bool {
111 + self.open
112 + && self.rect(size, theme, text).contains(p)
113 + && !self
114 + .action_rect(size, theme, text)
115 + .is_some_and(|r| r.contains(p))
116 + }
117 +
109 118 /// Rysuje powiadomienie (na wierzchu sceny).
110 119 pub fn paint(&self, ctx: &mut PaintCtx, size: Size) {
111 120 if !self.open {
@@ -176,6 +185,22 @@ mod tests {
176 185 assert!(!toast.action_at(size(), &theme, &text, Point::new(5.0, 5.0)));
177 186 }
178 187
188 + #[test]
189 + fn body_dismisses_but_action_does_not() {
190 + let theme = Theme::light();
191 + let text = TextEngine::system();
192 + let mut toast = Toast::new("Saved").action("Undo");
193 + toast.show();
194 + let r = toast.rect(size(), &theme, &text);
195 + // Klik w lewy skraj treści zamyka.
196 + assert!(toast.dismiss_at(size(), &theme, &text, Point::new(r.x() + 6.0, r.center().y)));
197 + // Klik w akcję nie zamyka (obsługiwany osobno).
198 + let ar = toast.action_rect(size(), &theme, &text).unwrap();
199 + assert!(!toast.dismiss_at(size(), &theme, &text, ar.center()));
200 + // Klik poza powiadomieniem nie zamyka.
201 + assert!(!toast.dismiss_at(size(), &theme, &text, Point::new(5.0, 5.0)));
202 + }
203 +
179 204 #[test]
180 205 fn toast_is_centered_and_above_bottom() {
181 206 let theme = Theme::light();
182 207 Binary files a/docs/images/demo-montage.png and b/docs/images/demo-montage.png differ
183 208 Binary files a/docs/images/demo-step-10.png and b/docs/images/demo-step-10.png differ
184 209 Binary files a/docs/images/demo-step-11.png and b/docs/images/demo-step-11.png differ
185 210 Binary files a/docs/images/demo-step-4.png and b/docs/images/demo-step-4.png differ
186 211 Binary files a/docs/images/demo-step-5.png and b/docs/images/demo-step-5.png differ
187 212 Binary files a/docs/images/demo-step-6.png and b/docs/images/demo-step-6.png differ
188 213 Binary files a/docs/images/demo-step-7.png and b/docs/images/demo-step-7.png differ
189 214 Binary files a/docs/images/demo-step-8.png and b/docs/images/demo-step-8.png differ
190 215 Binary files a/docs/images/demo-step-9.png and b/docs/images/demo-step-9.png differ
@@ -37,7 +37,8 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
37 37 `Menu` (popover `☰`), `Button` (warianty + chevron), `Switch`, `Slider`, `DropDown`
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 - `PathBar` (okruszki), `SearchEntry` (pole wyszukiwania), `ItemView` (pliki/katalogi: siatka
40 + `PathBar` (okruszki), `Entry` (pole tekstowe) i `SearchEntry` (wyszukiwanie na jego bazie),
41 + `ItemView` (pliki/katalogi: siatka
41 42 lub lista) i `ScrollView` (przewijanie), oraz `Stepper`, `CheckButton` i `RadioGroup`
42 43 (krokowiec, checkbox, wybór jednokrotny); do tego `Card` i `ActionRow` (karty i wiersze
43 44 ustawień), `Toast` (powiadomienie nakładkowe z akcją) i `StatusPage` (ekran stanu: ikona,
@@ -100,6 +101,7 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
100 101 | Pager (karuzela stron) | `libluna-widgets` | (przeglądarka, onboarding) |
101 102 | Sidebar (sekcje, wiersze) | `libluna-adwaita` | obie |
102 103 | PathBar (okruszki) | `libluna-widgets` | Files |
104 +| Entry (pole tekstowe) | `libluna-widgets` | Settings, Files |
103 105 | SearchEntry / SearchBar | `libluna-widgets` | Files |
104 106 | GridView / ListView | `libluna-widgets` (`ItemView`) | Files |
105 107 | ScrollView | `libluna-widgets` | obie |
@@ -50,6 +50,7 @@ Name = Nom
50 50 Date modified = Date de modification
51 51 Size = Taille
52 52 Show hidden files = Afficher les fichiers cachés
53 +Your name = Votre nom
53 54 Accent changed = Accent modifié
54 55 Undo = Annuler
55 56 No results = Aucun résultat