@tryinget/pi-activity-strip 0.9.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 (57) hide show
  1. package/README.md +59 -22
  2. package/bin/pi-activity-strip.mjs +32 -4
  3. package/native/bin/linux-x64-gnu/artifact.json +3 -2
  4. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  5. package/native/panel/src/app.rs +572 -266
  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 +79 -17
  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 +4 -0
  17. package/native/panel/src/protocol.rs +22 -8
  18. package/native/panel/src/runtime.rs +43 -2
  19. package/package.json +5 -5
  20. package/src/broker/server.mjs +31 -14
  21. package/src/client/launcher.mjs +3 -7
  22. package/src/client/runtime-lock.mjs +2 -1
  23. package/src/common/agent-identity.mjs +6 -1
  24. package/src/common/contracts.d.ts +1 -0
  25. package/src/common/contracts.ts +38 -0
  26. package/src/common/keyboard-observation.mjs +35 -0
  27. package/src/common/overlay-readiness.mjs +11 -0
  28. package/src/common/presence-apps.mjs +57 -0
  29. package/src/common/recovery-identity.mjs +85 -0
  30. package/src/common/recovery-manifest.mjs +417 -0
  31. package/src/common/recovery-types.ts +75 -0
  32. package/src/common/ribbon-status.mjs +41 -0
  33. package/src/common/status-report.mjs +37 -2
  34. package/src/common/workspace-view.mjs +54 -0
  35. package/src/native/ak-runtime.mjs +50 -9
  36. package/src/native/focus-visibility.mjs +67 -0
  37. package/src/native/ghostty-tab-inventory.py +40 -23
  38. package/src/native/hover-keyboard.mjs +32 -0
  39. package/src/native/interaction-context.mjs +45 -0
  40. package/src/native/keyboard-entry.mjs +32 -0
  41. package/src/native/main.mjs +77 -71
  42. package/src/native/panel-binary.mjs +25 -8
  43. package/src/native/panel-neutral-source.mjs +53 -0
  44. package/src/native/panel-presentation.mjs +137 -0
  45. package/src/native/panel-projection.mjs +338 -24
  46. package/src/native/panel-protocol.mjs +23 -0
  47. package/src/native/panel-window-jump.mjs +90 -0
  48. package/src/native/placement.mjs +83 -20
  49. package/src/native/recovery-inventory.mjs +209 -0
  50. package/src/native/recovery-proc.mjs +178 -0
  51. package/src/native/recovery-sources.mjs +358 -0
  52. package/src/native/runtime-diagnostics.mjs +242 -0
  53. package/src/native/strip-hotkey-kdl.mjs +252 -0
  54. package/src/native/strip-hotkey.mjs +62 -0
  55. package/src/native/tab-inventory.mjs +62 -11
  56. package/src/native/window-jump.mjs +43 -0
  57. package/src/native/workspace-events.mjs +123 -2
@@ -2,6 +2,12 @@ use crate::card_view::CardView;
2
2
  use crate::directory_view::{
3
3
  DirectoryView, JumpChoice, is_query_char, jump_choice, push_query_char,
4
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
+ };
5
11
  use crate::protocol::{Card, ViewMessage, WindowEntry, demo_view, emit, emit_error, emit_ready};
6
12
  use crate::runtime::{apply_theme, duplicate_labels, install_css, now_ms, start_input_reader};
7
13
  use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
@@ -12,7 +18,7 @@ use relm4::gtk::prelude::*;
12
18
  use relm4::{Component, ComponentParts, ComponentSender};
13
19
  use serde_json::json;
14
20
  use std::cell::Cell;
15
- use std::collections::{HashMap, HashSet};
21
+ use std::collections::HashMap;
16
22
  use std::rc::Rc;
17
23
  use std::time::Duration;
18
24
 
@@ -20,16 +26,8 @@ const COMPACT_HEIGHT: i32 = 84;
20
26
  /// The ribbon sits on the same rhythm as tiled windows, so it reads as one of them rather than as
21
27
  /// a bar bolted to the top edge. Matches `gaps` in the compositor's layout.
22
28
  const OUTER_MARGIN: i32 = 8;
23
- const EXPANDED_HEIGHT: i32 = 276;
24
29
  const ORDER_REFRESH_MS: i64 = 15_000;
25
30
  const BRAND_WIDTH: i32 = 196;
26
- /// Grace before a hovered-away window list closes, so crossing the brand's own padding does not
27
- /// close and reopen it.
28
- const DIRECTORY_CLOSE_MS: u64 = 200;
29
- /// Hovering the ribbon takes the keyboard so a window number can be typed at once. Without a digit
30
- /// within this time it is handed back, so a pointer resting on the ribbon cannot keep swallowing
31
- /// what is typed into the window below; a click on the ribbon still takes it again.
32
- const HOVER_GRAB_MS: u64 = 3_000;
33
31
 
34
32
  pub struct AppInit {
35
33
  pub demo: bool,
@@ -41,9 +39,21 @@ pub struct App {
41
39
  data: HashMap<String, Card>,
42
40
  order: Vec<String>,
43
41
  focused_card_id: Option<String>,
44
- 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,
45
49
  keyboard_focused: Option<String>,
50
+ /// Explicit/manual keyboard mode; hover must NOT inherit its arrow or unlisted-window powers.
46
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,
47
57
  open_card_id: Option<String>,
48
58
  revision: u64,
49
59
  visible: bool,
@@ -60,29 +70,18 @@ pub struct App {
60
70
  /// Counts brand hover changes, so a leave only collapses if the pointer did not come back
61
71
  /// (or land on a card) within the collapse grace.
62
72
  brand_generation: u64,
63
- directory_generation: u64,
64
- /// Opened by hovering the brand, so leaving it closes the list; a list opened by a typed
65
- /// digit in keyboard mode stays open until it jumps or is dismissed.
66
- directory_opened_by_hover: bool,
67
73
  /// Counts list openings, so a jump or activation result only acts on the list that asked.
68
74
  directory_session: u64,
69
75
  pending_request: Option<PendingRequest>,
70
- /// The list changed the keyboard mode and must set it back when it closes.
71
- directory_owns_keyboard: bool,
72
- /// The keyboard was taken on hover and is still held. Released when the pointer leaves, and
73
- /// after HOVER_GRAB_MS without a key while nothing is typed.
74
- hover_grab: bool,
75
- /// Counts grab timers, so only the latest one (re-armed on every key) can release the grab.
76
- grab_generation: u64,
77
- /// Whether the ribbon has keyboard focus right now: after a click on the list, or in
78
- /// keyboard mode. Hovering alone never takes the keyboard from the window being typed into.
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.
79
81
  window_active: bool,
80
82
  query: String,
81
83
  /// Read by the window's key controller: which keys belong to the window list right now.
82
84
  key_capture: Rc<Cell<KeyCapture>>,
83
- /// Read by the key controller: the keyboard is held only because of hover, so any key that is
84
- /// not part of a search means the typing was meant for the window below.
85
- hover_guard: Rc<Cell<bool>>,
86
85
  }
87
86
 
88
87
  /// Keys the window list takes before any card sees them.
@@ -119,8 +118,6 @@ pub enum DirectoryKey {
119
118
  Backspace,
120
119
  Enter,
121
120
  Escape,
122
- /// A key that cannot be part of a search, pressed while only hover holds the keyboard.
123
- Interrupt,
124
121
  }
125
122
 
126
123
  #[derive(Debug)]
@@ -133,7 +130,11 @@ pub enum AppMsg {
133
130
  Navigate(String, i32, bool),
134
131
  Activate(String),
135
132
  ActivationResult(String, bool, String),
136
- FocusStrip,
133
+ FocusStrip(u64, u64),
134
+ HoverGrant(u64, u64, u64),
135
+ HoverDenied(u64),
136
+ HoverInterrupt,
137
+ PointerIntent,
137
138
  Collapse,
138
139
  CollapseIf(u64),
139
140
  WindowActive(bool),
@@ -142,8 +143,6 @@ pub enum AppMsg {
142
143
  DirectoryHover(bool),
143
144
  BrandHover(bool),
144
145
  BrandSettle(u64),
145
- DirectoryCloseIf(u64),
146
- DirectoryGrabExpired(u64),
147
146
  DirectoryKey(DirectoryKey),
148
147
  DirectoryActivate(String),
149
148
  JumpResult(i64, bool, String),
@@ -251,7 +250,7 @@ impl Component for App {
251
250
  root.set_margin(Edge::Top, OUTER_MARGIN);
252
251
  root.set_margin(Edge::Left, OUTER_MARGIN);
253
252
  root.set_margin(Edge::Right, OUTER_MARGIN);
254
- root.set_exclusive_zone(COMPACT_HEIGHT);
253
+ root.set_exclusive_zone(-1);
255
254
  root.set_keyboard_mode(KeyboardMode::None);
256
255
  if init.click_through {
257
256
  root.connect_realize(|window| {
@@ -268,15 +267,23 @@ impl Component for App {
268
267
 
269
268
  let directory = DirectoryView::new(sender.input_sender());
270
269
  let key_capture = Rc::new(Cell::new(KeyCapture::None));
271
- let hover_guard = Rc::new(Cell::new(false));
270
+ let hover_capture = Rc::new(Cell::new(false));
271
+ let hover = gtk::EventControllerMotion::new();
272
+ let brand_hover = gtk::EventControllerMotion::new();
272
273
  let model = App {
273
274
  cards: HashMap::new(),
274
275
  data: HashMap::new(),
275
276
  order: Vec::new(),
276
277
  focused_card_id: None,
277
- hovered: HashSet::new(),
278
+ hovered: None,
279
+ hotkey: None,
280
+ scroll_deferred: false,
278
281
  keyboard_focused: None,
279
282
  keyboard_active: false,
283
+ hover_keyboard: HoverKeyboard::default(),
284
+ pointer_anchor: None,
285
+ hover_capture: Rc::clone(&hover_capture),
286
+ interaction_epoch: 0,
280
287
  open_card_id: None,
281
288
  revision: 0,
282
289
  visible: false,
@@ -289,24 +296,32 @@ impl Component for App {
289
296
  directory_hovered: false,
290
297
  brand_hovered: false,
291
298
  brand_generation: 0,
292
- directory_generation: 0,
293
- directory_opened_by_hover: false,
294
299
  directory_session: 0,
295
300
  pending_request: None,
296
- directory_owns_keyboard: false,
297
- hover_grab: false,
298
- grab_generation: 0,
301
+ lifetime: InteractionLifetime::default(),
302
+ pointer: hover.clone(),
303
+ brand_pointer: brand_hover.clone(),
304
+ keyboard_request: 0,
299
305
  window_active: false,
300
306
  query: String::new(),
301
307
  key_capture: Rc::clone(&key_capture),
302
- hover_guard: Rc::clone(&hover_guard),
303
308
  };
304
309
  let widgets = view_output!();
305
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);
306
321
  widgets.brand.append(&model.directory.root);
307
322
 
308
- // Hovering anywhere on the ribbon opens the window list in the brand column.
309
- let hover = gtk::EventControllerMotion::new();
323
+ // The body is ONE region (including brand, cards and directory). Entry requests controller
324
+ // permission; child crossings do not drop/regrab ownership.
310
325
  let tx = sender.input_sender().clone();
311
326
  hover.connect_enter(move |_, _, _| {
312
327
  let _ = tx.send(AppMsg::DirectoryHover(true));
@@ -316,8 +331,14 @@ impl Component for App {
316
331
  let _ = tx.send(AppMsg::DirectoryHover(false));
317
332
  });
318
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);
319
341
 
320
- let brand_hover = gtk::EventControllerMotion::new();
321
342
  let tx = sender.input_sender().clone();
322
343
  brand_hover.connect_enter(move |_, _, _| {
323
344
  let _ = tx.send(AppMsg::BrandHover(true));
@@ -337,8 +358,15 @@ impl Component for App {
337
358
  let chord = is_chord(modifiers);
338
359
  let message = match directory_key(key_capture.get(), key, chord) {
339
360
  Some(message) => message,
340
- None if hover_guard.get() && !is_modifier_key(key) => DirectoryKey::Interrupt,
341
- None => return glib::Propagation::Proceed,
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
+ }
342
370
  };
343
371
  let _ = tx.send(AppMsg::DirectoryKey(message));
344
372
  glib::Propagation::Stop
@@ -346,10 +374,12 @@ impl Component for App {
346
374
  widgets.root.add_controller(keys);
347
375
 
348
376
  let tick_tx = sender.input_sender().clone();
349
- glib::timeout_add_seconds_local(1, move || {
377
+ let _tick_source = glib::timeout_add_seconds_local(1, move || {
350
378
  let _ = tick_tx.send(AppMsg::Tick);
351
379
  glib::ControlFlow::Continue
352
380
  });
381
+ #[cfg(test)]
382
+ gui_tests::configure_tick_source(_tick_source);
353
383
 
354
384
  if init.demo {
355
385
  let _ = sender.input_sender().send(AppMsg::View(demo_view()));
@@ -367,18 +397,54 @@ impl Component for App {
367
397
  sender: ComponentSender<Self>,
368
398
  root: &Self::Root,
369
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
+ }
370
417
  match message {
371
418
  AppMsg::View(view) => self.apply_view(widgets, root, &sender, view),
372
419
  AppMsg::Theme(definitions) => apply_theme(&definitions),
373
420
  AppMsg::Tick => {
374
- 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
+ {
375
441
  let focused_at = self.focused_position();
376
442
  self.regroup();
377
443
  self.reorder_widgets(&widgets.cards_box);
378
444
  // Only when regrouping moved the focused card: a row the operator scrolled
379
445
  // by hand stays where they left it.
380
446
  if self.focused_position() != focused_at {
381
- self.scroll_focused_into_view(widgets);
447
+ self.follow_focus(widgets);
382
448
  }
383
449
  self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
384
450
  }
@@ -387,14 +453,27 @@ impl Component for App {
387
453
  }
388
454
  AppMsg::Hover(id, entered) => {
389
455
  if entered {
390
- self.hovered.insert(id);
391
- } else {
392
- 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;
393
469
  }
394
470
  self.reconcile_engagement(widgets, root, &sender);
395
471
  }
396
472
  AppMsg::Focus(id, entered) => {
397
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);
398
477
  self.keyboard_focused = Some(id);
399
478
  } else if !entered && self.keyboard_focused.as_deref() == Some(id.as_str()) {
400
479
  self.keyboard_focused = None;
@@ -407,14 +486,9 @@ impl Component for App {
407
486
  }
408
487
  }
409
488
  AppMsg::Activate(id) => {
410
- emit(json!({ "protocol": 1, "type": "activate", "cardId": id }));
411
- // Focus is moving to that card's window, so the ribbon must hold nothing: not
412
- // keyboard mode, and not the hover grab that clicking inside the ribbon implies.
413
- if self.keyboard_active {
414
- self.leave_keyboard_mode(widgets, root);
415
- } else {
416
- self.close_directory(widgets, root);
417
- }
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);
418
492
  }
419
493
  AppMsg::ActivationResult(id, ok, message) => {
420
494
  if let Some(card) = self.cards.get(&id) {
@@ -428,19 +502,18 @@ impl Component for App {
428
502
  }
429
503
  }
430
504
  }
431
- AppMsg::FocusStrip => {
432
- 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;
433
511
  if self.keyboard_active {
434
512
  self.leave_keyboard_mode(widgets, root);
435
513
  } else {
436
514
  self.keyboard_active = true;
437
- // Keyboard mode now holds the keyboard, whoever opened the list, and no
438
- // hover timeout may hand it back.
439
- self.directory_owns_keyboard = false;
440
- self.hover_grab = false;
441
515
  self.sync_key_capture();
442
- self.hovered.clear();
443
- self.keyboard_focused = self.order.first().cloned();
516
+ self.keyboard_focused = self.keyboard_entry_card(widgets);
444
517
  root.set_keyboard_mode(KeyboardMode::Exclusive);
445
518
  root.present();
446
519
  let focus_grabbed = self
@@ -450,37 +523,129 @@ impl Component for App {
450
523
  .is_some_and(|first| first.root.grab_focus());
451
524
  if focus_grabbed {
452
525
  self.reconcile_engagement(widgets, root, &sender);
526
+ self.update_brand(widgets);
527
+ self.observe_keyboard("acquired");
453
528
  emit(
454
- json!({ "protocol": 1, "type": "keyboard-active", "active": true }),
529
+ json!({ "protocol": 2, "type": "keyboard-active", "active": true, "revision": self.keyboard_request }),
455
530
  );
456
531
  } else {
457
532
  self.leave_keyboard_mode(widgets, root);
458
533
  }
459
534
  }
535
+ } else {
536
+ emit(
537
+ json!({ "protocol": 2, "type": "keyboard-active", "active": false, "revision": request }),
538
+ );
460
539
  }
461
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),
462
601
  AppMsg::Collapse => {
463
602
  self.leave_keyboard_mode(widgets, root);
464
603
  }
465
604
  AppMsg::WindowActive(active) => {
605
+ if active != root.is_active() {
606
+ return;
607
+ }
466
608
  self.window_active = active;
467
609
  if active {
468
- // Only the hint changes. Rebuilding the rows here would replace the very row
469
- // whose click just handed over the keyboard, and swallow that click.
470
- self.refresh_directory_status();
471
- } else if self.keyboard_active {
472
- // Keyboard mode lost the keyboard to another window: leave it entirely.
473
- self.leave_keyboard_mode(widgets, root);
474
- } else if !self.directory_hovered {
475
- // A click elsewhere dismisses a clicked-into list. Hover expansion of cards is
476
- // pointer business and is left alone.
477
- self.close_directory(widgets, root);
478
- } else {
479
- // Still hovered but no longer typing (the hover grab ran out): say so.
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);
623
+ }
624
+ }
480
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
+ );
481
636
  }
482
637
  }
483
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
+ }
484
649
  self.brand_hovered = entered;
485
650
  self.brand_generation += 1;
486
651
  // Every card opens (entered) or falls back to the hovered one (left) at once.
@@ -507,7 +672,7 @@ impl Component for App {
507
672
  }
508
673
  AppMsg::CollapseIf(generation) => {
509
674
  if generation == self.collapse_generation
510
- && self.hovered.is_empty()
675
+ && self.hovered.is_none()
511
676
  && !self.brand_hovered
512
677
  && (!self.keyboard_active || self.keyboard_focused.is_none())
513
678
  {
@@ -516,47 +681,43 @@ impl Component for App {
516
681
  }
517
682
  AppMsg::InputError(message) => emit_error(message),
518
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.
519
693
  self.directory_hovered = entered;
520
- self.directory_generation += 1;
521
694
  if entered {
522
- self.open_directory(widgets, root, true);
523
- // Also on re-entry within the close grace: the grab and its timer start over.
524
- if self.take_hover_grab(root) {
525
- self.arm_grab_timer(&sender);
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);
526
706
  }
527
707
  } else {
528
- // Hovering is the only reason the ribbon holds the keyboard: leaving returns it.
529
- self.release_hover_grab(root);
530
- let generation = self.directory_generation;
531
- let tx = sender.input_sender().clone();
532
- glib::timeout_add_local_once(
533
- Duration::from_millis(DIRECTORY_CLOSE_MS),
534
- move || {
535
- let _ = tx.send(AppMsg::DirectoryCloseIf(generation));
536
- },
537
- );
538
- }
539
- }
540
- AppMsg::DirectoryGrabExpired(generation) => {
541
- if generation == self.grab_generation && self.query.is_empty() {
542
- self.release_hover_grab(root);
543
- }
544
- }
545
- AppMsg::DirectoryCloseIf(generation) => {
546
- if generation == self.directory_generation
547
- && !self.directory_hovered
548
- && self.directory_opened_by_hover
549
- {
550
- self.close_directory(widgets, root);
708
+ self.pointer_anchor = None;
709
+ self.hover_keyboard.inside = false;
710
+ if !self.keyboard_active {
711
+ self.leave_keyboard_mode(widgets, root);
712
+ }
551
713
  }
552
714
  }
553
715
  AppMsg::DirectoryKey(key) => {
554
- // Every key keeps a hover grab alive for another HOVER_GRAB_MS; the grab is only
555
- // released idle and empty, never mid-number.
556
- if self.hover_grab {
557
- self.arm_grab_timer(&sender);
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);
558
720
  }
559
- self.handle_directory_key(widgets, root, key);
560
721
  }
561
722
  AppMsg::DirectoryActivate(card_id) => {
562
723
  self.pending_request = Some(PendingRequest {
@@ -564,7 +725,8 @@ impl Component for App {
564
725
  target: RequestTarget::Card(card_id.clone()),
565
726
  });
566
727
  self.directory.set_status("Opening…");
567
- emit(json!({ "protocol": 1, "type": "activate", "cardId": card_id }));
728
+ emit(json!({ "protocol": 2, "type": "activate", "cardId": card_id }));
729
+ self.leave_keyboard_mode(widgets, root);
568
730
  }
569
731
  AppMsg::JumpResult(window_id, ok, message) => {
570
732
  if self.take_own_request(&RequestTarget::Window(window_id)) {
@@ -598,6 +760,19 @@ fn answers_request(pending: Option<&PendingRequest>, session: u64, target: &Requ
598
760
  pending.is_some_and(|request| request.session == session && &request.target == target)
599
761
  }
600
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
+
601
776
  /// Ctrl, Alt, Super or Meta held: a shortcut, not typing. Shift is typing (capital letters).
602
777
  fn is_chord(modifiers: gdk::ModifierType) -> bool {
603
778
  modifiers.intersects(
@@ -608,26 +783,6 @@ fn is_chord(modifiers: gdk::ModifierType) -> bool {
608
783
  )
609
784
  }
610
785
 
611
- /// A modifier pressed on its own, on the way to a capital letter or a chord: neither search input
612
- /// nor a sign that the typing is meant elsewhere.
613
- fn is_modifier_key(key: gdk::Key) -> bool {
614
- matches!(
615
- key,
616
- gdk::Key::Shift_L
617
- | gdk::Key::Shift_R
618
- | gdk::Key::Control_L
619
- | gdk::Key::Control_R
620
- | gdk::Key::Alt_L
621
- | gdk::Key::Alt_R
622
- | gdk::Key::Super_L
623
- | gdk::Key::Super_R
624
- | gdk::Key::Meta_L
625
- | gdk::Key::Meta_R
626
- | gdk::Key::ISO_Level3_Shift
627
- | gdk::Key::Caps_Lock
628
- )
629
- }
630
-
631
786
  /// The window-list key a press means under the current capture, if it belongs to the list at all.
632
787
  fn directory_key(capture: KeyCapture, key: gdk::Key, chord: bool) -> Option<DirectoryKey> {
633
788
  // Ctrl, Alt and Super combinations are shortcuts, never search characters.
@@ -715,28 +870,86 @@ fn short_path(path: &str, home: &str) -> String {
715
870
  }
716
871
 
717
872
  fn engaged_card_id(
718
- hovered: &HashSet<String>,
873
+ hovered: &Option<String>,
719
874
  keyboard_active: bool,
720
875
  keyboard_focused: &Option<String>,
721
876
  ) -> Option<String> {
722
877
  if keyboard_active {
723
878
  keyboard_focused.clone()
724
879
  } else {
725
- hovered.iter().next().cloned()
880
+ hovered.clone()
726
881
  }
727
882
  }
728
883
 
729
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
+
730
938
  fn end_engagement(&mut self) {
731
- let was_keyboard_active = self.keyboard_active;
732
- self.hovered.clear();
939
+ self.hovered = None;
940
+ self.brand_hovered = false;
941
+ self.directory_hovered = false;
733
942
  self.keyboard_focused = None;
734
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();
735
949
  self.sync_key_capture();
736
950
  self.collapse_generation += 1;
737
- if was_keyboard_active {
738
- emit(json!({ "protocol": 1, "type": "keyboard-active", "active": false }));
739
- }
951
+ self.brand_generation += 1;
952
+ self.lifetime.invalidate();
740
953
  }
741
954
 
742
955
  fn apply_view(
@@ -746,11 +959,40 @@ impl App {
746
959
  sender: &ComponentSender<Self>,
747
960
  view: ViewMessage,
748
961
  ) {
749
- 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 {
750
963
  return;
751
964
  }
752
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;
753
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;
754
996
  self.focused_card_id = view.focused_card_id;
755
997
  if self.windows != view.windows {
756
998
  self.windows = view.windows;
@@ -772,7 +1014,13 @@ impl App {
772
1014
  self.order.push(id);
773
1015
  }
774
1016
  }
775
- 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
+ }
776
1024
  let keyboard_card_removed = self.keyboard_active
777
1025
  && self
778
1026
  .keyboard_focused
@@ -809,22 +1057,19 @@ impl App {
809
1057
  self.refresh_cards();
810
1058
  self.update_brand(widgets);
811
1059
  if focus_moved {
812
- self.scroll_focused_into_view(widgets);
1060
+ self.follow_focus(widgets);
813
1061
  }
814
1062
 
815
- let should_show = view.visible && !self.data.is_empty();
816
- if should_show != self.visible {
817
- self.visible = should_show;
1063
+ if visibility_changed {
818
1064
  if should_show {
819
1065
  root.set_exclusive_zone(COMPACT_HEIGHT);
820
1066
  root.present();
821
1067
  } else {
822
- self.leave_keyboard_mode(widgets, root);
823
1068
  root.set_exclusive_zone(-1);
824
1069
  root.set_visible(false);
825
1070
  }
826
1071
  emit(
827
- 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 }),
828
1073
  );
829
1074
  }
830
1075
  }
@@ -855,6 +1100,7 @@ impl App {
855
1100
  let now = now_ms();
856
1101
  // Hovering the brand column is an overview: every card shows its details at once.
857
1102
  let all_open = self.brand_hovered && self.expanded();
1103
+ let key_hint = hints::card_hint(self.hotkey.as_deref());
858
1104
  for (id, view) in &self.cards {
859
1105
  if let Some(card) = self.data.get(id) {
860
1106
  view.update(
@@ -862,6 +1108,7 @@ impl App {
862
1108
  self.focused_card_id.as_deref() == Some(id),
863
1109
  duplicates.contains(&card.repo_label),
864
1110
  now,
1111
+ key_hint.as_deref(),
865
1112
  );
866
1113
  view.set_expanded(all_open || self.open_card_id.as_deref() == Some(id));
867
1114
  }
@@ -880,14 +1127,22 @@ impl App {
880
1127
  .and_then(|id| self.data.get(id));
881
1128
  let home = std::env::var("HOME").unwrap_or_default();
882
1129
  let brand = brand_text(focused, &counts, &home);
883
- widgets.eyebrow.set_text(&brand.eyebrow);
884
1130
  widgets.title.set_text(&brand.title);
885
- widgets.meta.set_text(&brand.meta);
886
- widgets.brand.set_tooltip_text(
887
- focused
888
- .map(|card| card.cwd.as_str())
889
- .filter(|cwd| !cwd.is_empty()),
890
- );
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
+ )));
891
1146
  }
892
1147
 
893
1148
  fn regroup(&mut self) {
@@ -929,7 +1184,7 @@ impl App {
929
1184
  move_order_item(&mut self.order, index, target);
930
1185
  self.reorder_widgets(parent);
931
1186
  self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
932
- emit(json!({ "protocol": 1, "type": "moved", "cardId": id, "direction": direction }));
1187
+ emit(json!({ "protocol": 2, "type": "moved", "cardId": id, "direction": direction }));
933
1188
  }
934
1189
  let focus_id = if manual { id } else { &self.order[target] };
935
1190
  if let Some(card) = self.cards.get(focus_id) {
@@ -943,6 +1198,9 @@ impl App {
943
1198
  root: &gtk::Window,
944
1199
  sender: &ComponentSender<Self>,
945
1200
  ) {
1201
+ if !self.visible || !self.interactive {
1202
+ return;
1203
+ }
946
1204
  if let Some(id) =
947
1205
  engaged_card_id(&self.hovered, self.keyboard_active, &self.keyboard_focused)
948
1206
  {
@@ -967,7 +1225,11 @@ impl App {
967
1225
  if self.open_card_id == card_id {
968
1226
  return;
969
1227
  }
970
- self.open_card_id = card_id;
1228
+ self.open_card_id = if self.visible && self.interactive {
1229
+ card_id
1230
+ } else {
1231
+ None
1232
+ };
971
1233
  self.refresh_cards();
972
1234
  self.resize(root);
973
1235
  self.sync_directory_layout(widgets);
@@ -976,11 +1238,62 @@ impl App {
976
1238
  /// Scroll the card row so the focused window's card is fully in view, as Niri focus moves
977
1239
  /// between windows. Runs once layout has placed the card, so its position in the row is known.
978
1240
  fn scroll_focused_into_view(&self, widgets: &AppWidgets) {
979
- let Some(card) = self
980
- .focused_card_id
981
- .as_ref()
982
- .and_then(|id| self.cards.get(id))
983
- else {
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 {
984
1297
  return;
985
1298
  };
986
1299
  let row = widgets.cards_box.clone();
@@ -989,6 +1302,9 @@ impl App {
989
1302
  // card's position is final, and a panel that is not shown yet simply waits until it is.
990
1303
  let frames = Cell::new(0u8);
991
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
+ }
992
1308
  frames.set(frames.get() + 1);
993
1309
  if frames.get() < 2 {
994
1310
  return glib::ControlFlow::Continue;
@@ -1016,8 +1332,11 @@ impl App {
1016
1332
  /// The ribbon is tall while a card is open (hovered or in keyboard mode), while the pointer is
1017
1333
  /// on the brand column with the list open, or while a typed number needs the list shown.
1018
1334
  fn expanded(&self) -> bool {
1019
- self.open_card_id.is_some()
1020
- || (self.directory_open && (self.brand_hovered || !self.query.is_empty()))
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()))))
1021
1340
  }
1022
1341
 
1023
1342
  /// The list fills the brand column, top to bottom, whenever the ribbon is expanded; compact,
@@ -1034,33 +1353,30 @@ impl App {
1034
1353
 
1035
1354
  fn resize(&self, root: &gtk::Window) {
1036
1355
  let expanded = self.expanded();
1037
- root.set_default_size(
1038
- 1,
1039
- if expanded {
1040
- EXPANDED_HEIGHT
1041
- } else {
1042
- COMPACT_HEIGHT
1043
- },
1044
- );
1045
- root.set_exclusive_zone(COMPACT_HEIGHT);
1356
+ let (height, zone) = surface_state(self.visible, expanded);
1357
+ root.set_default_size(1, height);
1358
+ root.set_exclusive_zone(zone);
1046
1359
  emit(
1047
- json!({ "protocol": 1, "type": "expanded", "expanded": expanded, "cardId": self.open_card_id }),
1360
+ json!({ "protocol": 2, "type": "expanded", "expanded": expanded, "cardId": self.open_card_id, "queryLength": self.query.chars().count() }),
1048
1361
  );
1049
1362
  }
1050
1363
 
1051
1364
  fn sync_key_capture(&self) {
1052
- self.hover_guard
1053
- .set(self.directory_open && self.hover_grab && !self.keyboard_active);
1054
- self.key_capture
1055
- .set(if self.directory_open && !self.query.is_empty() {
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() {
1056
1371
  KeyCapture::ListTyped
1057
1372
  } else if self.directory_open {
1058
1373
  KeyCapture::List
1059
- } else if self.keyboard_active {
1374
+ } else if self.owns_keyboard() {
1060
1375
  KeyCapture::Digits
1061
1376
  } else {
1062
1377
  KeyCapture::None
1063
- });
1378
+ },
1379
+ );
1064
1380
  }
1065
1381
 
1066
1382
  fn handle_directory_key(
@@ -1071,7 +1387,7 @@ impl App {
1071
1387
  ) {
1072
1388
  match key {
1073
1389
  DirectoryKey::Char(digit) => {
1074
- self.open_directory(widgets, root, false);
1390
+ self.open_directory(widgets, root);
1075
1391
  if push_query_char(&mut self.query, digit) {
1076
1392
  self.render_directory();
1077
1393
  self.sync_key_capture();
@@ -1095,7 +1411,10 @@ impl App {
1095
1411
  });
1096
1412
  self.directory
1097
1413
  .set_status(&format!("Jumping to #{window_id}…"));
1098
- emit(json!({ "protocol": 1, "type": "jump", "windowId": 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);
1099
1418
  }
1100
1419
  Some(JumpChoice::Card(card_id)) => {
1101
1420
  self.pending_request = Some(PendingRequest {
@@ -1103,16 +1422,13 @@ impl App {
1103
1422
  target: RequestTarget::Card(card_id.clone()),
1104
1423
  });
1105
1424
  self.directory.set_status("Opening…");
1106
- emit(json!({ "protocol": 1, "type": "activate", "cardId": card_id }));
1425
+ emit(json!({ "protocol": 2, "type": "activate", "cardId": card_id }));
1426
+ self.leave_keyboard_mode(widgets, root);
1107
1427
  }
1108
- // Enter leading nowhere while only hover holds the keyboard: the typing was meant
1109
- // for the window below, so hand the keyboard back.
1110
- None if self.hover_guard.get() => self.cancel_hover_search(widgets, root),
1111
1428
  None => {}
1112
1429
  }
1113
1430
  }
1114
- DirectoryKey::Escape => self.dismiss_directory(widgets, root),
1115
- DirectoryKey::Interrupt => self.cancel_hover_search(widgets, root),
1431
+ DirectoryKey::Escape => self.leave_keyboard_mode(widgets, root),
1116
1432
  }
1117
1433
  }
1118
1434
 
@@ -1138,8 +1454,9 @@ impl App {
1138
1454
  self.directory.set_prompt(
1139
1455
  &self.windows,
1140
1456
  &self.query,
1141
- self.window_active || self.keyboard_active,
1457
+ self.owns_keyboard(),
1142
1458
  self.allow_unlisted(),
1459
+ self.hotkey.as_deref(),
1143
1460
  );
1144
1461
  }
1145
1462
  }
@@ -1149,79 +1466,24 @@ impl App {
1149
1466
  self.directory.render(
1150
1467
  &self.windows,
1151
1468
  &self.query,
1152
- self.window_active || self.keyboard_active,
1469
+ self.owns_keyboard(),
1153
1470
  self.allow_unlisted(),
1471
+ self.hotkey.as_deref(),
1154
1472
  );
1155
1473
  }
1156
1474
  }
1157
1475
 
1158
- /// Take the keyboard for a hover-opened list, so a number can be typed at once. Clears the
1159
- /// window's focus widget first: Enter with nothing typed must not press a card or row that
1160
- /// kept focus from an earlier keyboard mode or click.
1161
- fn take_hover_grab(&mut self, root: &gtk::Window) -> bool {
1162
- if !self.directory_open || !self.directory_opened_by_hover || self.keyboard_active {
1163
- return false;
1164
- }
1165
- if !self.hover_grab {
1166
- GtkWindowExt::set_focus(root, None::<&gtk::Widget>);
1167
- root.set_keyboard_mode(KeyboardMode::Exclusive);
1168
- self.hover_grab = true;
1169
- self.directory_owns_keyboard = true;
1170
- self.sync_key_capture();
1171
- }
1172
- true
1173
- }
1174
-
1175
- /// Typing held only by hover turned out to be meant for the window below: drop it, hand the
1176
- /// keyboard back and close the list, so keys still in flight find nothing to type into. The
1177
- /// list reopens when the pointer re-enters the ribbon.
1178
- fn cancel_hover_search(&mut self, widgets: &mut AppWidgets, root: &gtk::Window) {
1179
- self.query.clear();
1180
- self.release_hover_grab(root);
1181
- self.dismiss_directory(widgets, root);
1182
- }
1183
-
1184
- /// Unlisted window numbers are reachable only when the keyboard was given on purpose (a
1185
- /// click or keyboard mode), not merely held by hover.
1186
1476
  fn allow_unlisted(&self) -> bool {
1187
- !(self.hover_grab && !self.keyboard_active)
1188
- }
1189
-
1190
- fn release_hover_grab(&mut self, root: &gtk::Window) {
1191
- if self.hover_grab {
1192
- self.hover_grab = false;
1193
- self.grab_generation += 1;
1194
- if !self.keyboard_active {
1195
- root.set_keyboard_mode(KeyboardMode::OnDemand);
1196
- }
1197
- self.sync_key_capture();
1198
- self.refresh_directory_status();
1199
- }
1477
+ self.keyboard_active
1200
1478
  }
1201
1479
 
1202
- fn arm_grab_timer(&mut self, sender: &ComponentSender<Self>) {
1203
- self.grab_generation += 1;
1204
- let generation = self.grab_generation;
1205
- let tx = sender.input_sender().clone();
1206
- glib::timeout_add_local_once(Duration::from_millis(HOVER_GRAB_MS), move || {
1207
- let _ = tx.send(AppMsg::DirectoryGrabExpired(generation));
1208
- });
1209
- }
1210
-
1211
- fn open_directory(&mut self, widgets: &AppWidgets, root: &gtk::Window, by_hover: bool) {
1480
+ fn open_directory(&mut self, widgets: &AppWidgets, root: &gtk::Window) {
1212
1481
  if self.directory_open || !self.visible || !self.interactive {
1213
1482
  return;
1214
1483
  }
1215
1484
  self.directory_open = true;
1216
- self.directory_opened_by_hover = by_hover;
1217
1485
  self.directory_session += 1;
1218
1486
  self.query.clear();
1219
- // On-demand until a hover grab (take_hover_grab) upgrades it; a click can always take the
1220
- // keyboard. Keyboard mode already holds it.
1221
- if !self.keyboard_active {
1222
- root.set_keyboard_mode(KeyboardMode::OnDemand);
1223
- self.directory_owns_keyboard = true;
1224
- }
1225
1487
  self.render_directory();
1226
1488
  self.sync_key_capture();
1227
1489
  self.resize(root);
@@ -1242,7 +1504,7 @@ impl App {
1242
1504
  /// pointer: the card kept open while the pointer crossed to the list has done its job.
1243
1505
  fn dismiss_directory(&mut self, widgets: &mut AppWidgets, root: &gtk::Window) {
1244
1506
  self.close_directory(widgets, root);
1245
- if self.hovered.is_empty() && !self.keyboard_active {
1507
+ if self.hovered.is_none() && !self.keyboard_active {
1246
1508
  self.apply_expansion(widgets, root, None);
1247
1509
  }
1248
1510
  }
@@ -1250,10 +1512,58 @@ impl App {
1250
1512
  /// The one way out of keyboard mode. The list closes with it: once the ribbon gives up the
1251
1513
  /// keyboard nothing could type into or dismiss a list left open.
1252
1514
  fn leave_keyboard_mode(&mut self, widgets: &mut AppWidgets, root: &gtk::Window) {
1515
+ let was_keyboard_active = self.owns_keyboard();
1253
1516
  self.close_directory(widgets, root);
1254
1517
  self.end_engagement();
1518
+ self.refresh_cards();
1255
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
+ }
1256
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
+ }
1257
1567
  }
1258
1568
 
1259
1569
  fn close_directory(&mut self, widgets: &AppWidgets, root: &gtk::Window) {
@@ -1261,22 +1571,18 @@ impl App {
1261
1571
  return;
1262
1572
  }
1263
1573
  self.directory_open = false;
1264
- self.directory_opened_by_hover = false;
1265
- self.hover_grab = false;
1266
1574
  self.pending_request = None;
1267
1575
  self.query.clear();
1268
- if self.directory_owns_keyboard {
1269
- self.directory_owns_keyboard = false;
1270
- if !self.keyboard_active {
1271
- root.set_keyboard_mode(KeyboardMode::None);
1272
- }
1273
- }
1274
1576
  self.sync_key_capture();
1275
1577
  self.resize(root);
1276
1578
  self.sync_directory_layout(widgets);
1277
1579
  }
1278
1580
  }
1279
1581
 
1582
+ #[cfg(test)]
1583
+ #[path = "app_gui_tests.rs"]
1584
+ mod gui_tests;
1585
+
1280
1586
  #[cfg(test)]
1281
1587
  mod tests {
1282
1588
  use super::{
@@ -1286,7 +1592,6 @@ mod tests {
1286
1592
  };
1287
1593
  use crate::protocol::Card;
1288
1594
  use relm4::gtk::gdk;
1289
- use std::collections::HashSet;
1290
1595
 
1291
1596
  fn counts() -> BrandCounts {
1292
1597
  BrandCounts {
@@ -1348,14 +1653,15 @@ mod tests {
1348
1653
 
1349
1654
  #[test]
1350
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));
1351
1659
  assert!(super::is_chord(gdk::ModifierType::CONTROL_MASK));
1352
1660
  assert!(super::is_chord(gdk::ModifierType::SUPER_MASK));
1353
1661
  assert!(
1354
1662
  !super::is_chord(gdk::ModifierType::SHIFT_MASK),
1355
1663
  "Shift types capitals"
1356
1664
  );
1357
- assert!(super::is_modifier_key(gdk::Key::Shift_L));
1358
- assert!(!super::is_modifier_key(gdk::Key::space));
1359
1665
  }
1360
1666
 
1361
1667
  #[test]
@@ -1444,13 +1750,13 @@ mod tests {
1444
1750
  #[test]
1445
1751
  fn stale_gtk_focus_does_not_retain_expansion_outside_keyboard_mode() {
1446
1752
  let focused = Some("card-a".to_owned());
1447
- assert_eq!(engaged_card_id(&HashSet::new(), false, &focused), None);
1448
- 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);
1449
1755
  }
1450
1756
 
1451
1757
  #[test]
1452
1758
  fn pointer_hover_retains_expansion_without_keyboard_mode() {
1453
- let hovered = HashSet::from(["card-a".to_owned()]);
1759
+ let hovered = Some("card-a".to_owned());
1454
1760
  assert_eq!(
1455
1761
  engaged_card_id(&hovered, false, &Some("card-b".to_owned())),
1456
1762
  Some("card-a".to_owned())
@@ -1459,7 +1765,7 @@ mod tests {
1459
1765
 
1460
1766
  #[test]
1461
1767
  fn explicit_keyboard_mode_takes_precedence_over_pointer_hover() {
1462
- let hovered = HashSet::from(["card-a".to_owned()]);
1768
+ let hovered = Some("card-a".to_owned());
1463
1769
  assert_eq!(
1464
1770
  engaged_card_id(&hovered, true, &Some("card-b".to_owned())),
1465
1771
  Some("card-b".to_owned())