@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.
- package/README.md +59 -22
- package/bin/pi-activity-strip.mjs +32 -4
- package/native/bin/linux-x64-gnu/artifact.json +3 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +572 -266
- package/native/panel/src/app_gui_tests.rs +730 -0
- package/native/panel/src/app_hover_gui_tests.rs +917 -0
- package/native/panel/src/card_view.rs +19 -4
- package/native/panel/src/directory_view.rs +79 -17
- package/native/panel/src/hints.rs +65 -0
- package/native/panel/src/hover-lifecycle-fixtures.json +30 -0
- package/native/panel/src/hover_keyboard.rs +237 -0
- package/native/panel/src/interaction.rs +156 -0
- package/native/panel/src/keyboard-observation-fixtures.json +241 -0
- package/native/panel/src/keyboard_observation.rs +70 -0
- package/native/panel/src/main.rs +4 -0
- package/native/panel/src/protocol.rs +22 -8
- package/native/panel/src/runtime.rs +43 -2
- package/package.json +5 -5
- package/src/broker/server.mjs +31 -14
- package/src/client/launcher.mjs +3 -7
- package/src/client/runtime-lock.mjs +2 -1
- package/src/common/agent-identity.mjs +6 -1
- package/src/common/contracts.d.ts +1 -0
- package/src/common/contracts.ts +38 -0
- package/src/common/keyboard-observation.mjs +35 -0
- package/src/common/overlay-readiness.mjs +11 -0
- package/src/common/presence-apps.mjs +57 -0
- package/src/common/recovery-identity.mjs +85 -0
- package/src/common/recovery-manifest.mjs +417 -0
- package/src/common/recovery-types.ts +75 -0
- package/src/common/ribbon-status.mjs +41 -0
- package/src/common/status-report.mjs +37 -2
- package/src/common/workspace-view.mjs +54 -0
- package/src/native/ak-runtime.mjs +50 -9
- package/src/native/focus-visibility.mjs +67 -0
- package/src/native/ghostty-tab-inventory.py +40 -23
- package/src/native/hover-keyboard.mjs +32 -0
- package/src/native/interaction-context.mjs +45 -0
- package/src/native/keyboard-entry.mjs +32 -0
- package/src/native/main.mjs +77 -71
- package/src/native/panel-binary.mjs +25 -8
- package/src/native/panel-neutral-source.mjs +53 -0
- package/src/native/panel-presentation.mjs +137 -0
- package/src/native/panel-projection.mjs +338 -24
- package/src/native/panel-protocol.mjs +23 -0
- package/src/native/panel-window-jump.mjs +90 -0
- package/src/native/placement.mjs +83 -20
- package/src/native/recovery-inventory.mjs +209 -0
- package/src/native/recovery-proc.mjs +178 -0
- package/src/native/recovery-sources.mjs +358 -0
- package/src/native/runtime-diagnostics.mjs +242 -0
- package/src/native/strip-hotkey-kdl.mjs +252 -0
- package/src/native/strip-hotkey.mjs +62 -0
- package/src/native/tab-inventory.mjs +62 -11
- package/src/native/window-jump.mjs +43 -0
- package/src/native/workspace-events.mjs +123 -2
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
//! Actual GTK/layer-shell interaction test; never run on the operator's display.
|
|
2
|
+
use super::*;
|
|
3
|
+
use relm4::ComponentController;
|
|
4
|
+
use std::io::{BufRead, BufReader, Write};
|
|
5
|
+
use std::process::{Child, ChildStdout, Command, Stdio};
|
|
6
|
+
use std::time::Instant;
|
|
7
|
+
|
|
8
|
+
fn pump() {
|
|
9
|
+
if let Some(display) = gdk::Display::default() {
|
|
10
|
+
// Flush without a blocking roundtrip before pumping GTK: nested compositor replies must
|
|
11
|
+
// be dispatched while bounded hover timers and input callbacks are actually running.
|
|
12
|
+
display.flush();
|
|
13
|
+
}
|
|
14
|
+
let until = Instant::now() + Duration::from_millis(250);
|
|
15
|
+
while Instant::now() < until {
|
|
16
|
+
while glib::MainContext::default().pending() {
|
|
17
|
+
glib::MainContext::default().iteration(false);
|
|
18
|
+
}
|
|
19
|
+
std::thread::sleep(Duration::from_millis(2));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fn pump_until(check: impl Fn() -> bool, label: &str) {
|
|
24
|
+
let start = Instant::now();
|
|
25
|
+
while !check() {
|
|
26
|
+
assert!(start.elapsed() < Duration::from_secs(30), "{label}");
|
|
27
|
+
pump();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fn move_pointer(pointer: &mut Child, events: &mut BufReader<ChildStdout>, x: i32, y: i32) {
|
|
32
|
+
writeln!(pointer.stdin.as_mut().unwrap(), "{x} {y}").unwrap();
|
|
33
|
+
let mut line = String::new();
|
|
34
|
+
assert!(events.read_line(&mut line).unwrap() > 0);
|
|
35
|
+
assert_eq!(line.trim(), "moved");
|
|
36
|
+
pump();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
fn type_keys(args: &[&str]) {
|
|
40
|
+
assert!(Command::new("wtype").args(args).status().unwrap().success());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fn assert_clean(app: &App) {
|
|
44
|
+
assert!(app.hovered.is_none());
|
|
45
|
+
assert!(!app.brand_hovered);
|
|
46
|
+
assert!(!app.directory_hovered);
|
|
47
|
+
assert!(!app.directory_open);
|
|
48
|
+
assert!(!app.keyboard_active);
|
|
49
|
+
assert!(app.keyboard_focused.is_none());
|
|
50
|
+
assert!(app.open_card_id.is_none());
|
|
51
|
+
assert!(app.pending_request.is_none());
|
|
52
|
+
assert!(app.query.is_empty());
|
|
53
|
+
assert_eq!(app.key_capture.get(), KeyCapture::None);
|
|
54
|
+
assert!(!app.hover_keyboard.active);
|
|
55
|
+
assert!(!app.hover_keyboard.captured);
|
|
56
|
+
assert!(app.hover_keyboard.pending.is_none());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[test]
|
|
60
|
+
#[ignore = "requires owned nested Niri, private runtime/bus, disabled AT-SPI and virtual pointer"]
|
|
61
|
+
fn isolated_gtk_interaction() {
|
|
62
|
+
assert_isolated_gui();
|
|
63
|
+
gtk::init().unwrap();
|
|
64
|
+
|
|
65
|
+
let fixture = gtk::Window::new();
|
|
66
|
+
fixture.set_title(Some("AK7114 isolated typing receiver (fixture, not agent)"));
|
|
67
|
+
let entry = gtk::Entry::new();
|
|
68
|
+
fixture.set_child(Some(&entry));
|
|
69
|
+
fixture.present();
|
|
70
|
+
entry.grab_focus();
|
|
71
|
+
pump();
|
|
72
|
+
let windows: serde_json::Value = serde_json::from_slice(
|
|
73
|
+
&Command::new("niri")
|
|
74
|
+
.args(["msg", "-j", "windows"])
|
|
75
|
+
.output()
|
|
76
|
+
.unwrap()
|
|
77
|
+
.stdout,
|
|
78
|
+
)
|
|
79
|
+
.unwrap();
|
|
80
|
+
let fixture_id = windows
|
|
81
|
+
.as_array()
|
|
82
|
+
.unwrap()
|
|
83
|
+
.iter()
|
|
84
|
+
.find(|window| window["pid"].as_u64() == Some(u64::from(std::process::id())))
|
|
85
|
+
.expect("own typing receiver mapped")["id"]
|
|
86
|
+
.as_u64()
|
|
87
|
+
.unwrap()
|
|
88
|
+
.to_string();
|
|
89
|
+
assert!(
|
|
90
|
+
Command::new("niri")
|
|
91
|
+
.args(["msg", "action", "focus-window", "--id", &fixture_id])
|
|
92
|
+
.status()
|
|
93
|
+
.unwrap()
|
|
94
|
+
.success()
|
|
95
|
+
);
|
|
96
|
+
pump();
|
|
97
|
+
entry.grab_focus();
|
|
98
|
+
let app = App::builder()
|
|
99
|
+
.launch(AppInit {
|
|
100
|
+
demo: true,
|
|
101
|
+
click_through: false,
|
|
102
|
+
})
|
|
103
|
+
.detach();
|
|
104
|
+
pump();
|
|
105
|
+
let root = app.widget();
|
|
106
|
+
let motion = gtk::EventControllerMotion::new();
|
|
107
|
+
motion.connect_motion(|_, x, y| println!("GUI root pointer {x},{y}"));
|
|
108
|
+
motion.connect_enter(|_, x, y| println!("GUI root enter {x},{y}"));
|
|
109
|
+
root.add_controller(motion);
|
|
110
|
+
let outputs: serde_json::Value = serde_json::from_slice(
|
|
111
|
+
&Command::new("niri")
|
|
112
|
+
.args(["msg", "-j", "outputs"])
|
|
113
|
+
.output()
|
|
114
|
+
.unwrap()
|
|
115
|
+
.stdout,
|
|
116
|
+
)
|
|
117
|
+
.unwrap();
|
|
118
|
+
let logical = &outputs.as_object().unwrap().values().next().unwrap()["logical"];
|
|
119
|
+
let width = logical["width"].as_u64().unwrap().to_string();
|
|
120
|
+
let height = logical["height"].as_u64().unwrap().to_string();
|
|
121
|
+
println!(
|
|
122
|
+
"GUI geometry: mapped={} size={}x{} outputs={outputs}",
|
|
123
|
+
root.is_mapped(),
|
|
124
|
+
root.width(),
|
|
125
|
+
root.height()
|
|
126
|
+
);
|
|
127
|
+
let mut pointer = Command::new(virtual_pointer_fixture())
|
|
128
|
+
.args(["300", "900", &width, &height])
|
|
129
|
+
.stdin(Stdio::piped())
|
|
130
|
+
.stdout(Stdio::piped())
|
|
131
|
+
.spawn()
|
|
132
|
+
.unwrap();
|
|
133
|
+
let mut events = BufReader::new(pointer.stdout.take().unwrap());
|
|
134
|
+
let mut ready = String::new();
|
|
135
|
+
assert!(events.read_line(&mut ready).unwrap() > 0);
|
|
136
|
+
assert_eq!(ready.trim(), "ready");
|
|
137
|
+
assert!(
|
|
138
|
+
Command::new("niri")
|
|
139
|
+
.args(["msg", "action", "focus-window", "--id", &fixture_id])
|
|
140
|
+
.status()
|
|
141
|
+
.unwrap()
|
|
142
|
+
.success()
|
|
143
|
+
);
|
|
144
|
+
pump();
|
|
145
|
+
entry.grab_focus();
|
|
146
|
+
println!(
|
|
147
|
+
"GUI before hover focused={}",
|
|
148
|
+
String::from_utf8_lossy(
|
|
149
|
+
&Command::new("niri")
|
|
150
|
+
.args(["msg", "-j", "focused-window"])
|
|
151
|
+
.output()
|
|
152
|
+
.unwrap()
|
|
153
|
+
.stdout
|
|
154
|
+
)
|
|
155
|
+
);
|
|
156
|
+
assert_eq!(root.keyboard_mode(), KeyboardMode::None);
|
|
157
|
+
assert_eq!(root.default_size().1, COMPACT_HEIGHT);
|
|
158
|
+
assert!(!app.model().expanded());
|
|
159
|
+
// Real pointer enter on the brand: expansion, but no keyboard mode, focus, or key capture.
|
|
160
|
+
move_pointer(&mut pointer, &mut events, 60, 40);
|
|
161
|
+
assert!(app.model().brand_hovered);
|
|
162
|
+
assert!(app.model().expanded());
|
|
163
|
+
assert_eq!(root.exclusive_zone(), COMPACT_HEIGHT);
|
|
164
|
+
assert_eq!(root.keyboard_mode(), KeyboardMode::None);
|
|
165
|
+
assert_eq!(app.model().key_capture.get(), KeyCapture::None);
|
|
166
|
+
println!(
|
|
167
|
+
"GUI typing ownership: fixture_active={} entry_focus={} root_active={} focused={}",
|
|
168
|
+
fixture.is_active(),
|
|
169
|
+
entry.has_focus(),
|
|
170
|
+
root.is_active(),
|
|
171
|
+
String::from_utf8_lossy(
|
|
172
|
+
&Command::new("niri")
|
|
173
|
+
.args(["msg", "-j", "focused-window"])
|
|
174
|
+
.output()
|
|
175
|
+
.unwrap()
|
|
176
|
+
.stdout
|
|
177
|
+
)
|
|
178
|
+
);
|
|
179
|
+
type_keys(&["hover-typing"]);
|
|
180
|
+
pump();
|
|
181
|
+
assert_eq!(
|
|
182
|
+
entry.text().as_str(),
|
|
183
|
+
"hover-typing",
|
|
184
|
+
"hover must not swallow any keys"
|
|
185
|
+
);
|
|
186
|
+
move_pointer(&mut pointer, &mut events, 300, 900);
|
|
187
|
+
assert_clean(&app.model());
|
|
188
|
+
assert_eq!(root.default_size().1, COMPACT_HEIGHT);
|
|
189
|
+
println!(
|
|
190
|
+
"GUI PASS: real pointer brand enter/leave, no hover keyboard capture, typing delivered to fixture"
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// GTK selecting/focusing a card without explicit keyboard mode never opens it.
|
|
194
|
+
let id = app.model().order[0].clone();
|
|
195
|
+
let card = app.model().cards[&id].root.clone();
|
|
196
|
+
let point = card
|
|
197
|
+
.compute_point(root, >k::graphene::Point::new(10.0, 10.0))
|
|
198
|
+
.unwrap();
|
|
199
|
+
move_pointer(
|
|
200
|
+
&mut pointer,
|
|
201
|
+
&mut events,
|
|
202
|
+
point.x() as i32 + 8,
|
|
203
|
+
point.y() as i32 + 8,
|
|
204
|
+
);
|
|
205
|
+
assert!(app.model().hovered.as_ref() == Some(&id));
|
|
206
|
+
assert_eq!(app.model().open_card_id.as_deref(), Some(id.as_str()));
|
|
207
|
+
assert_eq!(root.keyboard_mode(), KeyboardMode::None);
|
|
208
|
+
assert_eq!(app.model().key_capture.get(), KeyCapture::None);
|
|
209
|
+
type_keys(&["card-typing"]);
|
|
210
|
+
pump();
|
|
211
|
+
assert_eq!(entry.text().as_str(), "hover-typingcard-typing");
|
|
212
|
+
// Demo emits requests but has NO Node authority. Actual admission/typing/Enter is tested
|
|
213
|
+
// with the production controller in the separate harness, never inferred from this demo.
|
|
214
|
+
let (token, epoch) = app.model().hover_keyboard.pending.unwrap();
|
|
215
|
+
move_pointer(&mut pointer, &mut events, 300, 900);
|
|
216
|
+
assert_clean(&app.model());
|
|
217
|
+
println!(
|
|
218
|
+
"GUI PASS: real pointer expands card but cannot capture without controller grant; leave clears pending request"
|
|
219
|
+
);
|
|
220
|
+
app.emit(AppMsg::HoverGrant(token, epoch, 41));
|
|
221
|
+
app.emit(AppMsg::Focus(id.clone(), true));
|
|
222
|
+
pump();
|
|
223
|
+
assert_eq!(
|
|
224
|
+
root.keyboard_mode(),
|
|
225
|
+
KeyboardMode::None,
|
|
226
|
+
"late grant after leave denied"
|
|
227
|
+
);
|
|
228
|
+
assert!(!app.model().expanded());
|
|
229
|
+
let revision = app.model().revision;
|
|
230
|
+
app.emit(AppMsg::FocusStrip(revision, app.model().interaction_epoch));
|
|
231
|
+
pump();
|
|
232
|
+
assert!(app.model().keyboard_active);
|
|
233
|
+
assert!(app.model().expanded());
|
|
234
|
+
assert_eq!(root.keyboard_mode(), KeyboardMode::Exclusive);
|
|
235
|
+
// Entry starts where the operator is (hovered, else the focused window's card, else the first
|
|
236
|
+
// card on screen), never by jumping the row to its start.
|
|
237
|
+
assert_eq!(
|
|
238
|
+
app.model().keyboard_focused,
|
|
239
|
+
app.model().focused_card_id,
|
|
240
|
+
"explicit entry starts at the focused window's card"
|
|
241
|
+
);
|
|
242
|
+
// Read the length first: a `for` header keeps its temporaries (here the model borrow) alive
|
|
243
|
+
// for the whole loop, and the runtime cannot update a borrowed model while keys are pumped.
|
|
244
|
+
let cards = app.model().order.len();
|
|
245
|
+
for _ in 0..cards {
|
|
246
|
+
let at_start = app.model().keyboard_focused.as_ref() == app.model().order.first();
|
|
247
|
+
if at_start {
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
type_keys(&["-k", "Left"]);
|
|
251
|
+
pump();
|
|
252
|
+
}
|
|
253
|
+
assert_eq!(
|
|
254
|
+
app.model().keyboard_focused.as_ref(),
|
|
255
|
+
app.model().order.first(),
|
|
256
|
+
"Left walks the keyboard selection back to the row start"
|
|
257
|
+
);
|
|
258
|
+
let next = app.model().order[1].clone();
|
|
259
|
+
type_keys(&["-k", "Right"]);
|
|
260
|
+
pump();
|
|
261
|
+
assert_eq!(app.model().keyboard_focused.as_deref(), Some(next.as_str()));
|
|
262
|
+
type_keys(&["-M", "shift", "-k", "Right", "-m", "shift"]);
|
|
263
|
+
pump();
|
|
264
|
+
assert_eq!(app.model().order[2], next);
|
|
265
|
+
type_keys(&["fixture"]);
|
|
266
|
+
pump();
|
|
267
|
+
assert_eq!(app.model().query, "fixture");
|
|
268
|
+
type_keys(&["-k", "Escape"]);
|
|
269
|
+
pump();
|
|
270
|
+
assert_clean(&app.model());
|
|
271
|
+
assert_eq!(root.keyboard_mode(), KeyboardMode::None);
|
|
272
|
+
println!(
|
|
273
|
+
"GUI PASS: explicit shortcut captures keyboard/search, real Escape returns it and clears UI"
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
let mut scrollable = demo_view();
|
|
277
|
+
scrollable.revision = revision + 1;
|
|
278
|
+
scrollable.focused_card_id = None;
|
|
279
|
+
for i in 0..4 {
|
|
280
|
+
let mut extra = scrollable.sessions[0].clone();
|
|
281
|
+
extra.card_id = format!("scroll-extra-{i}");
|
|
282
|
+
extra.session_id = extra.card_id.clone();
|
|
283
|
+
scrollable.sessions.push(extra);
|
|
284
|
+
}
|
|
285
|
+
app.emit(AppMsg::View(scrollable.clone()));
|
|
286
|
+
pump();
|
|
287
|
+
let adjustment = app.widgets().scroller.hadjustment();
|
|
288
|
+
pump_until(
|
|
289
|
+
|| adjustment.upper() > adjustment.page_size(),
|
|
290
|
+
"fixture must actually overflow for stale-scroll proof",
|
|
291
|
+
);
|
|
292
|
+
move_pointer(&mut pointer, &mut events, 60, 40);
|
|
293
|
+
assert!(app.model().brand_hovered);
|
|
294
|
+
let brand_generation = app.model().brand_generation;
|
|
295
|
+
let collapse_generation = app.model().collapse_generation;
|
|
296
|
+
let ticket = app.model().lifetime.ticket();
|
|
297
|
+
// Queue a real two-frame focused-card scroll, then hide before its frames can run.
|
|
298
|
+
adjustment.set_value(0.0);
|
|
299
|
+
let mut pending_scroll = scrollable.clone();
|
|
300
|
+
pending_scroll.revision = revision + 2;
|
|
301
|
+
pending_scroll.focused_card_id = Some("scroll-extra-3".into());
|
|
302
|
+
app.emit(AppMsg::View(pending_scroll));
|
|
303
|
+
let mut hidden = scrollable.clone();
|
|
304
|
+
hidden.revision = revision + 3;
|
|
305
|
+
hidden.visible = false;
|
|
306
|
+
app.emit(AppMsg::View(hidden));
|
|
307
|
+
app.emit(AppMsg::BrandSettle(brand_generation));
|
|
308
|
+
app.emit(AppMsg::CollapseIf(collapse_generation));
|
|
309
|
+
app.emit(AppMsg::Hover(id.clone(), true));
|
|
310
|
+
app.emit(AppMsg::BrandHover(true));
|
|
311
|
+
app.emit(AppMsg::DirectoryHover(true));
|
|
312
|
+
app.emit(AppMsg::DirectoryKey(DirectoryKey::Char('4')));
|
|
313
|
+
app.emit(AppMsg::FocusStrip(revision, app.model().interaction_epoch));
|
|
314
|
+
pump();
|
|
315
|
+
assert_clean(&app.model());
|
|
316
|
+
assert!(!root.is_visible());
|
|
317
|
+
assert_eq!(root.default_size().1, COMPACT_HEIGHT);
|
|
318
|
+
assert_eq!(root.exclusive_zone(), -1);
|
|
319
|
+
assert!(!app.model().lifetime.accepts(ticket, true, true));
|
|
320
|
+
move_pointer(&mut pointer, &mut events, 300, 900);
|
|
321
|
+
let mut shown = scrollable;
|
|
322
|
+
shown.revision = revision + 4;
|
|
323
|
+
shown.focused_card_id = None;
|
|
324
|
+
app.emit(AppMsg::View(shown));
|
|
325
|
+
pump();
|
|
326
|
+
assert_clean(&app.model());
|
|
327
|
+
assert_eq!(root.default_size().1, COMPACT_HEIGHT);
|
|
328
|
+
assert_eq!(
|
|
329
|
+
adjustment.value(),
|
|
330
|
+
0.0,
|
|
331
|
+
"revoked scroll cannot run after remap"
|
|
332
|
+
);
|
|
333
|
+
// A queued synthetic enter after returning cannot substitute for actual pointer presence.
|
|
334
|
+
app.emit(AppMsg::Hover(id, true));
|
|
335
|
+
app.emit(AppMsg::BrandHover(true));
|
|
336
|
+
app.emit(AppMsg::DirectoryHover(true));
|
|
337
|
+
pump();
|
|
338
|
+
assert_clean(&app.model());
|
|
339
|
+
println!(
|
|
340
|
+
"GUI PASS: hidden surface releases zone; late brand/hover/key/focus callbacks no-op; return collapsed"
|
|
341
|
+
);
|
|
342
|
+
pointer.stdin.take();
|
|
343
|
+
assert!(pointer.wait().unwrap().success());
|
|
344
|
+
root.close();
|
|
345
|
+
drop(app);
|
|
346
|
+
pump();
|
|
347
|
+
|
|
348
|
+
let app = App::builder()
|
|
349
|
+
.launch(AppInit {
|
|
350
|
+
demo: true,
|
|
351
|
+
click_through: true,
|
|
352
|
+
})
|
|
353
|
+
.detach();
|
|
354
|
+
pump();
|
|
355
|
+
app.emit(AppMsg::FocusStrip(
|
|
356
|
+
app.model().revision,
|
|
357
|
+
app.model().interaction_epoch,
|
|
358
|
+
));
|
|
359
|
+
app.emit(AppMsg::BrandHover(true));
|
|
360
|
+
pump();
|
|
361
|
+
assert_clean(&app.model());
|
|
362
|
+
assert_eq!(app.widget().keyboard_mode(), KeyboardMode::None);
|
|
363
|
+
println!("GUI PASS: click-through rejects keyboard and hover engagement");
|
|
364
|
+
app.widget().close();
|
|
365
|
+
fixture.close();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#[test]
|
|
369
|
+
#[ignore = "requires owned nested Niri, private runtime/bus, disabled AT-SPI and virtual pointer"]
|
|
370
|
+
fn isolated_gtk_empty_visible_preserves_zone_without_keyboard_capture() {
|
|
371
|
+
assert_isolated_gui();
|
|
372
|
+
gtk::init().unwrap();
|
|
373
|
+
|
|
374
|
+
// Keep actual pointer input away from the ribbon before mapping it. Visibility alone,
|
|
375
|
+
// not pointer intent or a controller grant, is the subject of this test.
|
|
376
|
+
let outputs: serde_json::Value = serde_json::from_slice(
|
|
377
|
+
&Command::new("niri")
|
|
378
|
+
.args(["msg", "-j", "outputs"])
|
|
379
|
+
.output()
|
|
380
|
+
.unwrap()
|
|
381
|
+
.stdout,
|
|
382
|
+
)
|
|
383
|
+
.unwrap();
|
|
384
|
+
let logical = &outputs.as_object().unwrap().values().next().unwrap()["logical"];
|
|
385
|
+
let width = logical["width"].as_u64().unwrap().to_string();
|
|
386
|
+
let height = logical["height"].as_u64().unwrap();
|
|
387
|
+
let mut pointer = Command::new(virtual_pointer_fixture())
|
|
388
|
+
.args([
|
|
389
|
+
"300",
|
|
390
|
+
&(height - 40).to_string(),
|
|
391
|
+
&width,
|
|
392
|
+
&height.to_string(),
|
|
393
|
+
])
|
|
394
|
+
.stdin(Stdio::piped())
|
|
395
|
+
.stdout(Stdio::piped())
|
|
396
|
+
.spawn()
|
|
397
|
+
.unwrap();
|
|
398
|
+
let mut events = BufReader::new(pointer.stdout.take().unwrap());
|
|
399
|
+
let mut ready = String::new();
|
|
400
|
+
assert!(events.read_line(&mut ready).unwrap() > 0);
|
|
401
|
+
assert_eq!(ready.trim(), "ready");
|
|
402
|
+
|
|
403
|
+
let fixture = gtk::Window::new();
|
|
404
|
+
fixture.set_title(Some("AK8273 isolated empty-ribbon typing receiver"));
|
|
405
|
+
let entry = gtk::Entry::new();
|
|
406
|
+
fixture.set_child(Some(&entry));
|
|
407
|
+
fixture.present();
|
|
408
|
+
entry.grab_focus();
|
|
409
|
+
pump_until(
|
|
410
|
+
|| fixture.is_active(),
|
|
411
|
+
"SETUP: typing receiver must have focus",
|
|
412
|
+
);
|
|
413
|
+
let app = App::builder()
|
|
414
|
+
.launch(AppInit {
|
|
415
|
+
demo: true,
|
|
416
|
+
click_through: false,
|
|
417
|
+
})
|
|
418
|
+
.detach();
|
|
419
|
+
let root = app.widget();
|
|
420
|
+
let mut empty = demo_view();
|
|
421
|
+
empty.revision += 1;
|
|
422
|
+
empty.visible = true;
|
|
423
|
+
empty.sessions.clear();
|
|
424
|
+
empty.focused_card_id = None;
|
|
425
|
+
empty.windows.clear();
|
|
426
|
+
app.emit(AppMsg::View(empty.clone()));
|
|
427
|
+
pump();
|
|
428
|
+
|
|
429
|
+
// RED on the old sessions-count visibility guard: explicit true with no cards hides.
|
|
430
|
+
assert!(root.is_mapped(), "visible=true must map even without cards");
|
|
431
|
+
let assert_idle_ribbon = || {
|
|
432
|
+
assert!(app.model().visible);
|
|
433
|
+
assert!(root.is_visible() && root.is_mapped());
|
|
434
|
+
assert_eq!(root.exclusive_zone(), COMPACT_HEIGHT);
|
|
435
|
+
assert_eq!(root.default_size().1, COMPACT_HEIGHT);
|
|
436
|
+
assert!(!app.model().expanded());
|
|
437
|
+
assert_clean(&app.model());
|
|
438
|
+
assert!(!app.model().hover_capture.get());
|
|
439
|
+
assert_eq!(root.keyboard_mode(), KeyboardMode::None);
|
|
440
|
+
assert!(!root.is_active());
|
|
441
|
+
assert!(
|
|
442
|
+
fixture.is_active(),
|
|
443
|
+
"presentation must not steal window focus"
|
|
444
|
+
);
|
|
445
|
+
};
|
|
446
|
+
assert_idle_ribbon();
|
|
447
|
+
assert!(app.model().cards.is_empty());
|
|
448
|
+
type_keys(&["empty"]);
|
|
449
|
+
pump();
|
|
450
|
+
assert_eq!(entry.text().as_str(), "empty");
|
|
451
|
+
|
|
452
|
+
// Detect a transient unmap as well as checking the final zone; all updates keep the
|
|
453
|
+
// same admission epoch so epoch revocation cannot mask accidental capture.
|
|
454
|
+
let unmaps = Rc::new(Cell::new(0));
|
|
455
|
+
let observed_unmaps = Rc::clone(&unmaps);
|
|
456
|
+
root.connect_unmap(move |_| observed_unmaps.set(observed_unmaps.get() + 1));
|
|
457
|
+
let mut populated = demo_view();
|
|
458
|
+
populated.revision = empty.revision + 1;
|
|
459
|
+
let first_populated = populated.clone();
|
|
460
|
+
empty.revision = populated.revision + 1;
|
|
461
|
+
populated.revision = empty.revision + 1;
|
|
462
|
+
for (view, typed, expected) in [
|
|
463
|
+
(first_populated, " populated", "empty populated"),
|
|
464
|
+
(empty.clone(), " empty", "empty populated empty"),
|
|
465
|
+
(
|
|
466
|
+
populated.clone(),
|
|
467
|
+
" returned",
|
|
468
|
+
"empty populated empty returned",
|
|
469
|
+
),
|
|
470
|
+
] {
|
|
471
|
+
let card_count = view.sessions.len();
|
|
472
|
+
app.emit(AppMsg::View(view));
|
|
473
|
+
pump();
|
|
474
|
+
assert_idle_ribbon();
|
|
475
|
+
assert_eq!(app.model().cards.len(), card_count);
|
|
476
|
+
assert_eq!(
|
|
477
|
+
unmaps.get(),
|
|
478
|
+
0,
|
|
479
|
+
"membership updates must not unmap the ribbon"
|
|
480
|
+
);
|
|
481
|
+
type_keys(&[typed]);
|
|
482
|
+
pump();
|
|
483
|
+
assert_eq!(entry.text().as_str(), expected);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Removing the last card also releases an existing explicit card keyboard selection,
|
|
487
|
+
// without hiding the now-empty ribbon or giving up its compact reservation.
|
|
488
|
+
app.emit(AppMsg::FocusStrip(71, app.model().interaction_epoch));
|
|
489
|
+
pump_until(
|
|
490
|
+
|| root.is_active(),
|
|
491
|
+
"SETUP: explicit keyboard mode must acquire focus",
|
|
492
|
+
);
|
|
493
|
+
assert!(app.model().keyboard_active);
|
|
494
|
+
assert_eq!(root.keyboard_mode(), KeyboardMode::Exclusive);
|
|
495
|
+
empty.revision = populated.revision + 1;
|
|
496
|
+
app.emit(AppMsg::View(empty.clone()));
|
|
497
|
+
pump();
|
|
498
|
+
assert_idle_ribbon();
|
|
499
|
+
assert_eq!(unmaps.get(), 0);
|
|
500
|
+
type_keys(&[" released"]);
|
|
501
|
+
pump();
|
|
502
|
+
assert_eq!(
|
|
503
|
+
entry.text().as_str(),
|
|
504
|
+
"empty populated empty returned released"
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
// Explicit false must still revoke actual keyboard ownership and release the zone.
|
|
508
|
+
populated.revision = empty.revision + 1;
|
|
509
|
+
app.emit(AppMsg::View(populated.clone()));
|
|
510
|
+
pump();
|
|
511
|
+
app.emit(AppMsg::FocusStrip(72, app.model().interaction_epoch));
|
|
512
|
+
pump_until(
|
|
513
|
+
|| root.is_active(),
|
|
514
|
+
"SETUP: reacquire explicit keyboard mode",
|
|
515
|
+
);
|
|
516
|
+
assert_eq!(root.keyboard_mode(), KeyboardMode::Exclusive);
|
|
517
|
+
populated.revision += 1;
|
|
518
|
+
populated.visible = false;
|
|
519
|
+
app.emit(AppMsg::View(populated));
|
|
520
|
+
pump();
|
|
521
|
+
assert!(!app.model().visible);
|
|
522
|
+
assert!(!root.is_visible() && !root.is_mapped());
|
|
523
|
+
assert_eq!(root.exclusive_zone(), -1);
|
|
524
|
+
assert_eq!(root.keyboard_mode(), KeyboardMode::None);
|
|
525
|
+
assert_clean(&app.model());
|
|
526
|
+
assert!(!app.model().hover_capture.get());
|
|
527
|
+
assert!(fixture.is_active());
|
|
528
|
+
type_keys(&[" hidden"]);
|
|
529
|
+
pump();
|
|
530
|
+
assert_eq!(
|
|
531
|
+
entry.text().as_str(),
|
|
532
|
+
"empty populated empty returned released hidden"
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
// The explicit hide contract applies to an empty view too.
|
|
536
|
+
empty.revision = app.model().revision + 1;
|
|
537
|
+
app.emit(AppMsg::View(empty.clone()));
|
|
538
|
+
pump();
|
|
539
|
+
assert_idle_ribbon();
|
|
540
|
+
empty.revision += 1;
|
|
541
|
+
empty.visible = false;
|
|
542
|
+
app.emit(AppMsg::View(empty));
|
|
543
|
+
pump();
|
|
544
|
+
assert!(!app.model().visible);
|
|
545
|
+
assert!(!root.is_visible() && !root.is_mapped());
|
|
546
|
+
assert_eq!(root.exclusive_zone(), -1);
|
|
547
|
+
assert_eq!(root.keyboard_mode(), KeyboardMode::None);
|
|
548
|
+
assert_clean(&app.model());
|
|
549
|
+
assert!(fixture.is_active());
|
|
550
|
+
|
|
551
|
+
pointer.stdin.take();
|
|
552
|
+
assert!(pointer.wait().unwrap().success());
|
|
553
|
+
root.close();
|
|
554
|
+
fixture.close();
|
|
555
|
+
drop(app);
|
|
556
|
+
pump();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
#[test]
|
|
560
|
+
#[ignore = "requires owned nested Niri, private runtime/bus and disabled AT-SPI; AK8077 RED candidate"]
|
|
561
|
+
fn isolated_gtk_directory_focus_only_preserves_rows_and_scroll() {
|
|
562
|
+
assert_isolated_gui();
|
|
563
|
+
gtk::init().unwrap();
|
|
564
|
+
|
|
565
|
+
// Given an actual, overflowing DirectoryView with stable membership/order/query and a
|
|
566
|
+
// nonzero scroll offset. Keep strong GTK references: replacement rows cannot pass by
|
|
567
|
+
// reusing addresses of destroyed widgets.
|
|
568
|
+
let (sender, _receiver) = relm4::channel::<AppMsg>();
|
|
569
|
+
let directory = DirectoryView::new(&sender);
|
|
570
|
+
let mut windows: Vec<WindowEntry> = (0..80)
|
|
571
|
+
.map(|i| WindowEntry {
|
|
572
|
+
card_id: format!("directory-focus-{i}"),
|
|
573
|
+
label: format!("project-{i:02}"),
|
|
574
|
+
window_id: i + 100,
|
|
575
|
+
workspace_id: Some(101),
|
|
576
|
+
workspace_idx: Some(1),
|
|
577
|
+
workspace_name: Some("fixture".into()),
|
|
578
|
+
hidden_tab: false,
|
|
579
|
+
current: i == 0,
|
|
580
|
+
})
|
|
581
|
+
.collect();
|
|
582
|
+
directory.render(&windows, "", false, false, None);
|
|
583
|
+
directory.set_visible(true);
|
|
584
|
+
let fixture = gtk::Window::new();
|
|
585
|
+
fixture.set_title(Some("AK8077 isolated directory focus-only fixture"));
|
|
586
|
+
fixture.set_default_size(360, 240);
|
|
587
|
+
fixture.set_child(Some(&directory.root));
|
|
588
|
+
fixture.present();
|
|
589
|
+
let scroller = directory
|
|
590
|
+
.root
|
|
591
|
+
.last_child()
|
|
592
|
+
.unwrap()
|
|
593
|
+
.downcast::<gtk::ScrolledWindow>()
|
|
594
|
+
.expect("directory has a real GTK scroller");
|
|
595
|
+
let adjustment = scroller.vadjustment();
|
|
596
|
+
pump_until(
|
|
597
|
+
|| {
|
|
598
|
+
fixture.is_mapped()
|
|
599
|
+
&& adjustment.page_size() > 0.0
|
|
600
|
+
&& adjustment.upper() - adjustment.page_size() > 200.0
|
|
601
|
+
},
|
|
602
|
+
"SETUP: directory must map and actually overflow",
|
|
603
|
+
);
|
|
604
|
+
let before = directory.row_widgets();
|
|
605
|
+
assert_eq!(before.len(), windows.len());
|
|
606
|
+
assert!(before.iter().all(|row| row.is::<gtk::Button>()));
|
|
607
|
+
assert!(before[0].has_css_class("current"));
|
|
608
|
+
assert!(!before[1].has_css_class("current"));
|
|
609
|
+
adjustment.set_value((adjustment.upper() - adjustment.page_size()) / 2.0);
|
|
610
|
+
pump();
|
|
611
|
+
let scroll_before = adjustment.value();
|
|
612
|
+
assert!(scroll_before > 0.0, "SETUP: real nonzero scroll required");
|
|
613
|
+
|
|
614
|
+
// When only the focused-window flags change, call the production render path, not
|
|
615
|
+
// set_prompt or a projection/reconciliation helper. Everything else is identical.
|
|
616
|
+
windows[0].current = false;
|
|
617
|
+
windows[1].current = true;
|
|
618
|
+
directory.render(&windows, "", false, false, None);
|
|
619
|
+
let after = directory.row_widgets();
|
|
620
|
+
// Allow GTK layout/frame work to expose a delayed scroll reset too.
|
|
621
|
+
pump();
|
|
622
|
+
let scroll_after = adjustment.value();
|
|
623
|
+
fixture.close();
|
|
624
|
+
|
|
625
|
+
// Then every real row object survives in order, focus styling changes in place, and
|
|
626
|
+
// the operator's scroll offset survives. Widget equality is GObject identity, not text.
|
|
627
|
+
assert_eq!(after.len(), before.len());
|
|
628
|
+
for (index, (old, new)) in before.iter().zip(&after).enumerate() {
|
|
629
|
+
assert_eq!(
|
|
630
|
+
old, new,
|
|
631
|
+
"focus-only render replaced GTK directory row {index}"
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
assert!(!after[0].has_css_class("current"));
|
|
635
|
+
assert!(after[1].has_css_class("current"));
|
|
636
|
+
assert!(
|
|
637
|
+
(scroll_after - scroll_before).abs() < 0.5,
|
|
638
|
+
"focus-only render moved directory scroll: {scroll_before} -> {scroll_after}"
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Scheduling/observation helpers are compiled only through app's cfg(test) module.
|
|
643
|
+
#[path = "app_hover_gui_tests.rs"]
|
|
644
|
+
mod hover_regressions;
|
|
645
|
+
pub(super) use hover_regressions::{configure_tick_source, observe_dispatch};
|
|
646
|
+
|
|
647
|
+
fn assert_isolated_gui() {
|
|
648
|
+
assert_eq!(
|
|
649
|
+
std::env::var("PI_ACTIVITY_STRIP_ISOLATED_GUI").as_deref(),
|
|
650
|
+
Ok("1")
|
|
651
|
+
);
|
|
652
|
+
assert_eq!(std::env::var("GTK_A11Y").as_deref(), Ok("none"));
|
|
653
|
+
let state: serde_json::Value = serde_json::from_str(
|
|
654
|
+
&std::fs::read_to_string(std::env::var("PI_ACTIVITY_STRIP_GUI_STATE").unwrap()).unwrap(),
|
|
655
|
+
)
|
|
656
|
+
.unwrap();
|
|
657
|
+
let socket = std::env::var("NIRI_SOCKET").unwrap();
|
|
658
|
+
assert_ne!(Some(socket.as_str()), state["outer_socket"].as_str());
|
|
659
|
+
assert!(socket.ends_with(&format!(".{}.sock", state["pid"].as_u64().unwrap())));
|
|
660
|
+
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap();
|
|
661
|
+
assert!(
|
|
662
|
+
!socket.starts_with(&runtime),
|
|
663
|
+
"fixture runtime must be separate from the desktop runtime"
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/// The runner's compiled virtual-pointer fixture: an absolute path to a regular file named
|
|
668
|
+
/// `virtual-pointer`, never an arbitrary executable taken from the environment.
|
|
669
|
+
fn virtual_pointer_fixture() -> std::path::PathBuf {
|
|
670
|
+
let path = std::path::PathBuf::from(
|
|
671
|
+
std::env::var_os("PI_ACTIVITY_STRIP_VIRTUAL_POINTER")
|
|
672
|
+
.expect("SETUP: PI_ACTIVITY_STRIP_VIRTUAL_POINTER names the owned fixture"),
|
|
673
|
+
);
|
|
674
|
+
assert!(
|
|
675
|
+
path.is_absolute(),
|
|
676
|
+
"SETUP: virtual pointer fixture must be absolute"
|
|
677
|
+
);
|
|
678
|
+
assert_eq!(
|
|
679
|
+
path.file_name().and_then(|name| name.to_str()),
|
|
680
|
+
Some("virtual-pointer"),
|
|
681
|
+
"SETUP: only the virtual-pointer fixture may be executed"
|
|
682
|
+
);
|
|
683
|
+
assert!(
|
|
684
|
+
std::fs::metadata(&path).is_ok_and(|meta| meta.is_file()),
|
|
685
|
+
"SETUP: virtual pointer fixture must be a regular file"
|
|
686
|
+
);
|
|
687
|
+
path
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
#[test]
|
|
691
|
+
#[ignore = "KNOWN RED (AK7922, docs/project/2026-11-01-hover-reentry-anchor.md): a client cannot tell this genuine return from a remap; requires owned nested Niri, private runtime/bus, disabled AT-SPI and virtual pointer"]
|
|
692
|
+
fn isolated_gtk_rapid_hidden_reentry_requests_again() {
|
|
693
|
+
hover_regressions::rapid_hidden_reentry();
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
#[test]
|
|
697
|
+
#[ignore = "KNOWN RED (AK7922, docs/project/2026-11-01-hover-reentry-anchor.md): a client cannot tell this genuine return from a remap; requires owned nested Niri, private runtime/bus, disabled AT-SPI and virtual pointer"]
|
|
698
|
+
fn isolated_gtk_rapid_hidden_reentry_elsewhere_requests_again() {
|
|
699
|
+
hover_regressions::rapid_hidden_reentry_elsewhere();
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
#[test]
|
|
703
|
+
#[ignore = "requires owned nested Niri, private runtime/bus, disabled AT-SPI and virtual pointer"]
|
|
704
|
+
fn isolated_gtk_stationary_remap_does_not_request_again() {
|
|
705
|
+
hover_regressions::stationary_remap();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
#[test]
|
|
709
|
+
#[ignore = "requires owned nested Niri, private runtime/bus, disabled AT-SPI and virtual pointer"]
|
|
710
|
+
fn isolated_gtk_stationary_backend_loss_remap_does_not_request_again() {
|
|
711
|
+
hover_regressions::stationary_backend_loss_remap();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
#[test]
|
|
715
|
+
#[ignore = "requires owned nested Niri, private runtime/bus, disabled AT-SPI and virtual pointer"]
|
|
716
|
+
fn isolated_gtk_exact_pixel_return_after_two_ticks_requests_again() {
|
|
717
|
+
hover_regressions::exact_pixel_return_after_two_ticks();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
#[test]
|
|
721
|
+
#[ignore = "requires owned private Niri, exact virtual input, private runtime and disabled AT-SPI"]
|
|
722
|
+
fn isolated_gtk_hover_source_revocation_precedes_expiry() {
|
|
723
|
+
hover_regressions::hover_release_cause(false);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
#[test]
|
|
727
|
+
#[ignore = "requires owned private Niri, exact virtual input, private runtime and disabled AT-SPI"]
|
|
728
|
+
fn isolated_gtk_hover_expiry_releases_keyboard() {
|
|
729
|
+
hover_regressions::hover_release_cause(true);
|
|
730
|
+
}
|