@tryinget/pi-activity-strip 0.8.0 → 0.10.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 (58) hide show
  1. package/README.md +59 -21
  2. package/bin/pi-activity-strip.mjs +32 -4
  3. package/native/bin/linux-x64-gnu/artifact.json +4 -2
  4. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  5. package/native/panel/src/app.rs +1141 -84
  6. package/native/panel/src/app_gui_tests.rs +730 -0
  7. package/native/panel/src/app_hover_gui_tests.rs +917 -0
  8. package/native/panel/src/card_view.rs +19 -4
  9. package/native/panel/src/directory_view.rs +471 -0
  10. package/native/panel/src/hints.rs +65 -0
  11. package/native/panel/src/hover-lifecycle-fixtures.json +30 -0
  12. package/native/panel/src/hover_keyboard.rs +237 -0
  13. package/native/panel/src/interaction.rs +156 -0
  14. package/native/panel/src/keyboard-observation-fixtures.json +241 -0
  15. package/native/panel/src/keyboard_observation.rs +70 -0
  16. package/native/panel/src/main.rs +5 -0
  17. package/native/panel/src/protocol.rs +93 -8
  18. package/native/panel/src/runtime.rs +85 -12
  19. package/native/panel/src/style.css +85 -0
  20. package/package.json +6 -7
  21. package/src/broker/server.mjs +31 -14
  22. package/src/client/launcher.mjs +3 -7
  23. package/src/client/runtime-lock.mjs +2 -1
  24. package/src/common/agent-identity.mjs +6 -1
  25. package/src/common/contracts.d.ts +2 -0
  26. package/src/common/contracts.ts +39 -0
  27. package/src/common/keyboard-observation.mjs +35 -0
  28. package/src/common/overlay-readiness.mjs +11 -0
  29. package/src/common/presence-apps.mjs +57 -0
  30. package/src/common/recovery-identity.mjs +85 -0
  31. package/src/common/recovery-manifest.mjs +417 -0
  32. package/src/common/recovery-types.ts +75 -0
  33. package/src/common/ribbon-status.mjs +41 -0
  34. package/src/common/status-report.mjs +37 -2
  35. package/src/common/workspace-view.mjs +118 -0
  36. package/src/native/ak-runtime.mjs +50 -9
  37. package/src/native/focus-visibility.mjs +67 -0
  38. package/src/native/ghostty-tab-inventory.py +40 -23
  39. package/src/native/hover-keyboard.mjs +32 -0
  40. package/src/native/interaction-context.mjs +45 -0
  41. package/src/native/keyboard-entry.mjs +32 -0
  42. package/src/native/main.mjs +91 -42
  43. package/src/native/panel-binary.mjs +25 -8
  44. package/src/native/panel-neutral-source.mjs +53 -0
  45. package/src/native/panel-presentation.mjs +137 -0
  46. package/src/native/panel-projection.mjs +354 -18
  47. package/src/native/panel-protocol.mjs +23 -0
  48. package/src/native/panel-window-jump.mjs +90 -0
  49. package/src/native/placement.mjs +83 -20
  50. package/src/native/recovery-inventory.mjs +209 -0
  51. package/src/native/recovery-proc.mjs +178 -0
  52. package/src/native/recovery-sources.mjs +358 -0
  53. package/src/native/runtime-diagnostics.mjs +242 -0
  54. package/src/native/strip-hotkey-kdl.mjs +252 -0
  55. package/src/native/strip-hotkey.mjs +62 -0
  56. package/src/native/tab-inventory.mjs +62 -11
  57. package/src/native/window-jump.mjs +43 -0
  58. package/src/native/workspace-events.mjs +123 -2
@@ -1,20 +1,31 @@
1
1
  use crate::card_view::CardView;
2
- use crate::protocol::{Card, ViewMessage, demo_view, emit, emit_error, emit_ready};
2
+ use crate::directory_view::{
3
+ DirectoryView, JumpChoice, is_query_char, jump_choice, push_query_char,
4
+ };
5
+ use crate::hints;
6
+ use crate::hover_keyboard::{HoverKeyboard, monotonic_ms};
7
+ use crate::interaction::{
8
+ HideAnchor, InteractionLifetime, PointerAnchor, accepts_pointer, is_stationary_reentry,
9
+ surface_state,
10
+ };
11
+ use crate::protocol::{Card, ViewMessage, WindowEntry, demo_view, emit, emit_error, emit_ready};
3
12
  use crate::runtime::{apply_theme, duplicate_labels, install_css, now_ms, start_input_reader};
4
13
  use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
5
14
  use relm4::gtk;
15
+ use relm4::gtk::gdk;
6
16
  use relm4::gtk::glib;
7
17
  use relm4::gtk::prelude::*;
8
18
  use relm4::{Component, ComponentParts, ComponentSender};
9
19
  use serde_json::json;
10
- use std::collections::{HashMap, HashSet};
20
+ use std::cell::Cell;
21
+ use std::collections::HashMap;
22
+ use std::rc::Rc;
11
23
  use std::time::Duration;
12
24
 
13
25
  const COMPACT_HEIGHT: i32 = 84;
14
26
  /// The ribbon sits on the same rhythm as tiled windows, so it reads as one of them rather than as
15
27
  /// a bar bolted to the top edge. Matches `gaps` in the compositor's layout.
16
28
  const OUTER_MARGIN: i32 = 8;
17
- const EXPANDED_HEIGHT: i32 = 276;
18
29
  const ORDER_REFRESH_MS: i64 = 15_000;
19
30
  const BRAND_WIDTH: i32 = 196;
20
31
 
@@ -28,15 +39,85 @@ pub struct App {
28
39
  data: HashMap<String, Card>,
29
40
  order: Vec<String>,
30
41
  focused_card_id: Option<String>,
31
- hovered: HashSet<String>,
42
+ /// The card under the pointer. One at most: a pointer is in one place, and the card it
43
+ /// entered last is the one it is on, whatever order GTK delivers the old card's leave in.
44
+ hovered: Option<String>,
45
+ /// The compositor shortcut that enters keyboard mode, for the progressive hints.
46
+ hotkey: Option<String>,
47
+ /// Niri focus moved while the operator's hand was on the ribbon; follow it once they leave.
48
+ scroll_deferred: bool,
32
49
  keyboard_focused: Option<String>,
50
+ /// Explicit/manual keyboard mode; hover must NOT inherit its arrow or unlisted-window powers.
33
51
  keyboard_active: bool,
52
+ hover_keyboard: HoverKeyboard,
53
+ /// Set when the panel hides with the pointer inside; consumed by the next body entry.
54
+ pointer_anchor: Option<HideAnchor>,
55
+ hover_capture: Rc<Cell<bool>>,
56
+ interaction_epoch: u64,
34
57
  open_card_id: Option<String>,
35
58
  revision: u64,
36
59
  visible: bool,
37
60
  next_order_refresh_at: i64,
38
61
  collapse_generation: u64,
39
62
  interactive: bool,
63
+ directory: DirectoryView,
64
+ windows: Vec<WindowEntry>,
65
+ directory_open: bool,
66
+ directory_hovered: bool,
67
+ /// The pointer is over the brand column: it expands the ribbon like a card does, showing the
68
+ /// list at full height.
69
+ brand_hovered: bool,
70
+ /// Counts brand hover changes, so a leave only collapses if the pointer did not come back
71
+ /// (or land on a card) within the collapse grace.
72
+ brand_generation: u64,
73
+ /// Counts list openings, so a jump or activation result only acts on the list that asked.
74
+ directory_session: u64,
75
+ pending_request: Option<PendingRequest>,
76
+ lifetime: InteractionLifetime,
77
+ pointer: gtk::EventControllerMotion,
78
+ brand_pointer: gtk::EventControllerMotion,
79
+ keyboard_request: u64,
80
+ /// GTK activity alone is never permission to expand or capture keys.
81
+ window_active: bool,
82
+ query: String,
83
+ /// Read by the window's key controller: which keys belong to the window list right now.
84
+ key_capture: Rc<Cell<KeyCapture>>,
85
+ }
86
+
87
+ /// Keys the window list takes before any card sees them.
88
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
89
+ pub enum KeyCapture {
90
+ None,
91
+ /// Keyboard mode on the cards: a typed name or number character opens the list; Enter and
92
+ /// Escape stay with the cards.
93
+ Digits,
94
+ /// The list is open with nothing typed: digits, Backspace and Escape are its own; Enter still
95
+ /// reaches the focused card.
96
+ List,
97
+ /// The list is open with digits typed: Enter jumps.
98
+ ListTyped,
99
+ }
100
+
101
+ /// What the open list asked the controller for, so only that answer acts on it.
102
+ #[derive(Clone, Debug, PartialEq, Eq)]
103
+ pub struct PendingRequest {
104
+ session: u64,
105
+ target: RequestTarget,
106
+ }
107
+
108
+ #[derive(Clone, Debug, PartialEq, Eq)]
109
+ pub enum RequestTarget {
110
+ Card(String),
111
+ Window(i64),
112
+ }
113
+
114
+ #[derive(Debug)]
115
+ pub enum DirectoryKey {
116
+ /// A letter, digit, `-`, `_` or `.` of a name or window number.
117
+ Char(char),
118
+ Backspace,
119
+ Enter,
120
+ Escape,
40
121
  }
41
122
 
42
123
  #[derive(Debug)]
@@ -49,12 +130,22 @@ pub enum AppMsg {
49
130
  Navigate(String, i32, bool),
50
131
  Activate(String),
51
132
  ActivationResult(String, bool, String),
52
- FocusStrip,
133
+ FocusStrip(u64, u64),
134
+ HoverGrant(u64, u64, u64),
135
+ HoverDenied(u64),
136
+ HoverInterrupt,
137
+ PointerIntent,
53
138
  Collapse,
54
139
  CollapseIf(u64),
55
- WindowInactive,
140
+ WindowActive(bool),
56
141
  ParentGone,
57
142
  InputError(String),
143
+ DirectoryHover(bool),
144
+ BrandHover(bool),
145
+ BrandSettle(u64),
146
+ DirectoryKey(DirectoryKey),
147
+ DirectoryActivate(String),
148
+ JumpResult(i64, bool, String),
58
149
  }
59
150
 
60
151
  #[relm4::component(pub)]
@@ -85,6 +176,9 @@ impl Component for App {
85
176
  add_css_class: "brand",
86
177
  set_orientation: gtk::Orientation::Vertical,
87
178
  set_width_request: BRAND_WIDTH,
179
+ // Set explicitly so the window list's expanding rows cannot widen the brand
180
+ // and push the cards aside.
181
+ set_hexpand: false,
88
182
  set_valign: gtk::Align::Center,
89
183
 
90
184
  #[name = "eyebrow"]
@@ -156,7 +250,7 @@ impl Component for App {
156
250
  root.set_margin(Edge::Top, OUTER_MARGIN);
157
251
  root.set_margin(Edge::Left, OUTER_MARGIN);
158
252
  root.set_margin(Edge::Right, OUTER_MARGIN);
159
- root.set_exclusive_zone(COMPACT_HEIGHT);
253
+ root.set_exclusive_zone(-1);
160
254
  root.set_keyboard_mode(KeyboardMode::None);
161
255
  if init.click_through {
162
256
  root.connect_realize(|window| {
@@ -168,34 +262,124 @@ impl Component for App {
168
262
 
169
263
  let tx = sender.input_sender().clone();
170
264
  root.connect_is_active_notify(move |window| {
171
- if !window.is_active() {
172
- let _ = tx.send(AppMsg::WindowInactive);
173
- }
265
+ let _ = tx.send(AppMsg::WindowActive(window.is_active()));
174
266
  });
175
267
 
268
+ let directory = DirectoryView::new(sender.input_sender());
269
+ let key_capture = Rc::new(Cell::new(KeyCapture::None));
270
+ let hover_capture = Rc::new(Cell::new(false));
271
+ let hover = gtk::EventControllerMotion::new();
272
+ let brand_hover = gtk::EventControllerMotion::new();
176
273
  let model = App {
177
274
  cards: HashMap::new(),
178
275
  data: HashMap::new(),
179
276
  order: Vec::new(),
180
277
  focused_card_id: None,
181
- hovered: HashSet::new(),
278
+ hovered: None,
279
+ hotkey: None,
280
+ scroll_deferred: false,
182
281
  keyboard_focused: None,
183
282
  keyboard_active: false,
283
+ hover_keyboard: HoverKeyboard::default(),
284
+ pointer_anchor: None,
285
+ hover_capture: Rc::clone(&hover_capture),
286
+ interaction_epoch: 0,
184
287
  open_card_id: None,
185
288
  revision: 0,
186
289
  visible: false,
187
290
  next_order_refresh_at: 0,
188
291
  collapse_generation: 0,
189
292
  interactive: !init.click_through,
293
+ directory,
294
+ windows: Vec::new(),
295
+ directory_open: false,
296
+ directory_hovered: false,
297
+ brand_hovered: false,
298
+ brand_generation: 0,
299
+ directory_session: 0,
300
+ pending_request: None,
301
+ lifetime: InteractionLifetime::default(),
302
+ pointer: hover.clone(),
303
+ brand_pointer: brand_hover.clone(),
304
+ keyboard_request: 0,
305
+ window_active: false,
306
+ query: String::new(),
307
+ key_capture: Rc::clone(&key_capture),
190
308
  };
191
309
  let widgets = view_output!();
192
310
  widgets.body.set_can_target(!init.click_through);
311
+ // GTK's viewport scrolls whatever takes focus into view. Focus on this panel only routes
312
+ // keys, so a hover grab must never move the row under a stationary pointer; the row
313
+ // scrolls only where the code below asks it to.
314
+ if let Some(viewport) = widgets.scroller.child().and_downcast::<gtk::Viewport>() {
315
+ viewport.set_scroll_to_focus(false);
316
+ }
317
+ // A key-routing focus target that is not a card, for a hover grab with no card under the
318
+ // pointer: focusing a card there would draw its focus ring on a card nobody pointed at.
319
+ widgets.body.set_focusable(true);
320
+ widgets.body.set_focus_on_click(false);
321
+ widgets.brand.append(&model.directory.root);
322
+
323
+ // The body is ONE region (including brand, cards and directory). Entry requests controller
324
+ // permission; child crossings do not drop/regrab ownership.
325
+ let tx = sender.input_sender().clone();
326
+ hover.connect_enter(move |_, _, _| {
327
+ let _ = tx.send(AppMsg::DirectoryHover(true));
328
+ });
329
+ let tx = sender.input_sender().clone();
330
+ hover.connect_leave(move |_| {
331
+ let _ = tx.send(AppMsg::DirectoryHover(false));
332
+ });
333
+ widgets.body.add_controller(hover);
334
+ let click = gtk::GestureClick::new();
335
+ click.set_propagation_phase(gtk::PropagationPhase::Capture);
336
+ let tx = sender.input_sender().clone();
337
+ click.connect_pressed(move |_, _, _, _| {
338
+ let _ = tx.send(AppMsg::PointerIntent);
339
+ });
340
+ widgets.body.add_controller(click);
341
+
342
+ let tx = sender.input_sender().clone();
343
+ brand_hover.connect_enter(move |_, _, _| {
344
+ let _ = tx.send(AppMsg::BrandHover(true));
345
+ });
346
+ let tx = sender.input_sender().clone();
347
+ brand_hover.connect_leave(move |_| {
348
+ let _ = tx.send(AppMsg::BrandHover(false));
349
+ });
350
+ widgets.brand.add_controller(brand_hover);
351
+
352
+ // Captured before any card sees it, and only while the list (or keyboard mode) is live:
353
+ // see `directory_key` for which keys belong to the list in which state.
354
+ let keys = gtk::EventControllerKey::new();
355
+ keys.set_propagation_phase(gtk::PropagationPhase::Capture);
356
+ let tx = sender.input_sender().clone();
357
+ keys.connect_key_pressed(move |_, key, _, modifiers| {
358
+ let chord = is_chord(modifiers);
359
+ let message = match directory_key(key_capture.get(), key, chord) {
360
+ Some(message) => message,
361
+ None => {
362
+ if hover_capture.get() && !is_typing_modifier(key) {
363
+ // Interrupting keys release hover; they are NOT replayed or injected into
364
+ // the underlying application. Subsequent typing belongs to that app.
365
+ let _ = tx.send(AppMsg::HoverInterrupt);
366
+ return glib::Propagation::Stop;
367
+ }
368
+ return glib::Propagation::Proceed;
369
+ }
370
+ };
371
+ let _ = tx.send(AppMsg::DirectoryKey(message));
372
+ glib::Propagation::Stop
373
+ });
374
+ widgets.root.add_controller(keys);
193
375
 
194
376
  let tick_tx = sender.input_sender().clone();
195
- glib::timeout_add_seconds_local(1, move || {
377
+ let _tick_source = glib::timeout_add_seconds_local(1, move || {
196
378
  let _ = tick_tx.send(AppMsg::Tick);
197
379
  glib::ControlFlow::Continue
198
380
  });
381
+ #[cfg(test)]
382
+ gui_tests::configure_tick_source(_tick_source);
199
383
 
200
384
  if init.demo {
201
385
  let _ = sender.input_sender().send(AppMsg::View(demo_view()));
@@ -213,13 +397,55 @@ impl Component for App {
213
397
  sender: ComponentSender<Self>,
214
398
  root: &Self::Root,
215
399
  ) {
400
+ #[cfg(test)]
401
+ let _dispatch_observation = gui_tests::observe_dispatch(&message, self, root);
402
+ if !self.visible || !self.interactive {
403
+ match &message {
404
+ AppMsg::Hover(..)
405
+ | AppMsg::Focus(..)
406
+ | AppMsg::Navigate(..)
407
+ | AppMsg::Activate(..)
408
+ | AppMsg::DirectoryHover(..)
409
+ | AppMsg::BrandHover(..)
410
+ | AppMsg::BrandSettle(..)
411
+ | AppMsg::CollapseIf(..)
412
+ | AppMsg::DirectoryKey(..)
413
+ | AppMsg::DirectoryActivate(..) => return,
414
+ _ => {}
415
+ }
416
+ }
216
417
  match message {
217
418
  AppMsg::View(view) => self.apply_view(widgets, root, &sender, view),
218
419
  AppMsg::Theme(definitions) => apply_theme(&definitions),
219
420
  AppMsg::Tick => {
220
- if now_ms() >= self.next_order_refresh_at {
421
+ // Mapping a surface beneath a stationary pointer is not a fresh keyboard intent.
422
+ // Observe an actual mapped absence before rearming after release/hide.
423
+ if root.is_mapped() && !self.pointer.contains_pointer() {
424
+ self.hover_keyboard.inside = false;
425
+ self.pointer_anchor = self
426
+ .pointer_anchor
427
+ .and_then(HideAnchor::after_mapped_absence);
428
+ }
429
+ if self.hover_keyboard.expired(monotonic_ms()) {
430
+ // Idle typing time ends the keyboard grab, not the hover: the card under a
431
+ // stationary pointer stays open.
432
+ self.observe_keyboard("idle-expired");
433
+ self.release_keyboard(widgets, root);
434
+ }
435
+ // Regrouping moves cards. Never under the pointer or the keyboard selection; the
436
+ // next tick after the operator leaves catches up.
437
+ if now_ms() >= self.next_order_refresh_at
438
+ && !self.pointer.contains_pointer()
439
+ && !self.owns_keyboard()
440
+ {
441
+ let focused_at = self.focused_position();
221
442
  self.regroup();
222
443
  self.reorder_widgets(&widgets.cards_box);
444
+ // Only when regrouping moved the focused card: a row the operator scrolled
445
+ // by hand stays where they left it.
446
+ if self.focused_position() != focused_at {
447
+ self.follow_focus(widgets);
448
+ }
223
449
  self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
224
450
  }
225
451
  self.refresh_cards();
@@ -227,14 +453,27 @@ impl Component for App {
227
453
  }
228
454
  AppMsg::Hover(id, entered) => {
229
455
  if entered {
230
- self.hovered.insert(id);
231
- } else {
232
- self.hovered.remove(&id);
456
+ if !self.cards.get(&id).is_some_and(|card| {
457
+ accepts_pointer(
458
+ self.visible,
459
+ self.interactive,
460
+ card.root.is_mapped(),
461
+ card.motion.contains_pointer(),
462
+ )
463
+ }) {
464
+ return;
465
+ }
466
+ self.hovered = Some(id);
467
+ } else if self.hovered.as_deref() == Some(id.as_str()) {
468
+ self.hovered = None;
233
469
  }
234
470
  self.reconcile_engagement(widgets, root, &sender);
235
471
  }
236
472
  AppMsg::Focus(id, entered) => {
237
473
  if entered && self.keyboard_active {
474
+ // Keyboard selection is the one thing that may move the row: it is where the
475
+ // operator is looking. Pointer focus never scrolls (see init).
476
+ self.scroll_card_into_view(widgets, &id);
238
477
  self.keyboard_focused = Some(id);
239
478
  } else if !entered && self.keyboard_focused.as_deref() == Some(id.as_str()) {
240
479
  self.keyboard_focused = None;
@@ -247,28 +486,34 @@ impl Component for App {
247
486
  }
248
487
  }
249
488
  AppMsg::Activate(id) => {
250
- emit(json!({ "protocol": 1, "type": "activate", "cardId": id }));
251
- if self.keyboard_active {
252
- self.end_engagement();
253
- root.set_keyboard_mode(KeyboardMode::None);
254
- self.apply_expansion(widgets, root, None);
255
- }
489
+ emit(json!({ "protocol": 2, "type": "activate", "cardId": id }));
490
+ // Activation returns the keyboard to the exact target, never to a hover grab.
491
+ self.leave_keyboard_mode(widgets, root);
256
492
  }
257
493
  AppMsg::ActivationResult(id, ok, message) => {
258
494
  if let Some(card) = self.cards.get(&id) {
259
495
  card.set_activation(ok, &message);
260
496
  }
497
+ if self.take_own_request(&RequestTarget::Card(id)) {
498
+ if ok {
499
+ self.finish_directory_jump(widgets, root);
500
+ } else {
501
+ self.directory.set_status(&message);
502
+ }
503
+ }
261
504
  }
262
- AppMsg::FocusStrip => {
263
- if self.visible && self.interactive {
505
+ AppMsg::FocusStrip(request, epoch) => {
506
+ if epoch == self.interaction_epoch && self.visible && self.interactive {
507
+ // A shortcut upgrades hover first; only a second explicit shortcut exits.
508
+ self.cancel_pending_hover();
509
+ self.hover_keyboard.clear();
510
+ self.keyboard_request = request;
264
511
  if self.keyboard_active {
265
- self.end_engagement();
266
- root.set_keyboard_mode(KeyboardMode::None);
267
- self.apply_expansion(widgets, root, None);
512
+ self.leave_keyboard_mode(widgets, root);
268
513
  } else {
269
514
  self.keyboard_active = true;
270
- self.hovered.clear();
271
- self.keyboard_focused = self.order.first().cloned();
515
+ self.sync_key_capture();
516
+ self.keyboard_focused = self.keyboard_entry_card(widgets);
272
517
  root.set_keyboard_mode(KeyboardMode::Exclusive);
273
518
  root.present();
274
519
  let focus_grabbed = self
@@ -278,36 +523,289 @@ impl Component for App {
278
523
  .is_some_and(|first| first.root.grab_focus());
279
524
  if focus_grabbed {
280
525
  self.reconcile_engagement(widgets, root, &sender);
526
+ self.update_brand(widgets);
527
+ self.observe_keyboard("acquired");
281
528
  emit(
282
- json!({ "protocol": 1, "type": "keyboard-active", "active": true }),
529
+ json!({ "protocol": 2, "type": "keyboard-active", "active": true, "revision": self.keyboard_request }),
283
530
  );
284
531
  } else {
285
- self.end_engagement();
286
- root.set_keyboard_mode(KeyboardMode::None);
287
- self.apply_expansion(widgets, root, None);
532
+ self.leave_keyboard_mode(widgets, root);
533
+ }
534
+ }
535
+ } else {
536
+ emit(
537
+ json!({ "protocol": 2, "type": "keyboard-active", "active": false, "revision": request }),
538
+ );
539
+ }
540
+ }
541
+ AppMsg::HoverGrant(token, epoch, request) => {
542
+ let pointer_valid = accepts_pointer(
543
+ self.visible,
544
+ self.interactive,
545
+ root.is_mapped(),
546
+ self.pointer.contains_pointer(),
547
+ );
548
+ if !self.keyboard_active
549
+ && self.hover_keyboard.grant(
550
+ token,
551
+ epoch,
552
+ self.interaction_epoch,
553
+ pointer_valid,
554
+ monotonic_ms(),
555
+ )
556
+ {
557
+ self.keyboard_request = request;
558
+ self.sync_key_capture();
559
+ root.set_keyboard_mode(KeyboardMode::Exclusive);
560
+ root.present();
561
+ // GTK still needs a focus widget to route real key events through the root
562
+ // capture controller. This is NOT explicit/manual card-selection permission.
563
+ // The card under the pointer if there is one (so Enter opens it), else the
564
+ // body: never a card the pointer is not on. Neither scrolls the row.
565
+ let focus_grabbed =
566
+ match self.hovered.as_ref().and_then(|id| self.cards.get(id)) {
567
+ Some(card) => card.root.grab_focus(),
568
+ None => widgets.body.grab_focus(),
569
+ };
570
+ if focus_grabbed {
571
+ self.refresh_directory_status();
572
+ self.update_brand(widgets);
573
+ // Capability confirmation comes from the applied compositor focus, not
574
+ // grab_focus() (which merely selects a GTK widget before Wayland commits).
575
+ if root.is_active() {
576
+ self.acknowledge_hover_keyboard();
577
+ }
578
+ } else {
579
+ self.leave_keyboard_mode(widgets, root);
580
+ }
581
+ } else {
582
+ emit(json!({ "protocol": 2, "type": "hover-cancel", "token": token }));
583
+ }
584
+ }
585
+ AppMsg::HoverDenied(token) => {
586
+ if self
587
+ .hover_keyboard
588
+ .pending
589
+ .is_some_and(|(pending, _)| pending == token)
590
+ {
591
+ self.hover_keyboard.clear();
592
+ }
593
+ }
594
+ AppMsg::HoverInterrupt => {
595
+ if self.hover_keyboard.active {
596
+ // The key belongs to the app below; the pointer is still where it was.
597
+ self.release_keyboard(widgets, root);
598
+ }
599
+ }
600
+ AppMsg::PointerIntent => self.request_hover_keyboard(root),
601
+ AppMsg::Collapse => {
602
+ self.leave_keyboard_mode(widgets, root);
603
+ }
604
+ AppMsg::WindowActive(active) => {
605
+ if active != root.is_active() {
606
+ return;
607
+ }
608
+ self.window_active = active;
609
+ if active {
610
+ if self.keyboard_active {
611
+ self.observe_keyboard("acquired");
612
+ }
613
+ if self.hover_keyboard.active {
614
+ if accepts_pointer(
615
+ self.visible,
616
+ self.interactive,
617
+ root.is_mapped(),
618
+ self.pointer.contains_pointer(),
619
+ ) {
620
+ self.acknowledge_hover_keyboard();
621
+ } else {
622
+ self.leave_keyboard_mode(widgets, root);
288
623
  }
289
624
  }
625
+ self.refresh_directory_status();
626
+ } else if !self.hover_keyboard.active || self.hover_keyboard.captured {
627
+ // Explicit mode ends with the keyboard; a hover grab only gives it back.
628
+ if self.keyboard_active {
629
+ self.leave_keyboard_mode(widgets, root);
630
+ } else {
631
+ self.release_keyboard(widgets, root);
632
+ }
633
+ emit(
634
+ json!({ "protocol": 2, "type": "keyboard-active", "active": false, "revision": self.keyboard_request }),
635
+ );
636
+ }
637
+ }
638
+ AppMsg::BrandHover(entered) => {
639
+ if entered
640
+ && !accepts_pointer(
641
+ self.visible,
642
+ self.interactive,
643
+ root.is_mapped(),
644
+ self.brand_pointer.contains_pointer(),
645
+ )
646
+ {
647
+ return;
648
+ }
649
+ self.brand_hovered = entered;
650
+ self.brand_generation += 1;
651
+ // Every card opens (entered) or falls back to the hovered one (left) at once.
652
+ self.refresh_cards();
653
+ if entered {
654
+ self.resize(root);
655
+ self.sync_directory_layout(widgets);
656
+ } else {
657
+ // Collapse only after the same grace as a card, so crossing from the brand to
658
+ // a card does not flicker the ribbon down and up again.
659
+ self.reconcile_engagement(widgets, root, &sender);
660
+ let generation = self.brand_generation;
661
+ let tx = sender.input_sender().clone();
662
+ glib::timeout_add_local_once(Duration::from_millis(120), move || {
663
+ let _ = tx.send(AppMsg::BrandSettle(generation));
664
+ });
290
665
  }
291
666
  }
292
- AppMsg::Collapse | AppMsg::WindowInactive => {
293
- self.end_engagement();
294
- root.set_keyboard_mode(KeyboardMode::None);
295
- self.apply_expansion(widgets, root, None);
667
+ AppMsg::BrandSettle(generation) => {
668
+ if generation == self.brand_generation {
669
+ self.resize(root);
670
+ self.sync_directory_layout(widgets);
671
+ }
296
672
  }
297
673
  AppMsg::CollapseIf(generation) => {
298
674
  if generation == self.collapse_generation
299
- && self.hovered.is_empty()
675
+ && self.hovered.is_none()
676
+ && !self.brand_hovered
300
677
  && (!self.keyboard_active || self.keyboard_focused.is_none())
301
678
  {
302
679
  self.apply_expansion(widgets, root, None);
303
680
  }
304
681
  }
305
682
  AppMsg::InputError(message) => emit_error(message),
683
+ AppMsg::DirectoryHover(entered) => {
684
+ let contains = self.pointer.contains_pointer();
685
+ if entered
686
+ && !accepts_pointer(self.visible, self.interactive, root.is_mapped(), contains)
687
+ {
688
+ return;
689
+ }
690
+ if !entered && contains {
691
+ return;
692
+ } // Not a real region leave.
693
+ self.directory_hovered = entered;
694
+ if entered {
695
+ let fresh_entry = !self.hover_keyboard.inside;
696
+ // The compositor re-entering the pointer that sat here at hide is not intent,
697
+ // even after a mapped Tick cleared `inside`.
698
+ let stationary = is_stationary_reentry(
699
+ self.pointer_anchor.take().map(|anchor| anchor.at),
700
+ pointer_surface_position(root),
701
+ );
702
+ self.hover_keyboard.inside = true;
703
+ self.open_directory(widgets, root);
704
+ if fresh_entry && !stationary {
705
+ self.request_hover_keyboard(root);
706
+ }
707
+ } else {
708
+ self.pointer_anchor = None;
709
+ self.hover_keyboard.inside = false;
710
+ if !self.keyboard_active {
711
+ self.leave_keyboard_mode(widgets, root);
712
+ }
713
+ }
714
+ }
715
+ AppMsg::DirectoryKey(key) => {
716
+ if self.owns_keyboard() {
717
+ self.hover_keyboard.touch(monotonic_ms());
718
+ self.observe_keyboard("activity");
719
+ self.handle_directory_key(widgets, root, key);
720
+ }
721
+ }
722
+ AppMsg::DirectoryActivate(card_id) => {
723
+ self.pending_request = Some(PendingRequest {
724
+ session: self.directory_session,
725
+ target: RequestTarget::Card(card_id.clone()),
726
+ });
727
+ self.directory.set_status("Opening…");
728
+ emit(json!({ "protocol": 2, "type": "activate", "cardId": card_id }));
729
+ self.leave_keyboard_mode(widgets, root);
730
+ }
731
+ AppMsg::JumpResult(window_id, ok, message) => {
732
+ if self.take_own_request(&RequestTarget::Window(window_id)) {
733
+ if ok {
734
+ // The jumped-to window must get the keyboard back at once.
735
+ self.finish_directory_jump(widgets, root);
736
+ } else {
737
+ self.directory.set_status(&message);
738
+ }
739
+ }
740
+ }
306
741
  AppMsg::ParentGone => root.close(),
307
742
  }
308
743
  }
309
744
  }
310
745
 
746
+ /// The scroll offset that shows the span `start..end` with the least movement: unchanged when it
747
+ /// is already fully visible, otherwise just far enough to bring its nearer edge into view.
748
+ fn scroll_to_show(value: f64, page: f64, start: f64, end: f64) -> f64 {
749
+ if start < value {
750
+ start
751
+ } else if end > value + page {
752
+ (end - page).min(start)
753
+ } else {
754
+ value
755
+ }
756
+ }
757
+
758
+ /// Whether a result for `target` answers the request the list opened as `session` is waiting on.
759
+ fn answers_request(pending: Option<&PendingRequest>, session: u64, target: &RequestTarget) -> bool {
760
+ pending.is_some_and(|request| request.session == session && &request.target == target)
761
+ }
762
+
763
+ /// GDK's own surface-local pointer position, while this surface has pointer focus.
764
+ fn pointer_surface_position(root: &gtk::Window) -> Option<(f64, f64)> {
765
+ let pointer = WidgetExt::display(root).default_seat()?.pointer()?;
766
+ let (x, y, _) = root.surface()?.device_position(&pointer)?;
767
+ Some((x, y))
768
+ }
769
+
770
+ /// A physical Shift press prepares a capital query character; it is not an interrupt or an idle
771
+ /// extension by itself. Ctrl/Alt/Super remain interrupting shortcut intent in hover mode.
772
+ fn is_typing_modifier(key: gdk::Key) -> bool {
773
+ matches!(key, gdk::Key::Shift_L | gdk::Key::Shift_R)
774
+ }
775
+
776
+ /// Ctrl, Alt, Super or Meta held: a shortcut, not typing. Shift is typing (capital letters).
777
+ fn is_chord(modifiers: gdk::ModifierType) -> bool {
778
+ modifiers.intersects(
779
+ gdk::ModifierType::CONTROL_MASK
780
+ | gdk::ModifierType::ALT_MASK
781
+ | gdk::ModifierType::SUPER_MASK
782
+ | gdk::ModifierType::META_MASK,
783
+ )
784
+ }
785
+
786
+ /// The window-list key a press means under the current capture, if it belongs to the list at all.
787
+ fn directory_key(capture: KeyCapture, key: gdk::Key, chord: bool) -> Option<DirectoryKey> {
788
+ // Ctrl, Alt and Super combinations are shortcuts, never search characters.
789
+ if chord {
790
+ return None;
791
+ }
792
+ let typed = key
793
+ .to_unicode()
794
+ .filter(|character| is_query_char(*character));
795
+ match capture {
796
+ KeyCapture::None => None,
797
+ KeyCapture::Digits => typed.map(DirectoryKey::Char),
798
+ KeyCapture::List | KeyCapture::ListTyped => match key {
799
+ gdk::Key::BackSpace => Some(DirectoryKey::Backspace),
800
+ gdk::Key::Return | gdk::Key::KP_Enter if capture == KeyCapture::ListTyped => {
801
+ Some(DirectoryKey::Enter)
802
+ }
803
+ gdk::Key::Escape => Some(DirectoryKey::Escape),
804
+ _ => typed.map(DirectoryKey::Char),
805
+ },
806
+ }
807
+ }
808
+
311
809
  fn navigation_target(index: usize, direction: i32, len: usize) -> Option<usize> {
312
810
  if len == 0 {
313
811
  return None;
@@ -372,27 +870,86 @@ fn short_path(path: &str, home: &str) -> String {
372
870
  }
373
871
 
374
872
  fn engaged_card_id(
375
- hovered: &HashSet<String>,
873
+ hovered: &Option<String>,
376
874
  keyboard_active: bool,
377
875
  keyboard_focused: &Option<String>,
378
876
  ) -> Option<String> {
379
877
  if keyboard_active {
380
878
  keyboard_focused.clone()
381
879
  } else {
382
- hovered.iter().next().cloned()
880
+ hovered.clone()
383
881
  }
384
882
  }
385
883
 
386
884
  impl App {
885
+ fn owns_keyboard(&self) -> bool {
886
+ self.keyboard_active || self.hover_keyboard.active
887
+ }
888
+
889
+ /// Allowlisted native facts only. This message never authorizes input or changes timers.
890
+ fn observe_keyboard(&self, reason: &str) {
891
+ if let Some(event) = crate::keyboard_observation::normalize(&json!({
892
+ "protocol": 2, "type": "keyboard-observation", "request": self.keyboard_request,
893
+ "origin": if self.keyboard_active { "explicit" } else { "hover" },
894
+ "reason": reason, "nativeMs": monotonic_ms(),
895
+ "deadlineMs": self.hover_keyboard.captured_deadline_ms(),
896
+ "captured": self.hover_keyboard.captured || (self.keyboard_active && self.window_active),
897
+ })) {
898
+ emit(event);
899
+ }
900
+ }
901
+
902
+ fn acknowledge_hover_keyboard(&mut self) {
903
+ if self.hover_keyboard.acquired(monotonic_ms()) {
904
+ self.observe_keyboard("acquired");
905
+ emit(
906
+ json!({ "protocol": 2, "type": "keyboard-active", "active": true, "revision": self.keyboard_request }),
907
+ );
908
+ }
909
+ }
910
+
911
+ fn cancel_pending_hover(&mut self) {
912
+ if let Some((token, _)) = self.hover_keyboard.pending.take() {
913
+ emit(json!({ "protocol": 2, "type": "hover-cancel", "token": token }));
914
+ }
915
+ }
916
+
917
+ fn request_hover_keyboard(&mut self, root: &gtk::Window) {
918
+ if self.keyboard_active
919
+ || !accepts_pointer(
920
+ self.visible,
921
+ self.interactive,
922
+ root.is_mapped(),
923
+ self.pointer.contains_pointer(),
924
+ )
925
+ {
926
+ return;
927
+ }
928
+ if let Some(token) = self
929
+ .hover_keyboard
930
+ .request(self.interaction_epoch, monotonic_ms())
931
+ {
932
+ emit(
933
+ json!({ "protocol": 2, "type": "hover-request", "token": token, "epoch": self.interaction_epoch }),
934
+ );
935
+ }
936
+ }
937
+
387
938
  fn end_engagement(&mut self) {
388
- let was_keyboard_active = self.keyboard_active;
389
- self.hovered.clear();
939
+ self.hovered = None;
940
+ self.brand_hovered = false;
941
+ self.directory_hovered = false;
390
942
  self.keyboard_focused = None;
391
943
  self.keyboard_active = false;
944
+ self.cancel_pending_hover();
945
+ self.hover_keyboard.clear();
946
+ self.window_active = false;
947
+ self.pending_request = None;
948
+ self.query.clear();
949
+ self.sync_key_capture();
392
950
  self.collapse_generation += 1;
393
- if was_keyboard_active {
394
- emit(json!({ "protocol": 1, "type": "keyboard-active", "active": false }));
395
- }
951
+ self.brand_generation += 1;
952
+ self.lifetime.invalidate();
396
953
  }
397
954
 
398
955
  fn apply_view(
@@ -402,11 +959,45 @@ impl App {
402
959
  sender: &ComponentSender<Self>,
403
960
  view: ViewMessage,
404
961
  ) {
405
- if view.protocol != 1 || view.message_type != "view" || view.revision <= self.revision {
962
+ if view.protocol != 2 || view.message_type != "view" || view.revision <= self.revision {
406
963
  return;
407
964
  }
408
965
  self.revision = view.revision;
966
+ // Data changes revoke two-frame scroll callbacks, NOT keyboard ownership attempts. The
967
+ // latter are bound to the controller's exact admission epoch and a unique pointer token.
968
+ self.lifetime.invalidate();
969
+ // Visibility is controller policy; an empty workspace still has a compact ribbon.
970
+ let should_show = view.visible;
971
+ let visibility_changed = should_show != self.visible;
972
+ if self.visible && !should_show && self.hover_keyboard.inside {
973
+ self.pointer_anchor = Some(HideAnchor::new(
974
+ pointer_surface_position(root)
975
+ .map_or(PointerAnchor::Unknown, |(x, y)| PointerAnchor::At(x, y)),
976
+ ));
977
+ }
978
+ self.visible = should_show;
979
+ let focus_moved = self.focused_card_id != view.focused_card_id;
980
+ let hotkey_changed = self.hotkey != view.hotkey;
981
+ self.hotkey = view.hotkey.clone();
982
+ // Identity loss or hiding ends everything. A focus move inside the same identity (the
983
+ // controller's own activation echo) is not an app switch: it must not collapse the card
984
+ // under the pointer, only drop a hover request that has not been answered yet.
985
+ if !should_show || view.interaction_epoch != self.interaction_epoch || visibility_changed {
986
+ self.observe_keyboard("source-revoked");
987
+ self.leave_keyboard_mode(widgets, root);
988
+ } else if focus_moved && !self.owns_keyboard() {
989
+ self.cancel_pending_hover();
990
+ self.hover_keyboard.clear();
991
+ }
992
+ if hotkey_changed {
993
+ self.render_directory();
994
+ }
995
+ self.interaction_epoch = view.interaction_epoch;
409
996
  self.focused_card_id = view.focused_card_id;
997
+ if self.windows != view.windows {
998
+ self.windows = view.windows;
999
+ self.render_directory();
1000
+ }
410
1001
  let incoming_order: Vec<_> = view
411
1002
  .sessions
412
1003
  .iter()
@@ -423,7 +1014,13 @@ impl App {
423
1014
  self.order.push(id);
424
1015
  }
425
1016
  }
426
- self.hovered.retain(|id| self.data.contains_key(id));
1017
+ if self
1018
+ .hovered
1019
+ .as_ref()
1020
+ .is_some_and(|id| !self.data.contains_key(id))
1021
+ {
1022
+ self.hovered = None;
1023
+ }
427
1024
  let keyboard_card_removed = self.keyboard_active
428
1025
  && self
429
1026
  .keyboard_focused
@@ -450,9 +1047,7 @@ impl App {
450
1047
  if focus_grabbed {
451
1048
  self.reconcile_engagement(widgets, root, sender);
452
1049
  } else {
453
- self.end_engagement();
454
- root.set_keyboard_mode(KeyboardMode::None);
455
- self.apply_expansion(widgets, root, None);
1050
+ self.leave_keyboard_mode(widgets, root);
456
1051
  }
457
1052
  }
458
1053
  if open_card_removed {
@@ -461,22 +1056,20 @@ impl App {
461
1056
  self.reorder_widgets(&widgets.cards_box);
462
1057
  self.refresh_cards();
463
1058
  self.update_brand(widgets);
1059
+ if focus_moved {
1060
+ self.follow_focus(widgets);
1061
+ }
464
1062
 
465
- let should_show = view.visible && !self.data.is_empty();
466
- if should_show != self.visible {
467
- self.visible = should_show;
1063
+ if visibility_changed {
468
1064
  if should_show {
469
1065
  root.set_exclusive_zone(COMPACT_HEIGHT);
470
1066
  root.present();
471
1067
  } else {
472
- self.end_engagement();
473
- root.set_keyboard_mode(KeyboardMode::None);
474
- self.apply_expansion(widgets, root, None);
475
1068
  root.set_exclusive_zone(-1);
476
1069
  root.set_visible(false);
477
1070
  }
478
1071
  emit(
479
- json!({ "protocol": 1, "type": "visibility-applied", "revision": self.revision, "visible": should_show }),
1072
+ json!({ "protocol": 2, "type": "visibility-applied", "revision": self.revision, "visible": should_show }),
480
1073
  );
481
1074
  }
482
1075
  }
@@ -505,6 +1098,9 @@ impl App {
505
1098
  fn refresh_cards(&self) {
506
1099
  let duplicates = duplicate_labels(self.data.values());
507
1100
  let now = now_ms();
1101
+ // Hovering the brand column is an overview: every card shows its details at once.
1102
+ let all_open = self.brand_hovered && self.expanded();
1103
+ let key_hint = hints::card_hint(self.hotkey.as_deref());
508
1104
  for (id, view) in &self.cards {
509
1105
  if let Some(card) = self.data.get(id) {
510
1106
  view.update(
@@ -512,8 +1108,9 @@ impl App {
512
1108
  self.focused_card_id.as_deref() == Some(id),
513
1109
  duplicates.contains(&card.repo_label),
514
1110
  now,
1111
+ key_hint.as_deref(),
515
1112
  );
516
- view.set_expanded(self.open_card_id.as_deref() == Some(id));
1113
+ view.set_expanded(all_open || self.open_card_id.as_deref() == Some(id));
517
1114
  }
518
1115
  }
519
1116
  }
@@ -530,14 +1127,22 @@ impl App {
530
1127
  .and_then(|id| self.data.get(id));
531
1128
  let home = std::env::var("HOME").unwrap_or_default();
532
1129
  let brand = brand_text(focused, &counts, &home);
533
- widgets.eyebrow.set_text(&brand.eyebrow);
534
1130
  widgets.title.set_text(&brand.title);
535
- widgets.meta.set_text(&brand.meta);
536
- widgets.brand.set_tooltip_text(
537
- focused
538
- .map(|card| card.cwd.as_str())
539
- .filter(|cwd| !cwd.is_empty()),
540
- );
1131
+ // The last rung of the keyboard hints: in keyboard mode the key map replaces the folder,
1132
+ // ellipsized at its end so the first keys always show.
1133
+ if self.keyboard_active {
1134
+ widgets.eyebrow.set_text(hints::KEYBOARD_EYEBROW);
1135
+ widgets.meta.set_text(hints::KEYBOARD_META);
1136
+ widgets.meta.set_ellipsize(gtk::pango::EllipsizeMode::End);
1137
+ } else {
1138
+ widgets.eyebrow.set_text(&brand.eyebrow);
1139
+ widgets.meta.set_text(&brand.meta);
1140
+ widgets.meta.set_ellipsize(gtk::pango::EllipsizeMode::Start);
1141
+ }
1142
+ widgets.brand.set_tooltip_text(Some(&hints::brand_tooltip(
1143
+ focused.map(|card| card.cwd.as_str()),
1144
+ self.hotkey.as_deref(),
1145
+ )));
541
1146
  }
542
1147
 
543
1148
  fn regroup(&mut self) {
@@ -579,7 +1184,7 @@ impl App {
579
1184
  move_order_item(&mut self.order, index, target);
580
1185
  self.reorder_widgets(parent);
581
1186
  self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
582
- emit(json!({ "protocol": 1, "type": "moved", "cardId": id, "direction": direction }));
1187
+ emit(json!({ "protocol": 2, "type": "moved", "cardId": id, "direction": direction }));
583
1188
  }
584
1189
  let focus_id = if manual { id } else { &self.order[target] };
585
1190
  if let Some(card) = self.cards.get(focus_id) {
@@ -593,6 +1198,9 @@ impl App {
593
1198
  root: &gtk::Window,
594
1199
  sender: &ComponentSender<Self>,
595
1200
  ) {
1201
+ if !self.visible || !self.interactive {
1202
+ return;
1203
+ }
596
1204
  if let Some(id) =
597
1205
  engaged_card_id(&self.hovered, self.keyboard_active, &self.keyboard_focused)
598
1206
  {
@@ -610,38 +1218,380 @@ impl App {
610
1218
 
611
1219
  fn apply_expansion(
612
1220
  &mut self,
613
- _widgets: &mut AppWidgets,
1221
+ widgets: &mut AppWidgets,
614
1222
  root: &gtk::Window,
615
1223
  card_id: Option<String>,
616
1224
  ) {
617
1225
  if self.open_card_id == card_id {
618
1226
  return;
619
1227
  }
620
- self.open_card_id = card_id;
621
- let expanded = self.open_card_id.is_some();
622
- root.set_default_size(
623
- 1,
624
- if expanded {
625
- EXPANDED_HEIGHT
1228
+ self.open_card_id = if self.visible && self.interactive {
1229
+ card_id
1230
+ } else {
1231
+ None
1232
+ };
1233
+ self.refresh_cards();
1234
+ self.resize(root);
1235
+ self.sync_directory_layout(widgets);
1236
+ }
1237
+
1238
+ /// Scroll the card row so the focused window's card is fully in view, as Niri focus moves
1239
+ /// between windows. Runs once layout has placed the card, so its position in the row is known.
1240
+ fn scroll_focused_into_view(&self, widgets: &AppWidgets) {
1241
+ if let Some(id) = self.focused_card_id.clone() {
1242
+ self.scroll_card_into_view(widgets, &id);
1243
+ }
1244
+ }
1245
+
1246
+ /// Follow Niri focus along the row, but never under the operator's hand: while the pointer is
1247
+ /// on the ribbon or it holds the keyboard the scroll waits, and runs once they leave.
1248
+ fn follow_focus(&mut self, widgets: &AppWidgets) {
1249
+ if self.pointer.contains_pointer() || self.owns_keyboard() {
1250
+ self.scroll_deferred = true;
1251
+ } else {
1252
+ self.scroll_deferred = false;
1253
+ self.scroll_focused_into_view(widgets);
1254
+ }
1255
+ }
1256
+
1257
+ /// Where explicit keyboard mode starts: the card under the pointer, else the focused window's
1258
+ /// card, else the first card already on screen, so entering never jumps the row to its start.
1259
+ fn keyboard_entry_card(&self, widgets: &AppWidgets) -> Option<String> {
1260
+ let present = |id: &&String| self.cards.contains_key(*id);
1261
+ if let Some(id) = self.hovered.as_ref().filter(present) {
1262
+ return Some(id.clone());
1263
+ }
1264
+ if let Some(id) = self.focused_card_id.as_ref().filter(present) {
1265
+ return Some(id.clone());
1266
+ }
1267
+ let adjustment = widgets.scroller.hadjustment();
1268
+ let (left, right) = (
1269
+ adjustment.value(),
1270
+ adjustment.value() + adjustment.page_size(),
1271
+ );
1272
+ self.order
1273
+ .iter()
1274
+ .find(|id| {
1275
+ self.cards.get(*id).is_some_and(|card| {
1276
+ card.root
1277
+ .compute_point(&widgets.cards_box, &gtk::graphene::Point::new(0.0, 0.0))
1278
+ .is_some_and(|origin| {
1279
+ let start = f64::from(origin.x());
1280
+ start >= left - 1.0
1281
+ && start + f64::from(card.root.width()) <= right + 1.0
1282
+ })
1283
+ })
1284
+ })
1285
+ .or(self.order.first())
1286
+ .cloned()
1287
+ }
1288
+
1289
+ /// Bring one card fully into view with the least movement, once layout has placed it.
1290
+ fn scroll_card_into_view(&self, widgets: &AppWidgets, id: &str) {
1291
+ if !self.visible || !self.interactive {
1292
+ return;
1293
+ }
1294
+ let lifetime = self.lifetime.clone();
1295
+ let ticket = lifetime.ticket();
1296
+ let Some(card) = self.cards.get(id) else {
1297
+ return;
1298
+ };
1299
+ let row = widgets.cards_box.clone();
1300
+ let scroller = widgets.scroller.clone();
1301
+ // Measured on the second frame after the change: the first one lays the row out, so the
1302
+ // card's position is final, and a panel that is not shown yet simply waits until it is.
1303
+ let frames = Cell::new(0u8);
1304
+ card.root.add_tick_callback(move |card, _clock| {
1305
+ if !lifetime.accepts(ticket, card.is_mapped(), card.is_sensitive()) {
1306
+ return glib::ControlFlow::Break;
1307
+ }
1308
+ frames.set(frames.get() + 1);
1309
+ if frames.get() < 2 {
1310
+ return glib::ControlFlow::Continue;
1311
+ }
1312
+ if let Some(origin) = card.compute_point(&row, &gtk::graphene::Point::new(0.0, 0.0)) {
1313
+ let start = f64::from(origin.x());
1314
+ let end = start + f64::from(card.width());
1315
+ let adjustment = scroller.hadjustment();
1316
+ adjustment.set_value(scroll_to_show(
1317
+ adjustment.value(),
1318
+ adjustment.page_size(),
1319
+ start,
1320
+ end,
1321
+ ));
1322
+ }
1323
+ glib::ControlFlow::Break
1324
+ });
1325
+ }
1326
+
1327
+ fn focused_position(&self) -> Option<usize> {
1328
+ let id = self.focused_card_id.as_ref()?;
1329
+ self.order.iter().position(|candidate| candidate == id)
1330
+ }
1331
+
1332
+ /// The ribbon is tall while a card is open (hovered or in keyboard mode), while the pointer is
1333
+ /// on the brand column with the list open, or while a typed number needs the list shown.
1334
+ fn expanded(&self) -> bool {
1335
+ self.visible
1336
+ && self.interactive
1337
+ && (self.open_card_id.is_some()
1338
+ || (self.directory_open
1339
+ && (self.brand_hovered || (self.owns_keyboard() && !self.query.is_empty()))))
1340
+ }
1341
+
1342
+ /// The list fills the brand column, top to bottom, whenever the ribbon is expanded; compact,
1343
+ /// it is hidden.
1344
+ fn sync_directory_layout(&self, widgets: &AppWidgets) {
1345
+ let shown = self.directory_open && self.expanded();
1346
+ self.directory.set_visible(shown);
1347
+ widgets.brand.set_valign(if shown {
1348
+ gtk::Align::Fill
1349
+ } else {
1350
+ gtk::Align::Center
1351
+ });
1352
+ }
1353
+
1354
+ fn resize(&self, root: &gtk::Window) {
1355
+ let expanded = self.expanded();
1356
+ let (height, zone) = surface_state(self.visible, expanded);
1357
+ root.set_default_size(1, height);
1358
+ root.set_exclusive_zone(zone);
1359
+ emit(
1360
+ json!({ "protocol": 2, "type": "expanded", "expanded": expanded, "cardId": self.open_card_id, "queryLength": self.query.chars().count() }),
1361
+ );
1362
+ }
1363
+
1364
+ fn sync_key_capture(&self) {
1365
+ self.hover_capture
1366
+ .set(self.visible && self.interactive && self.hover_keyboard.active);
1367
+ self.key_capture.set(
1368
+ if !self.visible || !self.interactive || !self.owns_keyboard() {
1369
+ KeyCapture::None
1370
+ } else if self.directory_open && !self.query.is_empty() {
1371
+ KeyCapture::ListTyped
1372
+ } else if self.directory_open {
1373
+ KeyCapture::List
1374
+ } else if self.owns_keyboard() {
1375
+ KeyCapture::Digits
626
1376
  } else {
627
- COMPACT_HEIGHT
1377
+ KeyCapture::None
628
1378
  },
629
1379
  );
630
- root.set_exclusive_zone(COMPACT_HEIGHT);
1380
+ }
1381
+
1382
+ fn handle_directory_key(
1383
+ &mut self,
1384
+ widgets: &mut AppWidgets,
1385
+ root: &gtk::Window,
1386
+ key: DirectoryKey,
1387
+ ) {
1388
+ match key {
1389
+ DirectoryKey::Char(digit) => {
1390
+ self.open_directory(widgets, root);
1391
+ if push_query_char(&mut self.query, digit) {
1392
+ self.render_directory();
1393
+ self.sync_key_capture();
1394
+ self.resize(root);
1395
+ self.sync_directory_layout(widgets);
1396
+ }
1397
+ }
1398
+ DirectoryKey::Backspace => {
1399
+ self.query.pop();
1400
+ self.render_directory();
1401
+ self.sync_key_capture();
1402
+ self.resize(root);
1403
+ self.sync_directory_layout(widgets);
1404
+ }
1405
+ DirectoryKey::Enter => {
1406
+ match jump_choice(&self.windows, &self.query, self.allow_unlisted()) {
1407
+ Some(JumpChoice::Window(window_id)) => {
1408
+ self.pending_request = Some(PendingRequest {
1409
+ session: self.directory_session,
1410
+ target: RequestTarget::Window(window_id),
1411
+ });
1412
+ self.directory
1413
+ .set_status(&format!("Jumping to #{window_id}…"));
1414
+ emit(
1415
+ json!({ "protocol": 2, "type": "jump", "windowId": window_id, "request": self.keyboard_request, "epoch": self.interaction_epoch }),
1416
+ );
1417
+ self.leave_keyboard_mode(widgets, root);
1418
+ }
1419
+ Some(JumpChoice::Card(card_id)) => {
1420
+ self.pending_request = Some(PendingRequest {
1421
+ session: self.directory_session,
1422
+ target: RequestTarget::Card(card_id.clone()),
1423
+ });
1424
+ self.directory.set_status("Opening…");
1425
+ emit(json!({ "protocol": 2, "type": "activate", "cardId": card_id }));
1426
+ self.leave_keyboard_mode(widgets, root);
1427
+ }
1428
+ None => {}
1429
+ }
1430
+ }
1431
+ DirectoryKey::Escape => self.leave_keyboard_mode(widgets, root),
1432
+ }
1433
+ }
1434
+
1435
+ /// Whether a result answers exactly what the open list asked for. Anything else (a card's own
1436
+ /// activation, an earlier jump) leaves the pending request in place.
1437
+ fn take_own_request(&mut self, target: &RequestTarget) -> bool {
1438
+ if self.directory_open
1439
+ && answers_request(
1440
+ self.pending_request.as_ref(),
1441
+ self.directory_session,
1442
+ target,
1443
+ )
1444
+ {
1445
+ self.pending_request = None;
1446
+ true
1447
+ } else {
1448
+ false
1449
+ }
1450
+ }
1451
+
1452
+ fn refresh_directory_status(&self) {
1453
+ if self.directory_open {
1454
+ self.directory.set_prompt(
1455
+ &self.windows,
1456
+ &self.query,
1457
+ self.owns_keyboard(),
1458
+ self.allow_unlisted(),
1459
+ self.hotkey.as_deref(),
1460
+ );
1461
+ }
1462
+ }
1463
+
1464
+ fn render_directory(&self) {
1465
+ if self.directory_open {
1466
+ self.directory.render(
1467
+ &self.windows,
1468
+ &self.query,
1469
+ self.owns_keyboard(),
1470
+ self.allow_unlisted(),
1471
+ self.hotkey.as_deref(),
1472
+ );
1473
+ }
1474
+ }
1475
+
1476
+ fn allow_unlisted(&self) -> bool {
1477
+ self.keyboard_active
1478
+ }
1479
+
1480
+ fn open_directory(&mut self, widgets: &AppWidgets, root: &gtk::Window) {
1481
+ if self.directory_open || !self.visible || !self.interactive {
1482
+ return;
1483
+ }
1484
+ self.directory_open = true;
1485
+ self.directory_session += 1;
1486
+ self.query.clear();
1487
+ self.render_directory();
1488
+ self.sync_key_capture();
1489
+ self.resize(root);
1490
+ self.sync_directory_layout(widgets);
1491
+ }
1492
+
1493
+ /// After the list moved focus somewhere, nothing on the ribbon may keep the keyboard: the list
1494
+ /// closes, and keyboard mode ends exactly as activating a card ends it.
1495
+ fn finish_directory_jump(&mut self, widgets: &mut AppWidgets, root: &gtk::Window) {
1496
+ if self.keyboard_active {
1497
+ self.leave_keyboard_mode(widgets, root);
1498
+ } else {
1499
+ self.dismiss_directory(widgets, root);
1500
+ }
1501
+ }
1502
+
1503
+ /// Close the list and let the ribbon fall back to compact unless a card is still under the
1504
+ /// pointer: the card kept open while the pointer crossed to the list has done its job.
1505
+ fn dismiss_directory(&mut self, widgets: &mut AppWidgets, root: &gtk::Window) {
1506
+ self.close_directory(widgets, root);
1507
+ if self.hovered.is_none() && !self.keyboard_active {
1508
+ self.apply_expansion(widgets, root, None);
1509
+ }
1510
+ }
1511
+
1512
+ /// The one way out of keyboard mode. The list closes with it: once the ribbon gives up the
1513
+ /// keyboard nothing could type into or dismiss a list left open.
1514
+ fn leave_keyboard_mode(&mut self, widgets: &mut AppWidgets, root: &gtk::Window) {
1515
+ let was_keyboard_active = self.owns_keyboard();
1516
+ self.close_directory(widgets, root);
1517
+ self.end_engagement();
631
1518
  self.refresh_cards();
632
- emit(
633
- json!({ "protocol": 1, "type": "expanded", "expanded": expanded, "cardId": self.open_card_id }),
634
- );
1519
+ root.set_keyboard_mode(KeyboardMode::None);
1520
+ GtkWindowExt::set_focus(root, None::<&gtk::Widget>);
1521
+ // If GTK still owns focus, acknowledge release only on WindowActive(false), after Niri
1522
+ // applies None. Otherwise the controller could mistake the in-flight NULL for lost identity.
1523
+ if was_keyboard_active && !root.is_active() {
1524
+ emit(
1525
+ json!({ "protocol": 2, "type": "keyboard-active", "active": false, "revision": self.keyboard_request }),
1526
+ );
1527
+ }
1528
+ self.apply_expansion(widgets, root, None);
1529
+ self.resize(root);
1530
+ self.sync_directory_layout(widgets);
1531
+ self.update_brand(widgets);
1532
+ self.flush_deferred_scroll(widgets);
1533
+ }
1534
+
1535
+ /// Give the keyboard back without ending what the pointer is doing. An idle or interrupted
1536
+ /// hover grab must not collapse the card still under a stationary pointer, nor move the row:
1537
+ /// only the typing state goes, and the list's prompt says how to take the keyboard again.
1538
+ /// Explicit keyboard mode has no partial release; it leaves through `leave_keyboard_mode`.
1539
+ fn release_keyboard(&mut self, widgets: &mut AppWidgets, root: &gtk::Window) {
1540
+ if self.keyboard_active {
1541
+ self.leave_keyboard_mode(widgets, root);
1542
+ return;
1543
+ }
1544
+ let was_owning = self.owns_keyboard();
1545
+ self.cancel_pending_hover();
1546
+ self.hover_keyboard.clear();
1547
+ self.query.clear();
1548
+ self.sync_key_capture();
1549
+ root.set_keyboard_mode(KeyboardMode::None);
1550
+ GtkWindowExt::set_focus(root, None::<&gtk::Widget>);
1551
+ if was_owning && !root.is_active() {
1552
+ emit(
1553
+ json!({ "protocol": 2, "type": "keyboard-active", "active": false, "revision": self.keyboard_request }),
1554
+ );
1555
+ }
1556
+ self.render_directory();
1557
+ self.resize(root);
1558
+ self.sync_directory_layout(widgets);
1559
+ self.update_brand(widgets);
1560
+ }
1561
+
1562
+ fn flush_deferred_scroll(&mut self, widgets: &AppWidgets) {
1563
+ if self.scroll_deferred && !self.pointer.contains_pointer() && !self.owns_keyboard() {
1564
+ self.scroll_deferred = false;
1565
+ self.scroll_focused_into_view(widgets);
1566
+ }
1567
+ }
1568
+
1569
+ fn close_directory(&mut self, widgets: &AppWidgets, root: &gtk::Window) {
1570
+ if !self.directory_open {
1571
+ return;
1572
+ }
1573
+ self.directory_open = false;
1574
+ self.pending_request = None;
1575
+ self.query.clear();
1576
+ self.sync_key_capture();
1577
+ self.resize(root);
1578
+ self.sync_directory_layout(widgets);
635
1579
  }
636
1580
  }
637
1581
 
1582
+ #[cfg(test)]
1583
+ #[path = "app_gui_tests.rs"]
1584
+ mod gui_tests;
1585
+
638
1586
  #[cfg(test)]
639
1587
  mod tests {
640
1588
  use super::{
641
- BrandCounts, brand_text, engaged_card_id, move_order_item, navigation_target, short_path,
1589
+ BrandCounts, DirectoryKey, KeyCapture, PendingRequest, RequestTarget, answers_request,
1590
+ brand_text, directory_key, engaged_card_id, move_order_item, navigation_target,
1591
+ scroll_to_show, short_path,
642
1592
  };
643
1593
  use crate::protocol::Card;
644
- use std::collections::HashSet;
1594
+ use relm4::gtk::gdk;
645
1595
 
646
1596
  fn counts() -> BrandCounts {
647
1597
  BrandCounts {
@@ -650,6 +1600,113 @@ mod tests {
650
1600
  }
651
1601
  }
652
1602
 
1603
+ #[test]
1604
+ fn the_focused_card_scrolls_into_view_with_the_least_movement() {
1605
+ // Row viewport 0..1000.
1606
+ assert_eq!(
1607
+ scroll_to_show(0.0, 1000.0, 200.0, 440.0),
1608
+ 0.0,
1609
+ "already visible"
1610
+ );
1611
+ assert_eq!(
1612
+ scroll_to_show(0.0, 1000.0, 1500.0, 1740.0),
1613
+ 740.0,
1614
+ "right of view"
1615
+ );
1616
+ assert_eq!(
1617
+ scroll_to_show(800.0, 1000.0, 300.0, 540.0),
1618
+ 300.0,
1619
+ "left of view"
1620
+ );
1621
+ assert_eq!(
1622
+ scroll_to_show(0.0, 200.0, 500.0, 740.0),
1623
+ 500.0,
1624
+ "wider than the view: its start wins"
1625
+ );
1626
+ }
1627
+
1628
+ #[test]
1629
+ fn only_the_answer_to_what_the_open_list_asked_acts_on_it() {
1630
+ let pending = PendingRequest {
1631
+ session: 3,
1632
+ target: RequestTarget::Window(43),
1633
+ };
1634
+ assert!(answers_request(
1635
+ Some(&pending),
1636
+ 3,
1637
+ &RequestTarget::Window(43)
1638
+ ));
1639
+ assert!(
1640
+ !answers_request(Some(&pending), 3, &RequestTarget::Window(44)),
1641
+ "an earlier jump to another number"
1642
+ );
1643
+ assert!(
1644
+ !answers_request(Some(&pending), 3, &RequestTarget::Card("card-a".into())),
1645
+ "a card's own activation"
1646
+ );
1647
+ assert!(
1648
+ !answers_request(Some(&pending), 4, &RequestTarget::Window(43)),
1649
+ "a list opened again since"
1650
+ );
1651
+ assert!(!answers_request(None, 3, &RequestTarget::Window(43)));
1652
+ }
1653
+
1654
+ #[test]
1655
+ fn only_ctrl_alt_super_make_a_chord_and_bare_modifiers_are_not_typing() {
1656
+ assert!(super::is_typing_modifier(gdk::Key::Shift_L));
1657
+ assert!(super::is_typing_modifier(gdk::Key::Shift_R));
1658
+ assert!(!super::is_typing_modifier(gdk::Key::Control_L));
1659
+ assert!(super::is_chord(gdk::ModifierType::CONTROL_MASK));
1660
+ assert!(super::is_chord(gdk::ModifierType::SUPER_MASK));
1661
+ assert!(
1662
+ !super::is_chord(gdk::ModifierType::SHIFT_MASK),
1663
+ "Shift types capitals"
1664
+ );
1665
+ }
1666
+
1667
+ #[test]
1668
+ fn the_window_list_takes_keys_only_while_they_belong_to_it() {
1669
+ let key = |capture, key| format!("{:?}", directory_key(capture, key, false));
1670
+ assert_eq!(
1671
+ format!("{:?}", directory_key(KeyCapture::List, gdk::Key::c, true)),
1672
+ "None",
1673
+ "Ctrl+C is a shortcut, not a search for c"
1674
+ );
1675
+ assert_eq!(key(KeyCapture::None, gdk::Key::_4), "None");
1676
+ // Keyboard mode on the cards: a digit opens the list, Enter and Escape stay with the cards.
1677
+ assert_eq!(key(KeyCapture::Digits, gdk::Key::_4), "Some(Char('4'))");
1678
+ assert_eq!(
1679
+ key(KeyCapture::Digits, gdk::Key::e),
1680
+ "Some(Char('e'))",
1681
+ "a letter starts a name search"
1682
+ );
1683
+ assert_eq!(key(KeyCapture::Digits, gdk::Key::Return), "None");
1684
+ assert_eq!(key(KeyCapture::Digits, gdk::Key::Escape), "None");
1685
+ assert_eq!(key(KeyCapture::List, gdk::Key::KP_7), "Some(Char('7'))");
1686
+ assert_eq!(key(KeyCapture::List, gdk::Key::minus), "Some(Char('-'))");
1687
+ assert_eq!(
1688
+ key(KeyCapture::List, gdk::Key::space),
1689
+ "None",
1690
+ "space is never taken"
1691
+ );
1692
+ assert_eq!(
1693
+ key(KeyCapture::List, gdk::Key::Return),
1694
+ "None",
1695
+ "with nothing typed, Enter still reaches the focused card"
1696
+ );
1697
+ assert_eq!(key(KeyCapture::ListTyped, gdk::Key::Return), "Some(Enter)");
1698
+ assert_eq!(
1699
+ key(KeyCapture::List, gdk::Key::BackSpace),
1700
+ "Some(Backspace)"
1701
+ );
1702
+ assert_eq!(key(KeyCapture::List, gdk::Key::Escape), "Some(Escape)");
1703
+ assert_eq!(key(KeyCapture::None, gdk::Key::a), "None");
1704
+ assert!(matches!(
1705
+ directory_key(KeyCapture::ListTyped, gdk::Key::KP_Enter, false),
1706
+ Some(DirectoryKey::Enter)
1707
+ ));
1708
+ }
1709
+
653
1710
  #[test]
654
1711
  fn brand_names_the_focused_windows_project_and_folder() {
655
1712
  let card: Card = serde_json::from_value(serde_json::json!({
@@ -693,13 +1750,13 @@ mod tests {
693
1750
  #[test]
694
1751
  fn stale_gtk_focus_does_not_retain_expansion_outside_keyboard_mode() {
695
1752
  let focused = Some("card-a".to_owned());
696
- assert_eq!(engaged_card_id(&HashSet::new(), false, &focused), None);
697
- assert_eq!(engaged_card_id(&HashSet::new(), true, &focused), focused);
1753
+ assert_eq!(engaged_card_id(&None, false, &focused), None);
1754
+ assert_eq!(engaged_card_id(&None, true, &focused), focused);
698
1755
  }
699
1756
 
700
1757
  #[test]
701
1758
  fn pointer_hover_retains_expansion_without_keyboard_mode() {
702
- let hovered = HashSet::from(["card-a".to_owned()]);
1759
+ let hovered = Some("card-a".to_owned());
703
1760
  assert_eq!(
704
1761
  engaged_card_id(&hovered, false, &Some("card-b".to_owned())),
705
1762
  Some("card-a".to_owned())
@@ -708,7 +1765,7 @@ mod tests {
708
1765
 
709
1766
  #[test]
710
1767
  fn explicit_keyboard_mode_takes_precedence_over_pointer_hover() {
711
- let hovered = HashSet::from(["card-a".to_owned()]);
1768
+ let hovered = Some("card-a".to_owned());
712
1769
  assert_eq!(
713
1770
  engaged_card_id(&hovered, true, &Some("card-b".to_owned())),
714
1771
  Some("card-b".to_owned())