@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.
- package/README.md +3 -1
- package/native/bin/linux-x64-gnu/artifact.json +3 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +920 -41
- package/native/panel/src/card_view.rs +56 -24
- package/native/panel/src/directory_view.rs +409 -0
- package/native/panel/src/main.rs +1 -0
- package/native/panel/src/protocol.rs +71 -0
- package/native/panel/src/runtime.rs +43 -11
- package/native/panel/src/style.css +94 -5
- package/package.json +2 -3
- package/src/common/contracts.d.ts +1 -0
- package/src/common/contracts.ts +1 -0
- package/src/common/workspace-view.mjs +64 -0
- package/src/native/main.mjs +44 -1
- package/src/native/panel-projection.mjs +24 -2
package/native/panel/src/app.rs
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
use crate::card_view::CardView;
|
|
2
|
-
use crate::
|
|
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
|
-
|
|
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:
|
|
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::
|
|
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::
|
|
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::
|
|
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
|
-
|
|
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.
|
|
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.
|
|
238
|
-
|
|
239
|
-
self.
|
|
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.
|
|
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.
|
|
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
|
|
278
|
-
self.
|
|
279
|
-
|
|
280
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
871
|
+
fn update_brand(&self, widgets: &AppWidgets) {
|
|
456
872
|
let active = self.data.values().filter(|card| card.active()).count();
|
|
457
|
-
|
|
458
|
-
"{} active · {} settled · order {}s",
|
|
873
|
+
let counts = BrandCounts {
|
|
459
874
|
active,
|
|
460
|
-
self.data.len().saturating_sub(active),
|
|
461
|
-
|
|
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
|
-
|
|
963
|
+
widgets: &mut AppWidgets,
|
|
536
964
|
root: >k::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
|
-
|
|
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, >k::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: >k::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: >k::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: >k::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::<>k::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: >k::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: >k::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: >k::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: >k::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: >k::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: >k::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: >k::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::{
|
|
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());
|