@tryinget/pi-activity-strip 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +96 -152
  2. package/bin/pi-activity-strip.mjs +31 -62
  3. package/examples/simulate-sessions.mjs +1 -1
  4. package/extensions/activity-strip.js +2 -2
  5. package/native/bin/linux-x64-gnu/artifact.json +21 -0
  6. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  7. package/native/panel/Cargo.lock +1045 -0
  8. package/native/panel/Cargo.toml +23 -0
  9. package/native/panel/src/app.rs +606 -0
  10. package/native/panel/src/card_view.rs +303 -0
  11. package/native/panel/src/main.rs +31 -0
  12. package/native/panel/src/protocol.rs +234 -0
  13. package/native/panel/src/runtime.rs +110 -0
  14. package/native/panel/src/style.css +176 -0
  15. package/package.json +20 -7
  16. package/src/broker/server.mjs +43 -7
  17. package/src/broker/session-store.mjs +53 -11
  18. package/src/client/broker-client.mjs +25 -4
  19. package/src/client/session-telemetry.mjs +123 -13
  20. package/src/common/activity-order.mjs +15 -9
  21. package/src/common/alignment-controller.mjs +24 -42
  22. package/src/common/card-display.mjs +53 -0
  23. package/src/common/compatibility.mjs +15 -13
  24. package/src/common/constants.mjs +12 -28
  25. package/src/common/contracts.d.ts +54 -5
  26. package/src/common/contracts.ts +54 -5
  27. package/src/common/niri-focus.mjs +79 -79
  28. package/src/common/protocol.mjs +49 -14
  29. package/src/common/session-cards.mjs +102 -0
  30. package/src/common/status-report.mjs +2 -1
  31. package/src/common/telemetry.mjs +11 -1
  32. package/src/common/terminal-identity.mjs +149 -0
  33. package/src/native/main.mjs +393 -0
  34. package/src/native/panel-projection.mjs +99 -0
  35. package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +4 -1
  36. package/src/common/electron.mjs +0 -57
  37. package/src/electron/main.mjs +0 -499
  38. package/src/electron/niri-native-window-runtime.mjs +0 -178
  39. package/src/electron/preload.cjs +0 -50
  40. package/src/electron/renderer-visibility-runtime.mjs +0 -107
  41. package/src/electron/workspace-view-runtime.mjs +0 -360
  42. package/src/ui/strip-html.mjs +0 -484
@@ -0,0 +1,23 @@
1
+ [package]
2
+ name = "pi-activity-strip-panel"
3
+ version = "0.1.0"
4
+ edition = "2024"
5
+ rust-version = "1.93"
6
+ license = "MIT"
7
+ publish = false
8
+
9
+ [workspace]
10
+
11
+ [dependencies]
12
+ gtk4-layer-shell = "=0.8.1"
13
+ gtk4 = { version = "=0.11.4", features = ["v4_14"] }
14
+ libc = "=0.2.189"
15
+ relm4 = { version = "=0.11.0", default-features = false, features = ["macros", "gnome_42"] }
16
+ serde = { version = "=1.0.229", features = ["derive"] }
17
+ serde_json = "=1.0.151"
18
+
19
+ [profile.release]
20
+ codegen-units = 1
21
+ lto = "thin"
22
+ panic = "abort"
23
+ strip = "symbols"
@@ -0,0 +1,606 @@
1
+ use crate::card_view::CardView;
2
+ use crate::protocol::{Card, ViewMessage, demo_view, emit, emit_error, emit_ready};
3
+ use crate::runtime::{duplicate_labels, install_css, now_ms, start_input_reader};
4
+ use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
5
+ use relm4::gtk;
6
+ use relm4::gtk::glib;
7
+ use relm4::gtk::prelude::*;
8
+ use relm4::{Component, ComponentParts, ComponentSender};
9
+ use serde_json::json;
10
+ use std::collections::{HashMap, HashSet};
11
+ use std::time::Duration;
12
+
13
+ const COMPACT_HEIGHT: i32 = 84;
14
+ const EXPANDED_HEIGHT: i32 = 252;
15
+ const ORDER_REFRESH_MS: i64 = 15_000;
16
+
17
+ pub struct AppInit {
18
+ pub demo: bool,
19
+ pub click_through: bool,
20
+ }
21
+
22
+ pub struct App {
23
+ cards: HashMap<String, CardView>,
24
+ data: HashMap<String, Card>,
25
+ order: Vec<String>,
26
+ focused_card_id: Option<String>,
27
+ hovered: HashSet<String>,
28
+ keyboard_focused: Option<String>,
29
+ keyboard_active: bool,
30
+ open_card_id: Option<String>,
31
+ revision: u64,
32
+ visible: bool,
33
+ next_order_refresh_at: i64,
34
+ collapse_generation: u64,
35
+ interactive: bool,
36
+ }
37
+
38
+ #[derive(Debug)]
39
+ pub enum AppMsg {
40
+ View(ViewMessage),
41
+ Tick,
42
+ Hover(String, bool),
43
+ Focus(String, bool),
44
+ Navigate(String, i32, bool),
45
+ Activate(String),
46
+ ActivationResult(String, bool, String),
47
+ FocusStrip,
48
+ Collapse,
49
+ CollapseIf(u64),
50
+ WindowInactive,
51
+ ParentGone,
52
+ InputError(String),
53
+ }
54
+
55
+ #[relm4::component(pub)]
56
+ impl Component for App {
57
+ type Init = AppInit;
58
+ type Input = AppMsg;
59
+ type Output = ();
60
+ type CommandOutput = ();
61
+
62
+ view! {
63
+ root = gtk::Window {
64
+ set_title: Some("Pi Activity Strip"),
65
+ set_decorated: false,
66
+ set_resizable: true,
67
+ set_default_size: (1, COMPACT_HEIGHT),
68
+ set_visible: false,
69
+
70
+ #[name = "body"]
71
+ gtk::Box {
72
+ set_orientation: gtk::Orientation::Horizontal,
73
+ set_spacing: 8,
74
+ set_margin_top: 4,
75
+ set_margin_bottom: 6,
76
+ set_margin_start: 10,
77
+ set_margin_end: 10,
78
+
79
+ #[name = "brand"]
80
+ gtk::Box {
81
+ add_css_class: "brand",
82
+ set_orientation: gtk::Orientation::Vertical,
83
+ set_width_request: 146,
84
+ set_vexpand: true,
85
+
86
+ gtk::Label {
87
+ add_css_class: "brand-eyebrow",
88
+ set_label: "π TELEMETRY RIBBON",
89
+ set_halign: gtk::Align::Start,
90
+ },
91
+ gtk::Label {
92
+ add_css_class: "brand-title",
93
+ set_label: "Activity",
94
+ set_halign: gtk::Align::Start,
95
+ },
96
+ gtk::Label {
97
+ add_css_class: "brand-subtitle",
98
+ set_label: "Calm order · live detail",
99
+ set_halign: gtk::Align::Start,
100
+ },
101
+ gtk::Box { set_vexpand: true },
102
+ #[name = "meta"]
103
+ gtk::Label {
104
+ add_css_class: "meta",
105
+ set_label: "Waiting for sessions…",
106
+ set_halign: gtk::Align::Start,
107
+ },
108
+ },
109
+
110
+ #[name = "scroller"]
111
+ gtk::ScrolledWindow {
112
+ add_css_class: "cards-panel",
113
+ set_hexpand: true,
114
+ set_vexpand: true,
115
+ set_policy: (gtk::PolicyType::Automatic, gtk::PolicyType::Never),
116
+ #[name = "cards_box"]
117
+ gtk::Box {
118
+ set_orientation: gtk::Orientation::Horizontal,
119
+ set_spacing: 8,
120
+ set_halign: gtk::Align::Start,
121
+ set_valign: gtk::Align::Start,
122
+ }
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ fn init(
129
+ init: Self::Init,
130
+ root: Self::Root,
131
+ sender: ComponentSender<Self>,
132
+ ) -> ComponentParts<Self> {
133
+ install_css();
134
+ if !gtk4_layer_shell::is_supported() {
135
+ emit_error("Wayland compositor does not support wlr-layer-shell.");
136
+ std::process::exit(1);
137
+ }
138
+ root.init_layer_shell();
139
+ root.set_namespace(Some("pi-activity-strip"));
140
+ root.set_layer(Layer::Top);
141
+ root.set_anchor(Edge::Top, true);
142
+ root.set_anchor(Edge::Left, true);
143
+ root.set_anchor(Edge::Right, true);
144
+ root.set_margin(Edge::Left, 8);
145
+ root.set_margin(Edge::Right, 8);
146
+ root.set_exclusive_zone(COMPACT_HEIGHT);
147
+ root.set_keyboard_mode(KeyboardMode::None);
148
+ if init.click_through {
149
+ root.connect_realize(|window| {
150
+ if let Some(surface) = window.surface() {
151
+ surface.set_input_region(Some(&gtk::cairo::Region::create()));
152
+ }
153
+ });
154
+ }
155
+
156
+ let tx = sender.input_sender().clone();
157
+ root.connect_is_active_notify(move |window| {
158
+ if !window.is_active() {
159
+ let _ = tx.send(AppMsg::WindowInactive);
160
+ }
161
+ });
162
+
163
+ let model = App {
164
+ cards: HashMap::new(),
165
+ data: HashMap::new(),
166
+ order: Vec::new(),
167
+ focused_card_id: None,
168
+ hovered: HashSet::new(),
169
+ keyboard_focused: None,
170
+ keyboard_active: false,
171
+ open_card_id: None,
172
+ revision: 0,
173
+ visible: false,
174
+ next_order_refresh_at: 0,
175
+ collapse_generation: 0,
176
+ interactive: !init.click_through,
177
+ };
178
+ let widgets = view_output!();
179
+ widgets.body.set_can_target(!init.click_through);
180
+
181
+ let tick_tx = sender.input_sender().clone();
182
+ glib::timeout_add_seconds_local(1, move || {
183
+ let _ = tick_tx.send(AppMsg::Tick);
184
+ glib::ControlFlow::Continue
185
+ });
186
+
187
+ if init.demo {
188
+ let _ = sender.input_sender().send(AppMsg::View(demo_view()));
189
+ } else {
190
+ start_input_reader(sender.input_sender().clone());
191
+ }
192
+ emit_ready();
193
+ ComponentParts { model, widgets }
194
+ }
195
+
196
+ fn update_with_view(
197
+ &mut self,
198
+ widgets: &mut Self::Widgets,
199
+ message: Self::Input,
200
+ sender: ComponentSender<Self>,
201
+ root: &Self::Root,
202
+ ) {
203
+ match message {
204
+ AppMsg::View(view) => self.apply_view(widgets, root, &sender, view),
205
+ AppMsg::Tick => {
206
+ if now_ms() >= self.next_order_refresh_at {
207
+ self.regroup();
208
+ self.reorder_widgets(&widgets.cards_box);
209
+ self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
210
+ }
211
+ self.refresh_cards();
212
+ self.update_meta(&widgets.meta);
213
+ }
214
+ AppMsg::Hover(id, entered) => {
215
+ if entered {
216
+ self.hovered.insert(id);
217
+ } else {
218
+ self.hovered.remove(&id);
219
+ }
220
+ self.reconcile_engagement(widgets, root, &sender);
221
+ }
222
+ AppMsg::Focus(id, entered) => {
223
+ if entered && self.keyboard_active {
224
+ self.keyboard_focused = Some(id);
225
+ } else if !entered && self.keyboard_focused.as_deref() == Some(id.as_str()) {
226
+ self.keyboard_focused = None;
227
+ }
228
+ self.reconcile_engagement(widgets, root, &sender);
229
+ }
230
+ AppMsg::Navigate(id, direction, manual) => {
231
+ if self.keyboard_active {
232
+ self.navigate(&widgets.cards_box, &id, direction, manual);
233
+ }
234
+ }
235
+ AppMsg::Activate(id) => {
236
+ emit(json!({ "protocol": 1, "type": "activate", "cardId": id }));
237
+ if self.keyboard_active {
238
+ self.end_engagement();
239
+ root.set_keyboard_mode(KeyboardMode::None);
240
+ self.apply_expansion(widgets, root, None);
241
+ }
242
+ }
243
+ AppMsg::ActivationResult(id, ok, message) => {
244
+ if let Some(card) = self.cards.get(&id) {
245
+ card.set_activation(ok, &message);
246
+ }
247
+ }
248
+ AppMsg::FocusStrip => {
249
+ if self.visible && self.interactive {
250
+ if self.keyboard_active {
251
+ self.end_engagement();
252
+ root.set_keyboard_mode(KeyboardMode::None);
253
+ self.apply_expansion(widgets, root, None);
254
+ } else {
255
+ self.keyboard_active = true;
256
+ self.hovered.clear();
257
+ self.keyboard_focused = self.order.first().cloned();
258
+ root.set_keyboard_mode(KeyboardMode::Exclusive);
259
+ root.present();
260
+ let focus_grabbed = self
261
+ .keyboard_focused
262
+ .as_ref()
263
+ .and_then(|id| self.cards.get(id))
264
+ .is_some_and(|first| first.root.grab_focus());
265
+ if focus_grabbed {
266
+ self.reconcile_engagement(widgets, root, &sender);
267
+ emit(
268
+ json!({ "protocol": 1, "type": "keyboard-active", "active": true }),
269
+ );
270
+ } else {
271
+ self.end_engagement();
272
+ root.set_keyboard_mode(KeyboardMode::None);
273
+ self.apply_expansion(widgets, root, None);
274
+ }
275
+ }
276
+ }
277
+ }
278
+ AppMsg::Collapse | AppMsg::WindowInactive => {
279
+ self.end_engagement();
280
+ root.set_keyboard_mode(KeyboardMode::None);
281
+ self.apply_expansion(widgets, root, None);
282
+ }
283
+ AppMsg::CollapseIf(generation) => {
284
+ if generation == self.collapse_generation
285
+ && self.hovered.is_empty()
286
+ && (!self.keyboard_active || self.keyboard_focused.is_none())
287
+ {
288
+ self.apply_expansion(widgets, root, None);
289
+ }
290
+ }
291
+ AppMsg::InputError(message) => emit_error(message),
292
+ AppMsg::ParentGone => root.close(),
293
+ }
294
+ }
295
+ }
296
+
297
+ fn navigation_target(index: usize, direction: i32, len: usize) -> Option<usize> {
298
+ if len == 0 {
299
+ return None;
300
+ }
301
+ Some((index as i64 + direction as i64).rem_euclid(len as i64) as usize)
302
+ }
303
+
304
+ fn move_order_item(order: &mut Vec<String>, index: usize, target: usize) {
305
+ let moved = order.remove(index);
306
+ order.insert(target, moved);
307
+ }
308
+
309
+ fn engaged_card_id(
310
+ hovered: &HashSet<String>,
311
+ keyboard_active: bool,
312
+ keyboard_focused: &Option<String>,
313
+ ) -> Option<String> {
314
+ if keyboard_active {
315
+ keyboard_focused.clone()
316
+ } else {
317
+ hovered.iter().next().cloned()
318
+ }
319
+ }
320
+
321
+ impl App {
322
+ fn end_engagement(&mut self) {
323
+ let was_keyboard_active = self.keyboard_active;
324
+ self.hovered.clear();
325
+ self.keyboard_focused = None;
326
+ self.keyboard_active = false;
327
+ self.collapse_generation += 1;
328
+ if was_keyboard_active {
329
+ emit(json!({ "protocol": 1, "type": "keyboard-active", "active": false }));
330
+ }
331
+ }
332
+
333
+ fn apply_view(
334
+ &mut self,
335
+ widgets: &mut AppWidgets,
336
+ root: &gtk::Window,
337
+ sender: &ComponentSender<Self>,
338
+ view: ViewMessage,
339
+ ) {
340
+ if view.protocol != 1 || view.message_type != "view" || view.revision <= self.revision {
341
+ return;
342
+ }
343
+ self.revision = view.revision;
344
+ self.focused_card_id = view.focused_card_id;
345
+ let incoming_order: Vec<_> = view
346
+ .sessions
347
+ .iter()
348
+ .map(|card| card.id().to_owned())
349
+ .collect();
350
+ self.data = view
351
+ .sessions
352
+ .into_iter()
353
+ .map(|card| (card.id().to_owned(), card))
354
+ .collect();
355
+ self.order.retain(|id| self.data.contains_key(id));
356
+ for id in incoming_order {
357
+ if !self.order.contains(&id) {
358
+ self.order.push(id);
359
+ }
360
+ }
361
+ self.hovered.retain(|id| self.data.contains_key(id));
362
+ let keyboard_card_removed = self.keyboard_active
363
+ && self
364
+ .keyboard_focused
365
+ .as_ref()
366
+ .is_some_and(|id| !self.data.contains_key(id));
367
+ if keyboard_card_removed {
368
+ self.keyboard_focused = self.order.first().cloned();
369
+ }
370
+ let open_card_removed = self
371
+ .open_card_id
372
+ .as_ref()
373
+ .is_some_and(|id| !self.data.contains_key(id));
374
+ if self.next_order_refresh_at == 0 {
375
+ self.regroup();
376
+ self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
377
+ }
378
+ self.reconcile_card_widgets(&widgets.cards_box, sender);
379
+ if keyboard_card_removed {
380
+ let focus_grabbed = self
381
+ .keyboard_focused
382
+ .as_ref()
383
+ .and_then(|id| self.cards.get(id))
384
+ .is_some_and(|card| card.root.grab_focus());
385
+ if focus_grabbed {
386
+ self.reconcile_engagement(widgets, root, sender);
387
+ } else {
388
+ self.end_engagement();
389
+ root.set_keyboard_mode(KeyboardMode::None);
390
+ self.apply_expansion(widgets, root, None);
391
+ }
392
+ }
393
+ if open_card_removed {
394
+ self.apply_expansion(widgets, root, None);
395
+ }
396
+ self.reorder_widgets(&widgets.cards_box);
397
+ self.refresh_cards();
398
+ self.update_meta(&widgets.meta);
399
+
400
+ let should_show = view.visible && !self.data.is_empty();
401
+ if should_show != self.visible {
402
+ self.visible = should_show;
403
+ if should_show {
404
+ root.set_exclusive_zone(COMPACT_HEIGHT);
405
+ root.present();
406
+ } else {
407
+ self.end_engagement();
408
+ root.set_keyboard_mode(KeyboardMode::None);
409
+ self.apply_expansion(widgets, root, None);
410
+ root.set_visible(false);
411
+ }
412
+ emit(
413
+ json!({ "protocol": 1, "type": "visibility-applied", "revision": self.revision, "visible": should_show }),
414
+ );
415
+ }
416
+ }
417
+
418
+ fn reconcile_card_widgets(&mut self, parent: &gtk::Box, sender: &ComponentSender<Self>) {
419
+ let removed: Vec<_> = self
420
+ .cards
421
+ .keys()
422
+ .filter(|id| !self.data.contains_key(*id))
423
+ .cloned()
424
+ .collect();
425
+ for id in removed {
426
+ if let Some(card) = self.cards.remove(&id) {
427
+ parent.remove(&card.root);
428
+ }
429
+ }
430
+ for id in &self.order {
431
+ if !self.cards.contains_key(id) {
432
+ let card = CardView::new(id, sender.input_sender());
433
+ parent.append(&card.root);
434
+ self.cards.insert(id.clone(), card);
435
+ }
436
+ }
437
+ }
438
+
439
+ fn refresh_cards(&self) {
440
+ let duplicates = duplicate_labels(self.data.values());
441
+ let now = now_ms();
442
+ for (id, view) in &self.cards {
443
+ if let Some(card) = self.data.get(id) {
444
+ view.update(
445
+ card,
446
+ self.focused_card_id.as_deref() == Some(id),
447
+ duplicates.contains(&card.repo_label),
448
+ now,
449
+ );
450
+ view.set_expanded(self.open_card_id.as_deref() == Some(id));
451
+ }
452
+ }
453
+ }
454
+
455
+ fn update_meta(&self, meta: &gtk::Label) {
456
+ let active = self.data.values().filter(|card| card.active()).count();
457
+ meta.set_text(&format!(
458
+ "{} active · {} settled · order {}s",
459
+ active,
460
+ self.data.len().saturating_sub(active),
461
+ ((self.next_order_refresh_at - now_ms()).max(0) + 999) / 1000
462
+ ));
463
+ }
464
+
465
+ fn regroup(&mut self) {
466
+ let previous: HashMap<_, _> = self
467
+ .order
468
+ .iter()
469
+ .enumerate()
470
+ .map(|(index, id)| (id.clone(), index))
471
+ .collect();
472
+ self.order.sort_by_key(|id| {
473
+ let card = self.data.get(id);
474
+ let group = match card {
475
+ Some(card) if card.monitoring() => 0,
476
+ Some(card) if card.active() => 1,
477
+ _ => 2,
478
+ };
479
+ (group, previous.get(id).copied().unwrap_or(usize::MAX))
480
+ });
481
+ }
482
+
483
+ fn reorder_widgets(&self, parent: &gtk::Box) {
484
+ let mut previous: Option<gtk::Widget> = None;
485
+ for id in &self.order {
486
+ if let Some(card) = self.cards.get(id) {
487
+ parent.reorder_child_after(&card.root, previous.as_ref());
488
+ previous = Some(card.root.clone().upcast());
489
+ }
490
+ }
491
+ }
492
+
493
+ fn navigate(&mut self, parent: &gtk::Box, id: &str, direction: i32, manual: bool) {
494
+ let Some(index) = self.order.iter().position(|candidate| candidate == id) else {
495
+ return;
496
+ };
497
+ let Some(target) = navigation_target(index, direction, self.order.len()) else {
498
+ return;
499
+ };
500
+ if manual && target != index {
501
+ move_order_item(&mut self.order, index, target);
502
+ self.reorder_widgets(parent);
503
+ self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
504
+ emit(json!({ "protocol": 1, "type": "moved", "cardId": id, "direction": direction }));
505
+ }
506
+ let focus_id = if manual { id } else { &self.order[target] };
507
+ if let Some(card) = self.cards.get(focus_id) {
508
+ card.root.grab_focus();
509
+ }
510
+ }
511
+
512
+ fn reconcile_engagement(
513
+ &mut self,
514
+ widgets: &mut AppWidgets,
515
+ root: &gtk::Window,
516
+ sender: &ComponentSender<Self>,
517
+ ) {
518
+ if let Some(id) =
519
+ engaged_card_id(&self.hovered, self.keyboard_active, &self.keyboard_focused)
520
+ {
521
+ self.collapse_generation += 1;
522
+ self.apply_expansion(widgets, root, Some(id));
523
+ return;
524
+ }
525
+ self.collapse_generation += 1;
526
+ let generation = self.collapse_generation;
527
+ let tx = sender.input_sender().clone();
528
+ glib::timeout_add_local_once(Duration::from_millis(120), move || {
529
+ let _ = tx.send(AppMsg::CollapseIf(generation));
530
+ });
531
+ }
532
+
533
+ fn apply_expansion(
534
+ &mut self,
535
+ _widgets: &mut AppWidgets,
536
+ root: &gtk::Window,
537
+ card_id: Option<String>,
538
+ ) {
539
+ if self.open_card_id == card_id {
540
+ return;
541
+ }
542
+ self.open_card_id = card_id;
543
+ let expanded = self.open_card_id.is_some();
544
+ root.set_default_size(
545
+ 1,
546
+ if expanded {
547
+ EXPANDED_HEIGHT
548
+ } else {
549
+ COMPACT_HEIGHT
550
+ },
551
+ );
552
+ root.set_exclusive_zone(COMPACT_HEIGHT);
553
+ self.refresh_cards();
554
+ emit(
555
+ json!({ "protocol": 1, "type": "expanded", "expanded": expanded, "cardId": self.open_card_id }),
556
+ );
557
+ }
558
+ }
559
+
560
+ #[cfg(test)]
561
+ mod tests {
562
+ use super::{engaged_card_id, move_order_item, navigation_target};
563
+ use std::collections::HashSet;
564
+
565
+ #[test]
566
+ fn stale_gtk_focus_does_not_retain_expansion_outside_keyboard_mode() {
567
+ let focused = Some("card-a".to_owned());
568
+ assert_eq!(engaged_card_id(&HashSet::new(), false, &focused), None);
569
+ assert_eq!(engaged_card_id(&HashSet::new(), true, &focused), focused);
570
+ }
571
+
572
+ #[test]
573
+ fn pointer_hover_retains_expansion_without_keyboard_mode() {
574
+ let hovered = HashSet::from(["card-a".to_owned()]);
575
+ assert_eq!(
576
+ engaged_card_id(&hovered, false, &Some("card-b".to_owned())),
577
+ Some("card-a".to_owned())
578
+ );
579
+ }
580
+
581
+ #[test]
582
+ fn explicit_keyboard_mode_takes_precedence_over_pointer_hover() {
583
+ let hovered = HashSet::from(["card-a".to_owned()]);
584
+ assert_eq!(
585
+ engaged_card_id(&hovered, true, &Some("card-b".to_owned())),
586
+ Some("card-b".to_owned())
587
+ );
588
+ }
589
+
590
+ #[test]
591
+ fn keyboard_navigation_wraps_in_both_directions() {
592
+ assert_eq!(navigation_target(0, -1, 4), Some(3));
593
+ assert_eq!(navigation_target(3, 1, 4), Some(0));
594
+ assert_eq!(navigation_target(1, 1, 4), Some(2));
595
+ assert_eq!(navigation_target(0, 1, 0), None);
596
+ }
597
+
598
+ #[test]
599
+ fn wrapped_manual_movement_relocates_instead_of_swapping_endpoints() {
600
+ let mut order = vec!["a".into(), "b".into(), "c".into(), "d".into()];
601
+ move_order_item(&mut order, 0, 3);
602
+ assert_eq!(order, ["b", "c", "d", "a"]);
603
+ move_order_item(&mut order, 3, 0);
604
+ assert_eq!(order, ["a", "b", "c", "d"]);
605
+ }
606
+ }