@tryinget/pi-activity-strip 0.4.0 → 0.6.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 (61) hide show
  1. package/README.md +136 -157
  2. package/bin/pi-activity-strip-claude-hook.mjs +71 -0
  3. package/bin/pi-activity-strip.mjs +50 -60
  4. package/native/bin/linux-x64-gnu/artifact.json +21 -0
  5. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  6. package/native/panel/Cargo.lock +1045 -0
  7. package/native/panel/Cargo.toml +23 -0
  8. package/native/panel/src/app.rs +606 -0
  9. package/native/panel/src/card_view.rs +602 -0
  10. package/native/panel/src/main.rs +31 -0
  11. package/native/panel/src/protocol.rs +297 -0
  12. package/native/panel/src/runtime.rs +151 -0
  13. package/native/panel/src/style.css +250 -0
  14. package/package.json +16 -4
  15. package/src/broker/server.mjs +43 -7
  16. package/src/broker/session-store.mjs +41 -13
  17. package/src/client/broker-client.mjs +10 -3
  18. package/src/client/session-telemetry.mjs +54 -14
  19. package/src/common/activity-order.mjs +15 -9
  20. package/src/common/agent-identity.mjs +163 -0
  21. package/src/common/ak-tasks.mjs +354 -0
  22. package/src/common/alignment-controller.mjs +24 -42
  23. package/src/common/card-display.mjs +5 -0
  24. package/src/common/claude-events.mjs +190 -0
  25. package/src/common/claude-hook-config.mjs +56 -0
  26. package/src/common/claude-transcript.mjs +163 -0
  27. package/src/common/codex-transcript.mjs +153 -0
  28. package/src/common/compatibility.mjs +28 -13
  29. package/src/common/constants.mjs +10 -28
  30. package/src/common/contracts.d.ts +68 -4
  31. package/src/common/contracts.ts +105 -4
  32. package/src/common/ghostty-present.mjs +100 -0
  33. package/src/common/ghostty-theme.mjs +149 -0
  34. package/src/common/niri-focus.mjs +209 -140
  35. package/src/common/protocol.mjs +48 -16
  36. package/src/common/session-cards.mjs +105 -0
  37. package/src/common/status-report.mjs +53 -1
  38. package/src/common/surface-bindings.mjs +234 -0
  39. package/src/common/telemetry.mjs +8 -1
  40. package/src/common/terminal-identity.mjs +212 -0
  41. package/src/common/window-placement.mjs +59 -0
  42. package/src/common/workspace-view.mjs +161 -0
  43. package/src/native/agent-discovery.mjs +294 -0
  44. package/src/native/ak-runtime.mjs +164 -0
  45. package/src/native/codex-discovery.mjs +128 -0
  46. package/src/native/ghostty-tab-inventory.py +99 -0
  47. package/src/native/height-repair.mjs +154 -0
  48. package/src/native/main.mjs +499 -0
  49. package/src/native/panel-projection.mjs +161 -0
  50. package/src/native/placement.mjs +225 -0
  51. package/src/native/tab-inventory.mjs +188 -0
  52. package/src/native/theme-runtime.mjs +72 -0
  53. package/src/native/theme-source.mjs +197 -0
  54. package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +20 -2
  55. package/src/common/electron.mjs +0 -57
  56. package/src/electron/main.mjs +0 -499
  57. package/src/electron/niri-native-window-runtime.mjs +0 -178
  58. package/src/electron/preload.cjs +0 -50
  59. package/src/electron/renderer-visibility-runtime.mjs +0 -107
  60. package/src/electron/workspace-view-runtime.mjs +0 -360
  61. package/src/ui/strip-html.mjs +0 -500
@@ -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::{apply_theme, 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
+ /// The ribbon sits on the same rhythm as tiled windows, so it reads as one of them rather than as
15
+ /// a bar bolted to the top edge. Matches `gaps` in the compositor's layout.
16
+ const OUTER_MARGIN: i32 = 8;
17
+ const EXPANDED_HEIGHT: i32 = 276;
18
+ const ORDER_REFRESH_MS: i64 = 15_000;
19
+
20
+ pub struct AppInit {
21
+ pub demo: bool,
22
+ pub click_through: bool,
23
+ }
24
+
25
+ pub struct App {
26
+ cards: HashMap<String, CardView>,
27
+ data: HashMap<String, Card>,
28
+ order: Vec<String>,
29
+ focused_card_id: Option<String>,
30
+ hovered: HashSet<String>,
31
+ keyboard_focused: Option<String>,
32
+ keyboard_active: bool,
33
+ open_card_id: Option<String>,
34
+ revision: u64,
35
+ visible: bool,
36
+ next_order_refresh_at: i64,
37
+ collapse_generation: u64,
38
+ interactive: bool,
39
+ }
40
+
41
+ #[derive(Debug)]
42
+ pub enum AppMsg {
43
+ View(ViewMessage),
44
+ Theme(String),
45
+ Tick,
46
+ Hover(String, bool),
47
+ Focus(String, bool),
48
+ Navigate(String, i32, bool),
49
+ Activate(String),
50
+ ActivationResult(String, bool, String),
51
+ FocusStrip,
52
+ Collapse,
53
+ CollapseIf(u64),
54
+ WindowInactive,
55
+ ParentGone,
56
+ InputError(String),
57
+ }
58
+
59
+ #[relm4::component(pub)]
60
+ impl Component for App {
61
+ type Init = AppInit;
62
+ type Input = AppMsg;
63
+ type Output = ();
64
+ type CommandOutput = ();
65
+
66
+ view! {
67
+ root = gtk::Window {
68
+ set_title: Some("Pi Activity Strip"),
69
+ set_decorated: false,
70
+ set_resizable: true,
71
+ set_default_size: (1, COMPACT_HEIGHT),
72
+ set_visible: false,
73
+
74
+ #[name = "body"]
75
+ gtk::Box {
76
+ add_css_class: "band",
77
+ set_orientation: gtk::Orientation::Horizontal,
78
+ set_spacing: 0,
79
+
80
+ #[name = "brand"]
81
+ gtk::Box {
82
+ add_css_class: "brand",
83
+ set_orientation: gtk::Orientation::Vertical,
84
+ set_width_request: 132,
85
+ set_valign: gtk::Align::Center,
86
+
87
+ gtk::Label {
88
+ add_css_class: "brand-eyebrow",
89
+ set_label: "π ACTIVITY",
90
+ set_halign: gtk::Align::Start,
91
+ },
92
+ gtk::Label {
93
+ add_css_class: "brand-title",
94
+ set_label: "Sessions",
95
+ set_halign: gtk::Align::Start,
96
+ },
97
+ #[name = "meta"]
98
+ gtk::Label {
99
+ add_css_class: "meta",
100
+ set_label: "Waiting for sessions…",
101
+ set_halign: gtk::Align::Start,
102
+ },
103
+ },
104
+
105
+ #[name = "scroller"]
106
+ gtk::ScrolledWindow {
107
+ add_css_class: "cards-panel",
108
+ set_hexpand: true,
109
+ set_vexpand: true,
110
+ set_policy: (gtk::PolicyType::Automatic, gtk::PolicyType::Never),
111
+ #[name = "cards_box"]
112
+ gtk::Box {
113
+ set_orientation: gtk::Orientation::Horizontal,
114
+ set_spacing: 8,
115
+ set_halign: gtk::Align::Start,
116
+ set_valign: gtk::Align::Center,
117
+ set_margin_top: 8,
118
+ set_margin_bottom: 8,
119
+ }
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ fn init(
126
+ init: Self::Init,
127
+ root: Self::Root,
128
+ sender: ComponentSender<Self>,
129
+ ) -> ComponentParts<Self> {
130
+ install_css();
131
+ if !gtk4_layer_shell::is_supported() {
132
+ emit_error("Wayland compositor does not support wlr-layer-shell.");
133
+ std::process::exit(1);
134
+ }
135
+ root.init_layer_shell();
136
+ root.set_namespace(Some("pi-activity-strip"));
137
+ root.set_layer(Layer::Top);
138
+ root.set_anchor(Edge::Top, true);
139
+ root.set_anchor(Edge::Left, true);
140
+ root.set_anchor(Edge::Right, true);
141
+ root.set_margin(Edge::Top, OUTER_MARGIN);
142
+ root.set_margin(Edge::Left, OUTER_MARGIN);
143
+ root.set_margin(Edge::Right, OUTER_MARGIN);
144
+ root.set_exclusive_zone(COMPACT_HEIGHT);
145
+ root.set_keyboard_mode(KeyboardMode::None);
146
+ if init.click_through {
147
+ root.connect_realize(|window| {
148
+ if let Some(surface) = window.surface() {
149
+ surface.set_input_region(Some(&gtk::cairo::Region::create()));
150
+ }
151
+ });
152
+ }
153
+
154
+ let tx = sender.input_sender().clone();
155
+ root.connect_is_active_notify(move |window| {
156
+ if !window.is_active() {
157
+ let _ = tx.send(AppMsg::WindowInactive);
158
+ }
159
+ });
160
+
161
+ let model = App {
162
+ cards: HashMap::new(),
163
+ data: HashMap::new(),
164
+ order: Vec::new(),
165
+ focused_card_id: None,
166
+ hovered: HashSet::new(),
167
+ keyboard_focused: None,
168
+ keyboard_active: false,
169
+ open_card_id: None,
170
+ revision: 0,
171
+ visible: false,
172
+ next_order_refresh_at: 0,
173
+ collapse_generation: 0,
174
+ interactive: !init.click_through,
175
+ };
176
+ let widgets = view_output!();
177
+ widgets.body.set_can_target(!init.click_through);
178
+
179
+ let tick_tx = sender.input_sender().clone();
180
+ glib::timeout_add_seconds_local(1, move || {
181
+ let _ = tick_tx.send(AppMsg::Tick);
182
+ glib::ControlFlow::Continue
183
+ });
184
+
185
+ if init.demo {
186
+ let _ = sender.input_sender().send(AppMsg::View(demo_view()));
187
+ } else {
188
+ start_input_reader(sender.input_sender().clone());
189
+ }
190
+ emit_ready();
191
+ ComponentParts { model, widgets }
192
+ }
193
+
194
+ fn update_with_view(
195
+ &mut self,
196
+ widgets: &mut Self::Widgets,
197
+ message: Self::Input,
198
+ sender: ComponentSender<Self>,
199
+ root: &Self::Root,
200
+ ) {
201
+ match message {
202
+ AppMsg::View(view) => self.apply_view(widgets, root, &sender, view),
203
+ AppMsg::Theme(definitions) => apply_theme(&definitions),
204
+ AppMsg::Tick => {
205
+ if now_ms() >= self.next_order_refresh_at {
206
+ self.regroup();
207
+ self.reorder_widgets(&widgets.cards_box);
208
+ self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
209
+ }
210
+ self.refresh_cards();
211
+ self.update_meta(&widgets.meta);
212
+ }
213
+ AppMsg::Hover(id, entered) => {
214
+ if entered {
215
+ self.hovered.insert(id);
216
+ } else {
217
+ self.hovered.remove(&id);
218
+ }
219
+ self.reconcile_engagement(widgets, root, &sender);
220
+ }
221
+ AppMsg::Focus(id, entered) => {
222
+ if entered && self.keyboard_active {
223
+ self.keyboard_focused = Some(id);
224
+ } else if !entered && self.keyboard_focused.as_deref() == Some(id.as_str()) {
225
+ self.keyboard_focused = None;
226
+ }
227
+ self.reconcile_engagement(widgets, root, &sender);
228
+ }
229
+ AppMsg::Navigate(id, direction, manual) => {
230
+ if self.keyboard_active {
231
+ self.navigate(&widgets.cards_box, &id, direction, manual);
232
+ }
233
+ }
234
+ AppMsg::Activate(id) => {
235
+ emit(json!({ "protocol": 1, "type": "activate", "cardId": id }));
236
+ if self.keyboard_active {
237
+ self.end_engagement();
238
+ root.set_keyboard_mode(KeyboardMode::None);
239
+ self.apply_expansion(widgets, root, None);
240
+ }
241
+ }
242
+ AppMsg::ActivationResult(id, ok, message) => {
243
+ if let Some(card) = self.cards.get(&id) {
244
+ card.set_activation(ok, &message);
245
+ }
246
+ }
247
+ AppMsg::FocusStrip => {
248
+ if self.visible && self.interactive {
249
+ if self.keyboard_active {
250
+ self.end_engagement();
251
+ root.set_keyboard_mode(KeyboardMode::None);
252
+ self.apply_expansion(widgets, root, None);
253
+ } else {
254
+ self.keyboard_active = true;
255
+ self.hovered.clear();
256
+ self.keyboard_focused = self.order.first().cloned();
257
+ root.set_keyboard_mode(KeyboardMode::Exclusive);
258
+ root.present();
259
+ let focus_grabbed = self
260
+ .keyboard_focused
261
+ .as_ref()
262
+ .and_then(|id| self.cards.get(id))
263
+ .is_some_and(|first| first.root.grab_focus());
264
+ if focus_grabbed {
265
+ self.reconcile_engagement(widgets, root, &sender);
266
+ emit(
267
+ json!({ "protocol": 1, "type": "keyboard-active", "active": true }),
268
+ );
269
+ } else {
270
+ self.end_engagement();
271
+ root.set_keyboard_mode(KeyboardMode::None);
272
+ self.apply_expansion(widgets, root, None);
273
+ }
274
+ }
275
+ }
276
+ }
277
+ AppMsg::Collapse | AppMsg::WindowInactive => {
278
+ self.end_engagement();
279
+ root.set_keyboard_mode(KeyboardMode::None);
280
+ self.apply_expansion(widgets, root, None);
281
+ }
282
+ AppMsg::CollapseIf(generation) => {
283
+ if generation == self.collapse_generation
284
+ && self.hovered.is_empty()
285
+ && (!self.keyboard_active || self.keyboard_focused.is_none())
286
+ {
287
+ self.apply_expansion(widgets, root, None);
288
+ }
289
+ }
290
+ AppMsg::InputError(message) => emit_error(message),
291
+ AppMsg::ParentGone => root.close(),
292
+ }
293
+ }
294
+ }
295
+
296
+ fn navigation_target(index: usize, direction: i32, len: usize) -> Option<usize> {
297
+ if len == 0 {
298
+ return None;
299
+ }
300
+ Some((index as i64 + direction as i64).rem_euclid(len as i64) as usize)
301
+ }
302
+
303
+ fn move_order_item(order: &mut Vec<String>, index: usize, target: usize) {
304
+ let moved = order.remove(index);
305
+ order.insert(target, moved);
306
+ }
307
+
308
+ fn engaged_card_id(
309
+ hovered: &HashSet<String>,
310
+ keyboard_active: bool,
311
+ keyboard_focused: &Option<String>,
312
+ ) -> Option<String> {
313
+ if keyboard_active {
314
+ keyboard_focused.clone()
315
+ } else {
316
+ hovered.iter().next().cloned()
317
+ }
318
+ }
319
+
320
+ impl App {
321
+ fn end_engagement(&mut self) {
322
+ let was_keyboard_active = self.keyboard_active;
323
+ self.hovered.clear();
324
+ self.keyboard_focused = None;
325
+ self.keyboard_active = false;
326
+ self.collapse_generation += 1;
327
+ if was_keyboard_active {
328
+ emit(json!({ "protocol": 1, "type": "keyboard-active", "active": false }));
329
+ }
330
+ }
331
+
332
+ fn apply_view(
333
+ &mut self,
334
+ widgets: &mut AppWidgets,
335
+ root: &gtk::Window,
336
+ sender: &ComponentSender<Self>,
337
+ view: ViewMessage,
338
+ ) {
339
+ if view.protocol != 1 || view.message_type != "view" || view.revision <= self.revision {
340
+ return;
341
+ }
342
+ self.revision = view.revision;
343
+ self.focused_card_id = view.focused_card_id;
344
+ let incoming_order: Vec<_> = view
345
+ .sessions
346
+ .iter()
347
+ .map(|card| card.id().to_owned())
348
+ .collect();
349
+ self.data = view
350
+ .sessions
351
+ .into_iter()
352
+ .map(|card| (card.id().to_owned(), card))
353
+ .collect();
354
+ self.order.retain(|id| self.data.contains_key(id));
355
+ for id in incoming_order {
356
+ if !self.order.contains(&id) {
357
+ self.order.push(id);
358
+ }
359
+ }
360
+ self.hovered.retain(|id| self.data.contains_key(id));
361
+ let keyboard_card_removed = self.keyboard_active
362
+ && self
363
+ .keyboard_focused
364
+ .as_ref()
365
+ .is_some_and(|id| !self.data.contains_key(id));
366
+ if keyboard_card_removed {
367
+ self.keyboard_focused = self.order.first().cloned();
368
+ }
369
+ let open_card_removed = self
370
+ .open_card_id
371
+ .as_ref()
372
+ .is_some_and(|id| !self.data.contains_key(id));
373
+ if self.next_order_refresh_at == 0 {
374
+ self.regroup();
375
+ self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
376
+ }
377
+ self.reconcile_card_widgets(&widgets.cards_box, sender);
378
+ if keyboard_card_removed {
379
+ let focus_grabbed = self
380
+ .keyboard_focused
381
+ .as_ref()
382
+ .and_then(|id| self.cards.get(id))
383
+ .is_some_and(|card| card.root.grab_focus());
384
+ if focus_grabbed {
385
+ self.reconcile_engagement(widgets, root, sender);
386
+ } else {
387
+ self.end_engagement();
388
+ root.set_keyboard_mode(KeyboardMode::None);
389
+ self.apply_expansion(widgets, root, None);
390
+ }
391
+ }
392
+ if open_card_removed {
393
+ self.apply_expansion(widgets, root, None);
394
+ }
395
+ self.reorder_widgets(&widgets.cards_box);
396
+ self.refresh_cards();
397
+ self.update_meta(&widgets.meta);
398
+
399
+ let should_show = view.visible && !self.data.is_empty();
400
+ if should_show != self.visible {
401
+ self.visible = should_show;
402
+ if should_show {
403
+ root.set_exclusive_zone(COMPACT_HEIGHT);
404
+ root.present();
405
+ } else {
406
+ self.end_engagement();
407
+ root.set_keyboard_mode(KeyboardMode::None);
408
+ self.apply_expansion(widgets, root, None);
409
+ root.set_exclusive_zone(-1);
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
+ }