@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.
@@ -1,13 +1,19 @@
1
1
  use crate::card_view::CardView;
2
- use crate::protocol::{Card, ViewMessage, demo_view, emit, emit_error, emit_ready};
2
+ use crate::directory_view::{
3
+ DirectoryView, JumpChoice, is_query_char, jump_choice, push_query_char,
4
+ };
5
+ use crate::protocol::{Card, ViewMessage, WindowEntry, demo_view, emit, emit_error, emit_ready};
3
6
  use crate::runtime::{apply_theme, duplicate_labels, install_css, now_ms, start_input_reader};
4
7
  use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
5
8
  use relm4::gtk;
9
+ use relm4::gtk::gdk;
6
10
  use relm4::gtk::glib;
7
11
  use relm4::gtk::prelude::*;
8
12
  use relm4::{Component, ComponentParts, ComponentSender};
9
13
  use serde_json::json;
14
+ use std::cell::Cell;
10
15
  use std::collections::{HashMap, HashSet};
16
+ use std::rc::Rc;
11
17
  use std::time::Duration;
12
18
 
13
19
  const COMPACT_HEIGHT: i32 = 84;
@@ -16,6 +22,14 @@ const COMPACT_HEIGHT: i32 = 84;
16
22
  const OUTER_MARGIN: i32 = 8;
17
23
  const EXPANDED_HEIGHT: i32 = 276;
18
24
  const ORDER_REFRESH_MS: i64 = 15_000;
25
+ const BRAND_WIDTH: i32 = 196;
26
+ /// Grace before a hovered-away window list closes, so crossing the brand's own padding does not
27
+ /// close and reopen it.
28
+ const DIRECTORY_CLOSE_MS: u64 = 200;
29
+ /// Hovering the ribbon takes the keyboard so a window number can be typed at once. Without a digit
30
+ /// within this time it is handed back, so a pointer resting on the ribbon cannot keep swallowing
31
+ /// what is typed into the window below; a click on the ribbon still takes it again.
32
+ const HOVER_GRAB_MS: u64 = 3_000;
19
33
 
20
34
  pub struct AppInit {
21
35
  pub demo: bool,
@@ -36,6 +50,77 @@ pub struct App {
36
50
  next_order_refresh_at: i64,
37
51
  collapse_generation: u64,
38
52
  interactive: bool,
53
+ directory: DirectoryView,
54
+ windows: Vec<WindowEntry>,
55
+ directory_open: bool,
56
+ directory_hovered: bool,
57
+ /// The pointer is over the brand column: it expands the ribbon like a card does, showing the
58
+ /// list at full height.
59
+ brand_hovered: bool,
60
+ /// Counts brand hover changes, so a leave only collapses if the pointer did not come back
61
+ /// (or land on a card) within the collapse grace.
62
+ brand_generation: u64,
63
+ directory_generation: u64,
64
+ /// Opened by hovering the brand, so leaving it closes the list; a list opened by a typed
65
+ /// digit in keyboard mode stays open until it jumps or is dismissed.
66
+ directory_opened_by_hover: bool,
67
+ /// Counts list openings, so a jump or activation result only acts on the list that asked.
68
+ directory_session: u64,
69
+ pending_request: Option<PendingRequest>,
70
+ /// The list changed the keyboard mode and must set it back when it closes.
71
+ directory_owns_keyboard: bool,
72
+ /// The keyboard was taken on hover and is still held. Released when the pointer leaves, and
73
+ /// after HOVER_GRAB_MS without a key while nothing is typed.
74
+ hover_grab: bool,
75
+ /// Counts grab timers, so only the latest one (re-armed on every key) can release the grab.
76
+ grab_generation: u64,
77
+ /// Whether the ribbon has keyboard focus right now: after a click on the list, or in
78
+ /// keyboard mode. Hovering alone never takes the keyboard from the window being typed into.
79
+ window_active: bool,
80
+ query: String,
81
+ /// Read by the window's key controller: which keys belong to the window list right now.
82
+ key_capture: Rc<Cell<KeyCapture>>,
83
+ /// Read by the key controller: the keyboard is held only because of hover, so any key that is
84
+ /// not part of a search means the typing was meant for the window below.
85
+ hover_guard: Rc<Cell<bool>>,
86
+ }
87
+
88
+ /// Keys the window list takes before any card sees them.
89
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
90
+ pub enum KeyCapture {
91
+ None,
92
+ /// Keyboard mode on the cards: a typed name or number character opens the list; Enter and
93
+ /// Escape stay with the cards.
94
+ Digits,
95
+ /// The list is open with nothing typed: digits, Backspace and Escape are its own; Enter still
96
+ /// reaches the focused card.
97
+ List,
98
+ /// The list is open with digits typed: Enter jumps.
99
+ ListTyped,
100
+ }
101
+
102
+ /// What the open list asked the controller for, so only that answer acts on it.
103
+ #[derive(Clone, Debug, PartialEq, Eq)]
104
+ pub struct PendingRequest {
105
+ session: u64,
106
+ target: RequestTarget,
107
+ }
108
+
109
+ #[derive(Clone, Debug, PartialEq, Eq)]
110
+ pub enum RequestTarget {
111
+ Card(String),
112
+ Window(i64),
113
+ }
114
+
115
+ #[derive(Debug)]
116
+ pub enum DirectoryKey {
117
+ /// A letter, digit, `-`, `_` or `.` of a name or window number.
118
+ Char(char),
119
+ Backspace,
120
+ Enter,
121
+ Escape,
122
+ /// A key that cannot be part of a search, pressed while only hover holds the keyboard.
123
+ Interrupt,
39
124
  }
40
125
 
41
126
  #[derive(Debug)]
@@ -51,9 +136,17 @@ pub enum AppMsg {
51
136
  FocusStrip,
52
137
  Collapse,
53
138
  CollapseIf(u64),
54
- WindowInactive,
139
+ WindowActive(bool),
55
140
  ParentGone,
56
141
  InputError(String),
142
+ DirectoryHover(bool),
143
+ BrandHover(bool),
144
+ BrandSettle(u64),
145
+ DirectoryCloseIf(u64),
146
+ DirectoryGrabExpired(u64),
147
+ DirectoryKey(DirectoryKey),
148
+ DirectoryActivate(String),
149
+ JumpResult(i64, bool, String),
57
150
  }
58
151
 
59
152
  #[relm4::component(pub)]
@@ -77,28 +170,45 @@ impl Component for App {
77
170
  set_orientation: gtk::Orientation::Horizontal,
78
171
  set_spacing: 0,
79
172
 
173
+ // "You are here": the project and folder of the focused window. The labels fill a
174
+ // fixed width instead of sizing it, so moving focus never shifts the cards beside it.
80
175
  #[name = "brand"]
81
176
  gtk::Box {
82
177
  add_css_class: "brand",
83
178
  set_orientation: gtk::Orientation::Vertical,
84
- set_width_request: 132,
179
+ set_width_request: BRAND_WIDTH,
180
+ // Set explicitly so the window list's expanding rows cannot widen the brand
181
+ // and push the cards aside.
182
+ set_hexpand: false,
85
183
  set_valign: gtk::Align::Center,
86
184
 
185
+ #[name = "eyebrow"]
87
186
  gtk::Label {
88
187
  add_css_class: "brand-eyebrow",
89
188
  set_label: "π ACTIVITY",
90
- set_halign: gtk::Align::Start,
189
+ set_halign: gtk::Align::Fill,
190
+ set_xalign: 0.0,
191
+ set_ellipsize: gtk::pango::EllipsizeMode::End,
192
+ set_max_width_chars: 1,
91
193
  },
194
+ #[name = "title"]
92
195
  gtk::Label {
93
196
  add_css_class: "brand-title",
94
197
  set_label: "Sessions",
95
- set_halign: gtk::Align::Start,
198
+ set_halign: gtk::Align::Fill,
199
+ set_xalign: 0.0,
200
+ set_ellipsize: gtk::pango::EllipsizeMode::End,
201
+ set_max_width_chars: 1,
96
202
  },
203
+ // Ellipsized at the start: the deepest folder is the part that tells you where you are.
97
204
  #[name = "meta"]
98
205
  gtk::Label {
99
206
  add_css_class: "meta",
100
207
  set_label: "Waiting for sessions…",
101
- set_halign: gtk::Align::Start,
208
+ set_halign: gtk::Align::Fill,
209
+ set_xalign: 0.0,
210
+ set_ellipsize: gtk::pango::EllipsizeMode::Start,
211
+ set_max_width_chars: 1,
102
212
  },
103
213
  },
104
214
 
@@ -153,11 +263,12 @@ impl Component for App {
153
263
 
154
264
  let tx = sender.input_sender().clone();
155
265
  root.connect_is_active_notify(move |window| {
156
- if !window.is_active() {
157
- let _ = tx.send(AppMsg::WindowInactive);
158
- }
266
+ let _ = tx.send(AppMsg::WindowActive(window.is_active()));
159
267
  });
160
268
 
269
+ let directory = DirectoryView::new(sender.input_sender());
270
+ let key_capture = Rc::new(Cell::new(KeyCapture::None));
271
+ let hover_guard = Rc::new(Cell::new(false));
161
272
  let model = App {
162
273
  cards: HashMap::new(),
163
274
  data: HashMap::new(),
@@ -172,9 +283,67 @@ impl Component for App {
172
283
  next_order_refresh_at: 0,
173
284
  collapse_generation: 0,
174
285
  interactive: !init.click_through,
286
+ directory,
287
+ windows: Vec::new(),
288
+ directory_open: false,
289
+ directory_hovered: false,
290
+ brand_hovered: false,
291
+ brand_generation: 0,
292
+ directory_generation: 0,
293
+ directory_opened_by_hover: false,
294
+ directory_session: 0,
295
+ pending_request: None,
296
+ directory_owns_keyboard: false,
297
+ hover_grab: false,
298
+ grab_generation: 0,
299
+ window_active: false,
300
+ query: String::new(),
301
+ key_capture: Rc::clone(&key_capture),
302
+ hover_guard: Rc::clone(&hover_guard),
175
303
  };
176
304
  let widgets = view_output!();
177
305
  widgets.body.set_can_target(!init.click_through);
306
+ widgets.brand.append(&model.directory.root);
307
+
308
+ // Hovering anywhere on the ribbon opens the window list in the brand column.
309
+ let hover = gtk::EventControllerMotion::new();
310
+ let tx = sender.input_sender().clone();
311
+ hover.connect_enter(move |_, _, _| {
312
+ let _ = tx.send(AppMsg::DirectoryHover(true));
313
+ });
314
+ let tx = sender.input_sender().clone();
315
+ hover.connect_leave(move |_| {
316
+ let _ = tx.send(AppMsg::DirectoryHover(false));
317
+ });
318
+ widgets.body.add_controller(hover);
319
+
320
+ let brand_hover = gtk::EventControllerMotion::new();
321
+ let tx = sender.input_sender().clone();
322
+ brand_hover.connect_enter(move |_, _, _| {
323
+ let _ = tx.send(AppMsg::BrandHover(true));
324
+ });
325
+ let tx = sender.input_sender().clone();
326
+ brand_hover.connect_leave(move |_| {
327
+ let _ = tx.send(AppMsg::BrandHover(false));
328
+ });
329
+ widgets.brand.add_controller(brand_hover);
330
+
331
+ // Captured before any card sees it, and only while the list (or keyboard mode) is live:
332
+ // see `directory_key` for which keys belong to the list in which state.
333
+ let keys = gtk::EventControllerKey::new();
334
+ keys.set_propagation_phase(gtk::PropagationPhase::Capture);
335
+ let tx = sender.input_sender().clone();
336
+ keys.connect_key_pressed(move |_, key, _, modifiers| {
337
+ let chord = is_chord(modifiers);
338
+ let message = match directory_key(key_capture.get(), key, chord) {
339
+ Some(message) => message,
340
+ None if hover_guard.get() && !is_modifier_key(key) => DirectoryKey::Interrupt,
341
+ None => return glib::Propagation::Proceed,
342
+ };
343
+ let _ = tx.send(AppMsg::DirectoryKey(message));
344
+ glib::Propagation::Stop
345
+ });
346
+ widgets.root.add_controller(keys);
178
347
 
179
348
  let tick_tx = sender.input_sender().clone();
180
349
  glib::timeout_add_seconds_local(1, move || {
@@ -203,12 +372,18 @@ impl Component for App {
203
372
  AppMsg::Theme(definitions) => apply_theme(&definitions),
204
373
  AppMsg::Tick => {
205
374
  if now_ms() >= self.next_order_refresh_at {
375
+ let focused_at = self.focused_position();
206
376
  self.regroup();
207
377
  self.reorder_widgets(&widgets.cards_box);
378
+ // Only when regrouping moved the focused card: a row the operator scrolled
379
+ // by hand stays where they left it.
380
+ if self.focused_position() != focused_at {
381
+ self.scroll_focused_into_view(widgets);
382
+ }
208
383
  self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
209
384
  }
210
385
  self.refresh_cards();
211
- self.update_meta(&widgets.meta);
386
+ self.update_brand(widgets);
212
387
  }
213
388
  AppMsg::Hover(id, entered) => {
214
389
  if entered {
@@ -233,25 +408,37 @@ impl Component for App {
233
408
  }
234
409
  AppMsg::Activate(id) => {
235
410
  emit(json!({ "protocol": 1, "type": "activate", "cardId": id }));
411
+ // Focus is moving to that card's window, so the ribbon must hold nothing: not
412
+ // keyboard mode, and not the hover grab that clicking inside the ribbon implies.
236
413
  if self.keyboard_active {
237
- self.end_engagement();
238
- root.set_keyboard_mode(KeyboardMode::None);
239
- self.apply_expansion(widgets, root, None);
414
+ self.leave_keyboard_mode(widgets, root);
415
+ } else {
416
+ self.close_directory(widgets, root);
240
417
  }
241
418
  }
242
419
  AppMsg::ActivationResult(id, ok, message) => {
243
420
  if let Some(card) = self.cards.get(&id) {
244
421
  card.set_activation(ok, &message);
245
422
  }
423
+ if self.take_own_request(&RequestTarget::Card(id)) {
424
+ if ok {
425
+ self.finish_directory_jump(widgets, root);
426
+ } else {
427
+ self.directory.set_status(&message);
428
+ }
429
+ }
246
430
  }
247
431
  AppMsg::FocusStrip => {
248
432
  if self.visible && self.interactive {
249
433
  if self.keyboard_active {
250
- self.end_engagement();
251
- root.set_keyboard_mode(KeyboardMode::None);
252
- self.apply_expansion(widgets, root, None);
434
+ self.leave_keyboard_mode(widgets, root);
253
435
  } else {
254
436
  self.keyboard_active = true;
437
+ // Keyboard mode now holds the keyboard, whoever opened the list, and no
438
+ // hover timeout may hand it back.
439
+ self.directory_owns_keyboard = false;
440
+ self.hover_grab = false;
441
+ self.sync_key_capture();
255
442
  self.hovered.clear();
256
443
  self.keyboard_focused = self.order.first().cloned();
257
444
  root.set_keyboard_mode(KeyboardMode::Exclusive);
@@ -267,32 +454,203 @@ impl Component for App {
267
454
  json!({ "protocol": 1, "type": "keyboard-active", "active": true }),
268
455
  );
269
456
  } else {
270
- self.end_engagement();
271
- root.set_keyboard_mode(KeyboardMode::None);
272
- self.apply_expansion(widgets, root, None);
457
+ self.leave_keyboard_mode(widgets, root);
273
458
  }
274
459
  }
275
460
  }
276
461
  }
277
- AppMsg::Collapse | AppMsg::WindowInactive => {
278
- self.end_engagement();
279
- root.set_keyboard_mode(KeyboardMode::None);
280
- self.apply_expansion(widgets, root, None);
462
+ AppMsg::Collapse => {
463
+ self.leave_keyboard_mode(widgets, root);
464
+ }
465
+ AppMsg::WindowActive(active) => {
466
+ self.window_active = active;
467
+ if active {
468
+ // Only the hint changes. Rebuilding the rows here would replace the very row
469
+ // whose click just handed over the keyboard, and swallow that click.
470
+ self.refresh_directory_status();
471
+ } else if self.keyboard_active {
472
+ // Keyboard mode lost the keyboard to another window: leave it entirely.
473
+ self.leave_keyboard_mode(widgets, root);
474
+ } else if !self.directory_hovered {
475
+ // A click elsewhere dismisses a clicked-into list. Hover expansion of cards is
476
+ // pointer business and is left alone.
477
+ self.close_directory(widgets, root);
478
+ } else {
479
+ // Still hovered but no longer typing (the hover grab ran out): say so.
480
+ self.refresh_directory_status();
481
+ }
482
+ }
483
+ AppMsg::BrandHover(entered) => {
484
+ self.brand_hovered = entered;
485
+ self.brand_generation += 1;
486
+ // Every card opens (entered) or falls back to the hovered one (left) at once.
487
+ self.refresh_cards();
488
+ if entered {
489
+ self.resize(root);
490
+ self.sync_directory_layout(widgets);
491
+ } else {
492
+ // Collapse only after the same grace as a card, so crossing from the brand to
493
+ // a card does not flicker the ribbon down and up again.
494
+ self.reconcile_engagement(widgets, root, &sender);
495
+ let generation = self.brand_generation;
496
+ let tx = sender.input_sender().clone();
497
+ glib::timeout_add_local_once(Duration::from_millis(120), move || {
498
+ let _ = tx.send(AppMsg::BrandSettle(generation));
499
+ });
500
+ }
501
+ }
502
+ AppMsg::BrandSettle(generation) => {
503
+ if generation == self.brand_generation {
504
+ self.resize(root);
505
+ self.sync_directory_layout(widgets);
506
+ }
281
507
  }
282
508
  AppMsg::CollapseIf(generation) => {
283
509
  if generation == self.collapse_generation
284
510
  && self.hovered.is_empty()
511
+ && !self.brand_hovered
285
512
  && (!self.keyboard_active || self.keyboard_focused.is_none())
286
513
  {
287
514
  self.apply_expansion(widgets, root, None);
288
515
  }
289
516
  }
290
517
  AppMsg::InputError(message) => emit_error(message),
518
+ AppMsg::DirectoryHover(entered) => {
519
+ self.directory_hovered = entered;
520
+ self.directory_generation += 1;
521
+ if entered {
522
+ self.open_directory(widgets, root, true);
523
+ // Also on re-entry within the close grace: the grab and its timer start over.
524
+ if self.take_hover_grab(root) {
525
+ self.arm_grab_timer(&sender);
526
+ }
527
+ } else {
528
+ // Hovering is the only reason the ribbon holds the keyboard: leaving returns it.
529
+ self.release_hover_grab(root);
530
+ let generation = self.directory_generation;
531
+ let tx = sender.input_sender().clone();
532
+ glib::timeout_add_local_once(
533
+ Duration::from_millis(DIRECTORY_CLOSE_MS),
534
+ move || {
535
+ let _ = tx.send(AppMsg::DirectoryCloseIf(generation));
536
+ },
537
+ );
538
+ }
539
+ }
540
+ AppMsg::DirectoryGrabExpired(generation) => {
541
+ if generation == self.grab_generation && self.query.is_empty() {
542
+ self.release_hover_grab(root);
543
+ }
544
+ }
545
+ AppMsg::DirectoryCloseIf(generation) => {
546
+ if generation == self.directory_generation
547
+ && !self.directory_hovered
548
+ && self.directory_opened_by_hover
549
+ {
550
+ self.close_directory(widgets, root);
551
+ }
552
+ }
553
+ AppMsg::DirectoryKey(key) => {
554
+ // Every key keeps a hover grab alive for another HOVER_GRAB_MS; the grab is only
555
+ // released idle and empty, never mid-number.
556
+ if self.hover_grab {
557
+ self.arm_grab_timer(&sender);
558
+ }
559
+ self.handle_directory_key(widgets, root, key);
560
+ }
561
+ AppMsg::DirectoryActivate(card_id) => {
562
+ self.pending_request = Some(PendingRequest {
563
+ session: self.directory_session,
564
+ target: RequestTarget::Card(card_id.clone()),
565
+ });
566
+ self.directory.set_status("Opening…");
567
+ emit(json!({ "protocol": 1, "type": "activate", "cardId": card_id }));
568
+ }
569
+ AppMsg::JumpResult(window_id, ok, message) => {
570
+ if self.take_own_request(&RequestTarget::Window(window_id)) {
571
+ if ok {
572
+ // The jumped-to window must get the keyboard back at once.
573
+ self.finish_directory_jump(widgets, root);
574
+ } else {
575
+ self.directory.set_status(&message);
576
+ }
577
+ }
578
+ }
291
579
  AppMsg::ParentGone => root.close(),
292
580
  }
293
581
  }
294
582
  }
295
583
 
584
+ /// The scroll offset that shows the span `start..end` with the least movement: unchanged when it
585
+ /// is already fully visible, otherwise just far enough to bring its nearer edge into view.
586
+ fn scroll_to_show(value: f64, page: f64, start: f64, end: f64) -> f64 {
587
+ if start < value {
588
+ start
589
+ } else if end > value + page {
590
+ (end - page).min(start)
591
+ } else {
592
+ value
593
+ }
594
+ }
595
+
596
+ /// Whether a result for `target` answers the request the list opened as `session` is waiting on.
597
+ fn answers_request(pending: Option<&PendingRequest>, session: u64, target: &RequestTarget) -> bool {
598
+ pending.is_some_and(|request| request.session == session && &request.target == target)
599
+ }
600
+
601
+ /// Ctrl, Alt, Super or Meta held: a shortcut, not typing. Shift is typing (capital letters).
602
+ fn is_chord(modifiers: gdk::ModifierType) -> bool {
603
+ modifiers.intersects(
604
+ gdk::ModifierType::CONTROL_MASK
605
+ | gdk::ModifierType::ALT_MASK
606
+ | gdk::ModifierType::SUPER_MASK
607
+ | gdk::ModifierType::META_MASK,
608
+ )
609
+ }
610
+
611
+ /// A modifier pressed on its own, on the way to a capital letter or a chord: neither search input
612
+ /// nor a sign that the typing is meant elsewhere.
613
+ fn is_modifier_key(key: gdk::Key) -> bool {
614
+ matches!(
615
+ key,
616
+ gdk::Key::Shift_L
617
+ | gdk::Key::Shift_R
618
+ | gdk::Key::Control_L
619
+ | gdk::Key::Control_R
620
+ | gdk::Key::Alt_L
621
+ | gdk::Key::Alt_R
622
+ | gdk::Key::Super_L
623
+ | gdk::Key::Super_R
624
+ | gdk::Key::Meta_L
625
+ | gdk::Key::Meta_R
626
+ | gdk::Key::ISO_Level3_Shift
627
+ | gdk::Key::Caps_Lock
628
+ )
629
+ }
630
+
631
+ /// The window-list key a press means under the current capture, if it belongs to the list at all.
632
+ fn directory_key(capture: KeyCapture, key: gdk::Key, chord: bool) -> Option<DirectoryKey> {
633
+ // Ctrl, Alt and Super combinations are shortcuts, never search characters.
634
+ if chord {
635
+ return None;
636
+ }
637
+ let typed = key
638
+ .to_unicode()
639
+ .filter(|character| is_query_char(*character));
640
+ match capture {
641
+ KeyCapture::None => None,
642
+ KeyCapture::Digits => typed.map(DirectoryKey::Char),
643
+ KeyCapture::List | KeyCapture::ListTyped => match key {
644
+ gdk::Key::BackSpace => Some(DirectoryKey::Backspace),
645
+ gdk::Key::Return | gdk::Key::KP_Enter if capture == KeyCapture::ListTyped => {
646
+ Some(DirectoryKey::Enter)
647
+ }
648
+ gdk::Key::Escape => Some(DirectoryKey::Escape),
649
+ _ => typed.map(DirectoryKey::Char),
650
+ },
651
+ }
652
+ }
653
+
296
654
  fn navigation_target(index: usize, direction: i32, len: usize) -> Option<usize> {
297
655
  if len == 0 {
298
656
  return None;
@@ -305,6 +663,57 @@ fn move_order_item(order: &mut Vec<String>, index: usize, target: usize) {
305
663
  order.insert(target, moved);
306
664
  }
307
665
 
666
+ struct BrandCounts {
667
+ active: usize,
668
+ settled: usize,
669
+ }
670
+
671
+ #[derive(Debug, PartialEq)]
672
+ struct BrandText {
673
+ eyebrow: String,
674
+ title: String,
675
+ meta: String,
676
+ }
677
+
678
+ /// The brand block names the focused window's project and folder. Without a focused agent window
679
+ /// it falls back to the ribbon's own name and the session counts.
680
+ fn brand_text(focused: Option<&Card>, counts: &BrandCounts, home: &str) -> BrandText {
681
+ let summary = format!("{} active · {} settled", counts.active, counts.settled);
682
+ match focused {
683
+ Some(card) if !card.repo_label.is_empty() || !card.cwd.is_empty() => BrandText {
684
+ eyebrow: format!("π {}", summary.to_uppercase()),
685
+ title: if card.repo_label.is_empty() {
686
+ short_path(&card.cwd, home)
687
+ } else {
688
+ card.repo_label.clone()
689
+ },
690
+ meta: if card.cwd.is_empty() {
691
+ "—".to_owned()
692
+ } else {
693
+ short_path(&card.cwd, home)
694
+ },
695
+ },
696
+ _ => BrandText {
697
+ eyebrow: "π ACTIVITY".to_owned(),
698
+ title: "Sessions".to_owned(),
699
+ meta: summary,
700
+ },
701
+ }
702
+ }
703
+
704
+ /// A path with the home directory written as `~`.
705
+ fn short_path(path: &str, home: &str) -> String {
706
+ let home = home.trim_end_matches('/');
707
+ if home.is_empty() {
708
+ return path.to_owned();
709
+ }
710
+ match path.strip_prefix(home) {
711
+ Some("") => "~".to_owned(),
712
+ Some(rest) if rest.starts_with('/') => format!("~{rest}"),
713
+ _ => path.to_owned(),
714
+ }
715
+ }
716
+
308
717
  fn engaged_card_id(
309
718
  hovered: &HashSet<String>,
310
719
  keyboard_active: bool,
@@ -323,6 +732,7 @@ impl App {
323
732
  self.hovered.clear();
324
733
  self.keyboard_focused = None;
325
734
  self.keyboard_active = false;
735
+ self.sync_key_capture();
326
736
  self.collapse_generation += 1;
327
737
  if was_keyboard_active {
328
738
  emit(json!({ "protocol": 1, "type": "keyboard-active", "active": false }));
@@ -340,7 +750,12 @@ impl App {
340
750
  return;
341
751
  }
342
752
  self.revision = view.revision;
753
+ let focus_moved = self.focused_card_id != view.focused_card_id;
343
754
  self.focused_card_id = view.focused_card_id;
755
+ if self.windows != view.windows {
756
+ self.windows = view.windows;
757
+ self.render_directory();
758
+ }
344
759
  let incoming_order: Vec<_> = view
345
760
  .sessions
346
761
  .iter()
@@ -384,9 +799,7 @@ impl App {
384
799
  if focus_grabbed {
385
800
  self.reconcile_engagement(widgets, root, sender);
386
801
  } else {
387
- self.end_engagement();
388
- root.set_keyboard_mode(KeyboardMode::None);
389
- self.apply_expansion(widgets, root, None);
802
+ self.leave_keyboard_mode(widgets, root);
390
803
  }
391
804
  }
392
805
  if open_card_removed {
@@ -394,7 +807,10 @@ impl App {
394
807
  }
395
808
  self.reorder_widgets(&widgets.cards_box);
396
809
  self.refresh_cards();
397
- self.update_meta(&widgets.meta);
810
+ self.update_brand(widgets);
811
+ if focus_moved {
812
+ self.scroll_focused_into_view(widgets);
813
+ }
398
814
 
399
815
  let should_show = view.visible && !self.data.is_empty();
400
816
  if should_show != self.visible {
@@ -403,9 +819,7 @@ impl App {
403
819
  root.set_exclusive_zone(COMPACT_HEIGHT);
404
820
  root.present();
405
821
  } else {
406
- self.end_engagement();
407
- root.set_keyboard_mode(KeyboardMode::None);
408
- self.apply_expansion(widgets, root, None);
822
+ self.leave_keyboard_mode(widgets, root);
409
823
  root.set_exclusive_zone(-1);
410
824
  root.set_visible(false);
411
825
  }
@@ -439,6 +853,8 @@ impl App {
439
853
  fn refresh_cards(&self) {
440
854
  let duplicates = duplicate_labels(self.data.values());
441
855
  let now = now_ms();
856
+ // Hovering the brand column is an overview: every card shows its details at once.
857
+ let all_open = self.brand_hovered && self.expanded();
442
858
  for (id, view) in &self.cards {
443
859
  if let Some(card) = self.data.get(id) {
444
860
  view.update(
@@ -447,19 +863,31 @@ impl App {
447
863
  duplicates.contains(&card.repo_label),
448
864
  now,
449
865
  );
450
- view.set_expanded(self.open_card_id.as_deref() == Some(id));
866
+ view.set_expanded(all_open || self.open_card_id.as_deref() == Some(id));
451
867
  }
452
868
  }
453
869
  }
454
870
 
455
- fn update_meta(&self, meta: &gtk::Label) {
871
+ fn update_brand(&self, widgets: &AppWidgets) {
456
872
  let active = self.data.values().filter(|card| card.active()).count();
457
- meta.set_text(&format!(
458
- "{} active · {} settled · order {}s",
873
+ let counts = BrandCounts {
459
874
  active,
460
- self.data.len().saturating_sub(active),
461
- ((self.next_order_refresh_at - now_ms()).max(0) + 999) / 1000
462
- ));
875
+ settled: self.data.len().saturating_sub(active),
876
+ };
877
+ let focused = self
878
+ .focused_card_id
879
+ .as_ref()
880
+ .and_then(|id| self.data.get(id));
881
+ let home = std::env::var("HOME").unwrap_or_default();
882
+ let brand = brand_text(focused, &counts, &home);
883
+ widgets.eyebrow.set_text(&brand.eyebrow);
884
+ widgets.title.set_text(&brand.title);
885
+ widgets.meta.set_text(&brand.meta);
886
+ widgets.brand.set_tooltip_text(
887
+ focused
888
+ .map(|card| card.cwd.as_str())
889
+ .filter(|cwd| !cwd.is_empty()),
890
+ );
463
891
  }
464
892
 
465
893
  fn regroup(&mut self) {
@@ -532,7 +960,7 @@ impl App {
532
960
 
533
961
  fn apply_expansion(
534
962
  &mut self,
535
- _widgets: &mut AppWidgets,
963
+ widgets: &mut AppWidgets,
536
964
  root: &gtk::Window,
537
965
  card_id: Option<String>,
538
966
  ) {
@@ -540,7 +968,72 @@ impl App {
540
968
  return;
541
969
  }
542
970
  self.open_card_id = card_id;
543
- let expanded = self.open_card_id.is_some();
971
+ self.refresh_cards();
972
+ self.resize(root);
973
+ self.sync_directory_layout(widgets);
974
+ }
975
+
976
+ /// Scroll the card row so the focused window's card is fully in view, as Niri focus moves
977
+ /// between windows. Runs once layout has placed the card, so its position in the row is known.
978
+ fn scroll_focused_into_view(&self, widgets: &AppWidgets) {
979
+ let Some(card) = self
980
+ .focused_card_id
981
+ .as_ref()
982
+ .and_then(|id| self.cards.get(id))
983
+ else {
984
+ return;
985
+ };
986
+ let row = widgets.cards_box.clone();
987
+ let scroller = widgets.scroller.clone();
988
+ // Measured on the second frame after the change: the first one lays the row out, so the
989
+ // card's position is final, and a panel that is not shown yet simply waits until it is.
990
+ let frames = Cell::new(0u8);
991
+ card.root.add_tick_callback(move |card, _clock| {
992
+ frames.set(frames.get() + 1);
993
+ if frames.get() < 2 {
994
+ return glib::ControlFlow::Continue;
995
+ }
996
+ if let Some(origin) = card.compute_point(&row, &gtk::graphene::Point::new(0.0, 0.0)) {
997
+ let start = f64::from(origin.x());
998
+ let end = start + f64::from(card.width());
999
+ let adjustment = scroller.hadjustment();
1000
+ adjustment.set_value(scroll_to_show(
1001
+ adjustment.value(),
1002
+ adjustment.page_size(),
1003
+ start,
1004
+ end,
1005
+ ));
1006
+ }
1007
+ glib::ControlFlow::Break
1008
+ });
1009
+ }
1010
+
1011
+ fn focused_position(&self) -> Option<usize> {
1012
+ let id = self.focused_card_id.as_ref()?;
1013
+ self.order.iter().position(|candidate| candidate == id)
1014
+ }
1015
+
1016
+ /// The ribbon is tall while a card is open (hovered or in keyboard mode), while the pointer is
1017
+ /// on the brand column with the list open, or while a typed number needs the list shown.
1018
+ fn expanded(&self) -> bool {
1019
+ self.open_card_id.is_some()
1020
+ || (self.directory_open && (self.brand_hovered || !self.query.is_empty()))
1021
+ }
1022
+
1023
+ /// The list fills the brand column, top to bottom, whenever the ribbon is expanded; compact,
1024
+ /// it is hidden.
1025
+ fn sync_directory_layout(&self, widgets: &AppWidgets) {
1026
+ let shown = self.directory_open && self.expanded();
1027
+ self.directory.set_visible(shown);
1028
+ widgets.brand.set_valign(if shown {
1029
+ gtk::Align::Fill
1030
+ } else {
1031
+ gtk::Align::Center
1032
+ });
1033
+ }
1034
+
1035
+ fn resize(&self, root: &gtk::Window) {
1036
+ let expanded = self.expanded();
544
1037
  root.set_default_size(
545
1038
  1,
546
1039
  if expanded {
@@ -550,18 +1043,404 @@ impl App {
550
1043
  },
551
1044
  );
552
1045
  root.set_exclusive_zone(COMPACT_HEIGHT);
553
- self.refresh_cards();
554
1046
  emit(
555
1047
  json!({ "protocol": 1, "type": "expanded", "expanded": expanded, "cardId": self.open_card_id }),
556
1048
  );
557
1049
  }
1050
+
1051
+ fn sync_key_capture(&self) {
1052
+ self.hover_guard
1053
+ .set(self.directory_open && self.hover_grab && !self.keyboard_active);
1054
+ self.key_capture
1055
+ .set(if self.directory_open && !self.query.is_empty() {
1056
+ KeyCapture::ListTyped
1057
+ } else if self.directory_open {
1058
+ KeyCapture::List
1059
+ } else if self.keyboard_active {
1060
+ KeyCapture::Digits
1061
+ } else {
1062
+ KeyCapture::None
1063
+ });
1064
+ }
1065
+
1066
+ fn handle_directory_key(
1067
+ &mut self,
1068
+ widgets: &mut AppWidgets,
1069
+ root: &gtk::Window,
1070
+ key: DirectoryKey,
1071
+ ) {
1072
+ match key {
1073
+ DirectoryKey::Char(digit) => {
1074
+ self.open_directory(widgets, root, false);
1075
+ if push_query_char(&mut self.query, digit) {
1076
+ self.render_directory();
1077
+ self.sync_key_capture();
1078
+ self.resize(root);
1079
+ self.sync_directory_layout(widgets);
1080
+ }
1081
+ }
1082
+ DirectoryKey::Backspace => {
1083
+ self.query.pop();
1084
+ self.render_directory();
1085
+ self.sync_key_capture();
1086
+ self.resize(root);
1087
+ self.sync_directory_layout(widgets);
1088
+ }
1089
+ DirectoryKey::Enter => {
1090
+ match jump_choice(&self.windows, &self.query, self.allow_unlisted()) {
1091
+ Some(JumpChoice::Window(window_id)) => {
1092
+ self.pending_request = Some(PendingRequest {
1093
+ session: self.directory_session,
1094
+ target: RequestTarget::Window(window_id),
1095
+ });
1096
+ self.directory
1097
+ .set_status(&format!("Jumping to #{window_id}…"));
1098
+ emit(json!({ "protocol": 1, "type": "jump", "windowId": window_id }));
1099
+ }
1100
+ Some(JumpChoice::Card(card_id)) => {
1101
+ self.pending_request = Some(PendingRequest {
1102
+ session: self.directory_session,
1103
+ target: RequestTarget::Card(card_id.clone()),
1104
+ });
1105
+ self.directory.set_status("Opening…");
1106
+ emit(json!({ "protocol": 1, "type": "activate", "cardId": card_id }));
1107
+ }
1108
+ // Enter leading nowhere while only hover holds the keyboard: the typing was meant
1109
+ // for the window below, so hand the keyboard back.
1110
+ None if self.hover_guard.get() => self.cancel_hover_search(widgets, root),
1111
+ None => {}
1112
+ }
1113
+ }
1114
+ DirectoryKey::Escape => self.dismiss_directory(widgets, root),
1115
+ DirectoryKey::Interrupt => self.cancel_hover_search(widgets, root),
1116
+ }
1117
+ }
1118
+
1119
+ /// Whether a result answers exactly what the open list asked for. Anything else (a card's own
1120
+ /// activation, an earlier jump) leaves the pending request in place.
1121
+ fn take_own_request(&mut self, target: &RequestTarget) -> bool {
1122
+ if self.directory_open
1123
+ && answers_request(
1124
+ self.pending_request.as_ref(),
1125
+ self.directory_session,
1126
+ target,
1127
+ )
1128
+ {
1129
+ self.pending_request = None;
1130
+ true
1131
+ } else {
1132
+ false
1133
+ }
1134
+ }
1135
+
1136
+ fn refresh_directory_status(&self) {
1137
+ if self.directory_open {
1138
+ self.directory.set_prompt(
1139
+ &self.windows,
1140
+ &self.query,
1141
+ self.window_active || self.keyboard_active,
1142
+ self.allow_unlisted(),
1143
+ );
1144
+ }
1145
+ }
1146
+
1147
+ fn render_directory(&self) {
1148
+ if self.directory_open {
1149
+ self.directory.render(
1150
+ &self.windows,
1151
+ &self.query,
1152
+ self.window_active || self.keyboard_active,
1153
+ self.allow_unlisted(),
1154
+ );
1155
+ }
1156
+ }
1157
+
1158
+ /// Take the keyboard for a hover-opened list, so a number can be typed at once. Clears the
1159
+ /// window's focus widget first: Enter with nothing typed must not press a card or row that
1160
+ /// kept focus from an earlier keyboard mode or click.
1161
+ fn take_hover_grab(&mut self, root: &gtk::Window) -> bool {
1162
+ if !self.directory_open || !self.directory_opened_by_hover || self.keyboard_active {
1163
+ return false;
1164
+ }
1165
+ if !self.hover_grab {
1166
+ GtkWindowExt::set_focus(root, None::<&gtk::Widget>);
1167
+ root.set_keyboard_mode(KeyboardMode::Exclusive);
1168
+ self.hover_grab = true;
1169
+ self.directory_owns_keyboard = true;
1170
+ self.sync_key_capture();
1171
+ }
1172
+ true
1173
+ }
1174
+
1175
+ /// Typing held only by hover turned out to be meant for the window below: drop it, hand the
1176
+ /// keyboard back and close the list, so keys still in flight find nothing to type into. The
1177
+ /// list reopens when the pointer re-enters the ribbon.
1178
+ fn cancel_hover_search(&mut self, widgets: &mut AppWidgets, root: &gtk::Window) {
1179
+ self.query.clear();
1180
+ self.release_hover_grab(root);
1181
+ self.dismiss_directory(widgets, root);
1182
+ }
1183
+
1184
+ /// Unlisted window numbers are reachable only when the keyboard was given on purpose (a
1185
+ /// click or keyboard mode), not merely held by hover.
1186
+ fn allow_unlisted(&self) -> bool {
1187
+ !(self.hover_grab && !self.keyboard_active)
1188
+ }
1189
+
1190
+ fn release_hover_grab(&mut self, root: &gtk::Window) {
1191
+ if self.hover_grab {
1192
+ self.hover_grab = false;
1193
+ self.grab_generation += 1;
1194
+ if !self.keyboard_active {
1195
+ root.set_keyboard_mode(KeyboardMode::OnDemand);
1196
+ }
1197
+ self.sync_key_capture();
1198
+ self.refresh_directory_status();
1199
+ }
1200
+ }
1201
+
1202
+ fn arm_grab_timer(&mut self, sender: &ComponentSender<Self>) {
1203
+ self.grab_generation += 1;
1204
+ let generation = self.grab_generation;
1205
+ let tx = sender.input_sender().clone();
1206
+ glib::timeout_add_local_once(Duration::from_millis(HOVER_GRAB_MS), move || {
1207
+ let _ = tx.send(AppMsg::DirectoryGrabExpired(generation));
1208
+ });
1209
+ }
1210
+
1211
+ fn open_directory(&mut self, widgets: &AppWidgets, root: &gtk::Window, by_hover: bool) {
1212
+ if self.directory_open || !self.visible || !self.interactive {
1213
+ return;
1214
+ }
1215
+ self.directory_open = true;
1216
+ self.directory_opened_by_hover = by_hover;
1217
+ self.directory_session += 1;
1218
+ self.query.clear();
1219
+ // On-demand until a hover grab (take_hover_grab) upgrades it; a click can always take the
1220
+ // keyboard. Keyboard mode already holds it.
1221
+ if !self.keyboard_active {
1222
+ root.set_keyboard_mode(KeyboardMode::OnDemand);
1223
+ self.directory_owns_keyboard = true;
1224
+ }
1225
+ self.render_directory();
1226
+ self.sync_key_capture();
1227
+ self.resize(root);
1228
+ self.sync_directory_layout(widgets);
1229
+ }
1230
+
1231
+ /// After the list moved focus somewhere, nothing on the ribbon may keep the keyboard: the list
1232
+ /// closes, and keyboard mode ends exactly as activating a card ends it.
1233
+ fn finish_directory_jump(&mut self, widgets: &mut AppWidgets, root: &gtk::Window) {
1234
+ if self.keyboard_active {
1235
+ self.leave_keyboard_mode(widgets, root);
1236
+ } else {
1237
+ self.dismiss_directory(widgets, root);
1238
+ }
1239
+ }
1240
+
1241
+ /// Close the list and let the ribbon fall back to compact unless a card is still under the
1242
+ /// pointer: the card kept open while the pointer crossed to the list has done its job.
1243
+ fn dismiss_directory(&mut self, widgets: &mut AppWidgets, root: &gtk::Window) {
1244
+ self.close_directory(widgets, root);
1245
+ if self.hovered.is_empty() && !self.keyboard_active {
1246
+ self.apply_expansion(widgets, root, None);
1247
+ }
1248
+ }
1249
+
1250
+ /// The one way out of keyboard mode. The list closes with it: once the ribbon gives up the
1251
+ /// keyboard nothing could type into or dismiss a list left open.
1252
+ fn leave_keyboard_mode(&mut self, widgets: &mut AppWidgets, root: &gtk::Window) {
1253
+ self.close_directory(widgets, root);
1254
+ self.end_engagement();
1255
+ root.set_keyboard_mode(KeyboardMode::None);
1256
+ self.apply_expansion(widgets, root, None);
1257
+ }
1258
+
1259
+ fn close_directory(&mut self, widgets: &AppWidgets, root: &gtk::Window) {
1260
+ if !self.directory_open {
1261
+ return;
1262
+ }
1263
+ self.directory_open = false;
1264
+ self.directory_opened_by_hover = false;
1265
+ self.hover_grab = false;
1266
+ self.pending_request = None;
1267
+ self.query.clear();
1268
+ if self.directory_owns_keyboard {
1269
+ self.directory_owns_keyboard = false;
1270
+ if !self.keyboard_active {
1271
+ root.set_keyboard_mode(KeyboardMode::None);
1272
+ }
1273
+ }
1274
+ self.sync_key_capture();
1275
+ self.resize(root);
1276
+ self.sync_directory_layout(widgets);
1277
+ }
558
1278
  }
559
1279
 
560
1280
  #[cfg(test)]
561
1281
  mod tests {
562
- use super::{engaged_card_id, move_order_item, navigation_target};
1282
+ use super::{
1283
+ BrandCounts, DirectoryKey, KeyCapture, PendingRequest, RequestTarget, answers_request,
1284
+ brand_text, directory_key, engaged_card_id, move_order_item, navigation_target,
1285
+ scroll_to_show, short_path,
1286
+ };
1287
+ use crate::protocol::Card;
1288
+ use relm4::gtk::gdk;
563
1289
  use std::collections::HashSet;
564
1290
 
1291
+ fn counts() -> BrandCounts {
1292
+ BrandCounts {
1293
+ active: 2,
1294
+ settled: 3,
1295
+ }
1296
+ }
1297
+
1298
+ #[test]
1299
+ fn the_focused_card_scrolls_into_view_with_the_least_movement() {
1300
+ // Row viewport 0..1000.
1301
+ assert_eq!(
1302
+ scroll_to_show(0.0, 1000.0, 200.0, 440.0),
1303
+ 0.0,
1304
+ "already visible"
1305
+ );
1306
+ assert_eq!(
1307
+ scroll_to_show(0.0, 1000.0, 1500.0, 1740.0),
1308
+ 740.0,
1309
+ "right of view"
1310
+ );
1311
+ assert_eq!(
1312
+ scroll_to_show(800.0, 1000.0, 300.0, 540.0),
1313
+ 300.0,
1314
+ "left of view"
1315
+ );
1316
+ assert_eq!(
1317
+ scroll_to_show(0.0, 200.0, 500.0, 740.0),
1318
+ 500.0,
1319
+ "wider than the view: its start wins"
1320
+ );
1321
+ }
1322
+
1323
+ #[test]
1324
+ fn only_the_answer_to_what_the_open_list_asked_acts_on_it() {
1325
+ let pending = PendingRequest {
1326
+ session: 3,
1327
+ target: RequestTarget::Window(43),
1328
+ };
1329
+ assert!(answers_request(
1330
+ Some(&pending),
1331
+ 3,
1332
+ &RequestTarget::Window(43)
1333
+ ));
1334
+ assert!(
1335
+ !answers_request(Some(&pending), 3, &RequestTarget::Window(44)),
1336
+ "an earlier jump to another number"
1337
+ );
1338
+ assert!(
1339
+ !answers_request(Some(&pending), 3, &RequestTarget::Card("card-a".into())),
1340
+ "a card's own activation"
1341
+ );
1342
+ assert!(
1343
+ !answers_request(Some(&pending), 4, &RequestTarget::Window(43)),
1344
+ "a list opened again since"
1345
+ );
1346
+ assert!(!answers_request(None, 3, &RequestTarget::Window(43)));
1347
+ }
1348
+
1349
+ #[test]
1350
+ fn only_ctrl_alt_super_make_a_chord_and_bare_modifiers_are_not_typing() {
1351
+ assert!(super::is_chord(gdk::ModifierType::CONTROL_MASK));
1352
+ assert!(super::is_chord(gdk::ModifierType::SUPER_MASK));
1353
+ assert!(
1354
+ !super::is_chord(gdk::ModifierType::SHIFT_MASK),
1355
+ "Shift types capitals"
1356
+ );
1357
+ assert!(super::is_modifier_key(gdk::Key::Shift_L));
1358
+ assert!(!super::is_modifier_key(gdk::Key::space));
1359
+ }
1360
+
1361
+ #[test]
1362
+ fn the_window_list_takes_keys_only_while_they_belong_to_it() {
1363
+ let key = |capture, key| format!("{:?}", directory_key(capture, key, false));
1364
+ assert_eq!(
1365
+ format!("{:?}", directory_key(KeyCapture::List, gdk::Key::c, true)),
1366
+ "None",
1367
+ "Ctrl+C is a shortcut, not a search for c"
1368
+ );
1369
+ assert_eq!(key(KeyCapture::None, gdk::Key::_4), "None");
1370
+ // Keyboard mode on the cards: a digit opens the list, Enter and Escape stay with the cards.
1371
+ assert_eq!(key(KeyCapture::Digits, gdk::Key::_4), "Some(Char('4'))");
1372
+ assert_eq!(
1373
+ key(KeyCapture::Digits, gdk::Key::e),
1374
+ "Some(Char('e'))",
1375
+ "a letter starts a name search"
1376
+ );
1377
+ assert_eq!(key(KeyCapture::Digits, gdk::Key::Return), "None");
1378
+ assert_eq!(key(KeyCapture::Digits, gdk::Key::Escape), "None");
1379
+ assert_eq!(key(KeyCapture::List, gdk::Key::KP_7), "Some(Char('7'))");
1380
+ assert_eq!(key(KeyCapture::List, gdk::Key::minus), "Some(Char('-'))");
1381
+ assert_eq!(
1382
+ key(KeyCapture::List, gdk::Key::space),
1383
+ "None",
1384
+ "space is never taken"
1385
+ );
1386
+ assert_eq!(
1387
+ key(KeyCapture::List, gdk::Key::Return),
1388
+ "None",
1389
+ "with nothing typed, Enter still reaches the focused card"
1390
+ );
1391
+ assert_eq!(key(KeyCapture::ListTyped, gdk::Key::Return), "Some(Enter)");
1392
+ assert_eq!(
1393
+ key(KeyCapture::List, gdk::Key::BackSpace),
1394
+ "Some(Backspace)"
1395
+ );
1396
+ assert_eq!(key(KeyCapture::List, gdk::Key::Escape), "Some(Escape)");
1397
+ assert_eq!(key(KeyCapture::None, gdk::Key::a), "None");
1398
+ assert!(matches!(
1399
+ directory_key(KeyCapture::ListTyped, gdk::Key::KP_Enter, false),
1400
+ Some(DirectoryKey::Enter)
1401
+ ));
1402
+ }
1403
+
1404
+ #[test]
1405
+ fn brand_names_the_focused_windows_project_and_folder() {
1406
+ let card: Card = serde_json::from_value(serde_json::json!({
1407
+ "repoLabel": "pi-activity-strip",
1408
+ "cwd": "/home/op/ai-society/softwareco/owned/pi-extensions/packages/pi-activity-strip"
1409
+ }))
1410
+ .expect("card");
1411
+ let brand = brand_text(Some(&card), &counts(), "/home/op");
1412
+ assert_eq!(brand.title, "pi-activity-strip");
1413
+ assert_eq!(
1414
+ brand.meta,
1415
+ "~/ai-society/softwareco/owned/pi-extensions/packages/pi-activity-strip"
1416
+ );
1417
+ assert_eq!(brand.eyebrow, "π 2 ACTIVE · 3 SETTLED");
1418
+ }
1419
+
1420
+ #[test]
1421
+ fn brand_falls_back_to_counts_without_a_focused_window() {
1422
+ let brand = brand_text(None, &counts(), "/home/op");
1423
+ assert_eq!(brand.title, "Sessions");
1424
+ assert_eq!(brand.meta, "2 active · 3 settled");
1425
+
1426
+ let unlabelled: Card = serde_json::from_value(serde_json::json!({})).expect("card");
1427
+ assert_eq!(
1428
+ brand_text(Some(&unlabelled), &counts(), "/home/op").title,
1429
+ "Sessions"
1430
+ );
1431
+ }
1432
+
1433
+ #[test]
1434
+ fn short_path_abbreviates_only_the_whole_home_directory() {
1435
+ assert_eq!(short_path("/home/op/repo", "/home/op"), "~/repo");
1436
+ assert_eq!(short_path("/home/op", "/home/op/"), "~");
1437
+ assert_eq!(
1438
+ short_path("/home/operator/repo", "/home/op"),
1439
+ "/home/operator/repo"
1440
+ );
1441
+ assert_eq!(short_path("/srv/repo", ""), "/srv/repo");
1442
+ }
1443
+
565
1444
  #[test]
566
1445
  fn stale_gtk_focus_does_not_retain_expansion_outside_keyboard_mode() {
567
1446
  let focused = Some("card-a".to_owned());