@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
@@ -16,6 +16,7 @@ const AK_CHIP_SLOTS: usize = 1;
16
16
 
17
17
  pub struct CardView {
18
18
  pub root: gtk::Button,
19
+ pub motion: gtk::EventControllerMotion,
19
20
  phase: gtk::Label,
20
21
  repo: gtk::Label,
21
22
  state: gtk::Label,
@@ -154,7 +155,7 @@ impl CardView {
154
155
  motion.connect_leave(move |_| {
155
156
  let _ = tx.send(AppMsg::Hover(card_id.clone(), false));
156
157
  });
157
- root.add_controller(motion);
158
+ root.add_controller(motion.clone());
158
159
 
159
160
  let focus = gtk::EventControllerFocus::new();
160
161
  let card_id = id.to_owned();
@@ -193,6 +194,7 @@ impl CardView {
193
194
 
194
195
  Self {
195
196
  root,
197
+ motion,
196
198
  phase,
197
199
  repo,
198
200
  state,
@@ -216,7 +218,14 @@ impl CardView {
216
218
  }
217
219
  }
218
220
 
219
- pub fn update(&self, card: &Card, focused: bool, duplicate_label: bool, now_ms: i64) {
221
+ pub fn update(
222
+ &self,
223
+ card: &Card,
224
+ focused: bool,
225
+ duplicate_label: bool,
226
+ now_ms: i64,
227
+ key_hint: Option<&str>,
228
+ ) {
220
229
  let hidden_tab = card.hidden_tab();
221
230
  self.repo.set_text(&display_repo(card, duplicate_label));
222
231
  self.phase.set_text(text_or(&card.phase, "Idle"));
@@ -301,7 +310,7 @@ impl CardView {
301
310
  };
302
311
  let ak_note = ak_accessible_note(&card.ak_tasks);
303
312
  let window_note = window_note(card);
304
- self.root.set_tooltip_text(Some(&format!(
313
+ let mut tooltip = format!(
305
314
  "{} {} ({}{}{}){}",
306
315
  if hidden_tab { "Present" } else { "Focus" },
307
316
  text_or(&card.repo_label, "Pi session"),
@@ -309,7 +318,13 @@ impl CardView {
309
318
  window_note,
310
319
  if hidden_tab { ", hidden tab" } else { "" },
311
320
  ak_note
312
- )));
321
+ );
322
+ // The first rung of the keyboard hints: every card names the shortcut.
323
+ if let Some(hint) = key_hint {
324
+ tooltip.push('\n');
325
+ tooltip.push_str(hint);
326
+ }
327
+ self.root.set_tooltip_text(Some(&tooltip));
313
328
  let accessible_label = format!(
314
329
  "{}{}, {}, {}{}",
315
330
  text_or(&card.repo_label, "Pi session"),
@@ -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
+ ]