@tryinget/pi-activity-strip 0.9.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 -22
- package/bin/pi-activity-strip.mjs +32 -4
- 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 +572 -266
- 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 +79 -17
- 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 +4 -0
- package/native/panel/src/protocol.rs +22 -8
- package/native/panel/src/runtime.rs +43 -2
- package/package.json +5 -5
- 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 +1 -0
- package/src/common/contracts.ts +38 -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 +54 -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 +77 -71
- 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 +338 -24
- 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
|
@@ -2,6 +2,12 @@ use crate::card_view::CardView;
|
|
|
2
2
|
use crate::directory_view::{
|
|
3
3
|
DirectoryView, JumpChoice, is_query_char, jump_choice, push_query_char,
|
|
4
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
|
+
};
|
|
5
11
|
use crate::protocol::{Card, ViewMessage, WindowEntry, demo_view, emit, emit_error, emit_ready};
|
|
6
12
|
use crate::runtime::{apply_theme, duplicate_labels, install_css, now_ms, start_input_reader};
|
|
7
13
|
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
|
@@ -12,7 +18,7 @@ use relm4::gtk::prelude::*;
|
|
|
12
18
|
use relm4::{Component, ComponentParts, ComponentSender};
|
|
13
19
|
use serde_json::json;
|
|
14
20
|
use std::cell::Cell;
|
|
15
|
-
use std::collections::
|
|
21
|
+
use std::collections::HashMap;
|
|
16
22
|
use std::rc::Rc;
|
|
17
23
|
use std::time::Duration;
|
|
18
24
|
|
|
@@ -20,16 +26,8 @@ const COMPACT_HEIGHT: i32 = 84;
|
|
|
20
26
|
/// The ribbon sits on the same rhythm as tiled windows, so it reads as one of them rather than as
|
|
21
27
|
/// a bar bolted to the top edge. Matches `gaps` in the compositor's layout.
|
|
22
28
|
const OUTER_MARGIN: i32 = 8;
|
|
23
|
-
const EXPANDED_HEIGHT: i32 = 276;
|
|
24
29
|
const ORDER_REFRESH_MS: i64 = 15_000;
|
|
25
30
|
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;
|
|
33
31
|
|
|
34
32
|
pub struct AppInit {
|
|
35
33
|
pub demo: bool,
|
|
@@ -41,9 +39,21 @@ pub struct App {
|
|
|
41
39
|
data: HashMap<String, Card>,
|
|
42
40
|
order: Vec<String>,
|
|
43
41
|
focused_card_id: Option<String>,
|
|
44
|
-
|
|
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,
|
|
45
49
|
keyboard_focused: Option<String>,
|
|
50
|
+
/// Explicit/manual keyboard mode; hover must NOT inherit its arrow or unlisted-window powers.
|
|
46
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,
|
|
47
57
|
open_card_id: Option<String>,
|
|
48
58
|
revision: u64,
|
|
49
59
|
visible: bool,
|
|
@@ -60,29 +70,18 @@ pub struct App {
|
|
|
60
70
|
/// Counts brand hover changes, so a leave only collapses if the pointer did not come back
|
|
61
71
|
/// (or land on a card) within the collapse grace.
|
|
62
72
|
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
73
|
/// Counts list openings, so a jump or activation result only acts on the list that asked.
|
|
68
74
|
directory_session: u64,
|
|
69
75
|
pending_request: Option<PendingRequest>,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
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.
|
|
79
81
|
window_active: bool,
|
|
80
82
|
query: String,
|
|
81
83
|
/// Read by the window's key controller: which keys belong to the window list right now.
|
|
82
84
|
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
85
|
}
|
|
87
86
|
|
|
88
87
|
/// Keys the window list takes before any card sees them.
|
|
@@ -119,8 +118,6 @@ pub enum DirectoryKey {
|
|
|
119
118
|
Backspace,
|
|
120
119
|
Enter,
|
|
121
120
|
Escape,
|
|
122
|
-
/// A key that cannot be part of a search, pressed while only hover holds the keyboard.
|
|
123
|
-
Interrupt,
|
|
124
121
|
}
|
|
125
122
|
|
|
126
123
|
#[derive(Debug)]
|
|
@@ -133,7 +130,11 @@ pub enum AppMsg {
|
|
|
133
130
|
Navigate(String, i32, bool),
|
|
134
131
|
Activate(String),
|
|
135
132
|
ActivationResult(String, bool, String),
|
|
136
|
-
FocusStrip,
|
|
133
|
+
FocusStrip(u64, u64),
|
|
134
|
+
HoverGrant(u64, u64, u64),
|
|
135
|
+
HoverDenied(u64),
|
|
136
|
+
HoverInterrupt,
|
|
137
|
+
PointerIntent,
|
|
137
138
|
Collapse,
|
|
138
139
|
CollapseIf(u64),
|
|
139
140
|
WindowActive(bool),
|
|
@@ -142,8 +143,6 @@ pub enum AppMsg {
|
|
|
142
143
|
DirectoryHover(bool),
|
|
143
144
|
BrandHover(bool),
|
|
144
145
|
BrandSettle(u64),
|
|
145
|
-
DirectoryCloseIf(u64),
|
|
146
|
-
DirectoryGrabExpired(u64),
|
|
147
146
|
DirectoryKey(DirectoryKey),
|
|
148
147
|
DirectoryActivate(String),
|
|
149
148
|
JumpResult(i64, bool, String),
|
|
@@ -251,7 +250,7 @@ impl Component for App {
|
|
|
251
250
|
root.set_margin(Edge::Top, OUTER_MARGIN);
|
|
252
251
|
root.set_margin(Edge::Left, OUTER_MARGIN);
|
|
253
252
|
root.set_margin(Edge::Right, OUTER_MARGIN);
|
|
254
|
-
root.set_exclusive_zone(
|
|
253
|
+
root.set_exclusive_zone(-1);
|
|
255
254
|
root.set_keyboard_mode(KeyboardMode::None);
|
|
256
255
|
if init.click_through {
|
|
257
256
|
root.connect_realize(|window| {
|
|
@@ -268,15 +267,23 @@ impl Component for App {
|
|
|
268
267
|
|
|
269
268
|
let directory = DirectoryView::new(sender.input_sender());
|
|
270
269
|
let key_capture = Rc::new(Cell::new(KeyCapture::None));
|
|
271
|
-
let
|
|
270
|
+
let hover_capture = Rc::new(Cell::new(false));
|
|
271
|
+
let hover = gtk::EventControllerMotion::new();
|
|
272
|
+
let brand_hover = gtk::EventControllerMotion::new();
|
|
272
273
|
let model = App {
|
|
273
274
|
cards: HashMap::new(),
|
|
274
275
|
data: HashMap::new(),
|
|
275
276
|
order: Vec::new(),
|
|
276
277
|
focused_card_id: None,
|
|
277
|
-
hovered:
|
|
278
|
+
hovered: None,
|
|
279
|
+
hotkey: None,
|
|
280
|
+
scroll_deferred: false,
|
|
278
281
|
keyboard_focused: None,
|
|
279
282
|
keyboard_active: false,
|
|
283
|
+
hover_keyboard: HoverKeyboard::default(),
|
|
284
|
+
pointer_anchor: None,
|
|
285
|
+
hover_capture: Rc::clone(&hover_capture),
|
|
286
|
+
interaction_epoch: 0,
|
|
280
287
|
open_card_id: None,
|
|
281
288
|
revision: 0,
|
|
282
289
|
visible: false,
|
|
@@ -289,24 +296,32 @@ impl Component for App {
|
|
|
289
296
|
directory_hovered: false,
|
|
290
297
|
brand_hovered: false,
|
|
291
298
|
brand_generation: 0,
|
|
292
|
-
directory_generation: 0,
|
|
293
|
-
directory_opened_by_hover: false,
|
|
294
299
|
directory_session: 0,
|
|
295
300
|
pending_request: None,
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
301
|
+
lifetime: InteractionLifetime::default(),
|
|
302
|
+
pointer: hover.clone(),
|
|
303
|
+
brand_pointer: brand_hover.clone(),
|
|
304
|
+
keyboard_request: 0,
|
|
299
305
|
window_active: false,
|
|
300
306
|
query: String::new(),
|
|
301
307
|
key_capture: Rc::clone(&key_capture),
|
|
302
|
-
hover_guard: Rc::clone(&hover_guard),
|
|
303
308
|
};
|
|
304
309
|
let widgets = view_output!();
|
|
305
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);
|
|
306
321
|
widgets.brand.append(&model.directory.root);
|
|
307
322
|
|
|
308
|
-
//
|
|
309
|
-
|
|
323
|
+
// The body is ONE region (including brand, cards and directory). Entry requests controller
|
|
324
|
+
// permission; child crossings do not drop/regrab ownership.
|
|
310
325
|
let tx = sender.input_sender().clone();
|
|
311
326
|
hover.connect_enter(move |_, _, _| {
|
|
312
327
|
let _ = tx.send(AppMsg::DirectoryHover(true));
|
|
@@ -316,8 +331,14 @@ impl Component for App {
|
|
|
316
331
|
let _ = tx.send(AppMsg::DirectoryHover(false));
|
|
317
332
|
});
|
|
318
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);
|
|
319
341
|
|
|
320
|
-
let brand_hover = gtk::EventControllerMotion::new();
|
|
321
342
|
let tx = sender.input_sender().clone();
|
|
322
343
|
brand_hover.connect_enter(move |_, _, _| {
|
|
323
344
|
let _ = tx.send(AppMsg::BrandHover(true));
|
|
@@ -337,8 +358,15 @@ impl Component for App {
|
|
|
337
358
|
let chord = is_chord(modifiers);
|
|
338
359
|
let message = match directory_key(key_capture.get(), key, chord) {
|
|
339
360
|
Some(message) => message,
|
|
340
|
-
None
|
|
341
|
-
|
|
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
|
+
}
|
|
342
370
|
};
|
|
343
371
|
let _ = tx.send(AppMsg::DirectoryKey(message));
|
|
344
372
|
glib::Propagation::Stop
|
|
@@ -346,10 +374,12 @@ impl Component for App {
|
|
|
346
374
|
widgets.root.add_controller(keys);
|
|
347
375
|
|
|
348
376
|
let tick_tx = sender.input_sender().clone();
|
|
349
|
-
glib::timeout_add_seconds_local(1, move || {
|
|
377
|
+
let _tick_source = glib::timeout_add_seconds_local(1, move || {
|
|
350
378
|
let _ = tick_tx.send(AppMsg::Tick);
|
|
351
379
|
glib::ControlFlow::Continue
|
|
352
380
|
});
|
|
381
|
+
#[cfg(test)]
|
|
382
|
+
gui_tests::configure_tick_source(_tick_source);
|
|
353
383
|
|
|
354
384
|
if init.demo {
|
|
355
385
|
let _ = sender.input_sender().send(AppMsg::View(demo_view()));
|
|
@@ -367,18 +397,54 @@ impl Component for App {
|
|
|
367
397
|
sender: ComponentSender<Self>,
|
|
368
398
|
root: &Self::Root,
|
|
369
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
|
+
}
|
|
370
417
|
match message {
|
|
371
418
|
AppMsg::View(view) => self.apply_view(widgets, root, &sender, view),
|
|
372
419
|
AppMsg::Theme(definitions) => apply_theme(&definitions),
|
|
373
420
|
AppMsg::Tick => {
|
|
374
|
-
|
|
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
|
+
{
|
|
375
441
|
let focused_at = self.focused_position();
|
|
376
442
|
self.regroup();
|
|
377
443
|
self.reorder_widgets(&widgets.cards_box);
|
|
378
444
|
// Only when regrouping moved the focused card: a row the operator scrolled
|
|
379
445
|
// by hand stays where they left it.
|
|
380
446
|
if self.focused_position() != focused_at {
|
|
381
|
-
self.
|
|
447
|
+
self.follow_focus(widgets);
|
|
382
448
|
}
|
|
383
449
|
self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
|
|
384
450
|
}
|
|
@@ -387,14 +453,27 @@ impl Component for App {
|
|
|
387
453
|
}
|
|
388
454
|
AppMsg::Hover(id, entered) => {
|
|
389
455
|
if entered {
|
|
390
|
-
self.
|
|
391
|
-
|
|
392
|
-
|
|
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;
|
|
393
469
|
}
|
|
394
470
|
self.reconcile_engagement(widgets, root, &sender);
|
|
395
471
|
}
|
|
396
472
|
AppMsg::Focus(id, entered) => {
|
|
397
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);
|
|
398
477
|
self.keyboard_focused = Some(id);
|
|
399
478
|
} else if !entered && self.keyboard_focused.as_deref() == Some(id.as_str()) {
|
|
400
479
|
self.keyboard_focused = None;
|
|
@@ -407,14 +486,9 @@ impl Component for App {
|
|
|
407
486
|
}
|
|
408
487
|
}
|
|
409
488
|
AppMsg::Activate(id) => {
|
|
410
|
-
emit(json!({ "protocol":
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
if self.keyboard_active {
|
|
414
|
-
self.leave_keyboard_mode(widgets, root);
|
|
415
|
-
} else {
|
|
416
|
-
self.close_directory(widgets, root);
|
|
417
|
-
}
|
|
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);
|
|
418
492
|
}
|
|
419
493
|
AppMsg::ActivationResult(id, ok, message) => {
|
|
420
494
|
if let Some(card) = self.cards.get(&id) {
|
|
@@ -428,19 +502,18 @@ impl Component for App {
|
|
|
428
502
|
}
|
|
429
503
|
}
|
|
430
504
|
}
|
|
431
|
-
AppMsg::FocusStrip => {
|
|
432
|
-
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;
|
|
433
511
|
if self.keyboard_active {
|
|
434
512
|
self.leave_keyboard_mode(widgets, root);
|
|
435
513
|
} else {
|
|
436
514
|
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
515
|
self.sync_key_capture();
|
|
442
|
-
self.
|
|
443
|
-
self.keyboard_focused = self.order.first().cloned();
|
|
516
|
+
self.keyboard_focused = self.keyboard_entry_card(widgets);
|
|
444
517
|
root.set_keyboard_mode(KeyboardMode::Exclusive);
|
|
445
518
|
root.present();
|
|
446
519
|
let focus_grabbed = self
|
|
@@ -450,37 +523,129 @@ impl Component for App {
|
|
|
450
523
|
.is_some_and(|first| first.root.grab_focus());
|
|
451
524
|
if focus_grabbed {
|
|
452
525
|
self.reconcile_engagement(widgets, root, &sender);
|
|
526
|
+
self.update_brand(widgets);
|
|
527
|
+
self.observe_keyboard("acquired");
|
|
453
528
|
emit(
|
|
454
|
-
json!({ "protocol":
|
|
529
|
+
json!({ "protocol": 2, "type": "keyboard-active", "active": true, "revision": self.keyboard_request }),
|
|
455
530
|
);
|
|
456
531
|
} else {
|
|
457
532
|
self.leave_keyboard_mode(widgets, root);
|
|
458
533
|
}
|
|
459
534
|
}
|
|
535
|
+
} else {
|
|
536
|
+
emit(
|
|
537
|
+
json!({ "protocol": 2, "type": "keyboard-active", "active": false, "revision": request }),
|
|
538
|
+
);
|
|
460
539
|
}
|
|
461
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),
|
|
462
601
|
AppMsg::Collapse => {
|
|
463
602
|
self.leave_keyboard_mode(widgets, root);
|
|
464
603
|
}
|
|
465
604
|
AppMsg::WindowActive(active) => {
|
|
605
|
+
if active != root.is_active() {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
466
608
|
self.window_active = active;
|
|
467
609
|
if active {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
480
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
|
+
);
|
|
481
636
|
}
|
|
482
637
|
}
|
|
483
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
|
+
}
|
|
484
649
|
self.brand_hovered = entered;
|
|
485
650
|
self.brand_generation += 1;
|
|
486
651
|
// Every card opens (entered) or falls back to the hovered one (left) at once.
|
|
@@ -507,7 +672,7 @@ impl Component for App {
|
|
|
507
672
|
}
|
|
508
673
|
AppMsg::CollapseIf(generation) => {
|
|
509
674
|
if generation == self.collapse_generation
|
|
510
|
-
&& self.hovered.
|
|
675
|
+
&& self.hovered.is_none()
|
|
511
676
|
&& !self.brand_hovered
|
|
512
677
|
&& (!self.keyboard_active || self.keyboard_focused.is_none())
|
|
513
678
|
{
|
|
@@ -516,47 +681,43 @@ impl Component for App {
|
|
|
516
681
|
}
|
|
517
682
|
AppMsg::InputError(message) => emit_error(message),
|
|
518
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.
|
|
519
693
|
self.directory_hovered = entered;
|
|
520
|
-
self.directory_generation += 1;
|
|
521
694
|
if entered {
|
|
522
|
-
self.
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
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);
|
|
526
706
|
}
|
|
527
707
|
} else {
|
|
528
|
-
|
|
529
|
-
self.
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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);
|
|
708
|
+
self.pointer_anchor = None;
|
|
709
|
+
self.hover_keyboard.inside = false;
|
|
710
|
+
if !self.keyboard_active {
|
|
711
|
+
self.leave_keyboard_mode(widgets, root);
|
|
712
|
+
}
|
|
551
713
|
}
|
|
552
714
|
}
|
|
553
715
|
AppMsg::DirectoryKey(key) => {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
self.
|
|
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);
|
|
558
720
|
}
|
|
559
|
-
self.handle_directory_key(widgets, root, key);
|
|
560
721
|
}
|
|
561
722
|
AppMsg::DirectoryActivate(card_id) => {
|
|
562
723
|
self.pending_request = Some(PendingRequest {
|
|
@@ -564,7 +725,8 @@ impl Component for App {
|
|
|
564
725
|
target: RequestTarget::Card(card_id.clone()),
|
|
565
726
|
});
|
|
566
727
|
self.directory.set_status("Opening…");
|
|
567
|
-
emit(json!({ "protocol":
|
|
728
|
+
emit(json!({ "protocol": 2, "type": "activate", "cardId": card_id }));
|
|
729
|
+
self.leave_keyboard_mode(widgets, root);
|
|
568
730
|
}
|
|
569
731
|
AppMsg::JumpResult(window_id, ok, message) => {
|
|
570
732
|
if self.take_own_request(&RequestTarget::Window(window_id)) {
|
|
@@ -598,6 +760,19 @@ fn answers_request(pending: Option<&PendingRequest>, session: u64, target: &Requ
|
|
|
598
760
|
pending.is_some_and(|request| request.session == session && &request.target == target)
|
|
599
761
|
}
|
|
600
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
|
+
|
|
601
776
|
/// Ctrl, Alt, Super or Meta held: a shortcut, not typing. Shift is typing (capital letters).
|
|
602
777
|
fn is_chord(modifiers: gdk::ModifierType) -> bool {
|
|
603
778
|
modifiers.intersects(
|
|
@@ -608,26 +783,6 @@ fn is_chord(modifiers: gdk::ModifierType) -> bool {
|
|
|
608
783
|
)
|
|
609
784
|
}
|
|
610
785
|
|
|
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
786
|
/// The window-list key a press means under the current capture, if it belongs to the list at all.
|
|
632
787
|
fn directory_key(capture: KeyCapture, key: gdk::Key, chord: bool) -> Option<DirectoryKey> {
|
|
633
788
|
// Ctrl, Alt and Super combinations are shortcuts, never search characters.
|
|
@@ -715,28 +870,86 @@ fn short_path(path: &str, home: &str) -> String {
|
|
|
715
870
|
}
|
|
716
871
|
|
|
717
872
|
fn engaged_card_id(
|
|
718
|
-
hovered: &
|
|
873
|
+
hovered: &Option<String>,
|
|
719
874
|
keyboard_active: bool,
|
|
720
875
|
keyboard_focused: &Option<String>,
|
|
721
876
|
) -> Option<String> {
|
|
722
877
|
if keyboard_active {
|
|
723
878
|
keyboard_focused.clone()
|
|
724
879
|
} else {
|
|
725
|
-
hovered.
|
|
880
|
+
hovered.clone()
|
|
726
881
|
}
|
|
727
882
|
}
|
|
728
883
|
|
|
729
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
|
+
|
|
730
938
|
fn end_engagement(&mut self) {
|
|
731
|
-
|
|
732
|
-
self.
|
|
939
|
+
self.hovered = None;
|
|
940
|
+
self.brand_hovered = false;
|
|
941
|
+
self.directory_hovered = false;
|
|
733
942
|
self.keyboard_focused = None;
|
|
734
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();
|
|
735
949
|
self.sync_key_capture();
|
|
736
950
|
self.collapse_generation += 1;
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
}
|
|
951
|
+
self.brand_generation += 1;
|
|
952
|
+
self.lifetime.invalidate();
|
|
740
953
|
}
|
|
741
954
|
|
|
742
955
|
fn apply_view(
|
|
@@ -746,11 +959,40 @@ impl App {
|
|
|
746
959
|
sender: &ComponentSender<Self>,
|
|
747
960
|
view: ViewMessage,
|
|
748
961
|
) {
|
|
749
|
-
if view.protocol !=
|
|
962
|
+
if view.protocol != 2 || view.message_type != "view" || view.revision <= self.revision {
|
|
750
963
|
return;
|
|
751
964
|
}
|
|
752
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;
|
|
753
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;
|
|
754
996
|
self.focused_card_id = view.focused_card_id;
|
|
755
997
|
if self.windows != view.windows {
|
|
756
998
|
self.windows = view.windows;
|
|
@@ -772,7 +1014,13 @@ impl App {
|
|
|
772
1014
|
self.order.push(id);
|
|
773
1015
|
}
|
|
774
1016
|
}
|
|
775
|
-
|
|
1017
|
+
if self
|
|
1018
|
+
.hovered
|
|
1019
|
+
.as_ref()
|
|
1020
|
+
.is_some_and(|id| !self.data.contains_key(id))
|
|
1021
|
+
{
|
|
1022
|
+
self.hovered = None;
|
|
1023
|
+
}
|
|
776
1024
|
let keyboard_card_removed = self.keyboard_active
|
|
777
1025
|
&& self
|
|
778
1026
|
.keyboard_focused
|
|
@@ -809,22 +1057,19 @@ impl App {
|
|
|
809
1057
|
self.refresh_cards();
|
|
810
1058
|
self.update_brand(widgets);
|
|
811
1059
|
if focus_moved {
|
|
812
|
-
self.
|
|
1060
|
+
self.follow_focus(widgets);
|
|
813
1061
|
}
|
|
814
1062
|
|
|
815
|
-
|
|
816
|
-
if should_show != self.visible {
|
|
817
|
-
self.visible = should_show;
|
|
1063
|
+
if visibility_changed {
|
|
818
1064
|
if should_show {
|
|
819
1065
|
root.set_exclusive_zone(COMPACT_HEIGHT);
|
|
820
1066
|
root.present();
|
|
821
1067
|
} else {
|
|
822
|
-
self.leave_keyboard_mode(widgets, root);
|
|
823
1068
|
root.set_exclusive_zone(-1);
|
|
824
1069
|
root.set_visible(false);
|
|
825
1070
|
}
|
|
826
1071
|
emit(
|
|
827
|
-
json!({ "protocol":
|
|
1072
|
+
json!({ "protocol": 2, "type": "visibility-applied", "revision": self.revision, "visible": should_show }),
|
|
828
1073
|
);
|
|
829
1074
|
}
|
|
830
1075
|
}
|
|
@@ -855,6 +1100,7 @@ impl App {
|
|
|
855
1100
|
let now = now_ms();
|
|
856
1101
|
// Hovering the brand column is an overview: every card shows its details at once.
|
|
857
1102
|
let all_open = self.brand_hovered && self.expanded();
|
|
1103
|
+
let key_hint = hints::card_hint(self.hotkey.as_deref());
|
|
858
1104
|
for (id, view) in &self.cards {
|
|
859
1105
|
if let Some(card) = self.data.get(id) {
|
|
860
1106
|
view.update(
|
|
@@ -862,6 +1108,7 @@ impl App {
|
|
|
862
1108
|
self.focused_card_id.as_deref() == Some(id),
|
|
863
1109
|
duplicates.contains(&card.repo_label),
|
|
864
1110
|
now,
|
|
1111
|
+
key_hint.as_deref(),
|
|
865
1112
|
);
|
|
866
1113
|
view.set_expanded(all_open || self.open_card_id.as_deref() == Some(id));
|
|
867
1114
|
}
|
|
@@ -880,14 +1127,22 @@ impl App {
|
|
|
880
1127
|
.and_then(|id| self.data.get(id));
|
|
881
1128
|
let home = std::env::var("HOME").unwrap_or_default();
|
|
882
1129
|
let brand = brand_text(focused, &counts, &home);
|
|
883
|
-
widgets.eyebrow.set_text(&brand.eyebrow);
|
|
884
1130
|
widgets.title.set_text(&brand.title);
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
+
)));
|
|
891
1146
|
}
|
|
892
1147
|
|
|
893
1148
|
fn regroup(&mut self) {
|
|
@@ -929,7 +1184,7 @@ impl App {
|
|
|
929
1184
|
move_order_item(&mut self.order, index, target);
|
|
930
1185
|
self.reorder_widgets(parent);
|
|
931
1186
|
self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
|
|
932
|
-
emit(json!({ "protocol":
|
|
1187
|
+
emit(json!({ "protocol": 2, "type": "moved", "cardId": id, "direction": direction }));
|
|
933
1188
|
}
|
|
934
1189
|
let focus_id = if manual { id } else { &self.order[target] };
|
|
935
1190
|
if let Some(card) = self.cards.get(focus_id) {
|
|
@@ -943,6 +1198,9 @@ impl App {
|
|
|
943
1198
|
root: >k::Window,
|
|
944
1199
|
sender: &ComponentSender<Self>,
|
|
945
1200
|
) {
|
|
1201
|
+
if !self.visible || !self.interactive {
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
946
1204
|
if let Some(id) =
|
|
947
1205
|
engaged_card_id(&self.hovered, self.keyboard_active, &self.keyboard_focused)
|
|
948
1206
|
{
|
|
@@ -967,7 +1225,11 @@ impl App {
|
|
|
967
1225
|
if self.open_card_id == card_id {
|
|
968
1226
|
return;
|
|
969
1227
|
}
|
|
970
|
-
self.open_card_id =
|
|
1228
|
+
self.open_card_id = if self.visible && self.interactive {
|
|
1229
|
+
card_id
|
|
1230
|
+
} else {
|
|
1231
|
+
None
|
|
1232
|
+
};
|
|
971
1233
|
self.refresh_cards();
|
|
972
1234
|
self.resize(root);
|
|
973
1235
|
self.sync_directory_layout(widgets);
|
|
@@ -976,11 +1238,62 @@ impl App {
|
|
|
976
1238
|
/// Scroll the card row so the focused window's card is fully in view, as Niri focus moves
|
|
977
1239
|
/// between windows. Runs once layout has placed the card, so its position in the row is known.
|
|
978
1240
|
fn scroll_focused_into_view(&self, widgets: &AppWidgets) {
|
|
979
|
-
let Some(
|
|
980
|
-
.
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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 {
|
|
984
1297
|
return;
|
|
985
1298
|
};
|
|
986
1299
|
let row = widgets.cards_box.clone();
|
|
@@ -989,6 +1302,9 @@ impl App {
|
|
|
989
1302
|
// card's position is final, and a panel that is not shown yet simply waits until it is.
|
|
990
1303
|
let frames = Cell::new(0u8);
|
|
991
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
|
+
}
|
|
992
1308
|
frames.set(frames.get() + 1);
|
|
993
1309
|
if frames.get() < 2 {
|
|
994
1310
|
return glib::ControlFlow::Continue;
|
|
@@ -1016,8 +1332,11 @@ impl App {
|
|
|
1016
1332
|
/// The ribbon is tall while a card is open (hovered or in keyboard mode), while the pointer is
|
|
1017
1333
|
/// on the brand column with the list open, or while a typed number needs the list shown.
|
|
1018
1334
|
fn expanded(&self) -> bool {
|
|
1019
|
-
self.
|
|
1020
|
-
|
|
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()))))
|
|
1021
1340
|
}
|
|
1022
1341
|
|
|
1023
1342
|
/// The list fills the brand column, top to bottom, whenever the ribbon is expanded; compact,
|
|
@@ -1034,33 +1353,30 @@ impl App {
|
|
|
1034
1353
|
|
|
1035
1354
|
fn resize(&self, root: >k::Window) {
|
|
1036
1355
|
let expanded = self.expanded();
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
EXPANDED_HEIGHT
|
|
1041
|
-
} else {
|
|
1042
|
-
COMPACT_HEIGHT
|
|
1043
|
-
},
|
|
1044
|
-
);
|
|
1045
|
-
root.set_exclusive_zone(COMPACT_HEIGHT);
|
|
1356
|
+
let (height, zone) = surface_state(self.visible, expanded);
|
|
1357
|
+
root.set_default_size(1, height);
|
|
1358
|
+
root.set_exclusive_zone(zone);
|
|
1046
1359
|
emit(
|
|
1047
|
-
json!({ "protocol":
|
|
1360
|
+
json!({ "protocol": 2, "type": "expanded", "expanded": expanded, "cardId": self.open_card_id, "queryLength": self.query.chars().count() }),
|
|
1048
1361
|
);
|
|
1049
1362
|
}
|
|
1050
1363
|
|
|
1051
1364
|
fn sync_key_capture(&self) {
|
|
1052
|
-
self.
|
|
1053
|
-
.set(self.
|
|
1054
|
-
self.key_capture
|
|
1055
|
-
|
|
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() {
|
|
1056
1371
|
KeyCapture::ListTyped
|
|
1057
1372
|
} else if self.directory_open {
|
|
1058
1373
|
KeyCapture::List
|
|
1059
|
-
} else if self.
|
|
1374
|
+
} else if self.owns_keyboard() {
|
|
1060
1375
|
KeyCapture::Digits
|
|
1061
1376
|
} else {
|
|
1062
1377
|
KeyCapture::None
|
|
1063
|
-
}
|
|
1378
|
+
},
|
|
1379
|
+
);
|
|
1064
1380
|
}
|
|
1065
1381
|
|
|
1066
1382
|
fn handle_directory_key(
|
|
@@ -1071,7 +1387,7 @@ impl App {
|
|
|
1071
1387
|
) {
|
|
1072
1388
|
match key {
|
|
1073
1389
|
DirectoryKey::Char(digit) => {
|
|
1074
|
-
self.open_directory(widgets, root
|
|
1390
|
+
self.open_directory(widgets, root);
|
|
1075
1391
|
if push_query_char(&mut self.query, digit) {
|
|
1076
1392
|
self.render_directory();
|
|
1077
1393
|
self.sync_key_capture();
|
|
@@ -1095,7 +1411,10 @@ impl App {
|
|
|
1095
1411
|
});
|
|
1096
1412
|
self.directory
|
|
1097
1413
|
.set_status(&format!("Jumping to #{window_id}…"));
|
|
1098
|
-
emit(
|
|
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);
|
|
1099
1418
|
}
|
|
1100
1419
|
Some(JumpChoice::Card(card_id)) => {
|
|
1101
1420
|
self.pending_request = Some(PendingRequest {
|
|
@@ -1103,16 +1422,13 @@ impl App {
|
|
|
1103
1422
|
target: RequestTarget::Card(card_id.clone()),
|
|
1104
1423
|
});
|
|
1105
1424
|
self.directory.set_status("Opening…");
|
|
1106
|
-
emit(json!({ "protocol":
|
|
1425
|
+
emit(json!({ "protocol": 2, "type": "activate", "cardId": card_id }));
|
|
1426
|
+
self.leave_keyboard_mode(widgets, root);
|
|
1107
1427
|
}
|
|
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
1428
|
None => {}
|
|
1112
1429
|
}
|
|
1113
1430
|
}
|
|
1114
|
-
DirectoryKey::Escape => self.
|
|
1115
|
-
DirectoryKey::Interrupt => self.cancel_hover_search(widgets, root),
|
|
1431
|
+
DirectoryKey::Escape => self.leave_keyboard_mode(widgets, root),
|
|
1116
1432
|
}
|
|
1117
1433
|
}
|
|
1118
1434
|
|
|
@@ -1138,8 +1454,9 @@ impl App {
|
|
|
1138
1454
|
self.directory.set_prompt(
|
|
1139
1455
|
&self.windows,
|
|
1140
1456
|
&self.query,
|
|
1141
|
-
self.
|
|
1457
|
+
self.owns_keyboard(),
|
|
1142
1458
|
self.allow_unlisted(),
|
|
1459
|
+
self.hotkey.as_deref(),
|
|
1143
1460
|
);
|
|
1144
1461
|
}
|
|
1145
1462
|
}
|
|
@@ -1149,79 +1466,24 @@ impl App {
|
|
|
1149
1466
|
self.directory.render(
|
|
1150
1467
|
&self.windows,
|
|
1151
1468
|
&self.query,
|
|
1152
|
-
self.
|
|
1469
|
+
self.owns_keyboard(),
|
|
1153
1470
|
self.allow_unlisted(),
|
|
1471
|
+
self.hotkey.as_deref(),
|
|
1154
1472
|
);
|
|
1155
1473
|
}
|
|
1156
1474
|
}
|
|
1157
1475
|
|
|
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
1476
|
fn allow_unlisted(&self) -> bool {
|
|
1187
|
-
|
|
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
|
-
}
|
|
1477
|
+
self.keyboard_active
|
|
1200
1478
|
}
|
|
1201
1479
|
|
|
1202
|
-
fn
|
|
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) {
|
|
1480
|
+
fn open_directory(&mut self, widgets: &AppWidgets, root: >k::Window) {
|
|
1212
1481
|
if self.directory_open || !self.visible || !self.interactive {
|
|
1213
1482
|
return;
|
|
1214
1483
|
}
|
|
1215
1484
|
self.directory_open = true;
|
|
1216
|
-
self.directory_opened_by_hover = by_hover;
|
|
1217
1485
|
self.directory_session += 1;
|
|
1218
1486
|
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
1487
|
self.render_directory();
|
|
1226
1488
|
self.sync_key_capture();
|
|
1227
1489
|
self.resize(root);
|
|
@@ -1242,7 +1504,7 @@ impl App {
|
|
|
1242
1504
|
/// pointer: the card kept open while the pointer crossed to the list has done its job.
|
|
1243
1505
|
fn dismiss_directory(&mut self, widgets: &mut AppWidgets, root: >k::Window) {
|
|
1244
1506
|
self.close_directory(widgets, root);
|
|
1245
|
-
if self.hovered.
|
|
1507
|
+
if self.hovered.is_none() && !self.keyboard_active {
|
|
1246
1508
|
self.apply_expansion(widgets, root, None);
|
|
1247
1509
|
}
|
|
1248
1510
|
}
|
|
@@ -1250,10 +1512,58 @@ impl App {
|
|
|
1250
1512
|
/// The one way out of keyboard mode. The list closes with it: once the ribbon gives up the
|
|
1251
1513
|
/// keyboard nothing could type into or dismiss a list left open.
|
|
1252
1514
|
fn leave_keyboard_mode(&mut self, widgets: &mut AppWidgets, root: >k::Window) {
|
|
1515
|
+
let was_keyboard_active = self.owns_keyboard();
|
|
1253
1516
|
self.close_directory(widgets, root);
|
|
1254
1517
|
self.end_engagement();
|
|
1518
|
+
self.refresh_cards();
|
|
1255
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
|
+
}
|
|
1256
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
|
+
}
|
|
1257
1567
|
}
|
|
1258
1568
|
|
|
1259
1569
|
fn close_directory(&mut self, widgets: &AppWidgets, root: >k::Window) {
|
|
@@ -1261,22 +1571,18 @@ impl App {
|
|
|
1261
1571
|
return;
|
|
1262
1572
|
}
|
|
1263
1573
|
self.directory_open = false;
|
|
1264
|
-
self.directory_opened_by_hover = false;
|
|
1265
|
-
self.hover_grab = false;
|
|
1266
1574
|
self.pending_request = None;
|
|
1267
1575
|
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
1576
|
self.sync_key_capture();
|
|
1275
1577
|
self.resize(root);
|
|
1276
1578
|
self.sync_directory_layout(widgets);
|
|
1277
1579
|
}
|
|
1278
1580
|
}
|
|
1279
1581
|
|
|
1582
|
+
#[cfg(test)]
|
|
1583
|
+
#[path = "app_gui_tests.rs"]
|
|
1584
|
+
mod gui_tests;
|
|
1585
|
+
|
|
1280
1586
|
#[cfg(test)]
|
|
1281
1587
|
mod tests {
|
|
1282
1588
|
use super::{
|
|
@@ -1286,7 +1592,6 @@ mod tests {
|
|
|
1286
1592
|
};
|
|
1287
1593
|
use crate::protocol::Card;
|
|
1288
1594
|
use relm4::gtk::gdk;
|
|
1289
|
-
use std::collections::HashSet;
|
|
1290
1595
|
|
|
1291
1596
|
fn counts() -> BrandCounts {
|
|
1292
1597
|
BrandCounts {
|
|
@@ -1348,14 +1653,15 @@ mod tests {
|
|
|
1348
1653
|
|
|
1349
1654
|
#[test]
|
|
1350
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));
|
|
1351
1659
|
assert!(super::is_chord(gdk::ModifierType::CONTROL_MASK));
|
|
1352
1660
|
assert!(super::is_chord(gdk::ModifierType::SUPER_MASK));
|
|
1353
1661
|
assert!(
|
|
1354
1662
|
!super::is_chord(gdk::ModifierType::SHIFT_MASK),
|
|
1355
1663
|
"Shift types capitals"
|
|
1356
1664
|
);
|
|
1357
|
-
assert!(super::is_modifier_key(gdk::Key::Shift_L));
|
|
1358
|
-
assert!(!super::is_modifier_key(gdk::Key::space));
|
|
1359
1665
|
}
|
|
1360
1666
|
|
|
1361
1667
|
#[test]
|
|
@@ -1444,13 +1750,13 @@ mod tests {
|
|
|
1444
1750
|
#[test]
|
|
1445
1751
|
fn stale_gtk_focus_does_not_retain_expansion_outside_keyboard_mode() {
|
|
1446
1752
|
let focused = Some("card-a".to_owned());
|
|
1447
|
-
assert_eq!(engaged_card_id(&
|
|
1448
|
-
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);
|
|
1449
1755
|
}
|
|
1450
1756
|
|
|
1451
1757
|
#[test]
|
|
1452
1758
|
fn pointer_hover_retains_expansion_without_keyboard_mode() {
|
|
1453
|
-
let hovered =
|
|
1759
|
+
let hovered = Some("card-a".to_owned());
|
|
1454
1760
|
assert_eq!(
|
|
1455
1761
|
engaged_card_id(&hovered, false, &Some("card-b".to_owned())),
|
|
1456
1762
|
Some("card-a".to_owned())
|
|
@@ -1459,7 +1765,7 @@ mod tests {
|
|
|
1459
1765
|
|
|
1460
1766
|
#[test]
|
|
1461
1767
|
fn explicit_keyboard_mode_takes_precedence_over_pointer_hover() {
|
|
1462
|
-
let hovered =
|
|
1768
|
+
let hovered = Some("card-a".to_owned());
|
|
1463
1769
|
assert_eq!(
|
|
1464
1770
|
engaged_card_id(&hovered, true, &Some("card-b".to_owned())),
|
|
1465
1771
|
Some("card-b".to_owned())
|