@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
@@ -0,0 +1,917 @@
1
+ //! Actual App regressions: schedule messages, never synthesize pointer membership or ownership.
2
+ use super::*;
3
+ use std::cell::RefCell;
4
+
5
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
6
+ enum Kind {
7
+ Tick,
8
+ Enter,
9
+ Leave,
10
+ PointerIntent,
11
+ View,
12
+ FocusLost,
13
+ Other,
14
+ }
15
+
16
+ #[derive(Clone, Debug)]
17
+ struct Dispatch {
18
+ kind: Kind,
19
+ completed: bool,
20
+ mapped: bool,
21
+ contains: bool,
22
+ visible: bool,
23
+ inside: bool,
24
+ pending: Option<(u64, u64)>,
25
+ native_ms: i64,
26
+ keyboard_request: u64,
27
+ hover_active: bool,
28
+ hover_captured: bool,
29
+ hover_expired: bool,
30
+ device_position: Option<(f64, f64)>,
31
+ device_surface_is_root: Option<bool>,
32
+ device_timestamp: Option<u32>,
33
+ }
34
+
35
+ #[derive(Default)]
36
+ struct Trace {
37
+ removed_timers: usize,
38
+ dispatches: Vec<Dispatch>,
39
+ }
40
+
41
+ thread_local! {
42
+ // None preserves automatic scheduling for every existing test.
43
+ static TRACE: RefCell<Option<Trace>> = const { RefCell::new(None) };
44
+ }
45
+
46
+ struct ManualTicks;
47
+ impl ManualTicks {
48
+ fn new() -> Self {
49
+ TRACE.with_borrow_mut(|trace| {
50
+ assert!(trace.is_none(), "SETUP: nested manual scheduler");
51
+ *trace = Some(Trace::default());
52
+ });
53
+ Self
54
+ }
55
+ }
56
+ impl Drop for ManualTicks {
57
+ fn drop(&mut self) {
58
+ let trace = TRACE.with_borrow_mut(Option::take);
59
+ if let Some(trace) = trace {
60
+ // Preserve the actual pre-dispatch observations on both success and unwind.
61
+ for (index, event) in trace.dispatches.iter().enumerate() {
62
+ eprintln!(
63
+ "HOVER DISPATCH {index}: {event:?}; inside={} device={:?}/{:?}/{:?}",
64
+ event.inside,
65
+ event.device_position,
66
+ event.device_surface_is_root,
67
+ event.device_timestamp
68
+ );
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ pub(crate) fn configure_tick_source(source: glib::SourceId) {
75
+ TRACE.with_borrow_mut(|trace| {
76
+ if let Some(trace) = trace {
77
+ source.remove();
78
+ trace.removed_timers += 1;
79
+ }
80
+ // SourceId has no removing Drop; the default automatic timer remains installed.
81
+ });
82
+ }
83
+
84
+ // Completion records even early returns from hidden/noninteractive filtering. It never
85
+ // observes or mutates App after dispatch, and does not require directory_open as an oracle.
86
+ pub(crate) struct DispatchObservation(usize);
87
+ impl Drop for DispatchObservation {
88
+ fn drop(&mut self) {
89
+ TRACE.with_borrow_mut(|trace| {
90
+ if let Some(trace) = trace {
91
+ trace.dispatches[self.0].completed = true;
92
+ }
93
+ });
94
+ }
95
+ }
96
+
97
+ pub(crate) fn observe_dispatch(
98
+ message: &AppMsg,
99
+ app: &App,
100
+ root: &gtk::Window,
101
+ ) -> Option<DispatchObservation> {
102
+ TRACE.with_borrow_mut(|trace| {
103
+ let trace = trace.as_mut()?;
104
+ let index = trace.dispatches.len();
105
+ // Observe GDK's own surface/device facts, not widget membership alone.
106
+ // These are diagnostics, not a settled-map or user-intent assumption.
107
+ let surface = root.surface();
108
+ let device = gdk::Display::default()
109
+ .and_then(|d| d.default_seat())
110
+ .and_then(|s| s.pointer());
111
+ let device_position = surface
112
+ .as_ref()
113
+ .zip(device.as_ref())
114
+ .and_then(|(s, d)| s.device_position(d))
115
+ .map(|(x, y, _)| (x, y));
116
+ let device_surface_is_root = surface
117
+ .as_ref()
118
+ .zip(device.as_ref())
119
+ .and_then(|(s, d)| d.surface_at_position().0.map(|actual| actual == *s));
120
+ let device_timestamp = device.as_ref().map(|d| d.timestamp());
121
+ let native_ms = monotonic_ms();
122
+ trace.dispatches.push(Dispatch {
123
+ completed: false,
124
+ kind: match message {
125
+ AppMsg::Tick => Kind::Tick,
126
+ AppMsg::DirectoryHover(true) => Kind::Enter,
127
+ AppMsg::DirectoryHover(false) => Kind::Leave,
128
+ AppMsg::PointerIntent => Kind::PointerIntent,
129
+ AppMsg::View(_) => Kind::View,
130
+ AppMsg::WindowActive(false) => Kind::FocusLost,
131
+ _ => Kind::Other,
132
+ },
133
+ mapped: root.is_mapped(),
134
+ contains: app.pointer.contains_pointer(),
135
+ visible: app.visible,
136
+ inside: app.hover_keyboard.inside,
137
+ pending: app.hover_keyboard.pending,
138
+ native_ms,
139
+ keyboard_request: app.keyboard_request,
140
+ hover_active: app.hover_keyboard.active,
141
+ hover_captured: app.hover_keyboard.captured,
142
+ hover_expired: app.hover_keyboard.expired(native_ms),
143
+ device_position,
144
+ device_surface_is_root,
145
+ device_timestamp,
146
+ });
147
+ Some(DispatchObservation(index))
148
+ })
149
+ }
150
+
151
+ fn dispatches() -> Vec<Dispatch> {
152
+ TRACE.with_borrow(|trace| trace.as_ref().unwrap().dispatches.clone())
153
+ }
154
+
155
+ fn mark() -> usize {
156
+ TRACE.with_borrow(|trace| trace.as_ref().unwrap().dispatches.len())
157
+ }
158
+
159
+ // One context iteration at a time: unlike pump(), never drain for a fixed dwell.
160
+ // Time bounds diagnose an unmet barrier, never establish behavioral success.
161
+ fn await_setup(check: impl Fn() -> bool, label: &str) {
162
+ let deadline = Instant::now() + Duration::from_secs(30);
163
+ let context = glib::MainContext::default();
164
+ while !check() {
165
+ assert!(Instant::now() < deadline, "SETUP/ORDER: {label}");
166
+ if let Some(display) = gdk::Display::default() {
167
+ display.flush();
168
+ }
169
+ if !context.iteration(false) {
170
+ std::thread::yield_now();
171
+ }
172
+ }
173
+ }
174
+
175
+ struct Pointer {
176
+ child: Child,
177
+ events: BufReader<ChildStdout>,
178
+ commands: usize,
179
+ }
180
+ impl Pointer {
181
+ fn new(width: i32, height: i32) -> Self {
182
+ let mut child = Command::new(virtual_pointer_fixture())
183
+ .args([
184
+ (width / 2).to_string(),
185
+ (height - 20).to_string(),
186
+ width.to_string(),
187
+ height.to_string(),
188
+ ])
189
+ .stdin(Stdio::piped())
190
+ .stdout(Stdio::piped())
191
+ .spawn()
192
+ .expect("SETUP: start private virtual pointer");
193
+ let events = BufReader::new(child.stdout.take().unwrap());
194
+ let mut pointer = Self {
195
+ child,
196
+ events,
197
+ commands: 1,
198
+ };
199
+ pointer.ack("ready");
200
+ pointer
201
+ }
202
+
203
+ fn ack(&mut self, expected: &str) {
204
+ let mut line = String::new();
205
+ assert!(
206
+ self.events
207
+ .read_line(&mut line)
208
+ .expect("SETUP: pointer reply")
209
+ > 0,
210
+ "SETUP: pointer exited before {expected}"
211
+ );
212
+ assert_eq!(line.trim(), expected, "SETUP: pointer protocol");
213
+ }
214
+
215
+ fn move_to(&mut self, x: i32, y: i32) {
216
+ writeln!(self.child.stdin.as_mut().unwrap(), "{x} {y}").expect("SETUP: pointer command");
217
+ self.commands += 1;
218
+ self.ack("moved");
219
+ // The helper's Wayland roundtrip acknowledges the command, NOT GTK dispatch.
220
+ }
221
+ }
222
+ impl Drop for Pointer {
223
+ fn drop(&mut self) {
224
+ // Only our own child handle; never signal compositor/host processes by numeric PID.
225
+ self.child.stdin.take();
226
+ let _ = self.child.kill();
227
+ let _ = self.child.wait();
228
+ }
229
+ }
230
+
231
+ struct Fixture {
232
+ app: relm4::Controller<App>,
233
+ receiver: gtk::Window,
234
+ pointer: Pointer,
235
+ outside: (i32, i32),
236
+ }
237
+ impl Fixture {
238
+ fn new() -> Self {
239
+ assert_isolated_gui();
240
+ gtk::init().expect("SETUP: private GTK display");
241
+ let receiver = gtk::Window::new();
242
+ receiver.set_title(Some("AK7417 private hover regression receiver"));
243
+ receiver.set_child(Some(&gtk::Entry::new()));
244
+ receiver.present();
245
+ await_setup(|| receiver.is_mapped(), "receiver must map");
246
+ // GTK mapping is not proof that the compositor has registered the window.
247
+ let receiver_id = RefCell::new(None);
248
+ await_setup(
249
+ || {
250
+ let windows = niri_json("windows");
251
+ let found = windows
252
+ .as_array()
253
+ .unwrap()
254
+ .iter()
255
+ .find(|window| window["pid"].as_u64() == Some(u64::from(std::process::id())))
256
+ .and_then(|window| window["id"].as_u64());
257
+ *receiver_id.borrow_mut() = found;
258
+ found.is_some()
259
+ },
260
+ "own receiver must be registered by the private compositor",
261
+ );
262
+ let id = receiver_id.into_inner().unwrap().to_string();
263
+ assert!(
264
+ Command::new("niri")
265
+ .args(["msg", "action", "focus-window", "--id", &id])
266
+ .status()
267
+ .expect("SETUP: focus receiver")
268
+ .success()
269
+ );
270
+ await_setup(
271
+ || receiver.is_active(),
272
+ "receiver must gain focus before panel launch",
273
+ );
274
+ let outputs = niri_json("outputs");
275
+ let outputs = outputs.as_object().unwrap();
276
+ assert_eq!(
277
+ outputs.len(),
278
+ 1,
279
+ "SETUP: exactly one private logical output"
280
+ );
281
+ let logical = &outputs.values().next().unwrap()["logical"];
282
+ let width = i32::try_from(logical["width"].as_u64().unwrap()).unwrap();
283
+ let height = i32::try_from(logical["height"].as_u64().unwrap()).unwrap();
284
+ assert!(width > 320 && height > 200, "SETUP: usable output geometry");
285
+ let pointer = Pointer::new(width, height);
286
+ let app = App::builder()
287
+ .launch(AppInit {
288
+ demo: true,
289
+ click_through: false,
290
+ })
291
+ .detach();
292
+ await_setup(
293
+ || app.widget().is_mapped() && app.model().visible,
294
+ "actual App must map",
295
+ );
296
+ assert!(
297
+ !app.model().pointer.contains_pointer(),
298
+ "SETUP: initially outside body"
299
+ );
300
+ assert!(
301
+ app.model().hover_keyboard.pending.is_none(),
302
+ "SETUP: no initial request"
303
+ );
304
+ TRACE.with_borrow(|trace| {
305
+ assert_eq!(
306
+ trace.as_ref().unwrap().removed_timers,
307
+ 1,
308
+ "SETUP: exactly the App's automatic Tick source removed"
309
+ )
310
+ });
311
+ Self {
312
+ app,
313
+ receiver,
314
+ pointer,
315
+ outside: (width / 2, height - 20),
316
+ }
317
+ }
318
+
319
+ fn enter(&mut self) -> (u64, u64) {
320
+ self.pointer.move_to(60, 40);
321
+ await_setup(
322
+ || {
323
+ let app = self.app.model();
324
+ app.pointer.contains_pointer()
325
+ && app.directory_hovered
326
+ && app.hover_keyboard.pending.is_some()
327
+ },
328
+ "initial real entry must complete and request permission",
329
+ );
330
+ assert!(
331
+ self.outside.1 > self.app.widget().height() + OUTER_MARGIN,
332
+ "SETUP: outside coordinate must be below expanded panel"
333
+ );
334
+ assert_eq!(
335
+ self.app.widget().keyboard_mode(),
336
+ KeyboardMode::None,
337
+ "SETUP: demo has no Node grant"
338
+ );
339
+ self.app.model().hover_keyboard.pending.unwrap()
340
+ }
341
+
342
+ fn view(&self, visible: bool) -> u64 {
343
+ let mut view = demo_view();
344
+ view.revision = self.app.model().revision + 1;
345
+ view.visible = visible;
346
+ let revision = view.revision;
347
+ self.app.emit(AppMsg::View(view));
348
+ revision
349
+ }
350
+
351
+ fn hide(&self) {
352
+ let begin = mark();
353
+ let revision = self.view(false);
354
+ await_setup(
355
+ || {
356
+ self.app.model().revision == revision
357
+ && !self.app.model().visible
358
+ && !self.app.widget().is_mapped()
359
+ && dispatches()[begin..].iter().any(|event| {
360
+ event.kind == Kind::Leave
361
+ && event.completed
362
+ && !event.visible
363
+ && !event.contains
364
+ })
365
+ },
366
+ "production hide and real body leave while hidden must both dispatch",
367
+ );
368
+ assert!(
369
+ self.app.model().hover_keyboard.pending.is_none(),
370
+ "SETUP: hide must cancel the first hover request"
371
+ );
372
+ }
373
+
374
+ fn await_entry(&self, begin: usize) {
375
+ await_setup(
376
+ || {
377
+ let app = self.app.model();
378
+ self.app.widget().is_mapped()
379
+ && app.visible
380
+ && app.pointer.contains_pointer()
381
+ && dispatches()[begin..].iter().any(|event| {
382
+ event.kind == Kind::Enter
383
+ && event.completed
384
+ && event.mapped
385
+ && event.contains
386
+ && event.visible
387
+ })
388
+ },
389
+ "real body entry must reach and complete the production handler",
390
+ );
391
+ }
392
+ }
393
+ impl Drop for Fixture {
394
+ fn drop(&mut self) {
395
+ self.app.widget().close();
396
+ self.receiver.close();
397
+ }
398
+ }
399
+
400
+ fn niri_json(subject: &str) -> serde_json::Value {
401
+ let output = Command::new("niri")
402
+ .args(["msg", "-j", subject])
403
+ .output()
404
+ .expect("SETUP: query private compositor");
405
+ assert!(output.status.success(), "SETUP: niri {subject} failed");
406
+ serde_json::from_slice(&output.stdout).expect("SETUP: compositor JSON")
407
+ }
408
+
409
+ /// Characterize actual native release causes without borrowing a controller wall-clock deadline.
410
+ /// Tick scheduling is controlled, but capture/focus, monotonic expiry and typing are real GTK/Niri.
411
+ pub(super) fn hover_release_cause(idle: bool) {
412
+ let _schedule = ManualTicks::new();
413
+ let mut fixture = Fixture::new();
414
+ let entry = fixture
415
+ .receiver
416
+ .child()
417
+ .unwrap()
418
+ .downcast::<gtk::Entry>()
419
+ .unwrap();
420
+ entry.grab_focus();
421
+ // Given real compositor pointer entry and an exact native request granted by the fixture.
422
+ let (token, epoch) = fixture.enter();
423
+ fixture.app.emit(AppMsg::HoverGrant(token, epoch, 41));
424
+ await_setup(
425
+ || fixture.app.model().hover_keyboard.captured && fixture.app.widget().is_active(),
426
+ "matching native grant must actually acquire GTK keyboard focus",
427
+ );
428
+ assert_eq!(fixture.app.model().keyboard_request, 41);
429
+ assert!(!fixture.app.model().hover_keyboard.expired(monotonic_ms()));
430
+ let begin = mark();
431
+ // When either a production hidden view arrives BEFORE native expiry, or a real Tick
432
+ // arrives AFTER native monotonic expiry. No synthetic WindowActive or keyboard state.
433
+ if idle {
434
+ await_setup(
435
+ || fixture.app.model().hover_keyboard.expired(monotonic_ms()),
436
+ "native monotonic idle deadline must actually expire",
437
+ );
438
+ assert!(
439
+ fixture.app.model().hover_keyboard.captured,
440
+ "no automatic Tick may release early"
441
+ );
442
+ fixture.app.emit(AppMsg::Tick);
443
+ } else {
444
+ fixture.view(false);
445
+ }
446
+ await_setup(
447
+ || {
448
+ !fixture.app.model().hover_keyboard.active
449
+ && !fixture.app.model().hover_keyboard.captured
450
+ && fixture.app.widget().keyboard_mode() == KeyboardMode::None
451
+ && fixture.receiver.is_active()
452
+ && dispatches()[begin..]
453
+ .iter()
454
+ .any(|event| event.kind == Kind::FocusLost && event.completed)
455
+ },
456
+ "native release must return actual keyboard focus to the private receiver",
457
+ );
458
+ let events = dispatches();
459
+ let cause = events[begin..]
460
+ .iter()
461
+ .find(|event| {
462
+ event.completed
463
+ && event.keyboard_request == 41
464
+ && event.hover_active
465
+ && event.hover_captured
466
+ && event.kind == if idle { Kind::Tick } else { Kind::View }
467
+ })
468
+ .expect("matching native capture must reach the selected production release branch");
469
+ assert_eq!(
470
+ cause.hover_expired, idle,
471
+ "hidden-view release must precede native expiry; idle release must follow it"
472
+ );
473
+ assert!(
474
+ events[begin..]
475
+ .iter()
476
+ .any(|event| event.kind == Kind::FocusLost && event.completed)
477
+ );
478
+ assert_eq!(
479
+ events[begin..]
480
+ .iter()
481
+ .filter(|event| event.kind == Kind::Tick)
482
+ .count(),
483
+ usize::from(idle),
484
+ "exactly the selected cause: no automatic Tick may stand in for hidden-view release"
485
+ );
486
+ type_keys(&["after-native-release"]);
487
+ await_setup(
488
+ || entry.text().as_str() == "after-native-release",
489
+ "fresh typing must reach the exact receiver after native release",
490
+ );
491
+ println!(
492
+ "NATIVE RELEASE CAUSE PASS request=41 idle={idle} native_ms={} expired={} captured={} token={token} epoch={epoch}",
493
+ cause.native_ms, cause.hover_expired, cause.hover_captured
494
+ );
495
+ }
496
+
497
+ pub(super) fn rapid_hidden_reentry() {
498
+ let _schedule = ManualTicks::new();
499
+ let mut fixture = Fixture::new();
500
+ // Given: actual GTK entry requests permission, but Node has not granted it.
501
+ let first = fixture.enter();
502
+ let begin = mark();
503
+ // When: hide/hidden leave, genuine outside movement, show, then genuine entry.
504
+ // No Tick, dwell, click, grant, or synthetic enter repairs the retained latch.
505
+ fixture.hide();
506
+ fixture
507
+ .pointer
508
+ .move_to(fixture.outside.0, fixture.outside.1);
509
+ let revision = fixture.view(true);
510
+ await_setup(
511
+ || {
512
+ fixture.app.model().revision == revision
513
+ && fixture.app.widget().is_mapped()
514
+ && !fixture.app.model().pointer.contains_pointer()
515
+ },
516
+ "show outside before genuine reentry",
517
+ );
518
+ let entry_begin = mark();
519
+ fixture.pointer.move_to(60, 40);
520
+ fixture.await_entry(entry_begin);
521
+ let events = dispatches();
522
+ assert!(
523
+ events[begin..].iter().all(|event| event.kind != Kind::Tick),
524
+ "SETUP/ORDER: rapid trace must have ZERO Ticks, including mapped-outside Ticks"
525
+ );
526
+ assert!(
527
+ events[begin..]
528
+ .iter()
529
+ .all(|event| event.kind != Kind::PointerIntent),
530
+ "SETUP/ORDER: no click may rescue reentry"
531
+ );
532
+ assert_eq!(
533
+ fixture.pointer.commands, 4,
534
+ "SETUP/ORDER: only initial/enter/outside/entry input"
535
+ );
536
+ // Then: genuine reentry requests NEW authority immediately, before any Node grant.
537
+ let app = fixture.app.model();
538
+ let next = app.hover_keyboard.pending.expect(
539
+ "BEHAVIOR: genuine hide/outside/show/entry must issue a new hover request without a Tick",
540
+ );
541
+ assert_ne!(next.0, first.0, "BEHAVIOR: never revive the canceled token");
542
+ assert_eq!(
543
+ next.1, app.interaction_epoch,
544
+ "BEHAVIOR: request current authority epoch"
545
+ );
546
+ assert_eq!(fixture.app.widget().keyboard_mode(), KeyboardMode::None);
547
+ }
548
+
549
+ pub(super) fn stationary_remap() {
550
+ let _schedule = ManualTicks::new();
551
+ let mut fixture = Fixture::new();
552
+ // Given: actual body entry, followed by production hide and hidden leave.
553
+ fixture.enter();
554
+ fixture.hide();
555
+ let commands = fixture.pointer.commands;
556
+ let begin = mark();
557
+ // When: show beneath the UNMOVED pointer. Queue a real Tick on actual map;
558
+ // never override contains_pointer, inject enter, or edit ownership fields.
559
+ let queued = Rc::new(Cell::new(false));
560
+ let queued_on_map = Rc::clone(&queued);
561
+ let tx = fixture.app.sender().clone();
562
+ let map_handler = fixture.app.widget().connect_map(move |_| {
563
+ if !queued_on_map.replace(true) {
564
+ tx.send(AppMsg::Tick).expect("SETUP: queue map-phase Tick");
565
+ }
566
+ });
567
+ fixture.view(true);
568
+ fixture.await_entry(begin);
569
+ fixture.app.widget().disconnect(map_handler);
570
+ assert!(
571
+ queued.get(),
572
+ "SETUP/ORDER: remap callback must schedule Tick"
573
+ );
574
+ let events = dispatches();
575
+ let events = &events[begin..];
576
+ let ticks: Vec<_> = events
577
+ .iter()
578
+ .enumerate()
579
+ .filter(|(_, event)| event.kind == Kind::Tick)
580
+ .collect();
581
+ assert_eq!(
582
+ ticks.len(),
583
+ 1,
584
+ "SETUP/ORDER: exactly one actual Tick dispatch"
585
+ );
586
+ let (tick_index, tick) = ticks[0];
587
+ assert!(
588
+ tick.completed && tick.mapped && !tick.contains && tick.visible,
589
+ "SETUP/ORDER: Tick must dispatch mapped/body-absent, not merely enqueue then: {tick:?}"
590
+ );
591
+ let enter_index = events
592
+ .iter()
593
+ .position(|event| event.kind == Kind::Enter)
594
+ .expect("SETUP/ORDER: compositor must deliver stationary entry");
595
+ assert!(
596
+ tick_index < enter_index,
597
+ "SETUP/ORDER: actual Tick handler must precede stationary entry"
598
+ );
599
+ assert_eq!(
600
+ fixture.pointer.commands, commands,
601
+ "SETUP/ORDER: no new pointer action"
602
+ );
603
+ assert!(
604
+ events.iter().all(|event| event.kind != Kind::PointerIntent),
605
+ "SETUP/ORDER: no new click intent"
606
+ );
607
+ // Then: neither the final state nor any later dispatch may expose a new request.
608
+ // Checking snapshots also catches a request subsequently canceled by another callback.
609
+ assert!(
610
+ fixture.app.model().hover_keyboard.pending.is_none()
611
+ && events.iter().all(|event| event.pending.is_none()),
612
+ "BEHAVIOR: mapped-body-absence Tick plus stationary remap must NOT request new authority"
613
+ );
614
+ assert_eq!(fixture.app.widget().keyboard_mode(), KeyboardMode::None);
615
+ }
616
+
617
+ pub(super) fn stationary_backend_loss_remap() {
618
+ let _schedule = ManualTicks::new();
619
+ let mut fixture = Fixture::new();
620
+ // Given: real entry and backend focus on this exact GDK surface, not just its body.
621
+ fixture.enter();
622
+ let surface = fixture.app.widget().surface().expect("SETUP: root surface");
623
+ let device = gdk::Display::default()
624
+ .and_then(|display| display.default_seat())
625
+ .and_then(|seat| seat.pointer())
626
+ .expect("SETUP: actual GDK pointer device");
627
+ let (x, y, _) = surface
628
+ .device_position(&device)
629
+ .expect("SETUP: initial backend pointer position must be on root");
630
+ assert_eq!(
631
+ device.surface_at_position().0.as_ref(),
632
+ Some(&surface),
633
+ "SETUP: initial backend pointer focus must be root"
634
+ );
635
+ let commands = fixture.pointer.commands;
636
+ fixture.hide();
637
+ let begin = mark();
638
+
639
+ // When: a real, private compositor workspace transition occurs while hidden.
640
+ // Select by actual window occupancy, never mistake NULL active_window_id for empty.
641
+ let windows = niri_json("windows");
642
+ let windows = windows.as_array().expect("SETUP: private window inventory");
643
+ let source = windows
644
+ .iter()
645
+ .find(|window| window["pid"].as_u64() == Some(u64::from(std::process::id())))
646
+ .and_then(|window| window["workspace_id"].as_u64())
647
+ .expect("SETUP: own receiver workspace");
648
+ let workspaces = niri_json("workspaces");
649
+ let empty = workspaces
650
+ .as_array()
651
+ .expect("SETUP: private workspace inventory")
652
+ .iter()
653
+ .find(|workspace| {
654
+ workspace["id"].as_u64().is_some_and(|id| {
655
+ id != source
656
+ && !windows
657
+ .iter()
658
+ .any(|window| window["workspace_id"].as_u64() == Some(id))
659
+ })
660
+ })
661
+ .expect("SETUP: a distinct empty private workspace must exist");
662
+ let target_id = empty["id"].as_u64().unwrap();
663
+ let target_index = empty["idx"]
664
+ .as_u64()
665
+ .expect("SETUP: workspace index")
666
+ .to_string();
667
+ assert!(
668
+ Command::new("niri")
669
+ .args(["msg", "action", "focus-workspace", &target_index])
670
+ .status()
671
+ .expect("SETUP: private workspace focus action")
672
+ .success(),
673
+ "SETUP: private workspace focus action failed"
674
+ );
675
+ await_setup(
676
+ || {
677
+ niri_json("workspaces")
678
+ .as_array()
679
+ .unwrap()
680
+ .iter()
681
+ .any(|workspace| {
682
+ workspace["id"].as_u64() == Some(target_id) && workspace["is_focused"] == true
683
+ })
684
+ },
685
+ "private compositor must confirm target workspace focus",
686
+ );
687
+
688
+ // A cached GTK body leave is insufficient. Query the retained, actual GDK
689
+ // surface/device: no coordinates on root AND no backend focus on that root.
690
+ // Keeping both objects rules out missing surface/device masquerading as absence.
691
+ await_setup(
692
+ || {
693
+ surface.device_position(&device).is_none()
694
+ && device.surface_at_position().0.as_ref() != Some(&surface)
695
+ },
696
+ "workspace transition while hidden must produce actual backend departure from root",
697
+ );
698
+ assert!(
699
+ !fixture.app.model().visible && !fixture.app.widget().is_mapped(),
700
+ "SETUP/ORDER: backend departure must be observed while hidden"
701
+ );
702
+ assert_eq!(
703
+ fixture.app.widget().surface().as_ref(),
704
+ Some(&surface),
705
+ "SETUP/ORDER: absence must concern the retained root surface"
706
+ );
707
+ assert_eq!(
708
+ fixture.pointer.commands, commands,
709
+ "SETUP/ORDER: no pointer command may manufacture backend departure"
710
+ );
711
+ eprintln!(
712
+ "BACKEND LOSS: workspace={target_id} root_position={:?} backend_is_root={} timestamp={} pointer_commands={commands}",
713
+ surface.device_position(&device),
714
+ device.surface_at_position().0.as_ref() == Some(&surface),
715
+ device.timestamp()
716
+ );
717
+
718
+ // Schedule actual Tick on actual map, as in the existing stationary control.
719
+ // No controller grants, fabricated enter, pointer movement, or ownership writes.
720
+ let queued = Rc::new(Cell::new(false));
721
+ let queued_on_map = Rc::clone(&queued);
722
+ let tx = fixture.app.sender().clone();
723
+ let map_handler = fixture.app.widget().connect_map(move |_| {
724
+ if !queued_on_map.replace(true) {
725
+ tx.send(AppMsg::Tick)
726
+ .expect("SETUP: queue backend-loss map Tick");
727
+ }
728
+ });
729
+ let show_begin = mark();
730
+ fixture.view(true);
731
+ fixture.await_entry(show_begin);
732
+ fixture.app.widget().disconnect(map_handler);
733
+ let all_events = dispatches();
734
+ let events = &all_events[show_begin..];
735
+ let ticks: Vec<_> = events
736
+ .iter()
737
+ .enumerate()
738
+ .filter(|(_, event)| event.kind == Kind::Tick)
739
+ .collect();
740
+ assert!(queued.get(), "SETUP/ORDER: actual map must schedule Tick");
741
+ assert_eq!(
742
+ ticks.len(),
743
+ 1,
744
+ "SETUP/ORDER: exactly one remap Tick dispatch"
745
+ );
746
+ assert!(
747
+ all_events[begin..show_begin]
748
+ .iter()
749
+ .all(|event| event.kind != Kind::Tick),
750
+ "SETUP/ORDER: no extra Tick during hidden backend departure"
751
+ );
752
+ let (tick_index, tick) = ticks[0];
753
+ assert!(
754
+ tick.completed && tick.mapped && tick.visible && !tick.contains,
755
+ "SETUP/ORDER: actual Tick must complete mapped/body-absent: {tick:?}"
756
+ );
757
+ assert!(
758
+ tick.device_position.is_none()
759
+ && tick.device_surface_is_root != Some(true)
760
+ && tick.device_timestamp.is_some(),
761
+ "SETUP/ORDER: Tick must also observe BACKEND absence, not only body absence: {tick:?}"
762
+ );
763
+ let enter_index = events
764
+ .iter()
765
+ .position(|event| event.kind == Kind::Enter)
766
+ .expect("SETUP/ORDER: actual stationary entry must dispatch");
767
+ let enter = &events[enter_index];
768
+ assert!(
769
+ tick_index < enter_index
770
+ && enter.completed
771
+ && enter.mapped
772
+ && enter.visible
773
+ && enter.contains
774
+ && enter.device_surface_is_root == Some(true),
775
+ "SETUP/ORDER: actual backend/body reentry must complete AFTER absence Tick: {enter:?}"
776
+ );
777
+ assert_eq!(
778
+ fixture.app.widget().surface().as_ref(),
779
+ Some(&surface),
780
+ "SETUP/ORDER: remap must retain the tested root surface"
781
+ );
782
+ assert_eq!(
783
+ enter.device_position,
784
+ Some((x, y)),
785
+ "SETUP/ORDER: reentry coordinates must match initial stationary coordinates"
786
+ );
787
+ assert_eq!(
788
+ fixture.pointer.commands, commands,
789
+ "SETUP/ORDER: no pointer command through hide/workspace change/show/entry"
790
+ );
791
+ assert!(
792
+ all_events[begin..]
793
+ .iter()
794
+ .all(|event| event.kind != Kind::PointerIntent),
795
+ "SETUP/ORDER: no click intent"
796
+ );
797
+
798
+ // Then: backend absence caused by lifecycle/compositor focus is NOT fresh
799
+ // physical intent. A naive body->backend absence substitution must fail here.
800
+ assert!(
801
+ fixture.app.model().hover_keyboard.pending.is_none()
802
+ && all_events[begin..]
803
+ .iter()
804
+ .all(|event| event.pending.is_none()),
805
+ "BEHAVIOR: stationary hide/backend-focus-loss/remap must NOT request new authority"
806
+ );
807
+ assert_eq!(fixture.app.widget().keyboard_mode(), KeyboardMode::None);
808
+ }
809
+
810
+ /// AK7922: the realistic F01 case. A genuine return to a DIFFERENT body position before any Tick
811
+ /// must request new authority; only the exact pre-hide position is indistinguishable from a remap.
812
+ pub(super) fn rapid_hidden_reentry_elsewhere() {
813
+ let _schedule = ManualTicks::new();
814
+ let mut fixture = Fixture::new();
815
+ // Given: actual GTK entry requests permission, but Node has not granted it.
816
+ let first = fixture.enter();
817
+ let begin = mark();
818
+ // When: hide/hidden leave, genuine outside movement, show, then genuine entry ELSEWHERE.
819
+ fixture.hide();
820
+ fixture
821
+ .pointer
822
+ .move_to(fixture.outside.0, fixture.outside.1);
823
+ let revision = fixture.view(true);
824
+ await_setup(
825
+ || {
826
+ fixture.app.model().revision == revision
827
+ && fixture.app.widget().is_mapped()
828
+ && !fixture.app.model().pointer.contains_pointer()
829
+ },
830
+ "show outside before genuine reentry elsewhere",
831
+ );
832
+ let entry_begin = mark();
833
+ fixture.pointer.move_to(90, 40);
834
+ fixture.await_entry(entry_begin);
835
+ let events = dispatches();
836
+ assert!(
837
+ events[begin..].iter().all(|event| event.kind != Kind::Tick),
838
+ "SETUP/ORDER: rapid trace must have ZERO Ticks, including mapped-outside Ticks"
839
+ );
840
+ assert!(
841
+ events[begin..]
842
+ .iter()
843
+ .all(|event| event.kind != Kind::PointerIntent),
844
+ "SETUP/ORDER: no click may rescue reentry"
845
+ );
846
+ assert_eq!(
847
+ fixture.pointer.commands, 4,
848
+ "SETUP/ORDER: only initial/enter/outside/entry input"
849
+ );
850
+ // Then: genuine reentry elsewhere requests NEW authority immediately, before any Node grant.
851
+ let app = fixture.app.model();
852
+ let next = app.hover_keyboard.pending.expect(
853
+ "BEHAVIOR: genuine hide/outside/show/entry elsewhere must issue a new hover request without a Tick",
854
+ );
855
+ assert_ne!(next.0, first.0, "BEHAVIOR: never revive the canceled token");
856
+ assert_eq!(
857
+ next.1, app.interaction_epoch,
858
+ "BEHAVIOR: request current authority epoch"
859
+ );
860
+ assert_eq!(fixture.app.widget().keyboard_mode(), KeyboardMode::None);
861
+ }
862
+
863
+ /// AK7922: a hide anchor lives through one mapped absence only. After two Ticks with the strip
864
+ /// mapped and the pointer outside, a return to the exact pre-hide pixel is new movement again.
865
+ pub(super) fn exact_pixel_return_after_two_ticks() {
866
+ let _schedule = ManualTicks::new();
867
+ let mut fixture = Fixture::new();
868
+ // Given: actual GTK entry requests permission, then the strip hides with the pointer inside.
869
+ let first = fixture.enter();
870
+ fixture.hide();
871
+ // When: the pointer leaves while hidden, the strip shows, two Ticks observe it mapped and
872
+ // body-absent, and the pointer returns to the same pixel.
873
+ fixture
874
+ .pointer
875
+ .move_to(fixture.outside.0, fixture.outside.1);
876
+ let revision = fixture.view(true);
877
+ await_setup(
878
+ || {
879
+ fixture.app.model().revision == revision
880
+ && fixture.app.widget().is_mapped()
881
+ && !fixture.app.model().pointer.contains_pointer()
882
+ },
883
+ "show outside before two Ticks",
884
+ );
885
+ let begin = mark();
886
+ let tx = fixture.app.sender().clone();
887
+ for _ in 0..2 {
888
+ tx.send(AppMsg::Tick)
889
+ .expect("SETUP: queue mapped-outside Tick");
890
+ }
891
+ await_setup(
892
+ || {
893
+ dispatches()[begin..]
894
+ .iter()
895
+ .filter(|event| {
896
+ event.kind == Kind::Tick && event.completed && event.mapped && !event.contains
897
+ })
898
+ .count()
899
+ == 2
900
+ },
901
+ "two mapped body-absent Ticks before the return",
902
+ );
903
+ let entry_begin = mark();
904
+ fixture.pointer.move_to(60, 40);
905
+ fixture.await_entry(entry_begin);
906
+ // Then: the expired anchor no longer suppresses the genuine return.
907
+ let app = fixture.app.model();
908
+ let next = app.hover_keyboard.pending.expect(
909
+ "BEHAVIOR: after two mapped absences an exact-pixel return must request new authority",
910
+ );
911
+ assert_ne!(next.0, first.0, "BEHAVIOR: never revive the canceled token");
912
+ assert_eq!(
913
+ next.1, app.interaction_epoch,
914
+ "BEHAVIOR: request current authority epoch"
915
+ );
916
+ assert_eq!(fixture.app.widget().keyboard_mode(), KeyboardMode::None);
917
+ }