@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.
- package/README.md +1 -0
- 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 +780 -29
- 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 +85 -0
- 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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
253
|
-
|
|
254
|
-
self.
|
|
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.
|
|
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.
|
|
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
|
|
293
|
-
self.
|
|
294
|
-
|
|
295
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
963
|
+
widgets: &mut AppWidgets,
|
|
614
964
|
root: >k::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
|
-
|
|
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();
|
|
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: >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
|
+
}
|
|
636
1278
|
}
|
|
637
1279
|
|
|
638
1280
|
#[cfg(test)]
|
|
639
1281
|
mod tests {
|
|
640
1282
|
use super::{
|
|
641
|
-
BrandCounts,
|
|
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!({
|