@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
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
///
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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!(
|
|
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
|
+
}
|