@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.
- package/README.md +59 -21
- package/bin/pi-activity-strip.mjs +32 -4
- package/native/bin/linux-x64-gnu/artifact.json +4 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +1141 -84
- 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 +471 -0
- 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 +5 -0
- package/native/panel/src/protocol.rs +93 -8
- package/native/panel/src/runtime.rs +85 -12
- package/native/panel/src/style.css +85 -0
- package/package.json +6 -7
- 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 +2 -0
- package/src/common/contracts.ts +39 -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 +118 -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 +91 -42
- 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 +354 -18
- 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"),
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
use crate::app::AppMsg;
|
|
2
|
+
use crate::protocol::WindowEntry;
|
|
3
|
+
use relm4::Sender;
|
|
4
|
+
use relm4::gtk;
|
|
5
|
+
use relm4::gtk::prelude::*;
|
|
6
|
+
use std::cell::RefCell;
|
|
7
|
+
|
|
8
|
+
/// Enough digits for any Niri window id; a longer number is a typo, not a window.
|
|
9
|
+
const MAX_WINDOW_DIGITS: usize = 9;
|
|
10
|
+
/// A search is a few letters of a project name, never a sentence.
|
|
11
|
+
const MAX_QUERY_CHARS: usize = 32;
|
|
12
|
+
|
|
13
|
+
/// What Enter does with the typed query.
|
|
14
|
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
15
|
+
pub enum JumpChoice {
|
|
16
|
+
/// A number: exactly that Niri window, listed or not.
|
|
17
|
+
Window(i64),
|
|
18
|
+
/// A name: the first listed match, opened like clicking its row.
|
|
19
|
+
Card(String),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// The brand block's window list: every agent window on every workspace, as its card's first line
|
|
23
|
+
/// and its Niri window number. The typed query narrows it by name or number; Enter goes to the
|
|
24
|
+
/// typed window number, or to the first match of a name.
|
|
25
|
+
pub struct DirectoryView {
|
|
26
|
+
pub root: gtk::Box,
|
|
27
|
+
status: gtk::Label,
|
|
28
|
+
rows: gtk::Box,
|
|
29
|
+
// Every material field (including the activation target), but not current styling.
|
|
30
|
+
rendered_rows: RefCell<Vec<WindowEntry>>,
|
|
31
|
+
sender: Sender<AppMsg>,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
impl DirectoryView {
|
|
35
|
+
pub fn new(sender: &Sender<AppMsg>) -> Self {
|
|
36
|
+
let root = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
|
37
|
+
root.add_css_class("directory");
|
|
38
|
+
root.set_visible(false);
|
|
39
|
+
root.set_vexpand(true);
|
|
40
|
+
root.set_margin_top(8);
|
|
41
|
+
|
|
42
|
+
let status = gtk::Label::new(None);
|
|
43
|
+
status.add_css_class("jump-line");
|
|
44
|
+
status.set_halign(gtk::Align::Fill);
|
|
45
|
+
status.set_xalign(0.0);
|
|
46
|
+
|
|
47
|
+
let rows = gtk::Box::new(gtk::Orientation::Vertical, 1);
|
|
48
|
+
let scroller = gtk::ScrolledWindow::new();
|
|
49
|
+
scroller.add_css_class("directory-scroller");
|
|
50
|
+
scroller.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
|
|
51
|
+
// Fill the brand column's height; rows beyond it scroll (wheel or touchpad, no bar).
|
|
52
|
+
scroller.set_vexpand(true);
|
|
53
|
+
scroller.set_child(Some(&rows));
|
|
54
|
+
|
|
55
|
+
root.append(&status);
|
|
56
|
+
root.append(&scroller);
|
|
57
|
+
Self {
|
|
58
|
+
root,
|
|
59
|
+
status,
|
|
60
|
+
rows,
|
|
61
|
+
rendered_rows: RefCell::new(Vec::new()),
|
|
62
|
+
sender: sender.clone(),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
pub fn set_visible(&self, visible: bool) {
|
|
67
|
+
self.root.set_visible(visible);
|
|
68
|
+
}
|
|
69
|
+
|
|
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
|
|
84
|
+
/// marked, so a typed name always shows where it leads.
|
|
85
|
+
pub fn render(
|
|
86
|
+
&self,
|
|
87
|
+
windows: &[WindowEntry],
|
|
88
|
+
query: &str,
|
|
89
|
+
can_type: bool,
|
|
90
|
+
allow_unlisted: bool,
|
|
91
|
+
hotkey: Option<&str>,
|
|
92
|
+
) {
|
|
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();
|
|
110
|
+
let choice = jump_choice(windows, query, allow_unlisted);
|
|
111
|
+
// Alphabetical from the controller, so workspace headings would repeat; each row names
|
|
112
|
+
// its workspace instead.
|
|
113
|
+
for entry in &shown {
|
|
114
|
+
let chosen = match &choice {
|
|
115
|
+
Some(JumpChoice::Window(id)) => entry.window_id == *id,
|
|
116
|
+
Some(JumpChoice::Card(card_id)) => &entry.card_id == card_id,
|
|
117
|
+
None => false,
|
|
118
|
+
};
|
|
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
|
+
}
|
|
132
|
+
}
|
|
133
|
+
*self.rendered_rows.borrow_mut() = key;
|
|
134
|
+
if shown.is_empty() {
|
|
135
|
+
let empty = gtk::Label::new(Some(if windows.is_empty() {
|
|
136
|
+
"No agent windows"
|
|
137
|
+
} else {
|
|
138
|
+
"No listed window matches"
|
|
139
|
+
}));
|
|
140
|
+
empty.add_css_class("directory-empty");
|
|
141
|
+
empty.set_xalign(0.0);
|
|
142
|
+
self.rows.append(&empty);
|
|
143
|
+
}
|
|
144
|
+
self.status.set_text(&status_text(
|
|
145
|
+
windows,
|
|
146
|
+
query,
|
|
147
|
+
can_type,
|
|
148
|
+
allow_unlisted,
|
|
149
|
+
hotkey,
|
|
150
|
+
));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// The typing hint alone, leaving the rows (and any click in progress on them) untouched.
|
|
154
|
+
pub fn set_prompt(
|
|
155
|
+
&self,
|
|
156
|
+
windows: &[WindowEntry],
|
|
157
|
+
query: &str,
|
|
158
|
+
can_type: bool,
|
|
159
|
+
allow_unlisted: bool,
|
|
160
|
+
hotkey: Option<&str>,
|
|
161
|
+
) {
|
|
162
|
+
self.status.set_text(&status_text(
|
|
163
|
+
windows,
|
|
164
|
+
query,
|
|
165
|
+
can_type,
|
|
166
|
+
allow_unlisted,
|
|
167
|
+
hotkey,
|
|
168
|
+
));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// A transient message after a jump or activation, until the next keystroke re-renders.
|
|
172
|
+
pub fn set_status(&self, message: &str) {
|
|
173
|
+
self.status.set_text(message);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
fn row(&self, entry: &WindowEntry, chosen: bool) -> gtk::Button {
|
|
177
|
+
let row = gtk::Button::new();
|
|
178
|
+
row.add_css_class("directory-row");
|
|
179
|
+
if entry.current {
|
|
180
|
+
row.add_css_class("current");
|
|
181
|
+
}
|
|
182
|
+
if chosen {
|
|
183
|
+
row.add_css_class("chosen");
|
|
184
|
+
}
|
|
185
|
+
let line = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
186
|
+
let label = gtk::Label::new(Some(&format!(
|
|
187
|
+
"{}{}",
|
|
188
|
+
if entry.hidden_tab { "⧉ " } else { "" },
|
|
189
|
+
entry.label
|
|
190
|
+
)));
|
|
191
|
+
label.add_css_class("directory-label");
|
|
192
|
+
label.set_xalign(0.0);
|
|
193
|
+
label.set_hexpand(true);
|
|
194
|
+
label.set_ellipsize(relm4::gtk::pango::EllipsizeMode::End);
|
|
195
|
+
label.set_max_width_chars(1);
|
|
196
|
+
let workspace = gtk::Label::new(Some(&workspace_tag(entry)));
|
|
197
|
+
workspace.add_css_class("directory-workspace");
|
|
198
|
+
let id = gtk::Label::new(Some(&format!("#{}", entry.window_id)));
|
|
199
|
+
id.add_css_class("directory-id");
|
|
200
|
+
line.append(&label);
|
|
201
|
+
line.append(&workspace);
|
|
202
|
+
line.append(&id);
|
|
203
|
+
row.set_child(Some(&line));
|
|
204
|
+
row.set_tooltip_text(Some(&format!(
|
|
205
|
+
"{} — window #{}, {}{}",
|
|
206
|
+
entry.label,
|
|
207
|
+
entry.window_id,
|
|
208
|
+
workspace_title(entry),
|
|
209
|
+
if entry.hidden_tab { ", hidden tab" } else { "" }
|
|
210
|
+
)));
|
|
211
|
+
let tx = self.sender.clone();
|
|
212
|
+
let card_id = entry.card_id.clone();
|
|
213
|
+
row.connect_clicked(move |_| {
|
|
214
|
+
let _ = tx.send(AppMsg::DirectoryActivate(card_id.clone()));
|
|
215
|
+
});
|
|
216
|
+
row
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/// Entries whose project name contains the query (ignoring case) or whose window number starts
|
|
221
|
+
/// with it; all of them when nothing is typed.
|
|
222
|
+
pub fn filter_entries<'a>(windows: &'a [WindowEntry], query: &str) -> Vec<&'a WindowEntry> {
|
|
223
|
+
let needle = query.to_lowercase();
|
|
224
|
+
windows
|
|
225
|
+
.iter()
|
|
226
|
+
.filter(|entry| {
|
|
227
|
+
entry.window_id.to_string().starts_with(&needle)
|
|
228
|
+
|| entry.label.to_lowercase().contains(&needle)
|
|
229
|
+
})
|
|
230
|
+
.collect()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
fn is_window_number(query: &str) -> bool {
|
|
234
|
+
!query.is_empty() && query.chars().all(|character| character.is_ascii_digit())
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/// What Enter does. Digits go to the listed window with exactly that number; failing that, to a
|
|
238
|
+
/// project whose name contains them (`0844` finds `pi-0844-…`); failing that, when
|
|
239
|
+
/// `allow_unlisted`, to exactly that number, listed or not, never a guessed completion. Anything
|
|
240
|
+
/// else goes to the first project whose name contains it. Nothing typed, or nothing matching,
|
|
241
|
+
/// does nothing. Typing held only by hover passes `allow_unlisted = false`, so a digit typed at a
|
|
242
|
+
/// terminal prompt cannot reach an arbitrary window.
|
|
243
|
+
pub fn jump_choice(
|
|
244
|
+
windows: &[WindowEntry],
|
|
245
|
+
query: &str,
|
|
246
|
+
allow_unlisted: bool,
|
|
247
|
+
) -> Option<JumpChoice> {
|
|
248
|
+
if query.is_empty() {
|
|
249
|
+
return None;
|
|
250
|
+
}
|
|
251
|
+
let needle = query.to_lowercase();
|
|
252
|
+
let named = || {
|
|
253
|
+
windows
|
|
254
|
+
.iter()
|
|
255
|
+
.find(|entry| entry.label.to_lowercase().contains(&needle))
|
|
256
|
+
.map(|entry| JumpChoice::Card(entry.card_id.clone()))
|
|
257
|
+
};
|
|
258
|
+
if !is_window_number(query) {
|
|
259
|
+
return named();
|
|
260
|
+
}
|
|
261
|
+
let number = (query.len() <= MAX_WINDOW_DIGITS)
|
|
262
|
+
.then(|| query.parse::<i64>().ok())
|
|
263
|
+
.flatten();
|
|
264
|
+
if let Some(id) = number
|
|
265
|
+
&& windows.iter().any(|entry| entry.window_id == id)
|
|
266
|
+
{
|
|
267
|
+
return Some(JumpChoice::Window(id));
|
|
268
|
+
}
|
|
269
|
+
named().or(number.filter(|_| allow_unlisted).map(JumpChoice::Window))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/// Append a typed character: letters and digits, and the `-`, `_` and `.` of project names.
|
|
273
|
+
pub fn push_query_char(query: &mut String, character: char) -> bool {
|
|
274
|
+
if !is_query_char(character) || query.chars().count() >= MAX_QUERY_CHARS {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
query.push(character);
|
|
278
|
+
true
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
pub fn is_query_char(character: char) -> bool {
|
|
282
|
+
character.is_alphanumeric() || matches!(character, '-' | '_' | '.')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/// The short workspace marker on each row: the number the operator sees in Niri.
|
|
286
|
+
fn workspace_tag(entry: &WindowEntry) -> String {
|
|
287
|
+
entry
|
|
288
|
+
.workspace_idx
|
|
289
|
+
.map(|idx| format!("ws{idx}"))
|
|
290
|
+
.unwrap_or_default()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/// The workspace in full, for the row's tooltip: its number and any name.
|
|
294
|
+
fn workspace_title(entry: &WindowEntry) -> String {
|
|
295
|
+
let number = entry
|
|
296
|
+
.workspace_idx
|
|
297
|
+
.map(|idx| format!("workspace {idx}"))
|
|
298
|
+
.unwrap_or_else(|| "workspace".to_owned());
|
|
299
|
+
match entry
|
|
300
|
+
.workspace_name
|
|
301
|
+
.as_deref()
|
|
302
|
+
.filter(|name| !name.is_empty())
|
|
303
|
+
{
|
|
304
|
+
Some(name) => format!("{number} · {name}"),
|
|
305
|
+
None => number,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
fn status_text(
|
|
310
|
+
windows: &[WindowEntry],
|
|
311
|
+
query: &str,
|
|
312
|
+
can_type: bool,
|
|
313
|
+
allow_unlisted: bool,
|
|
314
|
+
hotkey: Option<&str>,
|
|
315
|
+
) -> String {
|
|
316
|
+
if !can_type {
|
|
317
|
+
// The keyboard was handed back (hover timeout); a click or the shortcut takes it again.
|
|
318
|
+
return crate::hints::pointer_prompt(hotkey);
|
|
319
|
+
}
|
|
320
|
+
match jump_choice(windows, query, allow_unlisted) {
|
|
321
|
+
_ if query.is_empty() => "Type a name or number · ⏎ go".to_owned(),
|
|
322
|
+
Some(JumpChoice::Window(_)) => format!("#{query}▏ ⏎ jump · ⌫ edit"),
|
|
323
|
+
Some(JumpChoice::Card(card_id)) => {
|
|
324
|
+
let label = windows
|
|
325
|
+
.iter()
|
|
326
|
+
.find(|entry| entry.card_id == card_id)
|
|
327
|
+
.map(|entry| entry.label.as_str())
|
|
328
|
+
.unwrap_or("match");
|
|
329
|
+
format!("{query}▏ ⏎ {label}")
|
|
330
|
+
}
|
|
331
|
+
None => format!("{query}▏ no match"),
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#[cfg(test)]
|
|
336
|
+
mod tests {
|
|
337
|
+
use super::*;
|
|
338
|
+
|
|
339
|
+
fn jump_choice_open(windows: &[WindowEntry], query: &str) -> Option<JumpChoice> {
|
|
340
|
+
jump_choice(windows, query, true)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
fn entry(label: &str, window_id: i64, workspace_idx: i64) -> WindowEntry {
|
|
344
|
+
WindowEntry {
|
|
345
|
+
card_id: format!("card-{window_id}"),
|
|
346
|
+
label: label.into(),
|
|
347
|
+
window_id,
|
|
348
|
+
workspace_id: Some(workspace_idx + 100),
|
|
349
|
+
workspace_idx: Some(workspace_idx),
|
|
350
|
+
workspace_name: None,
|
|
351
|
+
hidden_tab: false,
|
|
352
|
+
current: false,
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#[test]
|
|
357
|
+
fn the_status_line_says_what_enter_will_do() {
|
|
358
|
+
let windows = vec![entry("pi-extensions", 432, 2)];
|
|
359
|
+
assert_eq!(
|
|
360
|
+
status_text(&windows, "", false, true, None),
|
|
361
|
+
"Click, then type a name or number"
|
|
362
|
+
);
|
|
363
|
+
assert_eq!(
|
|
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),
|
|
369
|
+
"Type a name or number · ⏎ go"
|
|
370
|
+
);
|
|
371
|
+
assert!(status_text(&windows, "43", true, true, None).starts_with("#43"));
|
|
372
|
+
assert_eq!(
|
|
373
|
+
status_text(&windows, "ext", true, true, None),
|
|
374
|
+
"ext▏ ⏎ pi-extensions"
|
|
375
|
+
);
|
|
376
|
+
assert_eq!(
|
|
377
|
+
status_text(&windows, "zzz", true, true, None),
|
|
378
|
+
"zzz▏ no match"
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
#[test]
|
|
383
|
+
fn the_query_matches_names_anywhere_and_numbers_from_the_start() {
|
|
384
|
+
let windows = vec![
|
|
385
|
+
entry("pi-extensions", 43, 2),
|
|
386
|
+
entry("dep-diet", 432, 2),
|
|
387
|
+
entry("Text-Tools", 344, 3),
|
|
388
|
+
];
|
|
389
|
+
let labels = |query: &str| -> Vec<String> {
|
|
390
|
+
filter_entries(&windows, query)
|
|
391
|
+
.iter()
|
|
392
|
+
.map(|entry| entry.label.clone())
|
|
393
|
+
.collect()
|
|
394
|
+
};
|
|
395
|
+
assert_eq!(labels("").len(), 3);
|
|
396
|
+
assert_eq!(
|
|
397
|
+
labels("ext"),
|
|
398
|
+
["pi-extensions", "Text-Tools"],
|
|
399
|
+
"any case, anywhere"
|
|
400
|
+
);
|
|
401
|
+
assert_eq!(labels("43"), ["pi-extensions", "dep-diet"]);
|
|
402
|
+
assert_eq!(labels("432"), ["dep-diet"]);
|
|
403
|
+
assert!(labels("9").is_empty());
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#[test]
|
|
407
|
+
fn enter_goes_to_the_typed_number_or_the_first_named_match() {
|
|
408
|
+
let windows = vec![entry("dep-diet", 432, 2), entry("pi-extensions", 43, 2)];
|
|
409
|
+
assert_eq!(
|
|
410
|
+
jump_choice_open(&windows, "43"),
|
|
411
|
+
Some(JumpChoice::Window(43))
|
|
412
|
+
);
|
|
413
|
+
assert_eq!(
|
|
414
|
+
jump_choice_open(&windows, "007"),
|
|
415
|
+
Some(JumpChoice::Window(7)),
|
|
416
|
+
"a number need not be listed"
|
|
417
|
+
);
|
|
418
|
+
assert_eq!(jump_choice_open(&windows, "1234567890"), None);
|
|
419
|
+
assert_eq!(
|
|
420
|
+
jump_choice_open(&windows, "d"),
|
|
421
|
+
Some(JumpChoice::Card("card-432".into())),
|
|
422
|
+
"the first listed match"
|
|
423
|
+
);
|
|
424
|
+
assert_eq!(jump_choice_open(&windows, "nothing"), None);
|
|
425
|
+
let numbered = vec![entry("pi-0844-qualification", 12, 2), entry("x", 844, 2)];
|
|
426
|
+
assert_eq!(
|
|
427
|
+
jump_choice_open(&numbered, "844"),
|
|
428
|
+
Some(JumpChoice::Window(844)),
|
|
429
|
+
"a listed window number wins"
|
|
430
|
+
);
|
|
431
|
+
assert_eq!(
|
|
432
|
+
jump_choice_open(&numbered[..1], "0844"),
|
|
433
|
+
Some(JumpChoice::Card("card-12".into())),
|
|
434
|
+
"digits inside a name, with no such window listed, go to that project"
|
|
435
|
+
);
|
|
436
|
+
assert_eq!(jump_choice_open(&windows, ""), None);
|
|
437
|
+
assert_eq!(
|
|
438
|
+
jump_choice(&windows, "1", false),
|
|
439
|
+
None,
|
|
440
|
+
"held only by hover, an unlisted number goes nowhere"
|
|
441
|
+
);
|
|
442
|
+
assert_eq!(
|
|
443
|
+
jump_choice(&windows, "43", false),
|
|
444
|
+
Some(JumpChoice::Window(43)),
|
|
445
|
+
"a listed number still jumps"
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
#[test]
|
|
450
|
+
fn the_query_takes_name_characters_up_to_a_bound() {
|
|
451
|
+
let mut query = String::new();
|
|
452
|
+
assert!(push_query_char(&mut query, 'e'));
|
|
453
|
+
assert!(push_query_char(&mut query, '-'));
|
|
454
|
+
assert!(push_query_char(&mut query, '4'));
|
|
455
|
+
assert!(!push_query_char(&mut query, ' '));
|
|
456
|
+
assert!(!push_query_char(&mut query, '/'));
|
|
457
|
+
for _ in 0..50 {
|
|
458
|
+
push_query_char(&mut query, 'x');
|
|
459
|
+
}
|
|
460
|
+
assert_eq!(query.chars().count(), MAX_QUERY_CHARS);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
#[test]
|
|
464
|
+
fn each_row_names_its_workspace() {
|
|
465
|
+
let mut named = entry("a", 1, 3);
|
|
466
|
+
named.workspace_name = Some("claude-recovery".into());
|
|
467
|
+
assert_eq!(workspace_tag(&named), "ws3");
|
|
468
|
+
assert_eq!(workspace_title(&named), "workspace 3 · claude-recovery");
|
|
469
|
+
assert_eq!(workspace_title(&entry("a", 1, 2)), "workspace 2");
|
|
470
|
+
}
|
|
471
|
+
}
|
|
@@ -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
|
+
]
|