@tryinget/pi-activity-strip 0.8.0 → 0.10.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 +59 -21
- package/bin/pi-activity-strip.mjs +32 -4
- package/native/bin/linux-x64-gnu/artifact.json +4 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +1141 -84
- package/native/panel/src/app_gui_tests.rs +730 -0
- package/native/panel/src/app_hover_gui_tests.rs +917 -0
- package/native/panel/src/card_view.rs +19 -4
- package/native/panel/src/directory_view.rs +471 -0
- package/native/panel/src/hints.rs +65 -0
- package/native/panel/src/hover-lifecycle-fixtures.json +30 -0
- package/native/panel/src/hover_keyboard.rs +237 -0
- package/native/panel/src/interaction.rs +156 -0
- package/native/panel/src/keyboard-observation-fixtures.json +241 -0
- package/native/panel/src/keyboard_observation.rs +70 -0
- package/native/panel/src/main.rs +5 -0
- package/native/panel/src/protocol.rs +93 -8
- package/native/panel/src/runtime.rs +85 -12
- package/native/panel/src/style.css +85 -0
- package/package.json +6 -7
- package/src/broker/server.mjs +31 -14
- package/src/client/launcher.mjs +3 -7
- package/src/client/runtime-lock.mjs +2 -1
- package/src/common/agent-identity.mjs +6 -1
- package/src/common/contracts.d.ts +2 -0
- package/src/common/contracts.ts +39 -0
- package/src/common/keyboard-observation.mjs +35 -0
- package/src/common/overlay-readiness.mjs +11 -0
- package/src/common/presence-apps.mjs +57 -0
- package/src/common/recovery-identity.mjs +85 -0
- package/src/common/recovery-manifest.mjs +417 -0
- package/src/common/recovery-types.ts +75 -0
- package/src/common/ribbon-status.mjs +41 -0
- package/src/common/status-report.mjs +37 -2
- package/src/common/workspace-view.mjs +118 -0
- package/src/native/ak-runtime.mjs +50 -9
- package/src/native/focus-visibility.mjs +67 -0
- package/src/native/ghostty-tab-inventory.py +40 -23
- package/src/native/hover-keyboard.mjs +32 -0
- package/src/native/interaction-context.mjs +45 -0
- package/src/native/keyboard-entry.mjs +32 -0
- package/src/native/main.mjs +91 -42
- package/src/native/panel-binary.mjs +25 -8
- package/src/native/panel-neutral-source.mjs +53 -0
- package/src/native/panel-presentation.mjs +137 -0
- package/src/native/panel-projection.mjs +354 -18
- package/src/native/panel-protocol.mjs +23 -0
- package/src/native/panel-window-jump.mjs +90 -0
- package/src/native/placement.mjs +83 -20
- package/src/native/recovery-inventory.mjs +209 -0
- package/src/native/recovery-proc.mjs +178 -0
- package/src/native/recovery-sources.mjs +358 -0
- package/src/native/runtime-diagnostics.mjs +242 -0
- package/src/native/strip-hotkey-kdl.mjs +252 -0
- package/src/native/strip-hotkey.mjs +62 -0
- package/src/native/tab-inventory.mjs +62 -11
- package/src/native/window-jump.mjs +43 -0
- package/src/native/workspace-events.mjs +123 -2
package/native/panel/src/app.rs
CHANGED
|
@@ -1,20 +1,31 @@
|
|
|
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::hints;
|
|
6
|
+
use crate::hover_keyboard::{HoverKeyboard, monotonic_ms};
|
|
7
|
+
use crate::interaction::{
|
|
8
|
+
HideAnchor, InteractionLifetime, PointerAnchor, accepts_pointer, is_stationary_reentry,
|
|
9
|
+
surface_state,
|
|
10
|
+
};
|
|
11
|
+
use crate::protocol::{Card, ViewMessage, WindowEntry, demo_view, emit, emit_error, emit_ready};
|
|
3
12
|
use crate::runtime::{apply_theme, duplicate_labels, install_css, now_ms, start_input_reader};
|
|
4
13
|
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
|
5
14
|
use relm4::gtk;
|
|
15
|
+
use relm4::gtk::gdk;
|
|
6
16
|
use relm4::gtk::glib;
|
|
7
17
|
use relm4::gtk::prelude::*;
|
|
8
18
|
use relm4::{Component, ComponentParts, ComponentSender};
|
|
9
19
|
use serde_json::json;
|
|
10
|
-
use std::
|
|
20
|
+
use std::cell::Cell;
|
|
21
|
+
use std::collections::HashMap;
|
|
22
|
+
use std::rc::Rc;
|
|
11
23
|
use std::time::Duration;
|
|
12
24
|
|
|
13
25
|
const COMPACT_HEIGHT: i32 = 84;
|
|
14
26
|
/// The ribbon sits on the same rhythm as tiled windows, so it reads as one of them rather than as
|
|
15
27
|
/// a bar bolted to the top edge. Matches `gaps` in the compositor's layout.
|
|
16
28
|
const OUTER_MARGIN: i32 = 8;
|
|
17
|
-
const EXPANDED_HEIGHT: i32 = 276;
|
|
18
29
|
const ORDER_REFRESH_MS: i64 = 15_000;
|
|
19
30
|
const BRAND_WIDTH: i32 = 196;
|
|
20
31
|
|
|
@@ -28,15 +39,85 @@ pub struct App {
|
|
|
28
39
|
data: HashMap<String, Card>,
|
|
29
40
|
order: Vec<String>,
|
|
30
41
|
focused_card_id: Option<String>,
|
|
31
|
-
|
|
42
|
+
/// The card under the pointer. One at most: a pointer is in one place, and the card it
|
|
43
|
+
/// entered last is the one it is on, whatever order GTK delivers the old card's leave in.
|
|
44
|
+
hovered: Option<String>,
|
|
45
|
+
/// The compositor shortcut that enters keyboard mode, for the progressive hints.
|
|
46
|
+
hotkey: Option<String>,
|
|
47
|
+
/// Niri focus moved while the operator's hand was on the ribbon; follow it once they leave.
|
|
48
|
+
scroll_deferred: bool,
|
|
32
49
|
keyboard_focused: Option<String>,
|
|
50
|
+
/// Explicit/manual keyboard mode; hover must NOT inherit its arrow or unlisted-window powers.
|
|
33
51
|
keyboard_active: bool,
|
|
52
|
+
hover_keyboard: HoverKeyboard,
|
|
53
|
+
/// Set when the panel hides with the pointer inside; consumed by the next body entry.
|
|
54
|
+
pointer_anchor: Option<HideAnchor>,
|
|
55
|
+
hover_capture: Rc<Cell<bool>>,
|
|
56
|
+
interaction_epoch: u64,
|
|
34
57
|
open_card_id: Option<String>,
|
|
35
58
|
revision: u64,
|
|
36
59
|
visible: bool,
|
|
37
60
|
next_order_refresh_at: i64,
|
|
38
61
|
collapse_generation: u64,
|
|
39
62
|
interactive: bool,
|
|
63
|
+
directory: DirectoryView,
|
|
64
|
+
windows: Vec<WindowEntry>,
|
|
65
|
+
directory_open: bool,
|
|
66
|
+
directory_hovered: bool,
|
|
67
|
+
/// The pointer is over the brand column: it expands the ribbon like a card does, showing the
|
|
68
|
+
/// list at full height.
|
|
69
|
+
brand_hovered: bool,
|
|
70
|
+
/// Counts brand hover changes, so a leave only collapses if the pointer did not come back
|
|
71
|
+
/// (or land on a card) within the collapse grace.
|
|
72
|
+
brand_generation: u64,
|
|
73
|
+
/// Counts list openings, so a jump or activation result only acts on the list that asked.
|
|
74
|
+
directory_session: u64,
|
|
75
|
+
pending_request: Option<PendingRequest>,
|
|
76
|
+
lifetime: InteractionLifetime,
|
|
77
|
+
pointer: gtk::EventControllerMotion,
|
|
78
|
+
brand_pointer: gtk::EventControllerMotion,
|
|
79
|
+
keyboard_request: u64,
|
|
80
|
+
/// GTK activity alone is never permission to expand or capture keys.
|
|
81
|
+
window_active: bool,
|
|
82
|
+
query: String,
|
|
83
|
+
/// Read by the window's key controller: which keys belong to the window list right now.
|
|
84
|
+
key_capture: Rc<Cell<KeyCapture>>,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Keys the window list takes before any card sees them.
|
|
88
|
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
89
|
+
pub enum KeyCapture {
|
|
90
|
+
None,
|
|
91
|
+
/// Keyboard mode on the cards: a typed name or number character opens the list; Enter and
|
|
92
|
+
/// Escape stay with the cards.
|
|
93
|
+
Digits,
|
|
94
|
+
/// The list is open with nothing typed: digits, Backspace and Escape are its own; Enter still
|
|
95
|
+
/// reaches the focused card.
|
|
96
|
+
List,
|
|
97
|
+
/// The list is open with digits typed: Enter jumps.
|
|
98
|
+
ListTyped,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// What the open list asked the controller for, so only that answer acts on it.
|
|
102
|
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
103
|
+
pub struct PendingRequest {
|
|
104
|
+
session: u64,
|
|
105
|
+
target: RequestTarget,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
109
|
+
pub enum RequestTarget {
|
|
110
|
+
Card(String),
|
|
111
|
+
Window(i64),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#[derive(Debug)]
|
|
115
|
+
pub enum DirectoryKey {
|
|
116
|
+
/// A letter, digit, `-`, `_` or `.` of a name or window number.
|
|
117
|
+
Char(char),
|
|
118
|
+
Backspace,
|
|
119
|
+
Enter,
|
|
120
|
+
Escape,
|
|
40
121
|
}
|
|
41
122
|
|
|
42
123
|
#[derive(Debug)]
|
|
@@ -49,12 +130,22 @@ pub enum AppMsg {
|
|
|
49
130
|
Navigate(String, i32, bool),
|
|
50
131
|
Activate(String),
|
|
51
132
|
ActivationResult(String, bool, String),
|
|
52
|
-
FocusStrip,
|
|
133
|
+
FocusStrip(u64, u64),
|
|
134
|
+
HoverGrant(u64, u64, u64),
|
|
135
|
+
HoverDenied(u64),
|
|
136
|
+
HoverInterrupt,
|
|
137
|
+
PointerIntent,
|
|
53
138
|
Collapse,
|
|
54
139
|
CollapseIf(u64),
|
|
55
|
-
|
|
140
|
+
WindowActive(bool),
|
|
56
141
|
ParentGone,
|
|
57
142
|
InputError(String),
|
|
143
|
+
DirectoryHover(bool),
|
|
144
|
+
BrandHover(bool),
|
|
145
|
+
BrandSettle(u64),
|
|
146
|
+
DirectoryKey(DirectoryKey),
|
|
147
|
+
DirectoryActivate(String),
|
|
148
|
+
JumpResult(i64, bool, String),
|
|
58
149
|
}
|
|
59
150
|
|
|
60
151
|
#[relm4::component(pub)]
|
|
@@ -85,6 +176,9 @@ impl Component for App {
|
|
|
85
176
|
add_css_class: "brand",
|
|
86
177
|
set_orientation: gtk::Orientation::Vertical,
|
|
87
178
|
set_width_request: BRAND_WIDTH,
|
|
179
|
+
// Set explicitly so the window list's expanding rows cannot widen the brand
|
|
180
|
+
// and push the cards aside.
|
|
181
|
+
set_hexpand: false,
|
|
88
182
|
set_valign: gtk::Align::Center,
|
|
89
183
|
|
|
90
184
|
#[name = "eyebrow"]
|
|
@@ -156,7 +250,7 @@ impl Component for App {
|
|
|
156
250
|
root.set_margin(Edge::Top, OUTER_MARGIN);
|
|
157
251
|
root.set_margin(Edge::Left, OUTER_MARGIN);
|
|
158
252
|
root.set_margin(Edge::Right, OUTER_MARGIN);
|
|
159
|
-
root.set_exclusive_zone(
|
|
253
|
+
root.set_exclusive_zone(-1);
|
|
160
254
|
root.set_keyboard_mode(KeyboardMode::None);
|
|
161
255
|
if init.click_through {
|
|
162
256
|
root.connect_realize(|window| {
|
|
@@ -168,34 +262,124 @@ impl Component for App {
|
|
|
168
262
|
|
|
169
263
|
let tx = sender.input_sender().clone();
|
|
170
264
|
root.connect_is_active_notify(move |window| {
|
|
171
|
-
|
|
172
|
-
let _ = tx.send(AppMsg::WindowInactive);
|
|
173
|
-
}
|
|
265
|
+
let _ = tx.send(AppMsg::WindowActive(window.is_active()));
|
|
174
266
|
});
|
|
175
267
|
|
|
268
|
+
let directory = DirectoryView::new(sender.input_sender());
|
|
269
|
+
let key_capture = Rc::new(Cell::new(KeyCapture::None));
|
|
270
|
+
let hover_capture = Rc::new(Cell::new(false));
|
|
271
|
+
let hover = gtk::EventControllerMotion::new();
|
|
272
|
+
let brand_hover = gtk::EventControllerMotion::new();
|
|
176
273
|
let model = App {
|
|
177
274
|
cards: HashMap::new(),
|
|
178
275
|
data: HashMap::new(),
|
|
179
276
|
order: Vec::new(),
|
|
180
277
|
focused_card_id: None,
|
|
181
|
-
hovered:
|
|
278
|
+
hovered: None,
|
|
279
|
+
hotkey: None,
|
|
280
|
+
scroll_deferred: false,
|
|
182
281
|
keyboard_focused: None,
|
|
183
282
|
keyboard_active: false,
|
|
283
|
+
hover_keyboard: HoverKeyboard::default(),
|
|
284
|
+
pointer_anchor: None,
|
|
285
|
+
hover_capture: Rc::clone(&hover_capture),
|
|
286
|
+
interaction_epoch: 0,
|
|
184
287
|
open_card_id: None,
|
|
185
288
|
revision: 0,
|
|
186
289
|
visible: false,
|
|
187
290
|
next_order_refresh_at: 0,
|
|
188
291
|
collapse_generation: 0,
|
|
189
292
|
interactive: !init.click_through,
|
|
293
|
+
directory,
|
|
294
|
+
windows: Vec::new(),
|
|
295
|
+
directory_open: false,
|
|
296
|
+
directory_hovered: false,
|
|
297
|
+
brand_hovered: false,
|
|
298
|
+
brand_generation: 0,
|
|
299
|
+
directory_session: 0,
|
|
300
|
+
pending_request: None,
|
|
301
|
+
lifetime: InteractionLifetime::default(),
|
|
302
|
+
pointer: hover.clone(),
|
|
303
|
+
brand_pointer: brand_hover.clone(),
|
|
304
|
+
keyboard_request: 0,
|
|
305
|
+
window_active: false,
|
|
306
|
+
query: String::new(),
|
|
307
|
+
key_capture: Rc::clone(&key_capture),
|
|
190
308
|
};
|
|
191
309
|
let widgets = view_output!();
|
|
192
310
|
widgets.body.set_can_target(!init.click_through);
|
|
311
|
+
// GTK's viewport scrolls whatever takes focus into view. Focus on this panel only routes
|
|
312
|
+
// keys, so a hover grab must never move the row under a stationary pointer; the row
|
|
313
|
+
// scrolls only where the code below asks it to.
|
|
314
|
+
if let Some(viewport) = widgets.scroller.child().and_downcast::<gtk::Viewport>() {
|
|
315
|
+
viewport.set_scroll_to_focus(false);
|
|
316
|
+
}
|
|
317
|
+
// A key-routing focus target that is not a card, for a hover grab with no card under the
|
|
318
|
+
// pointer: focusing a card there would draw its focus ring on a card nobody pointed at.
|
|
319
|
+
widgets.body.set_focusable(true);
|
|
320
|
+
widgets.body.set_focus_on_click(false);
|
|
321
|
+
widgets.brand.append(&model.directory.root);
|
|
322
|
+
|
|
323
|
+
// The body is ONE region (including brand, cards and directory). Entry requests controller
|
|
324
|
+
// permission; child crossings do not drop/regrab ownership.
|
|
325
|
+
let tx = sender.input_sender().clone();
|
|
326
|
+
hover.connect_enter(move |_, _, _| {
|
|
327
|
+
let _ = tx.send(AppMsg::DirectoryHover(true));
|
|
328
|
+
});
|
|
329
|
+
let tx = sender.input_sender().clone();
|
|
330
|
+
hover.connect_leave(move |_| {
|
|
331
|
+
let _ = tx.send(AppMsg::DirectoryHover(false));
|
|
332
|
+
});
|
|
333
|
+
widgets.body.add_controller(hover);
|
|
334
|
+
let click = gtk::GestureClick::new();
|
|
335
|
+
click.set_propagation_phase(gtk::PropagationPhase::Capture);
|
|
336
|
+
let tx = sender.input_sender().clone();
|
|
337
|
+
click.connect_pressed(move |_, _, _, _| {
|
|
338
|
+
let _ = tx.send(AppMsg::PointerIntent);
|
|
339
|
+
});
|
|
340
|
+
widgets.body.add_controller(click);
|
|
341
|
+
|
|
342
|
+
let tx = sender.input_sender().clone();
|
|
343
|
+
brand_hover.connect_enter(move |_, _, _| {
|
|
344
|
+
let _ = tx.send(AppMsg::BrandHover(true));
|
|
345
|
+
});
|
|
346
|
+
let tx = sender.input_sender().clone();
|
|
347
|
+
brand_hover.connect_leave(move |_| {
|
|
348
|
+
let _ = tx.send(AppMsg::BrandHover(false));
|
|
349
|
+
});
|
|
350
|
+
widgets.brand.add_controller(brand_hover);
|
|
351
|
+
|
|
352
|
+
// Captured before any card sees it, and only while the list (or keyboard mode) is live:
|
|
353
|
+
// see `directory_key` for which keys belong to the list in which state.
|
|
354
|
+
let keys = gtk::EventControllerKey::new();
|
|
355
|
+
keys.set_propagation_phase(gtk::PropagationPhase::Capture);
|
|
356
|
+
let tx = sender.input_sender().clone();
|
|
357
|
+
keys.connect_key_pressed(move |_, key, _, modifiers| {
|
|
358
|
+
let chord = is_chord(modifiers);
|
|
359
|
+
let message = match directory_key(key_capture.get(), key, chord) {
|
|
360
|
+
Some(message) => message,
|
|
361
|
+
None => {
|
|
362
|
+
if hover_capture.get() && !is_typing_modifier(key) {
|
|
363
|
+
// Interrupting keys release hover; they are NOT replayed or injected into
|
|
364
|
+
// the underlying application. Subsequent typing belongs to that app.
|
|
365
|
+
let _ = tx.send(AppMsg::HoverInterrupt);
|
|
366
|
+
return glib::Propagation::Stop;
|
|
367
|
+
}
|
|
368
|
+
return glib::Propagation::Proceed;
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
let _ = tx.send(AppMsg::DirectoryKey(message));
|
|
372
|
+
glib::Propagation::Stop
|
|
373
|
+
});
|
|
374
|
+
widgets.root.add_controller(keys);
|
|
193
375
|
|
|
194
376
|
let tick_tx = sender.input_sender().clone();
|
|
195
|
-
glib::timeout_add_seconds_local(1, move || {
|
|
377
|
+
let _tick_source = glib::timeout_add_seconds_local(1, move || {
|
|
196
378
|
let _ = tick_tx.send(AppMsg::Tick);
|
|
197
379
|
glib::ControlFlow::Continue
|
|
198
380
|
});
|
|
381
|
+
#[cfg(test)]
|
|
382
|
+
gui_tests::configure_tick_source(_tick_source);
|
|
199
383
|
|
|
200
384
|
if init.demo {
|
|
201
385
|
let _ = sender.input_sender().send(AppMsg::View(demo_view()));
|
|
@@ -213,13 +397,55 @@ impl Component for App {
|
|
|
213
397
|
sender: ComponentSender<Self>,
|
|
214
398
|
root: &Self::Root,
|
|
215
399
|
) {
|
|
400
|
+
#[cfg(test)]
|
|
401
|
+
let _dispatch_observation = gui_tests::observe_dispatch(&message, self, root);
|
|
402
|
+
if !self.visible || !self.interactive {
|
|
403
|
+
match &message {
|
|
404
|
+
AppMsg::Hover(..)
|
|
405
|
+
| AppMsg::Focus(..)
|
|
406
|
+
| AppMsg::Navigate(..)
|
|
407
|
+
| AppMsg::Activate(..)
|
|
408
|
+
| AppMsg::DirectoryHover(..)
|
|
409
|
+
| AppMsg::BrandHover(..)
|
|
410
|
+
| AppMsg::BrandSettle(..)
|
|
411
|
+
| AppMsg::CollapseIf(..)
|
|
412
|
+
| AppMsg::DirectoryKey(..)
|
|
413
|
+
| AppMsg::DirectoryActivate(..) => return,
|
|
414
|
+
_ => {}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
216
417
|
match message {
|
|
217
418
|
AppMsg::View(view) => self.apply_view(widgets, root, &sender, view),
|
|
218
419
|
AppMsg::Theme(definitions) => apply_theme(&definitions),
|
|
219
420
|
AppMsg::Tick => {
|
|
220
|
-
|
|
421
|
+
// Mapping a surface beneath a stationary pointer is not a fresh keyboard intent.
|
|
422
|
+
// Observe an actual mapped absence before rearming after release/hide.
|
|
423
|
+
if root.is_mapped() && !self.pointer.contains_pointer() {
|
|
424
|
+
self.hover_keyboard.inside = false;
|
|
425
|
+
self.pointer_anchor = self
|
|
426
|
+
.pointer_anchor
|
|
427
|
+
.and_then(HideAnchor::after_mapped_absence);
|
|
428
|
+
}
|
|
429
|
+
if self.hover_keyboard.expired(monotonic_ms()) {
|
|
430
|
+
// Idle typing time ends the keyboard grab, not the hover: the card under a
|
|
431
|
+
// stationary pointer stays open.
|
|
432
|
+
self.observe_keyboard("idle-expired");
|
|
433
|
+
self.release_keyboard(widgets, root);
|
|
434
|
+
}
|
|
435
|
+
// Regrouping moves cards. Never under the pointer or the keyboard selection; the
|
|
436
|
+
// next tick after the operator leaves catches up.
|
|
437
|
+
if now_ms() >= self.next_order_refresh_at
|
|
438
|
+
&& !self.pointer.contains_pointer()
|
|
439
|
+
&& !self.owns_keyboard()
|
|
440
|
+
{
|
|
441
|
+
let focused_at = self.focused_position();
|
|
221
442
|
self.regroup();
|
|
222
443
|
self.reorder_widgets(&widgets.cards_box);
|
|
444
|
+
// Only when regrouping moved the focused card: a row the operator scrolled
|
|
445
|
+
// by hand stays where they left it.
|
|
446
|
+
if self.focused_position() != focused_at {
|
|
447
|
+
self.follow_focus(widgets);
|
|
448
|
+
}
|
|
223
449
|
self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
|
|
224
450
|
}
|
|
225
451
|
self.refresh_cards();
|
|
@@ -227,14 +453,27 @@ impl Component for App {
|
|
|
227
453
|
}
|
|
228
454
|
AppMsg::Hover(id, entered) => {
|
|
229
455
|
if entered {
|
|
230
|
-
self.
|
|
231
|
-
|
|
232
|
-
|
|
456
|
+
if !self.cards.get(&id).is_some_and(|card| {
|
|
457
|
+
accepts_pointer(
|
|
458
|
+
self.visible,
|
|
459
|
+
self.interactive,
|
|
460
|
+
card.root.is_mapped(),
|
|
461
|
+
card.motion.contains_pointer(),
|
|
462
|
+
)
|
|
463
|
+
}) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
self.hovered = Some(id);
|
|
467
|
+
} else if self.hovered.as_deref() == Some(id.as_str()) {
|
|
468
|
+
self.hovered = None;
|
|
233
469
|
}
|
|
234
470
|
self.reconcile_engagement(widgets, root, &sender);
|
|
235
471
|
}
|
|
236
472
|
AppMsg::Focus(id, entered) => {
|
|
237
473
|
if entered && self.keyboard_active {
|
|
474
|
+
// Keyboard selection is the one thing that may move the row: it is where the
|
|
475
|
+
// operator is looking. Pointer focus never scrolls (see init).
|
|
476
|
+
self.scroll_card_into_view(widgets, &id);
|
|
238
477
|
self.keyboard_focused = Some(id);
|
|
239
478
|
} else if !entered && self.keyboard_focused.as_deref() == Some(id.as_str()) {
|
|
240
479
|
self.keyboard_focused = None;
|
|
@@ -247,28 +486,34 @@ impl Component for App {
|
|
|
247
486
|
}
|
|
248
487
|
}
|
|
249
488
|
AppMsg::Activate(id) => {
|
|
250
|
-
emit(json!({ "protocol":
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
root.set_keyboard_mode(KeyboardMode::None);
|
|
254
|
-
self.apply_expansion(widgets, root, None);
|
|
255
|
-
}
|
|
489
|
+
emit(json!({ "protocol": 2, "type": "activate", "cardId": id }));
|
|
490
|
+
// Activation returns the keyboard to the exact target, never to a hover grab.
|
|
491
|
+
self.leave_keyboard_mode(widgets, root);
|
|
256
492
|
}
|
|
257
493
|
AppMsg::ActivationResult(id, ok, message) => {
|
|
258
494
|
if let Some(card) = self.cards.get(&id) {
|
|
259
495
|
card.set_activation(ok, &message);
|
|
260
496
|
}
|
|
497
|
+
if self.take_own_request(&RequestTarget::Card(id)) {
|
|
498
|
+
if ok {
|
|
499
|
+
self.finish_directory_jump(widgets, root);
|
|
500
|
+
} else {
|
|
501
|
+
self.directory.set_status(&message);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
261
504
|
}
|
|
262
|
-
AppMsg::FocusStrip => {
|
|
263
|
-
if self.visible && self.interactive {
|
|
505
|
+
AppMsg::FocusStrip(request, epoch) => {
|
|
506
|
+
if epoch == self.interaction_epoch && self.visible && self.interactive {
|
|
507
|
+
// A shortcut upgrades hover first; only a second explicit shortcut exits.
|
|
508
|
+
self.cancel_pending_hover();
|
|
509
|
+
self.hover_keyboard.clear();
|
|
510
|
+
self.keyboard_request = request;
|
|
264
511
|
if self.keyboard_active {
|
|
265
|
-
self.
|
|
266
|
-
root.set_keyboard_mode(KeyboardMode::None);
|
|
267
|
-
self.apply_expansion(widgets, root, None);
|
|
512
|
+
self.leave_keyboard_mode(widgets, root);
|
|
268
513
|
} else {
|
|
269
514
|
self.keyboard_active = true;
|
|
270
|
-
self.
|
|
271
|
-
self.keyboard_focused = self.
|
|
515
|
+
self.sync_key_capture();
|
|
516
|
+
self.keyboard_focused = self.keyboard_entry_card(widgets);
|
|
272
517
|
root.set_keyboard_mode(KeyboardMode::Exclusive);
|
|
273
518
|
root.present();
|
|
274
519
|
let focus_grabbed = self
|
|
@@ -278,36 +523,289 @@ impl Component for App {
|
|
|
278
523
|
.is_some_and(|first| first.root.grab_focus());
|
|
279
524
|
if focus_grabbed {
|
|
280
525
|
self.reconcile_engagement(widgets, root, &sender);
|
|
526
|
+
self.update_brand(widgets);
|
|
527
|
+
self.observe_keyboard("acquired");
|
|
281
528
|
emit(
|
|
282
|
-
json!({ "protocol":
|
|
529
|
+
json!({ "protocol": 2, "type": "keyboard-active", "active": true, "revision": self.keyboard_request }),
|
|
283
530
|
);
|
|
284
531
|
} else {
|
|
285
|
-
self.
|
|
286
|
-
|
|
287
|
-
|
|
532
|
+
self.leave_keyboard_mode(widgets, root);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
} else {
|
|
536
|
+
emit(
|
|
537
|
+
json!({ "protocol": 2, "type": "keyboard-active", "active": false, "revision": request }),
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
AppMsg::HoverGrant(token, epoch, request) => {
|
|
542
|
+
let pointer_valid = accepts_pointer(
|
|
543
|
+
self.visible,
|
|
544
|
+
self.interactive,
|
|
545
|
+
root.is_mapped(),
|
|
546
|
+
self.pointer.contains_pointer(),
|
|
547
|
+
);
|
|
548
|
+
if !self.keyboard_active
|
|
549
|
+
&& self.hover_keyboard.grant(
|
|
550
|
+
token,
|
|
551
|
+
epoch,
|
|
552
|
+
self.interaction_epoch,
|
|
553
|
+
pointer_valid,
|
|
554
|
+
monotonic_ms(),
|
|
555
|
+
)
|
|
556
|
+
{
|
|
557
|
+
self.keyboard_request = request;
|
|
558
|
+
self.sync_key_capture();
|
|
559
|
+
root.set_keyboard_mode(KeyboardMode::Exclusive);
|
|
560
|
+
root.present();
|
|
561
|
+
// GTK still needs a focus widget to route real key events through the root
|
|
562
|
+
// capture controller. This is NOT explicit/manual card-selection permission.
|
|
563
|
+
// The card under the pointer if there is one (so Enter opens it), else the
|
|
564
|
+
// body: never a card the pointer is not on. Neither scrolls the row.
|
|
565
|
+
let focus_grabbed =
|
|
566
|
+
match self.hovered.as_ref().and_then(|id| self.cards.get(id)) {
|
|
567
|
+
Some(card) => card.root.grab_focus(),
|
|
568
|
+
None => widgets.body.grab_focus(),
|
|
569
|
+
};
|
|
570
|
+
if focus_grabbed {
|
|
571
|
+
self.refresh_directory_status();
|
|
572
|
+
self.update_brand(widgets);
|
|
573
|
+
// Capability confirmation comes from the applied compositor focus, not
|
|
574
|
+
// grab_focus() (which merely selects a GTK widget before Wayland commits).
|
|
575
|
+
if root.is_active() {
|
|
576
|
+
self.acknowledge_hover_keyboard();
|
|
577
|
+
}
|
|
578
|
+
} else {
|
|
579
|
+
self.leave_keyboard_mode(widgets, root);
|
|
580
|
+
}
|
|
581
|
+
} else {
|
|
582
|
+
emit(json!({ "protocol": 2, "type": "hover-cancel", "token": token }));
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
AppMsg::HoverDenied(token) => {
|
|
586
|
+
if self
|
|
587
|
+
.hover_keyboard
|
|
588
|
+
.pending
|
|
589
|
+
.is_some_and(|(pending, _)| pending == token)
|
|
590
|
+
{
|
|
591
|
+
self.hover_keyboard.clear();
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
AppMsg::HoverInterrupt => {
|
|
595
|
+
if self.hover_keyboard.active {
|
|
596
|
+
// The key belongs to the app below; the pointer is still where it was.
|
|
597
|
+
self.release_keyboard(widgets, root);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
AppMsg::PointerIntent => self.request_hover_keyboard(root),
|
|
601
|
+
AppMsg::Collapse => {
|
|
602
|
+
self.leave_keyboard_mode(widgets, root);
|
|
603
|
+
}
|
|
604
|
+
AppMsg::WindowActive(active) => {
|
|
605
|
+
if active != root.is_active() {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
self.window_active = active;
|
|
609
|
+
if active {
|
|
610
|
+
if self.keyboard_active {
|
|
611
|
+
self.observe_keyboard("acquired");
|
|
612
|
+
}
|
|
613
|
+
if self.hover_keyboard.active {
|
|
614
|
+
if accepts_pointer(
|
|
615
|
+
self.visible,
|
|
616
|
+
self.interactive,
|
|
617
|
+
root.is_mapped(),
|
|
618
|
+
self.pointer.contains_pointer(),
|
|
619
|
+
) {
|
|
620
|
+
self.acknowledge_hover_keyboard();
|
|
621
|
+
} else {
|
|
622
|
+
self.leave_keyboard_mode(widgets, root);
|
|
288
623
|
}
|
|
289
624
|
}
|
|
625
|
+
self.refresh_directory_status();
|
|
626
|
+
} else if !self.hover_keyboard.active || self.hover_keyboard.captured {
|
|
627
|
+
// Explicit mode ends with the keyboard; a hover grab only gives it back.
|
|
628
|
+
if self.keyboard_active {
|
|
629
|
+
self.leave_keyboard_mode(widgets, root);
|
|
630
|
+
} else {
|
|
631
|
+
self.release_keyboard(widgets, root);
|
|
632
|
+
}
|
|
633
|
+
emit(
|
|
634
|
+
json!({ "protocol": 2, "type": "keyboard-active", "active": false, "revision": self.keyboard_request }),
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
AppMsg::BrandHover(entered) => {
|
|
639
|
+
if entered
|
|
640
|
+
&& !accepts_pointer(
|
|
641
|
+
self.visible,
|
|
642
|
+
self.interactive,
|
|
643
|
+
root.is_mapped(),
|
|
644
|
+
self.brand_pointer.contains_pointer(),
|
|
645
|
+
)
|
|
646
|
+
{
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
self.brand_hovered = entered;
|
|
650
|
+
self.brand_generation += 1;
|
|
651
|
+
// Every card opens (entered) or falls back to the hovered one (left) at once.
|
|
652
|
+
self.refresh_cards();
|
|
653
|
+
if entered {
|
|
654
|
+
self.resize(root);
|
|
655
|
+
self.sync_directory_layout(widgets);
|
|
656
|
+
} else {
|
|
657
|
+
// Collapse only after the same grace as a card, so crossing from the brand to
|
|
658
|
+
// a card does not flicker the ribbon down and up again.
|
|
659
|
+
self.reconcile_engagement(widgets, root, &sender);
|
|
660
|
+
let generation = self.brand_generation;
|
|
661
|
+
let tx = sender.input_sender().clone();
|
|
662
|
+
glib::timeout_add_local_once(Duration::from_millis(120), move || {
|
|
663
|
+
let _ = tx.send(AppMsg::BrandSettle(generation));
|
|
664
|
+
});
|
|
290
665
|
}
|
|
291
666
|
}
|
|
292
|
-
AppMsg::
|
|
293
|
-
self.
|
|
294
|
-
|
|
295
|
-
|
|
667
|
+
AppMsg::BrandSettle(generation) => {
|
|
668
|
+
if generation == self.brand_generation {
|
|
669
|
+
self.resize(root);
|
|
670
|
+
self.sync_directory_layout(widgets);
|
|
671
|
+
}
|
|
296
672
|
}
|
|
297
673
|
AppMsg::CollapseIf(generation) => {
|
|
298
674
|
if generation == self.collapse_generation
|
|
299
|
-
&& self.hovered.
|
|
675
|
+
&& self.hovered.is_none()
|
|
676
|
+
&& !self.brand_hovered
|
|
300
677
|
&& (!self.keyboard_active || self.keyboard_focused.is_none())
|
|
301
678
|
{
|
|
302
679
|
self.apply_expansion(widgets, root, None);
|
|
303
680
|
}
|
|
304
681
|
}
|
|
305
682
|
AppMsg::InputError(message) => emit_error(message),
|
|
683
|
+
AppMsg::DirectoryHover(entered) => {
|
|
684
|
+
let contains = self.pointer.contains_pointer();
|
|
685
|
+
if entered
|
|
686
|
+
&& !accepts_pointer(self.visible, self.interactive, root.is_mapped(), contains)
|
|
687
|
+
{
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if !entered && contains {
|
|
691
|
+
return;
|
|
692
|
+
} // Not a real region leave.
|
|
693
|
+
self.directory_hovered = entered;
|
|
694
|
+
if entered {
|
|
695
|
+
let fresh_entry = !self.hover_keyboard.inside;
|
|
696
|
+
// The compositor re-entering the pointer that sat here at hide is not intent,
|
|
697
|
+
// even after a mapped Tick cleared `inside`.
|
|
698
|
+
let stationary = is_stationary_reentry(
|
|
699
|
+
self.pointer_anchor.take().map(|anchor| anchor.at),
|
|
700
|
+
pointer_surface_position(root),
|
|
701
|
+
);
|
|
702
|
+
self.hover_keyboard.inside = true;
|
|
703
|
+
self.open_directory(widgets, root);
|
|
704
|
+
if fresh_entry && !stationary {
|
|
705
|
+
self.request_hover_keyboard(root);
|
|
706
|
+
}
|
|
707
|
+
} else {
|
|
708
|
+
self.pointer_anchor = None;
|
|
709
|
+
self.hover_keyboard.inside = false;
|
|
710
|
+
if !self.keyboard_active {
|
|
711
|
+
self.leave_keyboard_mode(widgets, root);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
AppMsg::DirectoryKey(key) => {
|
|
716
|
+
if self.owns_keyboard() {
|
|
717
|
+
self.hover_keyboard.touch(monotonic_ms());
|
|
718
|
+
self.observe_keyboard("activity");
|
|
719
|
+
self.handle_directory_key(widgets, root, key);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
AppMsg::DirectoryActivate(card_id) => {
|
|
723
|
+
self.pending_request = Some(PendingRequest {
|
|
724
|
+
session: self.directory_session,
|
|
725
|
+
target: RequestTarget::Card(card_id.clone()),
|
|
726
|
+
});
|
|
727
|
+
self.directory.set_status("Opening…");
|
|
728
|
+
emit(json!({ "protocol": 2, "type": "activate", "cardId": card_id }));
|
|
729
|
+
self.leave_keyboard_mode(widgets, root);
|
|
730
|
+
}
|
|
731
|
+
AppMsg::JumpResult(window_id, ok, message) => {
|
|
732
|
+
if self.take_own_request(&RequestTarget::Window(window_id)) {
|
|
733
|
+
if ok {
|
|
734
|
+
// The jumped-to window must get the keyboard back at once.
|
|
735
|
+
self.finish_directory_jump(widgets, root);
|
|
736
|
+
} else {
|
|
737
|
+
self.directory.set_status(&message);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
306
741
|
AppMsg::ParentGone => root.close(),
|
|
307
742
|
}
|
|
308
743
|
}
|
|
309
744
|
}
|
|
310
745
|
|
|
746
|
+
/// The scroll offset that shows the span `start..end` with the least movement: unchanged when it
|
|
747
|
+
/// is already fully visible, otherwise just far enough to bring its nearer edge into view.
|
|
748
|
+
fn scroll_to_show(value: f64, page: f64, start: f64, end: f64) -> f64 {
|
|
749
|
+
if start < value {
|
|
750
|
+
start
|
|
751
|
+
} else if end > value + page {
|
|
752
|
+
(end - page).min(start)
|
|
753
|
+
} else {
|
|
754
|
+
value
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/// Whether a result for `target` answers the request the list opened as `session` is waiting on.
|
|
759
|
+
fn answers_request(pending: Option<&PendingRequest>, session: u64, target: &RequestTarget) -> bool {
|
|
760
|
+
pending.is_some_and(|request| request.session == session && &request.target == target)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/// GDK's own surface-local pointer position, while this surface has pointer focus.
|
|
764
|
+
fn pointer_surface_position(root: >k::Window) -> Option<(f64, f64)> {
|
|
765
|
+
let pointer = WidgetExt::display(root).default_seat()?.pointer()?;
|
|
766
|
+
let (x, y, _) = root.surface()?.device_position(&pointer)?;
|
|
767
|
+
Some((x, y))
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/// A physical Shift press prepares a capital query character; it is not an interrupt or an idle
|
|
771
|
+
/// extension by itself. Ctrl/Alt/Super remain interrupting shortcut intent in hover mode.
|
|
772
|
+
fn is_typing_modifier(key: gdk::Key) -> bool {
|
|
773
|
+
matches!(key, gdk::Key::Shift_L | gdk::Key::Shift_R)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/// Ctrl, Alt, Super or Meta held: a shortcut, not typing. Shift is typing (capital letters).
|
|
777
|
+
fn is_chord(modifiers: gdk::ModifierType) -> bool {
|
|
778
|
+
modifiers.intersects(
|
|
779
|
+
gdk::ModifierType::CONTROL_MASK
|
|
780
|
+
| gdk::ModifierType::ALT_MASK
|
|
781
|
+
| gdk::ModifierType::SUPER_MASK
|
|
782
|
+
| gdk::ModifierType::META_MASK,
|
|
783
|
+
)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/// The window-list key a press means under the current capture, if it belongs to the list at all.
|
|
787
|
+
fn directory_key(capture: KeyCapture, key: gdk::Key, chord: bool) -> Option<DirectoryKey> {
|
|
788
|
+
// Ctrl, Alt and Super combinations are shortcuts, never search characters.
|
|
789
|
+
if chord {
|
|
790
|
+
return None;
|
|
791
|
+
}
|
|
792
|
+
let typed = key
|
|
793
|
+
.to_unicode()
|
|
794
|
+
.filter(|character| is_query_char(*character));
|
|
795
|
+
match capture {
|
|
796
|
+
KeyCapture::None => None,
|
|
797
|
+
KeyCapture::Digits => typed.map(DirectoryKey::Char),
|
|
798
|
+
KeyCapture::List | KeyCapture::ListTyped => match key {
|
|
799
|
+
gdk::Key::BackSpace => Some(DirectoryKey::Backspace),
|
|
800
|
+
gdk::Key::Return | gdk::Key::KP_Enter if capture == KeyCapture::ListTyped => {
|
|
801
|
+
Some(DirectoryKey::Enter)
|
|
802
|
+
}
|
|
803
|
+
gdk::Key::Escape => Some(DirectoryKey::Escape),
|
|
804
|
+
_ => typed.map(DirectoryKey::Char),
|
|
805
|
+
},
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
311
809
|
fn navigation_target(index: usize, direction: i32, len: usize) -> Option<usize> {
|
|
312
810
|
if len == 0 {
|
|
313
811
|
return None;
|
|
@@ -372,27 +870,86 @@ fn short_path(path: &str, home: &str) -> String {
|
|
|
372
870
|
}
|
|
373
871
|
|
|
374
872
|
fn engaged_card_id(
|
|
375
|
-
hovered: &
|
|
873
|
+
hovered: &Option<String>,
|
|
376
874
|
keyboard_active: bool,
|
|
377
875
|
keyboard_focused: &Option<String>,
|
|
378
876
|
) -> Option<String> {
|
|
379
877
|
if keyboard_active {
|
|
380
878
|
keyboard_focused.clone()
|
|
381
879
|
} else {
|
|
382
|
-
hovered.
|
|
880
|
+
hovered.clone()
|
|
383
881
|
}
|
|
384
882
|
}
|
|
385
883
|
|
|
386
884
|
impl App {
|
|
885
|
+
fn owns_keyboard(&self) -> bool {
|
|
886
|
+
self.keyboard_active || self.hover_keyboard.active
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/// Allowlisted native facts only. This message never authorizes input or changes timers.
|
|
890
|
+
fn observe_keyboard(&self, reason: &str) {
|
|
891
|
+
if let Some(event) = crate::keyboard_observation::normalize(&json!({
|
|
892
|
+
"protocol": 2, "type": "keyboard-observation", "request": self.keyboard_request,
|
|
893
|
+
"origin": if self.keyboard_active { "explicit" } else { "hover" },
|
|
894
|
+
"reason": reason, "nativeMs": monotonic_ms(),
|
|
895
|
+
"deadlineMs": self.hover_keyboard.captured_deadline_ms(),
|
|
896
|
+
"captured": self.hover_keyboard.captured || (self.keyboard_active && self.window_active),
|
|
897
|
+
})) {
|
|
898
|
+
emit(event);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
fn acknowledge_hover_keyboard(&mut self) {
|
|
903
|
+
if self.hover_keyboard.acquired(monotonic_ms()) {
|
|
904
|
+
self.observe_keyboard("acquired");
|
|
905
|
+
emit(
|
|
906
|
+
json!({ "protocol": 2, "type": "keyboard-active", "active": true, "revision": self.keyboard_request }),
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
fn cancel_pending_hover(&mut self) {
|
|
912
|
+
if let Some((token, _)) = self.hover_keyboard.pending.take() {
|
|
913
|
+
emit(json!({ "protocol": 2, "type": "hover-cancel", "token": token }));
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
fn request_hover_keyboard(&mut self, root: >k::Window) {
|
|
918
|
+
if self.keyboard_active
|
|
919
|
+
|| !accepts_pointer(
|
|
920
|
+
self.visible,
|
|
921
|
+
self.interactive,
|
|
922
|
+
root.is_mapped(),
|
|
923
|
+
self.pointer.contains_pointer(),
|
|
924
|
+
)
|
|
925
|
+
{
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if let Some(token) = self
|
|
929
|
+
.hover_keyboard
|
|
930
|
+
.request(self.interaction_epoch, monotonic_ms())
|
|
931
|
+
{
|
|
932
|
+
emit(
|
|
933
|
+
json!({ "protocol": 2, "type": "hover-request", "token": token, "epoch": self.interaction_epoch }),
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
387
938
|
fn end_engagement(&mut self) {
|
|
388
|
-
|
|
389
|
-
self.
|
|
939
|
+
self.hovered = None;
|
|
940
|
+
self.brand_hovered = false;
|
|
941
|
+
self.directory_hovered = false;
|
|
390
942
|
self.keyboard_focused = None;
|
|
391
943
|
self.keyboard_active = false;
|
|
944
|
+
self.cancel_pending_hover();
|
|
945
|
+
self.hover_keyboard.clear();
|
|
946
|
+
self.window_active = false;
|
|
947
|
+
self.pending_request = None;
|
|
948
|
+
self.query.clear();
|
|
949
|
+
self.sync_key_capture();
|
|
392
950
|
self.collapse_generation += 1;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
}
|
|
951
|
+
self.brand_generation += 1;
|
|
952
|
+
self.lifetime.invalidate();
|
|
396
953
|
}
|
|
397
954
|
|
|
398
955
|
fn apply_view(
|
|
@@ -402,11 +959,45 @@ impl App {
|
|
|
402
959
|
sender: &ComponentSender<Self>,
|
|
403
960
|
view: ViewMessage,
|
|
404
961
|
) {
|
|
405
|
-
if view.protocol !=
|
|
962
|
+
if view.protocol != 2 || view.message_type != "view" || view.revision <= self.revision {
|
|
406
963
|
return;
|
|
407
964
|
}
|
|
408
965
|
self.revision = view.revision;
|
|
966
|
+
// Data changes revoke two-frame scroll callbacks, NOT keyboard ownership attempts. The
|
|
967
|
+
// latter are bound to the controller's exact admission epoch and a unique pointer token.
|
|
968
|
+
self.lifetime.invalidate();
|
|
969
|
+
// Visibility is controller policy; an empty workspace still has a compact ribbon.
|
|
970
|
+
let should_show = view.visible;
|
|
971
|
+
let visibility_changed = should_show != self.visible;
|
|
972
|
+
if self.visible && !should_show && self.hover_keyboard.inside {
|
|
973
|
+
self.pointer_anchor = Some(HideAnchor::new(
|
|
974
|
+
pointer_surface_position(root)
|
|
975
|
+
.map_or(PointerAnchor::Unknown, |(x, y)| PointerAnchor::At(x, y)),
|
|
976
|
+
));
|
|
977
|
+
}
|
|
978
|
+
self.visible = should_show;
|
|
979
|
+
let focus_moved = self.focused_card_id != view.focused_card_id;
|
|
980
|
+
let hotkey_changed = self.hotkey != view.hotkey;
|
|
981
|
+
self.hotkey = view.hotkey.clone();
|
|
982
|
+
// Identity loss or hiding ends everything. A focus move inside the same identity (the
|
|
983
|
+
// controller's own activation echo) is not an app switch: it must not collapse the card
|
|
984
|
+
// under the pointer, only drop a hover request that has not been answered yet.
|
|
985
|
+
if !should_show || view.interaction_epoch != self.interaction_epoch || visibility_changed {
|
|
986
|
+
self.observe_keyboard("source-revoked");
|
|
987
|
+
self.leave_keyboard_mode(widgets, root);
|
|
988
|
+
} else if focus_moved && !self.owns_keyboard() {
|
|
989
|
+
self.cancel_pending_hover();
|
|
990
|
+
self.hover_keyboard.clear();
|
|
991
|
+
}
|
|
992
|
+
if hotkey_changed {
|
|
993
|
+
self.render_directory();
|
|
994
|
+
}
|
|
995
|
+
self.interaction_epoch = view.interaction_epoch;
|
|
409
996
|
self.focused_card_id = view.focused_card_id;
|
|
997
|
+
if self.windows != view.windows {
|
|
998
|
+
self.windows = view.windows;
|
|
999
|
+
self.render_directory();
|
|
1000
|
+
}
|
|
410
1001
|
let incoming_order: Vec<_> = view
|
|
411
1002
|
.sessions
|
|
412
1003
|
.iter()
|
|
@@ -423,7 +1014,13 @@ impl App {
|
|
|
423
1014
|
self.order.push(id);
|
|
424
1015
|
}
|
|
425
1016
|
}
|
|
426
|
-
|
|
1017
|
+
if self
|
|
1018
|
+
.hovered
|
|
1019
|
+
.as_ref()
|
|
1020
|
+
.is_some_and(|id| !self.data.contains_key(id))
|
|
1021
|
+
{
|
|
1022
|
+
self.hovered = None;
|
|
1023
|
+
}
|
|
427
1024
|
let keyboard_card_removed = self.keyboard_active
|
|
428
1025
|
&& self
|
|
429
1026
|
.keyboard_focused
|
|
@@ -450,9 +1047,7 @@ impl App {
|
|
|
450
1047
|
if focus_grabbed {
|
|
451
1048
|
self.reconcile_engagement(widgets, root, sender);
|
|
452
1049
|
} else {
|
|
453
|
-
self.
|
|
454
|
-
root.set_keyboard_mode(KeyboardMode::None);
|
|
455
|
-
self.apply_expansion(widgets, root, None);
|
|
1050
|
+
self.leave_keyboard_mode(widgets, root);
|
|
456
1051
|
}
|
|
457
1052
|
}
|
|
458
1053
|
if open_card_removed {
|
|
@@ -461,22 +1056,20 @@ impl App {
|
|
|
461
1056
|
self.reorder_widgets(&widgets.cards_box);
|
|
462
1057
|
self.refresh_cards();
|
|
463
1058
|
self.update_brand(widgets);
|
|
1059
|
+
if focus_moved {
|
|
1060
|
+
self.follow_focus(widgets);
|
|
1061
|
+
}
|
|
464
1062
|
|
|
465
|
-
|
|
466
|
-
if should_show != self.visible {
|
|
467
|
-
self.visible = should_show;
|
|
1063
|
+
if visibility_changed {
|
|
468
1064
|
if should_show {
|
|
469
1065
|
root.set_exclusive_zone(COMPACT_HEIGHT);
|
|
470
1066
|
root.present();
|
|
471
1067
|
} else {
|
|
472
|
-
self.end_engagement();
|
|
473
|
-
root.set_keyboard_mode(KeyboardMode::None);
|
|
474
|
-
self.apply_expansion(widgets, root, None);
|
|
475
1068
|
root.set_exclusive_zone(-1);
|
|
476
1069
|
root.set_visible(false);
|
|
477
1070
|
}
|
|
478
1071
|
emit(
|
|
479
|
-
json!({ "protocol":
|
|
1072
|
+
json!({ "protocol": 2, "type": "visibility-applied", "revision": self.revision, "visible": should_show }),
|
|
480
1073
|
);
|
|
481
1074
|
}
|
|
482
1075
|
}
|
|
@@ -505,6 +1098,9 @@ impl App {
|
|
|
505
1098
|
fn refresh_cards(&self) {
|
|
506
1099
|
let duplicates = duplicate_labels(self.data.values());
|
|
507
1100
|
let now = now_ms();
|
|
1101
|
+
// Hovering the brand column is an overview: every card shows its details at once.
|
|
1102
|
+
let all_open = self.brand_hovered && self.expanded();
|
|
1103
|
+
let key_hint = hints::card_hint(self.hotkey.as_deref());
|
|
508
1104
|
for (id, view) in &self.cards {
|
|
509
1105
|
if let Some(card) = self.data.get(id) {
|
|
510
1106
|
view.update(
|
|
@@ -512,8 +1108,9 @@ impl App {
|
|
|
512
1108
|
self.focused_card_id.as_deref() == Some(id),
|
|
513
1109
|
duplicates.contains(&card.repo_label),
|
|
514
1110
|
now,
|
|
1111
|
+
key_hint.as_deref(),
|
|
515
1112
|
);
|
|
516
|
-
view.set_expanded(self.open_card_id.as_deref() == Some(id));
|
|
1113
|
+
view.set_expanded(all_open || self.open_card_id.as_deref() == Some(id));
|
|
517
1114
|
}
|
|
518
1115
|
}
|
|
519
1116
|
}
|
|
@@ -530,14 +1127,22 @@ impl App {
|
|
|
530
1127
|
.and_then(|id| self.data.get(id));
|
|
531
1128
|
let home = std::env::var("HOME").unwrap_or_default();
|
|
532
1129
|
let brand = brand_text(focused, &counts, &home);
|
|
533
|
-
widgets.eyebrow.set_text(&brand.eyebrow);
|
|
534
1130
|
widgets.title.set_text(&brand.title);
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
1131
|
+
// The last rung of the keyboard hints: in keyboard mode the key map replaces the folder,
|
|
1132
|
+
// ellipsized at its end so the first keys always show.
|
|
1133
|
+
if self.keyboard_active {
|
|
1134
|
+
widgets.eyebrow.set_text(hints::KEYBOARD_EYEBROW);
|
|
1135
|
+
widgets.meta.set_text(hints::KEYBOARD_META);
|
|
1136
|
+
widgets.meta.set_ellipsize(gtk::pango::EllipsizeMode::End);
|
|
1137
|
+
} else {
|
|
1138
|
+
widgets.eyebrow.set_text(&brand.eyebrow);
|
|
1139
|
+
widgets.meta.set_text(&brand.meta);
|
|
1140
|
+
widgets.meta.set_ellipsize(gtk::pango::EllipsizeMode::Start);
|
|
1141
|
+
}
|
|
1142
|
+
widgets.brand.set_tooltip_text(Some(&hints::brand_tooltip(
|
|
1143
|
+
focused.map(|card| card.cwd.as_str()),
|
|
1144
|
+
self.hotkey.as_deref(),
|
|
1145
|
+
)));
|
|
541
1146
|
}
|
|
542
1147
|
|
|
543
1148
|
fn regroup(&mut self) {
|
|
@@ -579,7 +1184,7 @@ impl App {
|
|
|
579
1184
|
move_order_item(&mut self.order, index, target);
|
|
580
1185
|
self.reorder_widgets(parent);
|
|
581
1186
|
self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
|
|
582
|
-
emit(json!({ "protocol":
|
|
1187
|
+
emit(json!({ "protocol": 2, "type": "moved", "cardId": id, "direction": direction }));
|
|
583
1188
|
}
|
|
584
1189
|
let focus_id = if manual { id } else { &self.order[target] };
|
|
585
1190
|
if let Some(card) = self.cards.get(focus_id) {
|
|
@@ -593,6 +1198,9 @@ impl App {
|
|
|
593
1198
|
root: >k::Window,
|
|
594
1199
|
sender: &ComponentSender<Self>,
|
|
595
1200
|
) {
|
|
1201
|
+
if !self.visible || !self.interactive {
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
596
1204
|
if let Some(id) =
|
|
597
1205
|
engaged_card_id(&self.hovered, self.keyboard_active, &self.keyboard_focused)
|
|
598
1206
|
{
|
|
@@ -610,38 +1218,380 @@ impl App {
|
|
|
610
1218
|
|
|
611
1219
|
fn apply_expansion(
|
|
612
1220
|
&mut self,
|
|
613
|
-
|
|
1221
|
+
widgets: &mut AppWidgets,
|
|
614
1222
|
root: >k::Window,
|
|
615
1223
|
card_id: Option<String>,
|
|
616
1224
|
) {
|
|
617
1225
|
if self.open_card_id == card_id {
|
|
618
1226
|
return;
|
|
619
1227
|
}
|
|
620
|
-
self.open_card_id =
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
1228
|
+
self.open_card_id = if self.visible && self.interactive {
|
|
1229
|
+
card_id
|
|
1230
|
+
} else {
|
|
1231
|
+
None
|
|
1232
|
+
};
|
|
1233
|
+
self.refresh_cards();
|
|
1234
|
+
self.resize(root);
|
|
1235
|
+
self.sync_directory_layout(widgets);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/// Scroll the card row so the focused window's card is fully in view, as Niri focus moves
|
|
1239
|
+
/// between windows. Runs once layout has placed the card, so its position in the row is known.
|
|
1240
|
+
fn scroll_focused_into_view(&self, widgets: &AppWidgets) {
|
|
1241
|
+
if let Some(id) = self.focused_card_id.clone() {
|
|
1242
|
+
self.scroll_card_into_view(widgets, &id);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/// Follow Niri focus along the row, but never under the operator's hand: while the pointer is
|
|
1247
|
+
/// on the ribbon or it holds the keyboard the scroll waits, and runs once they leave.
|
|
1248
|
+
fn follow_focus(&mut self, widgets: &AppWidgets) {
|
|
1249
|
+
if self.pointer.contains_pointer() || self.owns_keyboard() {
|
|
1250
|
+
self.scroll_deferred = true;
|
|
1251
|
+
} else {
|
|
1252
|
+
self.scroll_deferred = false;
|
|
1253
|
+
self.scroll_focused_into_view(widgets);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/// Where explicit keyboard mode starts: the card under the pointer, else the focused window's
|
|
1258
|
+
/// card, else the first card already on screen, so entering never jumps the row to its start.
|
|
1259
|
+
fn keyboard_entry_card(&self, widgets: &AppWidgets) -> Option<String> {
|
|
1260
|
+
let present = |id: &&String| self.cards.contains_key(*id);
|
|
1261
|
+
if let Some(id) = self.hovered.as_ref().filter(present) {
|
|
1262
|
+
return Some(id.clone());
|
|
1263
|
+
}
|
|
1264
|
+
if let Some(id) = self.focused_card_id.as_ref().filter(present) {
|
|
1265
|
+
return Some(id.clone());
|
|
1266
|
+
}
|
|
1267
|
+
let adjustment = widgets.scroller.hadjustment();
|
|
1268
|
+
let (left, right) = (
|
|
1269
|
+
adjustment.value(),
|
|
1270
|
+
adjustment.value() + adjustment.page_size(),
|
|
1271
|
+
);
|
|
1272
|
+
self.order
|
|
1273
|
+
.iter()
|
|
1274
|
+
.find(|id| {
|
|
1275
|
+
self.cards.get(*id).is_some_and(|card| {
|
|
1276
|
+
card.root
|
|
1277
|
+
.compute_point(&widgets.cards_box, >k::graphene::Point::new(0.0, 0.0))
|
|
1278
|
+
.is_some_and(|origin| {
|
|
1279
|
+
let start = f64::from(origin.x());
|
|
1280
|
+
start >= left - 1.0
|
|
1281
|
+
&& start + f64::from(card.root.width()) <= right + 1.0
|
|
1282
|
+
})
|
|
1283
|
+
})
|
|
1284
|
+
})
|
|
1285
|
+
.or(self.order.first())
|
|
1286
|
+
.cloned()
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/// Bring one card fully into view with the least movement, once layout has placed it.
|
|
1290
|
+
fn scroll_card_into_view(&self, widgets: &AppWidgets, id: &str) {
|
|
1291
|
+
if !self.visible || !self.interactive {
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
let lifetime = self.lifetime.clone();
|
|
1295
|
+
let ticket = lifetime.ticket();
|
|
1296
|
+
let Some(card) = self.cards.get(id) else {
|
|
1297
|
+
return;
|
|
1298
|
+
};
|
|
1299
|
+
let row = widgets.cards_box.clone();
|
|
1300
|
+
let scroller = widgets.scroller.clone();
|
|
1301
|
+
// Measured on the second frame after the change: the first one lays the row out, so the
|
|
1302
|
+
// card's position is final, and a panel that is not shown yet simply waits until it is.
|
|
1303
|
+
let frames = Cell::new(0u8);
|
|
1304
|
+
card.root.add_tick_callback(move |card, _clock| {
|
|
1305
|
+
if !lifetime.accepts(ticket, card.is_mapped(), card.is_sensitive()) {
|
|
1306
|
+
return glib::ControlFlow::Break;
|
|
1307
|
+
}
|
|
1308
|
+
frames.set(frames.get() + 1);
|
|
1309
|
+
if frames.get() < 2 {
|
|
1310
|
+
return glib::ControlFlow::Continue;
|
|
1311
|
+
}
|
|
1312
|
+
if let Some(origin) = card.compute_point(&row, >k::graphene::Point::new(0.0, 0.0)) {
|
|
1313
|
+
let start = f64::from(origin.x());
|
|
1314
|
+
let end = start + f64::from(card.width());
|
|
1315
|
+
let adjustment = scroller.hadjustment();
|
|
1316
|
+
adjustment.set_value(scroll_to_show(
|
|
1317
|
+
adjustment.value(),
|
|
1318
|
+
adjustment.page_size(),
|
|
1319
|
+
start,
|
|
1320
|
+
end,
|
|
1321
|
+
));
|
|
1322
|
+
}
|
|
1323
|
+
glib::ControlFlow::Break
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
fn focused_position(&self) -> Option<usize> {
|
|
1328
|
+
let id = self.focused_card_id.as_ref()?;
|
|
1329
|
+
self.order.iter().position(|candidate| candidate == id)
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/// The ribbon is tall while a card is open (hovered or in keyboard mode), while the pointer is
|
|
1333
|
+
/// on the brand column with the list open, or while a typed number needs the list shown.
|
|
1334
|
+
fn expanded(&self) -> bool {
|
|
1335
|
+
self.visible
|
|
1336
|
+
&& self.interactive
|
|
1337
|
+
&& (self.open_card_id.is_some()
|
|
1338
|
+
|| (self.directory_open
|
|
1339
|
+
&& (self.brand_hovered || (self.owns_keyboard() && !self.query.is_empty()))))
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/// The list fills the brand column, top to bottom, whenever the ribbon is expanded; compact,
|
|
1343
|
+
/// it is hidden.
|
|
1344
|
+
fn sync_directory_layout(&self, widgets: &AppWidgets) {
|
|
1345
|
+
let shown = self.directory_open && self.expanded();
|
|
1346
|
+
self.directory.set_visible(shown);
|
|
1347
|
+
widgets.brand.set_valign(if shown {
|
|
1348
|
+
gtk::Align::Fill
|
|
1349
|
+
} else {
|
|
1350
|
+
gtk::Align::Center
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
fn resize(&self, root: >k::Window) {
|
|
1355
|
+
let expanded = self.expanded();
|
|
1356
|
+
let (height, zone) = surface_state(self.visible, expanded);
|
|
1357
|
+
root.set_default_size(1, height);
|
|
1358
|
+
root.set_exclusive_zone(zone);
|
|
1359
|
+
emit(
|
|
1360
|
+
json!({ "protocol": 2, "type": "expanded", "expanded": expanded, "cardId": self.open_card_id, "queryLength": self.query.chars().count() }),
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
fn sync_key_capture(&self) {
|
|
1365
|
+
self.hover_capture
|
|
1366
|
+
.set(self.visible && self.interactive && self.hover_keyboard.active);
|
|
1367
|
+
self.key_capture.set(
|
|
1368
|
+
if !self.visible || !self.interactive || !self.owns_keyboard() {
|
|
1369
|
+
KeyCapture::None
|
|
1370
|
+
} else if self.directory_open && !self.query.is_empty() {
|
|
1371
|
+
KeyCapture::ListTyped
|
|
1372
|
+
} else if self.directory_open {
|
|
1373
|
+
KeyCapture::List
|
|
1374
|
+
} else if self.owns_keyboard() {
|
|
1375
|
+
KeyCapture::Digits
|
|
626
1376
|
} else {
|
|
627
|
-
|
|
1377
|
+
KeyCapture::None
|
|
628
1378
|
},
|
|
629
1379
|
);
|
|
630
|
-
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
fn handle_directory_key(
|
|
1383
|
+
&mut self,
|
|
1384
|
+
widgets: &mut AppWidgets,
|
|
1385
|
+
root: >k::Window,
|
|
1386
|
+
key: DirectoryKey,
|
|
1387
|
+
) {
|
|
1388
|
+
match key {
|
|
1389
|
+
DirectoryKey::Char(digit) => {
|
|
1390
|
+
self.open_directory(widgets, root);
|
|
1391
|
+
if push_query_char(&mut self.query, digit) {
|
|
1392
|
+
self.render_directory();
|
|
1393
|
+
self.sync_key_capture();
|
|
1394
|
+
self.resize(root);
|
|
1395
|
+
self.sync_directory_layout(widgets);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
DirectoryKey::Backspace => {
|
|
1399
|
+
self.query.pop();
|
|
1400
|
+
self.render_directory();
|
|
1401
|
+
self.sync_key_capture();
|
|
1402
|
+
self.resize(root);
|
|
1403
|
+
self.sync_directory_layout(widgets);
|
|
1404
|
+
}
|
|
1405
|
+
DirectoryKey::Enter => {
|
|
1406
|
+
match jump_choice(&self.windows, &self.query, self.allow_unlisted()) {
|
|
1407
|
+
Some(JumpChoice::Window(window_id)) => {
|
|
1408
|
+
self.pending_request = Some(PendingRequest {
|
|
1409
|
+
session: self.directory_session,
|
|
1410
|
+
target: RequestTarget::Window(window_id),
|
|
1411
|
+
});
|
|
1412
|
+
self.directory
|
|
1413
|
+
.set_status(&format!("Jumping to #{window_id}…"));
|
|
1414
|
+
emit(
|
|
1415
|
+
json!({ "protocol": 2, "type": "jump", "windowId": window_id, "request": self.keyboard_request, "epoch": self.interaction_epoch }),
|
|
1416
|
+
);
|
|
1417
|
+
self.leave_keyboard_mode(widgets, root);
|
|
1418
|
+
}
|
|
1419
|
+
Some(JumpChoice::Card(card_id)) => {
|
|
1420
|
+
self.pending_request = Some(PendingRequest {
|
|
1421
|
+
session: self.directory_session,
|
|
1422
|
+
target: RequestTarget::Card(card_id.clone()),
|
|
1423
|
+
});
|
|
1424
|
+
self.directory.set_status("Opening…");
|
|
1425
|
+
emit(json!({ "protocol": 2, "type": "activate", "cardId": card_id }));
|
|
1426
|
+
self.leave_keyboard_mode(widgets, root);
|
|
1427
|
+
}
|
|
1428
|
+
None => {}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
DirectoryKey::Escape => self.leave_keyboard_mode(widgets, root),
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/// Whether a result answers exactly what the open list asked for. Anything else (a card's own
|
|
1436
|
+
/// activation, an earlier jump) leaves the pending request in place.
|
|
1437
|
+
fn take_own_request(&mut self, target: &RequestTarget) -> bool {
|
|
1438
|
+
if self.directory_open
|
|
1439
|
+
&& answers_request(
|
|
1440
|
+
self.pending_request.as_ref(),
|
|
1441
|
+
self.directory_session,
|
|
1442
|
+
target,
|
|
1443
|
+
)
|
|
1444
|
+
{
|
|
1445
|
+
self.pending_request = None;
|
|
1446
|
+
true
|
|
1447
|
+
} else {
|
|
1448
|
+
false
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
fn refresh_directory_status(&self) {
|
|
1453
|
+
if self.directory_open {
|
|
1454
|
+
self.directory.set_prompt(
|
|
1455
|
+
&self.windows,
|
|
1456
|
+
&self.query,
|
|
1457
|
+
self.owns_keyboard(),
|
|
1458
|
+
self.allow_unlisted(),
|
|
1459
|
+
self.hotkey.as_deref(),
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
fn render_directory(&self) {
|
|
1465
|
+
if self.directory_open {
|
|
1466
|
+
self.directory.render(
|
|
1467
|
+
&self.windows,
|
|
1468
|
+
&self.query,
|
|
1469
|
+
self.owns_keyboard(),
|
|
1470
|
+
self.allow_unlisted(),
|
|
1471
|
+
self.hotkey.as_deref(),
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
fn allow_unlisted(&self) -> bool {
|
|
1477
|
+
self.keyboard_active
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
fn open_directory(&mut self, widgets: &AppWidgets, root: >k::Window) {
|
|
1481
|
+
if self.directory_open || !self.visible || !self.interactive {
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
self.directory_open = true;
|
|
1485
|
+
self.directory_session += 1;
|
|
1486
|
+
self.query.clear();
|
|
1487
|
+
self.render_directory();
|
|
1488
|
+
self.sync_key_capture();
|
|
1489
|
+
self.resize(root);
|
|
1490
|
+
self.sync_directory_layout(widgets);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/// After the list moved focus somewhere, nothing on the ribbon may keep the keyboard: the list
|
|
1494
|
+
/// closes, and keyboard mode ends exactly as activating a card ends it.
|
|
1495
|
+
fn finish_directory_jump(&mut self, widgets: &mut AppWidgets, root: >k::Window) {
|
|
1496
|
+
if self.keyboard_active {
|
|
1497
|
+
self.leave_keyboard_mode(widgets, root);
|
|
1498
|
+
} else {
|
|
1499
|
+
self.dismiss_directory(widgets, root);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/// Close the list and let the ribbon fall back to compact unless a card is still under the
|
|
1504
|
+
/// pointer: the card kept open while the pointer crossed to the list has done its job.
|
|
1505
|
+
fn dismiss_directory(&mut self, widgets: &mut AppWidgets, root: >k::Window) {
|
|
1506
|
+
self.close_directory(widgets, root);
|
|
1507
|
+
if self.hovered.is_none() && !self.keyboard_active {
|
|
1508
|
+
self.apply_expansion(widgets, root, None);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/// The one way out of keyboard mode. The list closes with it: once the ribbon gives up the
|
|
1513
|
+
/// keyboard nothing could type into or dismiss a list left open.
|
|
1514
|
+
fn leave_keyboard_mode(&mut self, widgets: &mut AppWidgets, root: >k::Window) {
|
|
1515
|
+
let was_keyboard_active = self.owns_keyboard();
|
|
1516
|
+
self.close_directory(widgets, root);
|
|
1517
|
+
self.end_engagement();
|
|
631
1518
|
self.refresh_cards();
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
)
|
|
1519
|
+
root.set_keyboard_mode(KeyboardMode::None);
|
|
1520
|
+
GtkWindowExt::set_focus(root, None::<>k::Widget>);
|
|
1521
|
+
// If GTK still owns focus, acknowledge release only on WindowActive(false), after Niri
|
|
1522
|
+
// applies None. Otherwise the controller could mistake the in-flight NULL for lost identity.
|
|
1523
|
+
if was_keyboard_active && !root.is_active() {
|
|
1524
|
+
emit(
|
|
1525
|
+
json!({ "protocol": 2, "type": "keyboard-active", "active": false, "revision": self.keyboard_request }),
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
self.apply_expansion(widgets, root, None);
|
|
1529
|
+
self.resize(root);
|
|
1530
|
+
self.sync_directory_layout(widgets);
|
|
1531
|
+
self.update_brand(widgets);
|
|
1532
|
+
self.flush_deferred_scroll(widgets);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/// Give the keyboard back without ending what the pointer is doing. An idle or interrupted
|
|
1536
|
+
/// hover grab must not collapse the card still under a stationary pointer, nor move the row:
|
|
1537
|
+
/// only the typing state goes, and the list's prompt says how to take the keyboard again.
|
|
1538
|
+
/// Explicit keyboard mode has no partial release; it leaves through `leave_keyboard_mode`.
|
|
1539
|
+
fn release_keyboard(&mut self, widgets: &mut AppWidgets, root: >k::Window) {
|
|
1540
|
+
if self.keyboard_active {
|
|
1541
|
+
self.leave_keyboard_mode(widgets, root);
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
let was_owning = self.owns_keyboard();
|
|
1545
|
+
self.cancel_pending_hover();
|
|
1546
|
+
self.hover_keyboard.clear();
|
|
1547
|
+
self.query.clear();
|
|
1548
|
+
self.sync_key_capture();
|
|
1549
|
+
root.set_keyboard_mode(KeyboardMode::None);
|
|
1550
|
+
GtkWindowExt::set_focus(root, None::<>k::Widget>);
|
|
1551
|
+
if was_owning && !root.is_active() {
|
|
1552
|
+
emit(
|
|
1553
|
+
json!({ "protocol": 2, "type": "keyboard-active", "active": false, "revision": self.keyboard_request }),
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
self.render_directory();
|
|
1557
|
+
self.resize(root);
|
|
1558
|
+
self.sync_directory_layout(widgets);
|
|
1559
|
+
self.update_brand(widgets);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
fn flush_deferred_scroll(&mut self, widgets: &AppWidgets) {
|
|
1563
|
+
if self.scroll_deferred && !self.pointer.contains_pointer() && !self.owns_keyboard() {
|
|
1564
|
+
self.scroll_deferred = false;
|
|
1565
|
+
self.scroll_focused_into_view(widgets);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
fn close_directory(&mut self, widgets: &AppWidgets, root: >k::Window) {
|
|
1570
|
+
if !self.directory_open {
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
self.directory_open = false;
|
|
1574
|
+
self.pending_request = None;
|
|
1575
|
+
self.query.clear();
|
|
1576
|
+
self.sync_key_capture();
|
|
1577
|
+
self.resize(root);
|
|
1578
|
+
self.sync_directory_layout(widgets);
|
|
635
1579
|
}
|
|
636
1580
|
}
|
|
637
1581
|
|
|
1582
|
+
#[cfg(test)]
|
|
1583
|
+
#[path = "app_gui_tests.rs"]
|
|
1584
|
+
mod gui_tests;
|
|
1585
|
+
|
|
638
1586
|
#[cfg(test)]
|
|
639
1587
|
mod tests {
|
|
640
1588
|
use super::{
|
|
641
|
-
BrandCounts,
|
|
1589
|
+
BrandCounts, DirectoryKey, KeyCapture, PendingRequest, RequestTarget, answers_request,
|
|
1590
|
+
brand_text, directory_key, engaged_card_id, move_order_item, navigation_target,
|
|
1591
|
+
scroll_to_show, short_path,
|
|
642
1592
|
};
|
|
643
1593
|
use crate::protocol::Card;
|
|
644
|
-
use
|
|
1594
|
+
use relm4::gtk::gdk;
|
|
645
1595
|
|
|
646
1596
|
fn counts() -> BrandCounts {
|
|
647
1597
|
BrandCounts {
|
|
@@ -650,6 +1600,113 @@ mod tests {
|
|
|
650
1600
|
}
|
|
651
1601
|
}
|
|
652
1602
|
|
|
1603
|
+
#[test]
|
|
1604
|
+
fn the_focused_card_scrolls_into_view_with_the_least_movement() {
|
|
1605
|
+
// Row viewport 0..1000.
|
|
1606
|
+
assert_eq!(
|
|
1607
|
+
scroll_to_show(0.0, 1000.0, 200.0, 440.0),
|
|
1608
|
+
0.0,
|
|
1609
|
+
"already visible"
|
|
1610
|
+
);
|
|
1611
|
+
assert_eq!(
|
|
1612
|
+
scroll_to_show(0.0, 1000.0, 1500.0, 1740.0),
|
|
1613
|
+
740.0,
|
|
1614
|
+
"right of view"
|
|
1615
|
+
);
|
|
1616
|
+
assert_eq!(
|
|
1617
|
+
scroll_to_show(800.0, 1000.0, 300.0, 540.0),
|
|
1618
|
+
300.0,
|
|
1619
|
+
"left of view"
|
|
1620
|
+
);
|
|
1621
|
+
assert_eq!(
|
|
1622
|
+
scroll_to_show(0.0, 200.0, 500.0, 740.0),
|
|
1623
|
+
500.0,
|
|
1624
|
+
"wider than the view: its start wins"
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
#[test]
|
|
1629
|
+
fn only_the_answer_to_what_the_open_list_asked_acts_on_it() {
|
|
1630
|
+
let pending = PendingRequest {
|
|
1631
|
+
session: 3,
|
|
1632
|
+
target: RequestTarget::Window(43),
|
|
1633
|
+
};
|
|
1634
|
+
assert!(answers_request(
|
|
1635
|
+
Some(&pending),
|
|
1636
|
+
3,
|
|
1637
|
+
&RequestTarget::Window(43)
|
|
1638
|
+
));
|
|
1639
|
+
assert!(
|
|
1640
|
+
!answers_request(Some(&pending), 3, &RequestTarget::Window(44)),
|
|
1641
|
+
"an earlier jump to another number"
|
|
1642
|
+
);
|
|
1643
|
+
assert!(
|
|
1644
|
+
!answers_request(Some(&pending), 3, &RequestTarget::Card("card-a".into())),
|
|
1645
|
+
"a card's own activation"
|
|
1646
|
+
);
|
|
1647
|
+
assert!(
|
|
1648
|
+
!answers_request(Some(&pending), 4, &RequestTarget::Window(43)),
|
|
1649
|
+
"a list opened again since"
|
|
1650
|
+
);
|
|
1651
|
+
assert!(!answers_request(None, 3, &RequestTarget::Window(43)));
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
#[test]
|
|
1655
|
+
fn only_ctrl_alt_super_make_a_chord_and_bare_modifiers_are_not_typing() {
|
|
1656
|
+
assert!(super::is_typing_modifier(gdk::Key::Shift_L));
|
|
1657
|
+
assert!(super::is_typing_modifier(gdk::Key::Shift_R));
|
|
1658
|
+
assert!(!super::is_typing_modifier(gdk::Key::Control_L));
|
|
1659
|
+
assert!(super::is_chord(gdk::ModifierType::CONTROL_MASK));
|
|
1660
|
+
assert!(super::is_chord(gdk::ModifierType::SUPER_MASK));
|
|
1661
|
+
assert!(
|
|
1662
|
+
!super::is_chord(gdk::ModifierType::SHIFT_MASK),
|
|
1663
|
+
"Shift types capitals"
|
|
1664
|
+
);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
#[test]
|
|
1668
|
+
fn the_window_list_takes_keys_only_while_they_belong_to_it() {
|
|
1669
|
+
let key = |capture, key| format!("{:?}", directory_key(capture, key, false));
|
|
1670
|
+
assert_eq!(
|
|
1671
|
+
format!("{:?}", directory_key(KeyCapture::List, gdk::Key::c, true)),
|
|
1672
|
+
"None",
|
|
1673
|
+
"Ctrl+C is a shortcut, not a search for c"
|
|
1674
|
+
);
|
|
1675
|
+
assert_eq!(key(KeyCapture::None, gdk::Key::_4), "None");
|
|
1676
|
+
// Keyboard mode on the cards: a digit opens the list, Enter and Escape stay with the cards.
|
|
1677
|
+
assert_eq!(key(KeyCapture::Digits, gdk::Key::_4), "Some(Char('4'))");
|
|
1678
|
+
assert_eq!(
|
|
1679
|
+
key(KeyCapture::Digits, gdk::Key::e),
|
|
1680
|
+
"Some(Char('e'))",
|
|
1681
|
+
"a letter starts a name search"
|
|
1682
|
+
);
|
|
1683
|
+
assert_eq!(key(KeyCapture::Digits, gdk::Key::Return), "None");
|
|
1684
|
+
assert_eq!(key(KeyCapture::Digits, gdk::Key::Escape), "None");
|
|
1685
|
+
assert_eq!(key(KeyCapture::List, gdk::Key::KP_7), "Some(Char('7'))");
|
|
1686
|
+
assert_eq!(key(KeyCapture::List, gdk::Key::minus), "Some(Char('-'))");
|
|
1687
|
+
assert_eq!(
|
|
1688
|
+
key(KeyCapture::List, gdk::Key::space),
|
|
1689
|
+
"None",
|
|
1690
|
+
"space is never taken"
|
|
1691
|
+
);
|
|
1692
|
+
assert_eq!(
|
|
1693
|
+
key(KeyCapture::List, gdk::Key::Return),
|
|
1694
|
+
"None",
|
|
1695
|
+
"with nothing typed, Enter still reaches the focused card"
|
|
1696
|
+
);
|
|
1697
|
+
assert_eq!(key(KeyCapture::ListTyped, gdk::Key::Return), "Some(Enter)");
|
|
1698
|
+
assert_eq!(
|
|
1699
|
+
key(KeyCapture::List, gdk::Key::BackSpace),
|
|
1700
|
+
"Some(Backspace)"
|
|
1701
|
+
);
|
|
1702
|
+
assert_eq!(key(KeyCapture::List, gdk::Key::Escape), "Some(Escape)");
|
|
1703
|
+
assert_eq!(key(KeyCapture::None, gdk::Key::a), "None");
|
|
1704
|
+
assert!(matches!(
|
|
1705
|
+
directory_key(KeyCapture::ListTyped, gdk::Key::KP_Enter, false),
|
|
1706
|
+
Some(DirectoryKey::Enter)
|
|
1707
|
+
));
|
|
1708
|
+
}
|
|
1709
|
+
|
|
653
1710
|
#[test]
|
|
654
1711
|
fn brand_names_the_focused_windows_project_and_folder() {
|
|
655
1712
|
let card: Card = serde_json::from_value(serde_json::json!({
|
|
@@ -693,13 +1750,13 @@ mod tests {
|
|
|
693
1750
|
#[test]
|
|
694
1751
|
fn stale_gtk_focus_does_not_retain_expansion_outside_keyboard_mode() {
|
|
695
1752
|
let focused = Some("card-a".to_owned());
|
|
696
|
-
assert_eq!(engaged_card_id(&
|
|
697
|
-
assert_eq!(engaged_card_id(&
|
|
1753
|
+
assert_eq!(engaged_card_id(&None, false, &focused), None);
|
|
1754
|
+
assert_eq!(engaged_card_id(&None, true, &focused), focused);
|
|
698
1755
|
}
|
|
699
1756
|
|
|
700
1757
|
#[test]
|
|
701
1758
|
fn pointer_hover_retains_expansion_without_keyboard_mode() {
|
|
702
|
-
let hovered =
|
|
1759
|
+
let hovered = Some("card-a".to_owned());
|
|
703
1760
|
assert_eq!(
|
|
704
1761
|
engaged_card_id(&hovered, false, &Some("card-b".to_owned())),
|
|
705
1762
|
Some("card-a".to_owned())
|
|
@@ -708,7 +1765,7 @@ mod tests {
|
|
|
708
1765
|
|
|
709
1766
|
#[test]
|
|
710
1767
|
fn explicit_keyboard_mode_takes_precedence_over_pointer_hover() {
|
|
711
|
-
let hovered =
|
|
1768
|
+
let hovered = Some("card-a".to_owned());
|
|
712
1769
|
assert_eq!(
|
|
713
1770
|
engaged_card_id(&hovered, true, &Some("card-b".to_owned())),
|
|
714
1771
|
Some("card-b".to_owned())
|