@tryinget/pi-activity-strip 0.8.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;
@@ -17,6 +23,13 @@ const OUTER_MARGIN: i32 = 8;
17
23
  const EXPANDED_HEIGHT: i32 = 276;
18
24
  const ORDER_REFRESH_MS: i64 = 15_000;
19
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;
20
33
 
21
34
  pub struct AppInit {
22
35
  pub demo: bool,
@@ -37,6 +50,77 @@ pub struct App {
37
50
  next_order_refresh_at: i64,
38
51
  collapse_generation: u64,
39
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,
40
124
  }
41
125
 
42
126
  #[derive(Debug)]
@@ -52,9 +136,17 @@ pub enum AppMsg {
52
136
  FocusStrip,
53
137
  Collapse,
54
138
  CollapseIf(u64),
55
- WindowInactive,
139
+ WindowActive(bool),
56
140
  ParentGone,
57
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),
58
150
  }
59
151
 
60
152
  #[relm4::component(pub)]
@@ -85,6 +177,9 @@ impl Component for App {
85
177
  add_css_class: "brand",
86
178
  set_orientation: gtk::Orientation::Vertical,
87
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,
88
183
  set_valign: gtk::Align::Center,
89
184
 
90
185
  #[name = "eyebrow"]
@@ -168,11 +263,12 @@ impl Component for App {
168
263
 
169
264
  let tx = sender.input_sender().clone();
170
265
  root.connect_is_active_notify(move |window| {
171
- if !window.is_active() {
172
- let _ = tx.send(AppMsg::WindowInactive);
173
- }
266
+ let _ = tx.send(AppMsg::WindowActive(window.is_active()));
174
267
  });
175
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));
176
272
  let model = App {
177
273
  cards: HashMap::new(),
178
274
  data: HashMap::new(),
@@ -187,9 +283,67 @@ impl Component for App {
187
283
  next_order_refresh_at: 0,
188
284
  collapse_generation: 0,
189
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),
190
303
  };
191
304
  let widgets = view_output!();
192
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);
193
347
 
194
348
  let tick_tx = sender.input_sender().clone();
195
349
  glib::timeout_add_seconds_local(1, move || {
@@ -218,8 +372,14 @@ impl Component for App {
218
372
  AppMsg::Theme(definitions) => apply_theme(&definitions),
219
373
  AppMsg::Tick => {
220
374
  if now_ms() >= self.next_order_refresh_at {
375
+ let focused_at = self.focused_position();
221
376
  self.regroup();
222
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
+ }
223
383
  self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
224
384
  }
225
385
  self.refresh_cards();
@@ -248,25 +408,37 @@ impl Component for App {
248
408
  }
249
409
  AppMsg::Activate(id) => {
250
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.
251
413
  if self.keyboard_active {
252
- self.end_engagement();
253
- root.set_keyboard_mode(KeyboardMode::None);
254
- self.apply_expansion(widgets, root, None);
414
+ self.leave_keyboard_mode(widgets, root);
415
+ } else {
416
+ self.close_directory(widgets, root);
255
417
  }
256
418
  }
257
419
  AppMsg::ActivationResult(id, ok, message) => {
258
420
  if let Some(card) = self.cards.get(&id) {
259
421
  card.set_activation(ok, &message);
260
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
+ }
261
430
  }
262
431
  AppMsg::FocusStrip => {
263
432
  if self.visible && self.interactive {
264
433
  if self.keyboard_active {
265
- self.end_engagement();
266
- root.set_keyboard_mode(KeyboardMode::None);
267
- self.apply_expansion(widgets, root, None);
434
+ self.leave_keyboard_mode(widgets, root);
268
435
  } else {
269
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();
270
442
  self.hovered.clear();
271
443
  self.keyboard_focused = self.order.first().cloned();
272
444
  root.set_keyboard_mode(KeyboardMode::Exclusive);
@@ -282,32 +454,203 @@ impl Component for App {
282
454
  json!({ "protocol": 1, "type": "keyboard-active", "active": true }),
283
455
  );
284
456
  } else {
285
- self.end_engagement();
286
- root.set_keyboard_mode(KeyboardMode::None);
287
- self.apply_expansion(widgets, root, None);
457
+ self.leave_keyboard_mode(widgets, root);
288
458
  }
289
459
  }
290
460
  }
291
461
  }
292
- AppMsg::Collapse | AppMsg::WindowInactive => {
293
- self.end_engagement();
294
- root.set_keyboard_mode(KeyboardMode::None);
295
- 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
+ }
296
507
  }
297
508
  AppMsg::CollapseIf(generation) => {
298
509
  if generation == self.collapse_generation
299
510
  && self.hovered.is_empty()
511
+ && !self.brand_hovered
300
512
  && (!self.keyboard_active || self.keyboard_focused.is_none())
301
513
  {
302
514
  self.apply_expansion(widgets, root, None);
303
515
  }
304
516
  }
305
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
+ }
306
579
  AppMsg::ParentGone => root.close(),
307
580
  }
308
581
  }
309
582
  }
310
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
+
311
654
  fn navigation_target(index: usize, direction: i32, len: usize) -> Option<usize> {
312
655
  if len == 0 {
313
656
  return None;
@@ -389,6 +732,7 @@ impl App {
389
732
  self.hovered.clear();
390
733
  self.keyboard_focused = None;
391
734
  self.keyboard_active = false;
735
+ self.sync_key_capture();
392
736
  self.collapse_generation += 1;
393
737
  if was_keyboard_active {
394
738
  emit(json!({ "protocol": 1, "type": "keyboard-active", "active": false }));
@@ -406,7 +750,12 @@ impl App {
406
750
  return;
407
751
  }
408
752
  self.revision = view.revision;
753
+ let focus_moved = self.focused_card_id != view.focused_card_id;
409
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
+ }
410
759
  let incoming_order: Vec<_> = view
411
760
  .sessions
412
761
  .iter()
@@ -450,9 +799,7 @@ impl App {
450
799
  if focus_grabbed {
451
800
  self.reconcile_engagement(widgets, root, sender);
452
801
  } else {
453
- self.end_engagement();
454
- root.set_keyboard_mode(KeyboardMode::None);
455
- self.apply_expansion(widgets, root, None);
802
+ self.leave_keyboard_mode(widgets, root);
456
803
  }
457
804
  }
458
805
  if open_card_removed {
@@ -461,6 +808,9 @@ impl App {
461
808
  self.reorder_widgets(&widgets.cards_box);
462
809
  self.refresh_cards();
463
810
  self.update_brand(widgets);
811
+ if focus_moved {
812
+ self.scroll_focused_into_view(widgets);
813
+ }
464
814
 
465
815
  let should_show = view.visible && !self.data.is_empty();
466
816
  if should_show != self.visible {
@@ -469,9 +819,7 @@ impl App {
469
819
  root.set_exclusive_zone(COMPACT_HEIGHT);
470
820
  root.present();
471
821
  } else {
472
- self.end_engagement();
473
- root.set_keyboard_mode(KeyboardMode::None);
474
- self.apply_expansion(widgets, root, None);
822
+ self.leave_keyboard_mode(widgets, root);
475
823
  root.set_exclusive_zone(-1);
476
824
  root.set_visible(false);
477
825
  }
@@ -505,6 +853,8 @@ impl App {
505
853
  fn refresh_cards(&self) {
506
854
  let duplicates = duplicate_labels(self.data.values());
507
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();
508
858
  for (id, view) in &self.cards {
509
859
  if let Some(card) = self.data.get(id) {
510
860
  view.update(
@@ -513,7 +863,7 @@ impl App {
513
863
  duplicates.contains(&card.repo_label),
514
864
  now,
515
865
  );
516
- 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));
517
867
  }
518
868
  }
519
869
  }
@@ -610,7 +960,7 @@ impl App {
610
960
 
611
961
  fn apply_expansion(
612
962
  &mut self,
613
- _widgets: &mut AppWidgets,
963
+ widgets: &mut AppWidgets,
614
964
  root: &gtk::Window,
615
965
  card_id: Option<String>,
616
966
  ) {
@@ -618,7 +968,72 @@ impl App {
618
968
  return;
619
969
  }
620
970
  self.open_card_id = card_id;
621
- 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();
622
1037
  root.set_default_size(
623
1038
  1,
624
1039
  if expanded {
@@ -628,19 +1043,249 @@ impl App {
628
1043
  },
629
1044
  );
630
1045
  root.set_exclusive_zone(COMPACT_HEIGHT);
631
- self.refresh_cards();
632
1046
  emit(
633
1047
  json!({ "protocol": 1, "type": "expanded", "expanded": expanded, "cardId": self.open_card_id }),
634
1048
  );
635
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
+ }
636
1278
  }
637
1279
 
638
1280
  #[cfg(test)]
639
1281
  mod tests {
640
1282
  use super::{
641
- BrandCounts, brand_text, engaged_card_id, move_order_item, navigation_target, short_path,
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,
642
1286
  };
643
1287
  use crate::protocol::Card;
1288
+ use relm4::gtk::gdk;
644
1289
  use std::collections::HashSet;
645
1290
 
646
1291
  fn counts() -> BrandCounts {
@@ -650,6 +1295,112 @@ mod tests {
650
1295
  }
651
1296
  }
652
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
+
653
1404
  #[test]
654
1405
  fn brand_names_the_focused_windows_project_and_folder() {
655
1406
  let card: Card = serde_json::from_value(serde_json::json!({