@tryinget/pi-activity-strip 0.7.0 → 0.9.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.
@@ -8,8 +8,11 @@ use relm4::gtk::prelude::*;
8
8
  use std::cell::Cell;
9
9
  use std::rc::Rc;
10
10
 
11
- /// Rendered AK chips per card: at most two live-claim buttons plus two state badges.
12
- const AK_CHIP_SLOTS: usize = 4;
11
+ /// Rendered AK chips per card. The controller sends live-claim buttons first, then state badges;
12
+ /// the card shows the first and folds the rest into the `+N` count. Nothing else in the footer
13
+ /// shrinks, so this bound and the bounded timers are what hold the card at its width. The
14
+ /// inspector still lists every reference.
15
+ const AK_CHIP_SLOTS: usize = 1;
13
16
 
14
17
  pub struct CardView {
15
18
  pub root: gtk::Button,
@@ -52,12 +55,8 @@ impl CardView {
52
55
  let phase = label("card-phase", gtk::Align::Start);
53
56
  label_box.append(&repo);
54
57
  label_box.append(&phase);
55
- let ak_row = gtk::Box::new(gtk::Orientation::Horizontal, 4);
56
- ak_row.set_valign(gtk::Align::Center);
57
- ak_row.set_visible(false);
58
58
  let state = label("card-state", gtk::Align::End);
59
59
  header.append(&label_box);
60
- header.append(&ak_row);
61
60
  header.append(&state);
62
61
 
63
62
  let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8);
@@ -65,11 +64,20 @@ impl CardView {
65
64
  // The Niri window id, so a window an agent or tool names by id can be found on the ribbon.
66
65
  // It leads the footer so it sits at the same place on every card and costs the title nothing.
67
66
  let window = label("card-window", gtk::Align::Start);
67
+ window.set_ellipsize(relm4::gtk::pango::EllipsizeMode::None);
68
68
  window.set_visible(false);
69
+ // AK references ride in the footer as bare task numbers: the header belongs to the folder
70
+ // name, which is what tells cards apart. The titles live in each chip's tooltip.
71
+ let ak_row = gtk::Box::new(gtk::Orientation::Horizontal, 3);
72
+ ak_row.set_valign(gtk::Align::Center);
73
+ ak_row.set_visible(false);
69
74
  let tool = label("card-tool", gtk::Align::Start);
70
75
  tool.set_hexpand(true);
71
76
  let elapsed = label("card-elapsed", gtk::Align::End);
77
+ // The window id and timers are never cut: the tool name gives way to the AK chips first.
78
+ elapsed.set_ellipsize(relm4::gtk::pango::EllipsizeMode::None);
72
79
  footer.append(&window);
80
+ footer.append(&ak_row);
73
81
  footer.append(&tool);
74
82
  footer.append(&elapsed);
75
83
 
@@ -111,9 +119,8 @@ impl CardView {
111
119
  let mut ak_chips = Vec::with_capacity(AK_CHIP_SLOTS);
112
120
  let mut ak_clickable = Vec::with_capacity(AK_CHIP_SLOTS);
113
121
  for _slot in 0..AK_CHIP_SLOTS {
114
- let chip = label("ak-task", gtk::Align::End);
115
- chip.set_ellipsize(relm4::gtk::pango::EllipsizeMode::End);
116
- chip.set_max_width_chars(20);
122
+ let chip = label("ak-task", gtk::Align::Start);
123
+ chip.set_ellipsize(relm4::gtk::pango::EllipsizeMode::None);
117
124
  chip.set_visible(false);
118
125
  let clickable = Rc::new(Cell::new(false));
119
126
  let click = gtk::GestureClick::new();
@@ -131,7 +138,8 @@ impl CardView {
131
138
  ak_chips.push(chip);
132
139
  ak_clickable.push(clickable);
133
140
  }
134
- let ak_overflow = label("ak-overflow", gtk::Align::End);
141
+ let ak_overflow = label("ak-overflow", gtk::Align::Start);
142
+ ak_overflow.set_ellipsize(relm4::gtk::pango::EllipsizeMode::None);
135
143
  ak_overflow.set_visible(false);
136
144
  ak_row.append(&ak_overflow);
137
145
 
@@ -349,13 +357,9 @@ impl CardView {
349
357
  }
350
358
  }
351
359
  self.ak_row.set_visible(!chips.is_empty());
352
- if card.ak_task_overflow > 0 {
353
- self.ak_overflow.set_visible(true);
354
- self.ak_overflow
355
- .set_text(&format!("+{} more", card.ak_task_overflow));
356
- } else {
357
- self.ak_overflow.set_visible(false);
358
- }
360
+ let overflow = ak_folded_count(chips.len(), card.ak_task_overflow);
361
+ self.ak_overflow.set_visible(overflow > 0);
362
+ self.ak_overflow.set_text(&format!("+{overflow}"));
359
363
  }
360
364
 
361
365
  pub fn set_expanded(&self, expanded: bool) {
@@ -490,9 +494,16 @@ fn ak_chip_class(state: &str) -> &'static str {
490
494
  }
491
495
  }
492
496
 
493
- /// Compact chip text: task id plus as much title as the pill can hold.
497
+ /// References beyond the rendered chips: those the card has no slot for plus the controller's own
498
+ /// overflow.
499
+ fn ak_folded_count(joined: usize, overflow: u32) -> u32 {
500
+ overflow.saturating_add(joined.saturating_sub(AK_CHIP_SLOTS) as u32)
501
+ }
502
+
503
+ /// Compact chip text: the task number as it is written in commits, `AK5701`, so it never reads as
504
+ /// the `#43` window chip beside it. The title is in the tooltip and the inspector.
494
505
  fn ak_chip_text(task: &AkTask) -> String {
495
- format!("AK #{} · {}", task.id, task.title)
506
+ format!("AK{}", task.id)
496
507
  }
497
508
 
498
509
  fn ak_chip_tooltip(task: &AkTask) -> String {
@@ -559,9 +570,19 @@ fn text_or<'a>(value: &'a str, fallback: &'a str) -> &'a str {
559
570
  if value.is_empty() { fallback } else { value }
560
571
  }
561
572
 
573
+ /// Elapsed time in at most five characters (`59:59`, `23h59`, `99d`), so a long-running session
574
+ /// never widens the card's footer.
562
575
  fn duration(now_ms: i64, anchor_ms: i64) -> String {
563
576
  let seconds = now_ms.saturating_sub(anchor_ms.max(1)) / 1000;
564
- format!("{}:{:02}", seconds / 60, seconds % 60)
577
+ let minutes = seconds / 60;
578
+ let hours = minutes / 60;
579
+ if hours == 0 {
580
+ format!("{}:{:02}", minutes, seconds % 60)
581
+ } else if hours < 24 {
582
+ format!("{}h{:02}", hours, minutes % 60)
583
+ } else {
584
+ format!("{}d", (hours / 24).min(99))
585
+ }
565
586
  }
566
587
 
567
588
  #[cfg(test)]
@@ -657,14 +678,25 @@ mod tests {
657
678
  assert!(active.is_active());
658
679
  assert!(!orphaned.is_active());
659
680
  assert!(!deferred.is_active());
660
- assert_eq!(
661
- ak_chip_text(&active),
662
- "AK #5701 · Show clickable AK-task references"
663
- );
681
+ assert_eq!(ak_chip_text(&active), "AK5701");
682
+ assert_eq!(ak_folded_count(1, 0), 0);
683
+ assert_eq!(ak_folded_count(4, 0), 3);
684
+ assert_eq!(ak_folded_count(4, 21), 24);
664
685
  assert!(ak_chip_tooltip(&orphaned).contains("not clickable"));
665
686
  assert!(ak_chip_tooltip(&active).contains("focus the claiming session"));
666
687
  }
667
688
 
689
+ #[test]
690
+ fn durations_stay_within_five_characters() {
691
+ let at = |seconds: i64| duration(1_000 + seconds * 1000, 1_000);
692
+ assert_eq!(at(65), "1:05");
693
+ assert_eq!(at(59 * 60 + 59), "59:59");
694
+ assert_eq!(at(3600 + 5 * 60), "1h05");
695
+ assert_eq!(at(23 * 3600 + 59 * 60), "23h59");
696
+ assert_eq!(at(3 * 86_400), "3d");
697
+ assert_eq!(at(400 * 86_400), "99d");
698
+ }
699
+
668
700
  #[test]
669
701
  fn ak_inspector_lists_every_reference_with_overflow() {
670
702
  let tasks = vec![
@@ -0,0 +1,409 @@
1
+ use crate::app::AppMsg;
2
+ use crate::protocol::WindowEntry;
3
+ use relm4::Sender;
4
+ use relm4::gtk;
5
+ use relm4::gtk::prelude::*;
6
+
7
+ /// Enough digits for any Niri window id; a longer number is a typo, not a window.
8
+ const MAX_WINDOW_DIGITS: usize = 9;
9
+ /// A search is a few letters of a project name, never a sentence.
10
+ const MAX_QUERY_CHARS: usize = 32;
11
+
12
+ /// What Enter does with the typed query.
13
+ #[derive(Clone, Debug, PartialEq, Eq)]
14
+ pub enum JumpChoice {
15
+ /// A number: exactly that Niri window, listed or not.
16
+ Window(i64),
17
+ /// A name: the first listed match, opened like clicking its row.
18
+ Card(String),
19
+ }
20
+
21
+ /// The brand block's window list: every agent window on every workspace, as its card's first line
22
+ /// and its Niri window number. The typed query narrows it by name or number; Enter goes to the
23
+ /// typed window number, or to the first match of a name.
24
+ pub struct DirectoryView {
25
+ pub root: gtk::Box,
26
+ status: gtk::Label,
27
+ rows: gtk::Box,
28
+ sender: Sender<AppMsg>,
29
+ }
30
+
31
+ impl DirectoryView {
32
+ pub fn new(sender: &Sender<AppMsg>) -> Self {
33
+ let root = gtk::Box::new(gtk::Orientation::Vertical, 6);
34
+ root.add_css_class("directory");
35
+ root.set_visible(false);
36
+ root.set_vexpand(true);
37
+ root.set_margin_top(8);
38
+
39
+ let status = gtk::Label::new(None);
40
+ status.add_css_class("jump-line");
41
+ status.set_halign(gtk::Align::Fill);
42
+ status.set_xalign(0.0);
43
+
44
+ let rows = gtk::Box::new(gtk::Orientation::Vertical, 1);
45
+ let scroller = gtk::ScrolledWindow::new();
46
+ scroller.add_css_class("directory-scroller");
47
+ scroller.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
48
+ // Fill the brand column's height; rows beyond it scroll (wheel or touchpad, no bar).
49
+ scroller.set_vexpand(true);
50
+ scroller.set_child(Some(&rows));
51
+
52
+ root.append(&status);
53
+ root.append(&scroller);
54
+ Self {
55
+ root,
56
+ status,
57
+ rows,
58
+ sender: sender.clone(),
59
+ }
60
+ }
61
+
62
+ pub fn set_visible(&self, visible: bool) {
63
+ self.root.set_visible(visible);
64
+ }
65
+
66
+ /// Rebuild the rows for the current list and query. Clicking a row activates its card, so a
67
+ /// hidden tab is presented exactly as clicking its card would. The row Enter would open is
68
+ /// marked, so a typed name always shows where it leads.
69
+ pub fn render(
70
+ &self,
71
+ windows: &[WindowEntry],
72
+ query: &str,
73
+ can_type: bool,
74
+ allow_unlisted: bool,
75
+ ) {
76
+ while let Some(child) = self.rows.first_child() {
77
+ self.rows.remove(&child);
78
+ }
79
+ let shown = filter_entries(windows, query);
80
+ let choice = jump_choice(windows, query, allow_unlisted);
81
+ // Alphabetical from the controller, so workspace headings would repeat; each row names
82
+ // its workspace instead.
83
+ for entry in &shown {
84
+ let chosen = match &choice {
85
+ Some(JumpChoice::Window(id)) => entry.window_id == *id,
86
+ Some(JumpChoice::Card(card_id)) => &entry.card_id == card_id,
87
+ None => false,
88
+ };
89
+ self.rows.append(&self.row(entry, chosen));
90
+ }
91
+ if shown.is_empty() {
92
+ let empty = gtk::Label::new(Some(if windows.is_empty() {
93
+ "No agent windows"
94
+ } else {
95
+ "No listed window matches"
96
+ }));
97
+ empty.add_css_class("directory-empty");
98
+ empty.set_xalign(0.0);
99
+ self.rows.append(&empty);
100
+ }
101
+ self.status
102
+ .set_text(&status_text(windows, query, can_type, allow_unlisted));
103
+ }
104
+
105
+ /// The typing hint alone, leaving the rows (and any click in progress on them) untouched.
106
+ pub fn set_prompt(
107
+ &self,
108
+ windows: &[WindowEntry],
109
+ query: &str,
110
+ can_type: bool,
111
+ allow_unlisted: bool,
112
+ ) {
113
+ self.status
114
+ .set_text(&status_text(windows, query, can_type, allow_unlisted));
115
+ }
116
+
117
+ /// A transient message after a jump or activation, until the next keystroke re-renders.
118
+ pub fn set_status(&self, message: &str) {
119
+ self.status.set_text(message);
120
+ }
121
+
122
+ fn row(&self, entry: &WindowEntry, chosen: bool) -> gtk::Button {
123
+ let row = gtk::Button::new();
124
+ row.add_css_class("directory-row");
125
+ if entry.current {
126
+ row.add_css_class("current");
127
+ }
128
+ if chosen {
129
+ row.add_css_class("chosen");
130
+ }
131
+ let line = gtk::Box::new(gtk::Orientation::Horizontal, 8);
132
+ let label = gtk::Label::new(Some(&format!(
133
+ "{}{}",
134
+ if entry.hidden_tab { "⧉ " } else { "" },
135
+ entry.label
136
+ )));
137
+ label.add_css_class("directory-label");
138
+ label.set_xalign(0.0);
139
+ label.set_hexpand(true);
140
+ label.set_ellipsize(relm4::gtk::pango::EllipsizeMode::End);
141
+ label.set_max_width_chars(1);
142
+ let workspace = gtk::Label::new(Some(&workspace_tag(entry)));
143
+ workspace.add_css_class("directory-workspace");
144
+ let id = gtk::Label::new(Some(&format!("#{}", entry.window_id)));
145
+ id.add_css_class("directory-id");
146
+ line.append(&label);
147
+ line.append(&workspace);
148
+ line.append(&id);
149
+ row.set_child(Some(&line));
150
+ row.set_tooltip_text(Some(&format!(
151
+ "{} — window #{}, {}{}",
152
+ entry.label,
153
+ entry.window_id,
154
+ workspace_title(entry),
155
+ if entry.hidden_tab { ", hidden tab" } else { "" }
156
+ )));
157
+ let tx = self.sender.clone();
158
+ let card_id = entry.card_id.clone();
159
+ row.connect_clicked(move |_| {
160
+ let _ = tx.send(AppMsg::DirectoryActivate(card_id.clone()));
161
+ });
162
+ row
163
+ }
164
+ }
165
+
166
+ /// Entries whose project name contains the query (ignoring case) or whose window number starts
167
+ /// with it; all of them when nothing is typed.
168
+ pub fn filter_entries<'a>(windows: &'a [WindowEntry], query: &str) -> Vec<&'a WindowEntry> {
169
+ let needle = query.to_lowercase();
170
+ windows
171
+ .iter()
172
+ .filter(|entry| {
173
+ entry.window_id.to_string().starts_with(&needle)
174
+ || entry.label.to_lowercase().contains(&needle)
175
+ })
176
+ .collect()
177
+ }
178
+
179
+ fn is_window_number(query: &str) -> bool {
180
+ !query.is_empty() && query.chars().all(|character| character.is_ascii_digit())
181
+ }
182
+
183
+ /// What Enter does. Digits go to the listed window with exactly that number; failing that, to a
184
+ /// project whose name contains them (`0844` finds `pi-0844-…`); failing that, when
185
+ /// `allow_unlisted`, to exactly that number, listed or not, never a guessed completion. Anything
186
+ /// else goes to the first project whose name contains it. Nothing typed, or nothing matching,
187
+ /// does nothing. Typing held only by hover passes `allow_unlisted = false`, so a digit typed at a
188
+ /// terminal prompt cannot reach an arbitrary window.
189
+ pub fn jump_choice(
190
+ windows: &[WindowEntry],
191
+ query: &str,
192
+ allow_unlisted: bool,
193
+ ) -> Option<JumpChoice> {
194
+ if query.is_empty() {
195
+ return None;
196
+ }
197
+ let needle = query.to_lowercase();
198
+ let named = || {
199
+ windows
200
+ .iter()
201
+ .find(|entry| entry.label.to_lowercase().contains(&needle))
202
+ .map(|entry| JumpChoice::Card(entry.card_id.clone()))
203
+ };
204
+ if !is_window_number(query) {
205
+ return named();
206
+ }
207
+ let number = (query.len() <= MAX_WINDOW_DIGITS)
208
+ .then(|| query.parse::<i64>().ok())
209
+ .flatten();
210
+ if let Some(id) = number
211
+ && windows.iter().any(|entry| entry.window_id == id)
212
+ {
213
+ return Some(JumpChoice::Window(id));
214
+ }
215
+ named().or(number.filter(|_| allow_unlisted).map(JumpChoice::Window))
216
+ }
217
+
218
+ /// Append a typed character: letters and digits, and the `-`, `_` and `.` of project names.
219
+ pub fn push_query_char(query: &mut String, character: char) -> bool {
220
+ if !is_query_char(character) || query.chars().count() >= MAX_QUERY_CHARS {
221
+ return false;
222
+ }
223
+ query.push(character);
224
+ true
225
+ }
226
+
227
+ pub fn is_query_char(character: char) -> bool {
228
+ character.is_alphanumeric() || matches!(character, '-' | '_' | '.')
229
+ }
230
+
231
+ /// The short workspace marker on each row: the number the operator sees in Niri.
232
+ fn workspace_tag(entry: &WindowEntry) -> String {
233
+ entry
234
+ .workspace_idx
235
+ .map(|idx| format!("ws{idx}"))
236
+ .unwrap_or_default()
237
+ }
238
+
239
+ /// The workspace in full, for the row's tooltip: its number and any name.
240
+ fn workspace_title(entry: &WindowEntry) -> String {
241
+ let number = entry
242
+ .workspace_idx
243
+ .map(|idx| format!("workspace {idx}"))
244
+ .unwrap_or_else(|| "workspace".to_owned());
245
+ match entry
246
+ .workspace_name
247
+ .as_deref()
248
+ .filter(|name| !name.is_empty())
249
+ {
250
+ Some(name) => format!("{number} · {name}"),
251
+ None => number,
252
+ }
253
+ }
254
+
255
+ fn status_text(
256
+ windows: &[WindowEntry],
257
+ query: &str,
258
+ can_type: bool,
259
+ allow_unlisted: bool,
260
+ ) -> String {
261
+ if !can_type {
262
+ // The keyboard was handed back (hover timeout); a click takes it again.
263
+ return "Click, then type a name or number".to_owned();
264
+ }
265
+ match jump_choice(windows, query, allow_unlisted) {
266
+ _ if query.is_empty() => "Type a name or number · ⏎ go".to_owned(),
267
+ Some(JumpChoice::Window(_)) => format!("#{query}▏ ⏎ jump · ⌫ edit"),
268
+ Some(JumpChoice::Card(card_id)) => {
269
+ let label = windows
270
+ .iter()
271
+ .find(|entry| entry.card_id == card_id)
272
+ .map(|entry| entry.label.as_str())
273
+ .unwrap_or("match");
274
+ format!("{query}▏ ⏎ {label}")
275
+ }
276
+ None => format!("{query}▏ no match"),
277
+ }
278
+ }
279
+
280
+ #[cfg(test)]
281
+ mod tests {
282
+ use super::*;
283
+
284
+ fn jump_choice_open(windows: &[WindowEntry], query: &str) -> Option<JumpChoice> {
285
+ jump_choice(windows, query, true)
286
+ }
287
+
288
+ fn entry(label: &str, window_id: i64, workspace_idx: i64) -> WindowEntry {
289
+ WindowEntry {
290
+ card_id: format!("card-{window_id}"),
291
+ label: label.into(),
292
+ window_id,
293
+ workspace_id: Some(workspace_idx + 100),
294
+ workspace_idx: Some(workspace_idx),
295
+ workspace_name: None,
296
+ hidden_tab: false,
297
+ current: false,
298
+ }
299
+ }
300
+
301
+ #[test]
302
+ fn the_status_line_says_what_enter_will_do() {
303
+ let windows = vec![entry("pi-extensions", 432, 2)];
304
+ assert_eq!(
305
+ status_text(&windows, "", false, true),
306
+ "Click, then type a name or number"
307
+ );
308
+ assert_eq!(
309
+ status_text(&windows, "", true, true),
310
+ "Type a name or number · ⏎ go"
311
+ );
312
+ assert!(status_text(&windows, "43", true, true).starts_with("#43"));
313
+ assert_eq!(
314
+ status_text(&windows, "ext", true, true),
315
+ "ext▏ ⏎ pi-extensions"
316
+ );
317
+ assert_eq!(status_text(&windows, "zzz", true, true), "zzz▏ no match");
318
+ }
319
+
320
+ #[test]
321
+ fn the_query_matches_names_anywhere_and_numbers_from_the_start() {
322
+ let windows = vec![
323
+ entry("pi-extensions", 43, 2),
324
+ entry("dep-diet", 432, 2),
325
+ entry("Text-Tools", 344, 3),
326
+ ];
327
+ let labels = |query: &str| -> Vec<String> {
328
+ filter_entries(&windows, query)
329
+ .iter()
330
+ .map(|entry| entry.label.clone())
331
+ .collect()
332
+ };
333
+ assert_eq!(labels("").len(), 3);
334
+ assert_eq!(
335
+ labels("ext"),
336
+ ["pi-extensions", "Text-Tools"],
337
+ "any case, anywhere"
338
+ );
339
+ assert_eq!(labels("43"), ["pi-extensions", "dep-diet"]);
340
+ assert_eq!(labels("432"), ["dep-diet"]);
341
+ assert!(labels("9").is_empty());
342
+ }
343
+
344
+ #[test]
345
+ fn enter_goes_to_the_typed_number_or_the_first_named_match() {
346
+ let windows = vec![entry("dep-diet", 432, 2), entry("pi-extensions", 43, 2)];
347
+ assert_eq!(
348
+ jump_choice_open(&windows, "43"),
349
+ Some(JumpChoice::Window(43))
350
+ );
351
+ assert_eq!(
352
+ jump_choice_open(&windows, "007"),
353
+ Some(JumpChoice::Window(7)),
354
+ "a number need not be listed"
355
+ );
356
+ assert_eq!(jump_choice_open(&windows, "1234567890"), None);
357
+ assert_eq!(
358
+ jump_choice_open(&windows, "d"),
359
+ Some(JumpChoice::Card("card-432".into())),
360
+ "the first listed match"
361
+ );
362
+ assert_eq!(jump_choice_open(&windows, "nothing"), None);
363
+ let numbered = vec![entry("pi-0844-qualification", 12, 2), entry("x", 844, 2)];
364
+ assert_eq!(
365
+ jump_choice_open(&numbered, "844"),
366
+ Some(JumpChoice::Window(844)),
367
+ "a listed window number wins"
368
+ );
369
+ assert_eq!(
370
+ jump_choice_open(&numbered[..1], "0844"),
371
+ Some(JumpChoice::Card("card-12".into())),
372
+ "digits inside a name, with no such window listed, go to that project"
373
+ );
374
+ assert_eq!(jump_choice_open(&windows, ""), None);
375
+ assert_eq!(
376
+ jump_choice(&windows, "1", false),
377
+ None,
378
+ "held only by hover, an unlisted number goes nowhere"
379
+ );
380
+ assert_eq!(
381
+ jump_choice(&windows, "43", false),
382
+ Some(JumpChoice::Window(43)),
383
+ "a listed number still jumps"
384
+ );
385
+ }
386
+
387
+ #[test]
388
+ fn the_query_takes_name_characters_up_to_a_bound() {
389
+ let mut query = String::new();
390
+ assert!(push_query_char(&mut query, 'e'));
391
+ assert!(push_query_char(&mut query, '-'));
392
+ assert!(push_query_char(&mut query, '4'));
393
+ assert!(!push_query_char(&mut query, ' '));
394
+ assert!(!push_query_char(&mut query, '/'));
395
+ for _ in 0..50 {
396
+ push_query_char(&mut query, 'x');
397
+ }
398
+ assert_eq!(query.chars().count(), MAX_QUERY_CHARS);
399
+ }
400
+
401
+ #[test]
402
+ fn each_row_names_its_workspace() {
403
+ let mut named = entry("a", 1, 3);
404
+ named.workspace_name = Some("claude-recovery".into());
405
+ assert_eq!(workspace_tag(&named), "ws3");
406
+ assert_eq!(workspace_title(&named), "workspace 3 · claude-recovery");
407
+ assert_eq!(workspace_title(&entry("a", 1, 2)), "workspace 2");
408
+ }
409
+ }
@@ -1,5 +1,6 @@
1
1
  mod app;
2
2
  mod card_view;
3
+ mod directory_view;
3
4
  mod protocol;
4
5
  mod runtime;
5
6
 
@@ -140,6 +140,31 @@ pub struct ViewMessage {
140
140
  pub focused_card_id: Option<String>,
141
141
  #[serde(default)]
142
142
  pub sessions: Vec<Card>,
143
+ /// Every agent window on every workspace, for the brand block's window list.
144
+ #[serde(default)]
145
+ pub windows: Vec<WindowEntry>,
146
+ }
147
+
148
+ /// One entry of the window list: the card's first line and the Niri window number to jump to.
149
+ #[derive(Clone, Debug, Deserialize, PartialEq)]
150
+ #[serde(rename_all = "camelCase")]
151
+ pub struct WindowEntry {
152
+ #[serde(default)]
153
+ pub card_id: String,
154
+ #[serde(default)]
155
+ pub label: String,
156
+ pub window_id: i64,
157
+ /// Niri's workspace id: the grouping key, since workspace numbers repeat across monitors.
158
+ #[serde(default, deserialize_with = "lenient_integer")]
159
+ pub workspace_id: Option<i64>,
160
+ #[serde(default, deserialize_with = "lenient_integer")]
161
+ pub workspace_idx: Option<i64>,
162
+ #[serde(default)]
163
+ pub workspace_name: Option<String>,
164
+ #[serde(default)]
165
+ pub hidden_tab: bool,
166
+ #[serde(default)]
167
+ pub current: bool,
143
168
  }
144
169
 
145
170
  fn protocol_version() -> u8 {
@@ -252,6 +277,33 @@ pub fn demo_view() -> ViewMessage {
252
277
  ..empty_card()
253
278
  },
254
279
  ],
280
+ windows: vec![
281
+ demo_window("demo-monitor", "activity-strip", 36, 2, false, false),
282
+ demo_window("demo-tool", "native-dogfood", 43, 2, false, true),
283
+ demo_window("demo-hidden", "pi-extensions", 43, 2, true, false),
284
+ demo_window("demo-other", "dep-surgeon", 344, 3, false, false),
285
+ demo_window("demo-far", "niri-desktop-continuity", 1048, 5, false, false),
286
+ ],
287
+ }
288
+ }
289
+
290
+ fn demo_window(
291
+ card_id: &str,
292
+ label: &str,
293
+ window_id: i64,
294
+ workspace_idx: i64,
295
+ hidden_tab: bool,
296
+ current: bool,
297
+ ) -> WindowEntry {
298
+ WindowEntry {
299
+ card_id: card_id.into(),
300
+ label: label.into(),
301
+ window_id,
302
+ workspace_id: Some(workspace_idx + 100),
303
+ workspace_idx: Some(workspace_idx),
304
+ workspace_name: None,
305
+ hidden_tab,
306
+ current,
255
307
  }
256
308
  }
257
309
 
@@ -300,6 +352,25 @@ mod tests {
300
352
  assert_eq!(view.sessions[0].pid, 4242);
301
353
  assert!(view.sessions[0].ak_tasks.is_empty());
302
354
  assert_eq!(view.sessions[0].ak_task_overflow, 0);
355
+ assert!(
356
+ view.windows.is_empty(),
357
+ "controllers without a window list still parse"
358
+ );
359
+ }
360
+
361
+ #[test]
362
+ fn view_messages_carry_the_window_list() {
363
+ let view: ViewMessage = serde_json::from_str(
364
+ r#"{"type":"view","revision":1,"windows":[{"cardId":"terminal:a","label":"pi-extensions","windowId":432,"workspaceIdx":"x","workspaceName":null,"hiddenTab":true,"current":false}]}"#,
365
+ )
366
+ .expect("view should parse");
367
+ assert_eq!(view.windows.len(), 1);
368
+ assert_eq!(view.windows[0].window_id, 432);
369
+ assert_eq!(
370
+ view.windows[0].workspace_idx, None,
371
+ "a malformed workspace number drops only that field"
372
+ );
373
+ assert!(view.windows[0].hidden_tab);
303
374
  }
304
375
 
305
376
  #[test]