← luna

Commit 45eef39

9
files
+311
added
-84
deleted
@@ -254,6 +254,74 @@ pub enum IconName {
254 254 App,
255 255 }
256 256
257 +impl IconName {
258 + /// Wszystkie ikony z pakietu (kolejność = kolejność w galerii).
259 + pub const ALL: [IconName; 28] = [
260 + IconName::Menu,
261 + IconName::Search,
262 + IconName::Files,
263 + IconName::Settings,
264 + IconName::Terminal,
265 + IconName::Music,
266 + IconName::Star,
267 + IconName::Clock,
268 + IconName::Store,
269 + IconName::Edit,
270 + IconName::Wifi,
271 + IconName::Volume,
272 + IconName::Battery,
273 + IconName::Power,
274 + IconName::Grid,
275 + IconName::List,
276 + IconName::Carousel,
277 + IconName::ChevronLeft,
278 + IconName::ChevronRight,
279 + IconName::Close,
280 + IconName::Minimize,
281 + IconName::Maximize,
282 + IconName::Check,
283 + IconName::Plus,
284 + IconName::Home,
285 + IconName::User,
286 + IconName::Info,
287 + IconName::App,
288 + ];
289 +
290 + /// Nazwa ikony (do galerii/diagnostyki).
291 + pub fn name(self) -> &'static str {
292 + match self {
293 + IconName::Menu => "Menu",
294 + IconName::Search => "Search",
295 + IconName::Files => "Files",
296 + IconName::Settings => "Settings",
297 + IconName::Terminal => "Terminal",
298 + IconName::Music => "Music",
299 + IconName::Star => "Star",
300 + IconName::Clock => "Clock",
301 + IconName::Store => "Store",
302 + IconName::Edit => "Edit",
303 + IconName::Wifi => "Wifi",
304 + IconName::Volume => "Volume",
305 + IconName::Battery => "Battery",
306 + IconName::Power => "Power",
307 + IconName::Grid => "Grid",
308 + IconName::List => "List",
309 + IconName::Carousel => "Carousel",
310 + IconName::ChevronLeft => "ChevronLeft",
311 + IconName::ChevronRight => "ChevronRight",
312 + IconName::Close => "Close",
313 + IconName::Minimize => "Minimize",
314 + IconName::Maximize => "Maximize",
315 + IconName::Check => "Check",
316 + IconName::Plus => "Plus",
317 + IconName::Home => "Home",
318 + IconName::User => "User",
319 + IconName::Info => "Info",
320 + IconName::App => "App",
321 + }
322 + }
323 +}
324 +
257 325 /// Współrzędne w siatce 24×24 mapowane na prostokąt docelowy.
258 326 struct Grid {
259 327 ox: f64,
@@ -579,36 +647,7 @@ mod icon_tests {
579 647 fn every_icon_draws_without_panic() {
580 648 let theme = Theme::dark();
581 649 let text = TextEngine::none();
582 - for name in [
583 - IconName::Menu,
584 - IconName::Search,
585 - IconName::Files,
586 - IconName::Settings,
587 - IconName::Terminal,
588 - IconName::Music,
589 - IconName::Star,
590 - IconName::Clock,
591 - IconName::Store,
592 - IconName::Edit,
593 - IconName::Wifi,
594 - IconName::Volume,
595 - IconName::Battery,
596 - IconName::Power,
597 - IconName::Grid,
598 - IconName::List,
599 - IconName::Carousel,
600 - IconName::ChevronLeft,
601 - IconName::ChevronRight,
602 - IconName::Close,
603 - IconName::Minimize,
604 - IconName::Maximize,
605 - IconName::Check,
606 - IconName::Plus,
607 - IconName::Home,
608 - IconName::User,
609 - IconName::Info,
610 - IconName::App,
611 - ] {
650 + for name in IconName::ALL {
612 651 let mut scene = Scene::new();
613 652 let mut ctx = PaintCtx::new(&mut scene, &theme, &text);
614 653 paint_icon(
@@ -8,7 +8,7 @@ use std::error::Error;
8 8
9 9 use libluna_core::{Rect, Size, Theme};
10 10 use libluna_render::{Scene, SoftwareRenderer, TextEngine};
11 -use libluna_widgets::PaintCtx;
11 +use libluna_widgets::{IconName, PaintCtx};
12 12 use luna::notifications::{Notification, Notifications};
13 13 use luna::osd::Osd;
14 14 use luna::panel::{self, App, Launcher, LauncherView, PanelState};
@@ -29,11 +29,12 @@ fn main() -> Result<(), Box<dyn Error>> {
29 29 let launcher = Launcher {
30 30 view: LauncherView::Grid,
31 31 carousel: 0,
32 + selected: None,
32 33 scroll: 0.0,
33 34 };
34 35 panel::paint_desktop(&pstate, &apps, &launcher, &mut paint, size);
35 36
36 - let mut osd = Osd::new("◆", "Głośność", 0.65);
37 + let mut osd = Osd::new(IconName::Volume, "Głośność", 0.65);
37 38 osd.advance(0.3);
38 39 osd.paint(
39 40 &mut paint,
@@ -43,9 +44,11 @@ fn main() -> Result<(), Box<dyn Error>> {
43 44 let mut notes = Notifications::new();
44 45 notes.push(
45 46 Notification::new("Pliki", "Kopiowanie zakończone", "12 plików → Dokumenty")
46 - .with_glyph("◆"),
47 + .with_icon(IconName::Files),
48 + );
49 + notes.push(
50 + Notification::new("System", "Bateria", "Pozostało 18%").with_icon(IconName::Battery),
47 51 );
48 - notes.push(Notification::new("System", "Bateria", "Pozostało 18%").with_glyph("■"));
49 52 notes.advance(0.3);
50 53 notes.paint(&mut paint, Rect::from_xywh(0.0, 56.0, W as f64, 240.0));
51 54
@@ -68,6 +71,7 @@ fn main() -> Result<(), Box<dyn Error>> {
68 71 let launcher = Launcher {
69 72 view,
70 73 carousel: 2,
74 + selected: None,
71 75 scroll: if view == LauncherView::List {
72 76 140.0
73 77 } else {
@@ -11,7 +11,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
11 11
12 12 use libluna_core::{ButtonState, Event, PointerButton, Rect};
13 13 use libluna_platform::{run, AppConfig, Frame, LayerConfig};
14 -use libluna_widgets::PaintCtx;
14 +use libluna_widgets::{IconName, PaintCtx};
15 15 use luna::notifications::{Notification, Notifications};
16 16 use luna::osd::Osd;
17 17 use luna::panel::{self, App, Launcher, PanelState, LAUNCHER_H, LAUNCHER_W, THICKNESS};
@@ -39,7 +39,7 @@ fn main() -> Result<(), Box<dyn Error>> {
39 39 };
40 40 let mut launcher = Launcher::new();
41 41 let mut open = false;
42 - let mut osd = Osd::new("◆", "Głośność", 0.0);
42 + let mut osd = Osd::new(IconName::Volume, "Głośność", 0.0);
43 43 let mut notes = Notifications::new();
44 44 let mut timer = 0.0f64;
45 45
@@ -88,10 +88,7 @@ fn main() -> Result<(), Box<dyn Error>> {
88 88 pe.position,
89 89 ) {
90 90 // Uruchom aplikację (jeśli ma polecenie).
91 - if let Some(exec) = apps.get(i).and_then(|a| a.exec.clone()) {
92 - let _ =
93 - std::process::Command::new("sh").arg("-c").arg(exec).spawn();
94 - }
91 + launch(&apps, i);
95 92 open = false;
96 93 }
97 94 }
@@ -106,7 +103,41 @@ fn main() -> Result<(), Box<dyn Error>> {
106 103 launcher.scroll = (launcher.scroll - se.dy * 24.0).clamp(0.0, max);
107 104 }
108 105 }
109 - Event::Key(ke) if ke.keysym == 0xff1b => open = false, // Escape
106 + Event::Key(ke) if ke.state == ButtonState::Pressed && open => match ke.keysym {
107 + 0xff1b => open = false, // Escape
108 + // Góra/dół: pionowa nawigacja.
109 + 0xff52 | 0xff54 => {
110 + let dy = if ke.keysym == 0xff52 { -1 } else { 1 };
111 + launcher.navigate(apps.len(), 0, dy);
112 + panel::ensure_selection_visible(
113 + &apps,
114 + &mut launcher,
115 + larea,
116 + &frame.theme,
117 + text,
118 + );
119 + }
120 + // Lewo/prawo: pozioma nawigacja (kolumna w siatce, karta w karuzeli).
121 + 0xff51 | 0xff53 => {
122 + let dx = if ke.keysym == 0xff51 { -1 } else { 1 };
123 + launcher.navigate(apps.len(), dx, 0);
124 + panel::ensure_selection_visible(
125 + &apps,
126 + &mut launcher,
127 + larea,
128 + &frame.theme,
129 + text,
130 + );
131 + }
132 + // Enter / Enter (numeryczny): uruchom zaznaczoną pozycję.
133 + 0xff0d | 0xff8d => {
134 + if let Some(i) = launcher.selected_index(apps.len()) {
135 + launch(&apps, i);
136 + open = false;
137 + }
138 + }
139 + _ => {}
140 + },
110 141 _ => {}
111 142 }
112 143 }
@@ -124,7 +155,7 @@ fn main() -> Result<(), Box<dyn Error>> {
124 155 if timer % 4.0 < dt {
125 156 notes.push(
126 157 Notification::new("Pliki", "Kopiowanie zakończone", "12 plików → Dokumenty")
127 - .with_glyph("◆"),
158 + .with_icon(IconName::Files),
128 159 );
129 160 }
130 161 osd.advance(dt);
@@ -152,6 +183,13 @@ fn main() -> Result<(), Box<dyn Error>> {
152 183 })
153 184 }
154 185
186 +/// Uruchamia aplikację o indeksie `i` (jeśli ma polecenie).
187 +fn launch(apps: &[App], i: usize) {
188 + if let Some(exec) = apps.get(i).and_then(|a| a.exec.clone()) {
189 + let _ = std::process::Command::new("sh").arg("-c").arg(exec).spawn();
190 + }
191 +}
192 +
155 193 /// Bieżąca godzina i data w UTC — `("HH:MM", "DD.MM")`.
156 194 fn now_clock() -> (String, String) {
157 195 let secs = SystemTime::now()
@@ -6,7 +6,7 @@
6 6
7 7 use libluna_core::style::weight;
8 8 use libluna_core::Rect;
9 -use libluna_widgets::PaintCtx;
9 +use libluna_widgets::{paint_icon, IconName, PaintCtx};
10 10
11 11 /// Czas animacji wejścia/wyjścia (s).
12 12 const SLIDE: f64 = 0.22;
@@ -24,8 +24,8 @@ pub struct Notification {
24 24 pub app: String,
25 25 pub title: String,
26 26 pub body: String,
27 - /// Symbol rysowany fontem interfejsu.
28 - pub glyph: String,
27 + /// Ikona z pakietu Luny.
28 + pub icon: IconName,
29 29 /// Czas życia (s); 0 = nie zamykaj automatycznie.
30 30 pub timeout: f64,
31 31 }
@@ -36,13 +36,13 @@ impl Notification {
36 36 app: app.into(),
37 37 title: title.into(),
38 38 body: body.into(),
39 - glyph: "●".to_owned(),
39 + icon: IconName::Info,
40 40 timeout: 5.0,
41 41 }
42 42 }
43 43
44 - pub fn with_glyph(mut self, glyph: impl Into<String>) -> Self {
45 - self.glyph = glyph.into();
44 + pub fn with_icon(mut self, icon: IconName) -> Self {
45 + self.icon = icon;
46 46 self
47 47 }
48 48 }
@@ -118,14 +118,8 @@ impl Notifications {
118 118 t.radius.small,
119 119 t.colors.accent.scale_alpha(0.18 * a),
120 120 );
121 - ctx.label_center(
122 - icon.center().x,
123 - icon.center().y,
124 - &n.glyph,
125 - 16.0,
126 - weight::MEDIUM,
127 - t.colors.accent.scale_alpha(a),
128 - );
121 + let inner = Rect::from_xywh(icon.x() + 7.0, icon.y() + 7.0, 18.0, 18.0);
122 + paint_icon(ctx, n.icon, inner, t.colors.accent.scale_alpha(a));
129 123
130 124 let tx = icon.right() + pad;
131 125 ctx.label_left(
@@ -216,7 +210,7 @@ mod tests {
216 210 let mut scene = Scene::new();
217 211 let mut ctx = PaintCtx::new(&mut scene, &theme, &text);
218 212 let mut n = Notifications::new();
219 - n.push(Notification::new("Pliki", "Kopiowanie", "12 plików"));
213 + n.push(Notification::new("Pliki", "Kopiowanie", "12 plików").with_icon(IconName::Files));
220 214 n.advance(0.3);
221 215 n.paint(&mut ctx, Rect::from_xywh(0.0, 40.0, 1280.0, 200.0));
222 216 assert!(scene.len() > 5);
@@ -4,7 +4,7 @@
4 4
5 5 use libluna_core::style::weight;
6 6 use libluna_core::Rect;
7 -use libluna_widgets::PaintCtx;
7 +use libluna_widgets::{paint_icon, IconName, PaintCtx};
8 8
9 9 /// Czas wchodzenia (s).
10 10 const FADE_IN: f64 = 0.18;
@@ -14,8 +14,8 @@ const FADE_OUT: f64 = 0.45;
14 14 /// Wskaźnik OSD.
15 15 #[derive(Debug, Clone)]
16 16 pub struct Osd {
17 - /// Symbol rysowany fontem interfejsu.
18 - pub glyph: String,
17 + /// Ikona z pakietu Luny.
18 + pub icon: IconName,
19 19 /// Etykieta (np. „Głośność”).
20 20 pub label: String,
21 21 /// Wartość 0..1 (poziom).
@@ -26,9 +26,9 @@ pub struct Osd {
26 26
27 27 impl Osd {
28 28 /// Tworzy wskaźnik i włącza jego wyświetlanie.
29 - pub fn new(glyph: impl Into<String>, label: impl Into<String>, value: f64) -> Self {
29 + pub fn new(icon: IconName, label: impl Into<String>, value: f64) -> Self {
30 30 Self {
31 - glyph: glyph.into(),
31 + icon,
32 32 label: label.into(),
33 33 value: value.clamp(0.0, 1.0),
34 34 age: 0.0,
@@ -80,14 +80,8 @@ impl Osd {
80 80 let icon = Rect::from_xywh(rect.x() + pad, cy - 20.0, 40.0, 40.0);
81 81 ctx.scene
82 82 .fill_rounded_rect(icon, t.radius.small, t.colors.accent.scale_alpha(0.18 * a));
83 - ctx.label_center(
84 - icon.center().x,
85 - icon.center().y,
86 - &self.glyph,
87 - 20.0,
88 - weight::MEDIUM,
89 - t.colors.accent.scale_alpha(a),
90 - );
83 + let icon_inner = Rect::from_xywh(icon.x() + 10.0, icon.y() + 10.0, 20.0, 20.0);
84 + paint_icon(ctx, self.icon, icon_inner, t.colors.accent.scale_alpha(a));
91 85
92 86 // Etykieta i wartość procentowa.
93 87 let text_x = icon.right() + t.spacing.md;
@@ -133,7 +127,7 @@ mod tests {
133 127
134 128 #[test]
135 129 fn fades_in_and_out() {
136 - let mut osd = Osd::new("◆", "Głośność", 0.5);
130 + let mut osd = Osd::new(IconName::Volume, "Głośność", 0.5);
137 131 assert!(osd.visible());
138 132 assert!(osd.opacity() < 0.05); // dopiero start
139 133 osd.advance(0.3);
@@ -149,7 +143,7 @@ mod tests {
149 143 let text = TextEngine::none();
150 144 let mut scene = Scene::new();
151 145 let mut ctx = PaintCtx::new(&mut scene, &theme, &text);
152 - let mut osd = Osd::new("◆", "Głośność", 0.65);
146 + let mut osd = Osd::new(IconName::Volume, "Głośność", 0.65);
153 147 osd.advance(0.3);
154 148 osd.paint(&mut ctx, Rect::from_xywh(0.0, 0.0, 320.0, 72.0));
155 149 assert!(scene.len() > 5);
@@ -127,6 +127,8 @@ impl LauncherView {
127 127 pub struct Launcher {
128 128 pub view: LauncherView,
129 129 pub carousel: usize,
130 + /// Zaznaczona pozycja (nawigacja klawiaturą); `None` = brak zaznaczenia.
131 + pub selected: Option<usize>,
130 132 /// Pionowe przewinięcie zawartości (Lista/Siatka), w pikselach.
131 133 pub scroll: f64,
132 134 }
@@ -140,6 +142,41 @@ impl Launcher {
140 142 pub fn next_view(&mut self) {
141 143 self.view = self.view.next();
142 144 }
145 +
146 + /// Bieżący indeks zaznaczenia (domyślnie 0, o ile są pozycje).
147 + pub fn selected_index(&self, count: usize) -> Option<usize> {
148 + if count == 0 {
149 + None
150 + } else {
151 + Some(self.selected.unwrap_or(0).min(count - 1))
152 + }
153 + }
154 +
155 + /// Przesuwa zaznaczenie o krok (`dx`, `dy`) zależnie od widoku.
156 + ///
157 + /// W siatce `dx`/`dy` to ruch odpowiednio w kolumnie/wierszu; w liście
158 + /// i karuzeli używany jest tylko jeden z nich (`dy`/`dx`).
159 + pub fn navigate(&mut self, count: usize, dx: i64, dy: i64) {
160 + if count == 0 {
161 + return;
162 + }
163 + let n = count as i64;
164 + let cur = self.selected.unwrap_or(0).min(count - 1) as i64;
165 + let next = match self.view {
166 + LauncherView::Grid => {
167 + let cols = GRID_COLS as i64;
168 + let rows = (n + cols - 1) / cols;
169 + let col = (cur % cols + dx).clamp(0, cols - 1);
170 + let row = (cur / cols + dy).clamp(0, rows - 1);
171 + (row * cols + col).min(n - 1)
172 + }
173 + _ => (cur + dx + dy).rem_euclid(n),
174 + };
175 + self.selected = Some(next as usize);
176 + if self.view == LauncherView::Carousel {
177 + self.carousel = next as usize;
178 + }
179 + }
143 180 }
144 181
145 182 /// Pozycja w launcherze.
@@ -502,6 +539,42 @@ pub fn launcher_scroll_max(
502 539 (total - content.height()).max(0.0)
503 540 }
504 541
542 +/// Dopasowuje przewinięcie tak, aby zaznaczona pozycja była widoczna.
543 +pub fn ensure_selection_visible(
544 + apps: &[App],
545 + launcher: &mut Launcher,
546 + area: Rect,
547 + theme: &Theme,
548 + text: &TextEngine,
549 +) {
550 + let Some(i) = launcher.selected else {
551 + return;
552 + };
553 + if i >= apps.len() {
554 + return;
555 + }
556 + let content = launcher_rects(area, theme, text).content;
557 + let (top, bottom) = match launcher.view {
558 + LauncherView::Grid => {
559 + let t = grid_tile(content, i);
560 + (t.y() - content.y(), t.bottom() - content.y())
561 + }
562 + LauncherView::List => {
563 + let t = list_row(content, i);
564 + (t.y() - content.y(), t.bottom() - content.y())
565 + }
566 + LauncherView::Carousel => return,
567 + };
568 + let view_h = content.height();
569 + if top < launcher.scroll {
570 + launcher.scroll = top;
571 + } else if bottom > launcher.scroll + view_h {
572 + launcher.scroll = bottom - view_h;
573 + }
574 + let max = launcher_scroll_max(apps, launcher, area, theme, text);
575 + launcher.scroll = launcher.scroll.clamp(0.0, max);
576 +}
577 +
505 578 /// Rysuje launcher w zadanym prostokącie.
506 579 pub fn paint_launcher(apps: &[App], launcher: &Launcher, ctx: &mut PaintCtx, area: Rect) {
507 580 let t = *ctx.theme;
@@ -549,6 +622,7 @@ pub fn paint_launcher(apps: &[App], launcher: &Launcher, ctx: &mut PaintCtx, are
549 622 let t = grid_tile(r.content, i);
550 623 paint_grid_tile(
551 624 app,
625 + launcher.selected_index(apps.len()) == Some(i),
552 626 ctx,
553 627 Rect::from_xywh(t.x(), t.y() - scroll, t.width(), t.height()),
554 628 );
@@ -559,6 +633,7 @@ pub fn paint_launcher(apps: &[App], launcher: &Launcher, ctx: &mut PaintCtx, are
559 633 let t = list_row(r.content, i);
560 634 paint_list_row(
561 635 app,
636 + launcher.selected_index(apps.len()) == Some(i),
562 637 ctx,
563 638 Rect::from_xywh(t.x(), t.y() - scroll, t.width(), t.height()),
564 639 );
@@ -578,13 +653,26 @@ pub fn paint_launcher(apps: &[App], launcher: &Launcher, ctx: &mut PaintCtx, are
578 653 ctx.scene.pop_clip();
579 654 }
580 655
581 -/// Kafelek siatki: ikona + nazwa.
582 -fn paint_grid_tile(app: &App, ctx: &mut PaintCtx, rect: Rect) {
656 +/// Kafelek siatki: ikona + nazwa; zaznaczony wyróżniony akcentem.
657 +fn paint_grid_tile(app: &App, selected: bool, ctx: &mut PaintCtx, rect: Rect) {
583 658 let t = ctx.theme;
584 - ctx.scene
585 - .fill_rounded_rect(rect, t.radius.medium, t.colors.view_bg);
586 - ctx.scene
587 - .stroke_rounded_rect(rect, t.radius.medium, 1.0, t.colors.border);
659 + let bg = if selected {
660 + t.colors.accent.scale_alpha(0.12)
661 + } else {
662 + t.colors.view_bg
663 + };
664 + ctx.scene.fill_rounded_rect(rect, t.radius.medium, bg);
665 + let border = if selected {
666 + t.colors.accent
667 + } else {
668 + t.colors.border
669 + };
670 + ctx.scene.stroke_rounded_rect(
671 + rect,
672 + t.radius.medium,
673 + if selected { 2.0 } else { 1.0 },
674 + border,
675 + );
588 676
589 677 let icon = Rect::from_xywh(rect.center().x - 18.0, rect.y() + 12.0, 36.0, 36.0);
590 678 ctx.scene
@@ -601,13 +689,26 @@ fn paint_grid_tile(app: &App, ctx: &mut PaintCtx, rect: Rect) {
601 689 );
602 690 }
603 691
604 -/// Wiersz listy: ikona + nazwa + kategoria.
605 -fn paint_list_row(app: &App, ctx: &mut PaintCtx, rect: Rect) {
692 +/// Wiersz listy: ikona + nazwa + kategoria; zaznaczony wyróżniony akcentem.
693 +fn paint_list_row(app: &App, selected: bool, ctx: &mut PaintCtx, rect: Rect) {
606 694 let t = ctx.theme;
607 - ctx.scene
608 - .fill_rounded_rect(rect, t.radius.medium, t.colors.view_bg);
609 - ctx.scene
610 - .stroke_rounded_rect(rect, t.radius.medium, 1.0, t.colors.border);
695 + let bg = if selected {
696 + t.colors.accent.scale_alpha(0.12)
697 + } else {
698 + t.colors.view_bg
699 + };
700 + ctx.scene.fill_rounded_rect(rect, t.radius.medium, bg);
701 + let border = if selected {
702 + t.colors.accent
703 + } else {
704 + t.colors.border
705 + };
706 + ctx.scene.stroke_rounded_rect(
707 + rect,
708 + t.radius.medium,
709 + if selected { 2.0 } else { 1.0 },
710 + border,
711 + );
611 712
612 713 let icon = Rect::from_xywh(rect.x() + t.spacing.md, rect.center().y - 16.0, 32.0, 32.0);
613 714 ctx.scene
@@ -739,6 +840,7 @@ mod tests {
739 840 let launcher = Launcher {
740 841 view,
741 842 carousel: 2,
843 + selected: None,
742 844 scroll: 0.0,
743 845 };
744 846 paint_launcher(&App::sample(), &launcher, &mut ctx, area());
@@ -778,6 +880,7 @@ mod tests {
778 880 let mut launcher = Launcher {
779 881 view: LauncherView::List,
780 882 carousel: 0,
883 + selected: None,
781 884 scroll: 0.0,
782 885 };
783 886 let max = launcher_scroll_max(&apps, &launcher, area(), &theme, &text);
@@ -809,6 +912,7 @@ mod tests {
809 912 let launcher = Launcher {
810 913 view,
811 914 carousel: 2,
915 + selected: None,
812 916 scroll: 0.0,
813 917 };
814 918 let r = launcher_rects(area(), &theme, &text);
@@ -826,4 +930,58 @@ mod tests {
826 930 );
827 931 }
828 932 }
933 +
934 + #[test]
935 + fn keyboard_navigation_moves_selection_per_view() {
936 + // Lista: +1/-1 po indeksie, z zawijaniem.
937 + let mut list = Launcher {
938 + view: LauncherView::List,
939 + ..Launcher::default()
940 + };
941 + assert_eq!(list.selected_index(0), None);
942 + list.navigate(5, 0, 1);
943 + assert_eq!(list.selected_index(5), Some(1));
944 + list.navigate(5, 0, -1);
945 + list.navigate(5, 0, -1);
946 + assert_eq!(list.selected_index(5), Some(4)); // zawija
947 +
948 + // Siatka: pion = ±GRID_COLS, poziom = ±1.
949 + let mut grid = Launcher {
950 + view: LauncherView::Grid,
951 + ..Launcher::default()
952 + };
953 + grid.navigate(24, 0, 1);
954 + assert_eq!(grid.selected_index(24), Some(GRID_COLS));
955 + grid.navigate(24, 1, 0);
956 + assert_eq!(grid.selected_index(24), Some(GRID_COLS + 1));
957 + grid.navigate(24, 0, -5);
958 + assert_eq!(grid.selected_index(24), Some(1)); // nie wychodzi nad wiersz 0
959 +
960 + // Karuzela: steruje indeksem karty.
961 + let mut car = Launcher {
962 + view: LauncherView::Carousel,
963 + ..Launcher::default()
964 + };
965 + car.navigate(6, 1, 0);
966 + assert_eq!(car.selected_index(6), Some(1));
967 + assert_eq!(car.carousel, 1);
968 + }
969 +
970 + #[test]
971 + fn selection_scrolls_into_view() {
972 + let theme = Theme::light();
973 + let text = TextEngine::none();
974 + let apps = App::sample();
975 + let mut launcher = Launcher {
976 + view: LauncherView::List,
977 + ..Launcher::default()
978 + };
979 + // Wybierz ostatnią pozycję i upewnij się, że lista się przewinęła.
980 + launcher.navigate(apps.len(), 0, -1);
981 + ensure_selection_visible(&apps, &mut launcher, area(), &theme, &text);
982 + assert!(
983 + launcher.scroll > 0.0,
984 + "zaznaczenie poza ekranem ma przewijać"
985 + );
986 + }
829 987 }
830 988 Binary files a/docs/images/luna-launcher-grid.png and b/docs/images/luna-launcher-grid.png differ
831 989 Binary files a/docs/images/luna-shell-dark.png and b/docs/images/luna-shell-dark.png differ
832 990 Binary files a/docs/images/luna-shell-light.png and b/docs/images/luna-shell-light.png differ