@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
@@ -16,6 +16,7 @@ const AK_CHIP_SLOTS: usize = 1;
16
16
 
17
17
  pub struct CardView {
18
18
  pub root: gtk::Button,
19
+ pub motion: gtk::EventControllerMotion,
19
20
  phase: gtk::Label,
20
21
  repo: gtk::Label,
21
22
  state: gtk::Label,
@@ -154,7 +155,7 @@ impl CardView {
154
155
  motion.connect_leave(move |_| {
155
156
  let _ = tx.send(AppMsg::Hover(card_id.clone(), false));
156
157
  });
157
- root.add_controller(motion);
158
+ root.add_controller(motion.clone());
158
159
 
159
160
  let focus = gtk::EventControllerFocus::new();
160
161
  let card_id = id.to_owned();
@@ -193,6 +194,7 @@ impl CardView {
193
194
 
194
195
  Self {
195
196
  root,
197
+ motion,
196
198
  phase,
197
199
  repo,
198
200
  state,
@@ -216,7 +218,14 @@ impl CardView {
216
218
  }
217
219
  }
218
220
 
219
- pub fn update(&self, card: &Card, focused: bool, duplicate_label: bool, now_ms: i64) {
221
+ pub fn update(
222
+ &self,
223
+ card: &Card,
224
+ focused: bool,
225
+ duplicate_label: bool,
226
+ now_ms: i64,
227
+ key_hint: Option<&str>,
228
+ ) {
220
229
  let hidden_tab = card.hidden_tab();
221
230
  self.repo.set_text(&display_repo(card, duplicate_label));
222
231
  self.phase.set_text(text_or(&card.phase, "Idle"));
@@ -301,7 +310,7 @@ impl CardView {
301
310
  };
302
311
  let ak_note = ak_accessible_note(&card.ak_tasks);
303
312
  let window_note = window_note(card);
304
- self.root.set_tooltip_text(Some(&format!(
313
+ let mut tooltip = format!(
305
314
  "{} {} ({}{}{}){}",
306
315
  if hidden_tab { "Present" } else { "Focus" },
307
316
  text_or(&card.repo_label, "Pi session"),
@@ -309,7 +318,13 @@ impl CardView {
309
318
  window_note,
310
319
  if hidden_tab { ", hidden tab" } else { "" },
311
320
  ak_note
312
- )));
321
+ );
322
+ // The first rung of the keyboard hints: every card names the shortcut.
323
+ if let Some(hint) = key_hint {
324
+ tooltip.push('\n');
325
+ tooltip.push_str(hint);
326
+ }
327
+ self.root.set_tooltip_text(Some(&tooltip));
313
328
  let accessible_label = format!(
314
329
  "{}{}, {}, {}{}",
315
330
  text_or(&card.repo_label, "Pi session"),
@@ -3,6 +3,7 @@ use crate::protocol::WindowEntry;
3
3
  use relm4::Sender;
4
4
  use relm4::gtk;
5
5
  use relm4::gtk::prelude::*;
6
+ use std::cell::RefCell;
6
7
 
7
8
  /// Enough digits for any Niri window id; a longer number is a typo, not a window.
8
9
  const MAX_WINDOW_DIGITS: usize = 9;
@@ -25,6 +26,8 @@ pub struct DirectoryView {
25
26
  pub root: gtk::Box,
26
27
  status: gtk::Label,
27
28
  rows: gtk::Box,
29
+ // Every material field (including the activation target), but not current styling.
30
+ rendered_rows: RefCell<Vec<WindowEntry>>,
28
31
  sender: Sender<AppMsg>,
29
32
  }
30
33
 
@@ -55,6 +58,7 @@ impl DirectoryView {
55
58
  root,
56
59
  status,
57
60
  rows,
61
+ rendered_rows: RefCell::new(Vec::new()),
58
62
  sender: sender.clone(),
59
63
  }
60
64
  }
@@ -63,8 +67,20 @@ impl DirectoryView {
63
67
  self.root.set_visible(visible);
64
68
  }
65
69
 
66
- /// Rebuild the rows for the current list and query. Clicking a row activates its card, so a
67
- /// hidden tab is presented exactly as clicking its card would. The row Enter would open is
70
+ /// Strong references let GTK tests distinguish retained rows from replacement widgets.
71
+ #[cfg(test)]
72
+ pub(crate) fn row_widgets(&self) -> Vec<gtk::Widget> {
73
+ let mut rows = Vec::new();
74
+ let mut child = self.rows.first_child();
75
+ while let Some(row) = child {
76
+ child = row.next_sibling();
77
+ rows.push(row);
78
+ }
79
+ rows
80
+ }
81
+
82
+ /// Retain rows when their material data and order match; update focus/choice styling in
83
+ /// place. Clicking a row activates its card, including hidden tabs. The row Enter opens is
68
84
  /// marked, so a typed name always shows where it leads.
69
85
  pub fn render(
70
86
  &self,
@@ -72,11 +88,25 @@ impl DirectoryView {
72
88
  query: &str,
73
89
  can_type: bool,
74
90
  allow_unlisted: bool,
91
+ hotkey: Option<&str>,
75
92
  ) {
76
- while let Some(child) = self.rows.first_child() {
77
- self.rows.remove(&child);
78
- }
79
93
  let shown = filter_entries(windows, query);
94
+ let key: Vec<WindowEntry> = shown
95
+ .iter()
96
+ .map(|entry| {
97
+ let mut key = (**entry).clone();
98
+ key.current = false;
99
+ key
100
+ })
101
+ .collect();
102
+ // Empty states have different messages; rebuild them rather than caching a label.
103
+ let reuse = !shown.is_empty() && *self.rendered_rows.borrow() == key;
104
+ if !reuse {
105
+ while let Some(child) = self.rows.first_child() {
106
+ self.rows.remove(&child);
107
+ }
108
+ }
109
+ let mut retained = self.rows.first_child();
80
110
  let choice = jump_choice(windows, query, allow_unlisted);
81
111
  // Alphabetical from the controller, so workspace headings would repeat; each row names
82
112
  // its workspace instead.
@@ -86,8 +116,21 @@ impl DirectoryView {
86
116
  Some(JumpChoice::Card(card_id)) => &entry.card_id == card_id,
87
117
  None => false,
88
118
  };
89
- self.rows.append(&self.row(entry, chosen));
119
+ if reuse {
120
+ let row = retained.take().expect("cached directory row exists");
121
+ retained = row.next_sibling();
122
+ for (class, enabled) in [("current", entry.current), ("chosen", chosen)] {
123
+ if enabled {
124
+ row.add_css_class(class);
125
+ } else {
126
+ row.remove_css_class(class);
127
+ }
128
+ }
129
+ } else {
130
+ self.rows.append(&self.row(entry, chosen));
131
+ }
90
132
  }
133
+ *self.rendered_rows.borrow_mut() = key;
91
134
  if shown.is_empty() {
92
135
  let empty = gtk::Label::new(Some(if windows.is_empty() {
93
136
  "No agent windows"
@@ -98,8 +141,13 @@ impl DirectoryView {
98
141
  empty.set_xalign(0.0);
99
142
  self.rows.append(&empty);
100
143
  }
101
- self.status
102
- .set_text(&status_text(windows, query, can_type, allow_unlisted));
144
+ self.status.set_text(&status_text(
145
+ windows,
146
+ query,
147
+ can_type,
148
+ allow_unlisted,
149
+ hotkey,
150
+ ));
103
151
  }
104
152
 
105
153
  /// The typing hint alone, leaving the rows (and any click in progress on them) untouched.
@@ -109,9 +157,15 @@ impl DirectoryView {
109
157
  query: &str,
110
158
  can_type: bool,
111
159
  allow_unlisted: bool,
160
+ hotkey: Option<&str>,
112
161
  ) {
113
- self.status
114
- .set_text(&status_text(windows, query, can_type, allow_unlisted));
162
+ self.status.set_text(&status_text(
163
+ windows,
164
+ query,
165
+ can_type,
166
+ allow_unlisted,
167
+ hotkey,
168
+ ));
115
169
  }
116
170
 
117
171
  /// A transient message after a jump or activation, until the next keystroke re-renders.
@@ -257,10 +311,11 @@ fn status_text(
257
311
  query: &str,
258
312
  can_type: bool,
259
313
  allow_unlisted: bool,
314
+ hotkey: Option<&str>,
260
315
  ) -> String {
261
316
  if !can_type {
262
- // The keyboard was handed back (hover timeout); a click takes it again.
263
- return "Click, then type a name or number".to_owned();
317
+ // The keyboard was handed back (hover timeout); a click or the shortcut takes it again.
318
+ return crate::hints::pointer_prompt(hotkey);
264
319
  }
265
320
  match jump_choice(windows, query, allow_unlisted) {
266
321
  _ if query.is_empty() => "Type a name or number · ⏎ go".to_owned(),
@@ -302,19 +357,26 @@ mod tests {
302
357
  fn the_status_line_says_what_enter_will_do() {
303
358
  let windows = vec![entry("pi-extensions", 432, 2)];
304
359
  assert_eq!(
305
- status_text(&windows, "", false, true),
360
+ status_text(&windows, "", false, true, None),
306
361
  "Click, then type a name or number"
307
362
  );
308
363
  assert_eq!(
309
- status_text(&windows, "", true, true),
364
+ status_text(&windows, "", false, true, Some("Super+Shift+A")),
365
+ "Click or Super+Shift+A to type"
366
+ );
367
+ assert_eq!(
368
+ status_text(&windows, "", true, true, None),
310
369
  "Type a name or number · ⏎ go"
311
370
  );
312
- assert!(status_text(&windows, "43", true, true).starts_with("#43"));
371
+ assert!(status_text(&windows, "43", true, true, None).starts_with("#43"));
313
372
  assert_eq!(
314
- status_text(&windows, "ext", true, true),
373
+ status_text(&windows, "ext", true, true, None),
315
374
  "ext▏ ⏎ pi-extensions"
316
375
  );
317
- assert_eq!(status_text(&windows, "zzz", true, true), "zzz▏ no match");
376
+ assert_eq!(
377
+ status_text(&windows, "zzz", true, true, None),
378
+ "zzz▏ no match"
379
+ );
318
380
  }
319
381
 
320
382
  #[test]
@@ -0,0 +1,65 @@
1
+ //! Progressive keyboard hints. The ribbon teaches its shortcut in three steps, each shown only
2
+ //! once the operator is already doing the step before it: every tooltip names the shortcut, the
3
+ //! list's status line says what the keyboard would do right now, and keyboard mode itself shows
4
+ //! the whole key map where the folder path normally is.
5
+
6
+ /// The whole key map, for the brand tooltip and the docs.
7
+ pub const KEY_MAP: &str =
8
+ "←/→ move · Shift+←/→ reorder · Enter open\nType a name or window # to jump · Esc leaves";
9
+
10
+ /// Brand lines while keyboard mode is on: the key map replaces the folder path.
11
+ pub const KEYBOARD_EYEBROW: &str = "⌨ KEYBOARD MODE";
12
+ pub const KEYBOARD_META: &str = "←→ move · ⏎ open · Esc";
13
+
14
+ /// One line under each card's tooltip: the shortcut that reaches the ribbon without the mouse.
15
+ pub fn card_hint(hotkey: Option<&str>) -> Option<String> {
16
+ hotkey.map(|key| format!("{key} drives the strip from the keyboard"))
17
+ }
18
+
19
+ /// The brand tooltip: where you are, then how to get here without the mouse.
20
+ pub fn brand_tooltip(cwd: Option<&str>, hotkey: Option<&str>) -> String {
21
+ let keys = match hotkey {
22
+ Some(key) => format!("{key} — keyboard mode\n{KEY_MAP}"),
23
+ None => format!("Keyboard mode\n{KEY_MAP}"),
24
+ };
25
+ match cwd.filter(|cwd| !cwd.is_empty()) {
26
+ Some(cwd) => format!("{cwd}\n\n{keys}"),
27
+ None => keys,
28
+ }
29
+ }
30
+
31
+ /// The list's prompt before it may type: the two ways to hand it the keyboard.
32
+ pub fn pointer_prompt(hotkey: Option<&str>) -> String {
33
+ match hotkey {
34
+ Some(key) => format!("Click or {key} to type"),
35
+ None => "Click, then type a name or number".to_owned(),
36
+ }
37
+ }
38
+
39
+ #[cfg(test)]
40
+ mod tests {
41
+ use super::*;
42
+
43
+ #[test]
44
+ fn every_level_names_the_bound_shortcut_and_none_invents_one() {
45
+ assert_eq!(card_hint(None), None);
46
+ assert!(
47
+ card_hint(Some("Super+Shift+A"))
48
+ .unwrap()
49
+ .starts_with("Super+Shift+A")
50
+ );
51
+ assert!(pointer_prompt(Some("Super+Shift+A")).contains("Super+Shift+A"));
52
+ assert_eq!(pointer_prompt(None), "Click, then type a name or number");
53
+ let tooltip = brand_tooltip(Some("/home/me/repo"), Some("Super+Shift+A"));
54
+ assert!(tooltip.starts_with("/home/me/repo\n\nSuper+Shift+A"));
55
+ assert!(tooltip.contains("Esc leaves"));
56
+ assert!(brand_tooltip(Some(""), None).starts_with("Keyboard mode"));
57
+ }
58
+
59
+ #[test]
60
+ fn the_keyboard_mode_lines_fit_the_brand_column() {
61
+ // BRAND_WIDTH is 196 px; the meta font fits about 26 characters before ellipsizing.
62
+ assert!(KEYBOARD_META.chars().count() <= 26);
63
+ assert!(KEYBOARD_EYEBROW.chars().count() <= 26);
64
+ }
65
+ }
@@ -0,0 +1,30 @@
1
+ [
2
+ {
3
+ "name": "capture releases and late focus cannot revive",
4
+ "steps": [["grant", true], ["capture", true], ["release", false], ["capture", false]]
5
+ },
6
+ {
7
+ "name": "release before GTK acquisition is final",
8
+ "steps": [["grant", true], ["release", false], ["capture", false]]
9
+ },
10
+ {
11
+ "name": "old permission cannot transfer to a newer request",
12
+ "steps": [
13
+ ["grant", true],
14
+ ["capture", true],
15
+ ["release", false],
16
+ ["grant", true],
17
+ ["stale-permission", true],
18
+ ["capture", true],
19
+ ["release", false]
20
+ ]
21
+ },
22
+ {
23
+ "name": "source revocation defeats later acquisition",
24
+ "steps": [["grant", true], ["capture", true], ["source-revoked", false], ["capture", false]]
25
+ },
26
+ {
27
+ "name": "duplicate acquisition preserves one request",
28
+ "steps": [["grant", true], ["capture", true], ["capture", true], ["release", false]]
29
+ }
30
+ ]
@@ -0,0 +1,237 @@
1
+ use std::sync::OnceLock;
2
+ use std::time::Instant;
3
+
4
+ /// Idle ownership uses monotonic time, never the wall clock used for telemetry labels.
5
+ pub fn monotonic_ms() -> i64 {
6
+ static START: OnceLock<Instant> = OnceLock::new();
7
+ START
8
+ .get_or_init(Instant::now)
9
+ .elapsed()
10
+ .as_millis()
11
+ .try_into()
12
+ .unwrap_or(i64::MAX)
13
+ }
14
+
15
+ /// Pointer-entry attempts are not data revisions or callback tickets. Tokens never repeat during
16
+ /// a panel lifetime; leaving, hiding or explicit entry invalidates the pending attempt, not the counter.
17
+ #[derive(Default, Debug)]
18
+ pub struct HoverKeyboard {
19
+ next_token: u64,
20
+ pub inside: bool,
21
+ pub pending: Option<(u64, u64)>, // token, controller identity epoch
22
+ pub active: bool,
23
+ pub captured: bool,
24
+ touched_at: i64,
25
+ }
26
+
27
+ impl HoverKeyboard {
28
+ pub fn request(&mut self, epoch: u64, now: i64) -> Option<u64> {
29
+ if self.pending.is_some() || self.active {
30
+ return None;
31
+ }
32
+ self.next_token += 1;
33
+ self.pending = Some((self.next_token, epoch));
34
+ self.touched_at = now;
35
+ Some(self.next_token)
36
+ }
37
+
38
+ pub fn grant(
39
+ &mut self,
40
+ token: u64,
41
+ epoch: u64,
42
+ current_epoch: u64,
43
+ pointer_valid: bool,
44
+ now: i64,
45
+ ) -> bool {
46
+ if self.pending != Some((token, epoch))
47
+ || epoch != current_epoch
48
+ || !pointer_valid
49
+ || now.saturating_sub(self.touched_at) >= 3_000
50
+ {
51
+ return false;
52
+ }
53
+ self.pending = None;
54
+ self.active = true;
55
+ self.captured = false;
56
+ self.touched_at = now;
57
+ true
58
+ }
59
+
60
+ /// GTK focus confirms application of an ALREADY authorized grab; it never grants permission.
61
+ pub fn acquired(&mut self, now: i64) -> bool {
62
+ if !self.active || self.captured || self.expired(now) {
63
+ return false;
64
+ }
65
+ self.captured = true;
66
+ self.touched_at = now;
67
+ true
68
+ }
69
+
70
+ pub fn touch(&mut self, now: i64) {
71
+ if self.captured {
72
+ self.touched_at = now;
73
+ }
74
+ }
75
+
76
+ /// Observation in this process's monotonic domain, never a controller deadline.
77
+ pub fn captured_deadline_ms(&self) -> Option<i64> {
78
+ (self.active && self.captured).then(|| self.touched_at.saturating_add(3_000))
79
+ }
80
+
81
+ pub fn expired(&self, now: i64) -> bool {
82
+ // A parked nested compositor can take several seconds to commit a layer-shell change.
83
+ // Acquisition is separately bounded; the typing idle clock starts on actual focus.
84
+ let limit = if self.active && !self.captured {
85
+ 10_000
86
+ } else {
87
+ 3_000
88
+ };
89
+ (self.active || self.pending.is_some()) && now.saturating_sub(self.touched_at) >= limit
90
+ }
91
+
92
+ /// Retain `inside` on timeout/Escape: ordinary telemetry or child crossings cannot recapture.
93
+ /// Only a fresh physical region entry or click may request again.
94
+ pub fn clear(&mut self) -> Option<u64> {
95
+ self.active = false;
96
+ self.captured = false;
97
+ self.pending.take().map(|(token, _)| token)
98
+ }
99
+ }
100
+
101
+ #[cfg(test)]
102
+ mod tests {
103
+ use super::*;
104
+
105
+ #[test]
106
+ fn shared_hover_lifecycle_fixtures() {
107
+ let cases: serde_json::Value =
108
+ serde_json::from_str(include_str!("hover-lifecycle-fixtures.json")).unwrap();
109
+ for case in cases.as_array().unwrap() {
110
+ let mut h = HoverKeyboard::default();
111
+ let mut current = 0;
112
+ let mut old = 0;
113
+ for (index, step) in case["steps"].as_array().unwrap().iter().enumerate() {
114
+ let now = i64::try_from(index).unwrap() * 10;
115
+ match step[0].as_str().unwrap() {
116
+ "grant" => {
117
+ old = current;
118
+ current = h.request(7, now).unwrap();
119
+ assert!(h.grant(current, 7, 7, true, now));
120
+ }
121
+ "capture" => {
122
+ h.acquired(now);
123
+ }
124
+ "release" | "source-revoked" => {
125
+ h.clear();
126
+ }
127
+ "stale-permission" => {
128
+ assert!(!h.grant(old, 7, 7, true, now));
129
+ }
130
+ other => panic!("unknown fixture operation {other}"),
131
+ }
132
+ assert_eq!(
133
+ h.active,
134
+ step[1].as_bool().unwrap(),
135
+ "{} step {index}",
136
+ case["name"]
137
+ );
138
+ assert_eq!(h.captured_deadline_ms().is_some(), h.captured);
139
+ }
140
+ }
141
+ }
142
+
143
+ #[test]
144
+ fn attempts_in_one_epoch_are_unique_and_old_grants_cannot_win() {
145
+ let mut h = HoverKeyboard::default();
146
+ let first = h.request(7, 0).unwrap();
147
+ assert_eq!(h.clear(), Some(first));
148
+ let second = h.request(7, 10).unwrap();
149
+ assert_ne!(first, second);
150
+ assert!(!h.grant(first, 7, 7, true, 11));
151
+ assert_eq!(h.pending, Some((second, 7)));
152
+ assert!(!h.grant(second, 7, 8, true, 11));
153
+ assert!(!h.grant(second, 7, 7, false, 11));
154
+ assert!(h.grant(second, 7, 7, true, 11));
155
+ }
156
+
157
+ #[test]
158
+ fn hide_show_and_explicit_upgrade_revoke_pending_grants() {
159
+ let mut h = HoverKeyboard::default();
160
+ let token = h.request(1, 0).unwrap();
161
+ h.clear();
162
+ assert!(!h.grant(token, 1, 1, true, 1));
163
+ let next = h.request(3, 2).unwrap();
164
+ assert!(!h.grant(token, 1, 3, true, 3));
165
+ assert!(h.grant(next, 3, 3, true, 3));
166
+ }
167
+
168
+ #[test]
169
+ fn grant_expires_even_before_the_next_tick_is_dispatched() {
170
+ let mut h = HoverKeyboard::default();
171
+ let token = h.request(7, 0).unwrap();
172
+ assert!(!h.grant(token, 7, 7, true, 3_000));
173
+ h.clear();
174
+ let next = h.request(7, 3_001).unwrap();
175
+ assert!(!h.grant(token, 7, 7, true, 3_002));
176
+ assert!(h.grant(next, 7, 7, true, 3_002));
177
+ }
178
+
179
+ #[test]
180
+ fn pending_acquisition_and_actual_capture_have_separate_bounded_lifetimes() {
181
+ let mut h = HoverKeyboard::default();
182
+ assert!(!h.acquired(0), "GTK focus alone is not authorization");
183
+ let token = h.request(1, 0).unwrap();
184
+ assert!(h.expired(3_000), "controller handshake cannot wait forever");
185
+ assert!(h.grant(token, 1, 1, true, 10));
186
+ assert!(!h.expired(3_010), "not yet holding any keyboard input");
187
+ h.touch(9_999);
188
+ assert!(
189
+ h.expired(10_010),
190
+ "pre-focus key/GTK noise cannot prolong acquisition"
191
+ );
192
+ assert!(h.acquired(6_000));
193
+ assert!(
194
+ !h.acquired(7_000),
195
+ "ordinary GTK activity cannot restart idle"
196
+ );
197
+ assert!(!h.expired(8_999));
198
+ assert!(h.expired(9_000));
199
+ h.clear();
200
+ assert!(
201
+ !h.acquired(9_001),
202
+ "late GTK focus cannot revive a revoked claim"
203
+ );
204
+ }
205
+
206
+ #[test]
207
+ fn applied_focus_at_acquisition_expiry_cannot_restart_an_expired_claim() {
208
+ let mut h = HoverKeyboard::default();
209
+ let token = h.request(1, 0).unwrap();
210
+ assert!(h.grant(token, 1, 1, true, 1));
211
+ assert!(!h.acquired(10_001));
212
+ h.clear();
213
+ let next = h.request(1, 10_002).unwrap();
214
+ assert!(!h.grant(token, 1, 1, true, 10_003));
215
+ assert!(h.grant(next, 1, 1, true, 10_003));
216
+ assert!(h.acquired(10_004));
217
+ }
218
+
219
+ #[test]
220
+ fn idle_is_bounded_even_with_query_and_does_not_invent_new_pointer_entry() {
221
+ let mut h = HoverKeyboard {
222
+ inside: true,
223
+ ..Default::default()
224
+ };
225
+ let token = h.request(1, 0).unwrap();
226
+ assert!(h.grant(token, 1, 1, true, 10));
227
+ assert!(h.acquired(10));
228
+ assert!(!h.expired(3_009));
229
+ h.touch(2_500);
230
+ assert!(!h.expired(5_499));
231
+ assert!(h.expired(5_500));
232
+ h.clear();
233
+ assert!(h.inside);
234
+ assert!(!h.active);
235
+ assert!(h.pending.is_none());
236
+ }
237
+ }