@@ -14,6 +14,8 @@ use std::sync::{
1414 Mutex ,
1515} ;
1616
17+ use voxctrl_routing:: GestureType ;
18+
1719/// Which mechanism is delivering shortcuts.
1820#[ derive( Debug , Clone , Copy , PartialEq , Eq , serde:: Serialize ) ]
1921#[ serde( rename_all = "snake_case" ) ]
@@ -23,6 +25,13 @@ pub enum Backend {
2325 /// `org.freedesktop.portal.GlobalShortcuts` — the compositor owns the key
2426 /// grab and VoxCtrl reads no input devices.
2527 Portal ,
28+ /// X11 raw key events (XInput2). Needs no permissions, and sees every
29+ /// press and release, so every gesture style works.
30+ X11 ,
31+ /// A native Cinnamon/MATE custom shortcut that pokes VoxCtrl over D-Bus.
32+ /// The desktop runs a command on key-press and never reports the release,
33+ /// so this can only ever serve `toggle`.
34+ MintDbus ,
2635 /// Reading `/dev/input/event*` directly. Only reachable when the user has
2736 /// already granted this process access to input devices.
2837 Evdev ,
@@ -32,6 +41,45 @@ pub enum Backend {
3241 None ,
3342}
3443
44+ /// Gesture styles a backend that only learns about key *presses* can serve.
45+ ///
46+ /// A desktop that runs a command on key-down tells VoxCtrl nothing on key-up,
47+ /// so there is no release to end a hold with and no way to tell a tap from a
48+ /// hold. Only `toggle` survives that.
49+ const PRESS_ONLY_GESTURES : & [ GestureType ] = & [ GestureType :: Toggle ] ;
50+
51+ /// Every gesture style, for the backends that see raw presses and releases.
52+ const ALL_GESTURES : & [ GestureType ] = & [
53+ GestureType :: Hold ,
54+ GestureType :: Toggle ,
55+ GestureType :: DoubleTap ,
56+ GestureType :: DoubleTapHold ,
57+ ] ;
58+
59+ impl Backend {
60+ /// Gesture styles this backend can actually deliver.
61+ ///
62+ /// The settings UI offers exactly these, so a user is never given a choice
63+ /// that silently does nothing on their desktop.
64+ pub fn gestures ( self ) -> & ' static [ GestureType ] {
65+ match self {
66+ Self :: MintDbus => PRESS_ONLY_GESTURES ,
67+ // `Starting` and `None` are not verdicts about what this machine can
68+ // do — one has not finished deciding and the other is broken in a
69+ // way the setup window explains properly. Narrowing the choices on
70+ // either would hide gestures that do work here.
71+ Self :: Portal | Self :: X11 | Self :: Evdev | Self :: WindowsHook | Self :: Starting
72+ | Self :: None => ALL_GESTURES ,
73+ }
74+ }
75+
76+ /// True when VoxCtrl watches the key stream itself, so a trigger need not be
77+ /// expressible as a desktop accelerator — bare modifiers included.
78+ pub fn sees_raw_keys ( self ) -> bool {
79+ matches ! ( self , Self :: X11 | Self :: Evdev | Self :: WindowsHook )
80+ }
81+ }
82+
3583/// One shortcut as the compositor actually bound it.
3684#[ derive( Debug , Clone , serde:: Serialize ) ]
3785pub struct BoundShortcut {
@@ -56,6 +104,10 @@ pub struct ListenerHealth {
56104 backend : Mutex < Option < Backend > > ,
57105 /// Why the portal could not be used, if it could not.
58106 portal_error : Mutex < Option < String > > ,
107+ /// Why the X11 backend could not be used, if it could not. Separate from
108+ /// the portal's reason: "no shortcuts portal" and "not an X11 session" are
109+ /// different facts and a user on Wayland-Cinnamon needs both.
110+ x11_error : Mutex < Option < String > > ,
59111 /// The portal is present and answered, but refused the session. A different
60112 /// problem from "this desktop has no portal", and it needs different advice.
61113 portal_refused : AtomicBool ,
@@ -110,6 +162,17 @@ impl ListenerHealth {
110162 self . portal_refused . load ( Ordering :: Relaxed )
111163 }
112164
165+ /// Why the X11 backend was not used. `None` means it was, or was never
166+ /// reached because something better answered first.
167+ pub fn x11_error ( & self ) -> Option < String > {
168+ self . x11_error . lock ( ) . ok ( ) . and_then ( |e| e. clone ( ) )
169+ }
170+
171+ /// Gesture styles the running backend can deliver.
172+ pub fn gestures ( & self ) -> & ' static [ GestureType ] {
173+ self . backend ( ) . gestures ( )
174+ }
175+
113176 pub fn bound_shortcuts ( & self ) -> Vec < BoundShortcut > {
114177 self . bound_shortcuts . lock ( ) . map ( |s| s. clone ( ) ) . unwrap_or_default ( )
115178 }
@@ -120,7 +183,7 @@ impl ListenerHealth {
120183 return true ;
121184 }
122185 match self . backend ( ) {
123- Backend :: Portal | Backend :: WindowsHook => true ,
186+ Backend :: Portal | Backend :: WindowsHook | Backend :: X11 | Backend :: MintDbus => true ,
124187 Backend :: Evdev => self . keyboards_open ( ) > 0 ,
125188 // Still deciding which backend to use — don't report a problem yet.
126189 Backend :: Starting => true ,
@@ -137,7 +200,10 @@ impl ListenerHealth {
137200
138201 /// Shortcuts are working without VoxCtrl having any access to input devices.
139202 pub fn is_private ( & self ) -> bool {
140- matches ! ( self . backend( ) , Backend :: Portal | Backend :: WindowsHook )
203+ matches ! (
204+ self . backend( ) ,
205+ Backend :: Portal | Backend :: WindowsHook | Backend :: MintDbus
206+ )
141207 }
142208
143209 pub fn set_supported ( & self , supported : bool ) {
@@ -162,6 +228,12 @@ impl ListenerHealth {
162228 }
163229 }
164230
231+ pub fn set_x11_error ( & self , error : String ) {
232+ if let Ok ( mut e) = self . x11_error . lock ( ) {
233+ * e = Some ( error) ;
234+ }
235+ }
236+
165237 pub fn set_portal_refused ( & self , refused : bool ) {
166238 self . portal_refused . store ( refused, Ordering :: Relaxed ) ;
167239 }
@@ -302,4 +374,57 @@ mod tests {
302374 assert ! ( !h. is_active( ) ) ;
303375 assert ! ( h. bound_shortcuts( ) . is_empty( ) ) ;
304376 }
377+
378+ #[ test]
379+ fn the_x11_backend_is_active_and_serves_every_gesture ( ) {
380+ let h = ListenerHealth :: default ( ) ;
381+ h. set_supported ( true ) ;
382+ h. set_portal_error ( "no such interface" . to_string ( ) ) ;
383+ h. set_backend ( Backend :: X11 ) ;
384+
385+ assert ! ( h. is_active( ) ) ;
386+ assert ! ( !h. is_private( ) , "raw X11 key events are every keystroke" ) ;
387+ assert ! (
388+ !h. permission_blocked( ) ,
389+ "X11 raw events need no device permission, so nothing is blocked"
390+ ) ;
391+ assert_eq ! ( h. gestures( ) . len( ) , 4 ) ;
392+ assert ! ( h. backend( ) . sees_raw_keys( ) ) ;
393+ }
394+
395+ #[ test]
396+ fn a_press_only_backend_advertises_toggle_and_nothing_else ( ) {
397+ // A Cinnamon custom shortcut runs a command on key-down and reports no
398+ // release: a hold has no end and a tap cannot be told from a hold. The
399+ // settings UI offers exactly what this returns, so getting it wrong
400+ // means offering a gesture that silently does nothing.
401+ let h = ListenerHealth :: default ( ) ;
402+ h. set_supported ( true ) ;
403+ h. set_backend ( Backend :: MintDbus ) ;
404+
405+ assert_eq ! ( h. gestures( ) , & [ GestureType :: Toggle ] ) ;
406+ assert ! ( h. is_active( ) ) ;
407+ assert ! ( h. is_private( ) , "the desktop holds the grab, VoxCtrl reads nothing" ) ;
408+ assert ! (
409+ !h. backend( ) . sees_raw_keys( ) ,
410+ "a bare modifier cannot be registered as a desktop accelerator"
411+ ) ;
412+ }
413+
414+ #[ test]
415+ fn a_backend_that_watches_keys_itself_never_hides_a_gesture ( ) {
416+ // Hiding a style on these would take away something that works.
417+ for backend in [ Backend :: X11 , Backend :: Evdev , Backend :: WindowsHook , Backend :: Portal ] {
418+ assert_eq ! ( backend. gestures( ) . len( ) , 4 , "{backend:?} lost a gesture" ) ;
419+ }
420+ }
421+
422+ #[ test]
423+ fn an_undecided_or_broken_backend_still_offers_every_gesture ( ) {
424+ // Neither is a verdict about what this machine can do, and an empty
425+ // gesture list would leave the settings UI with nothing to show.
426+ for backend in [ Backend :: Starting , Backend :: None ] {
427+ assert_eq ! ( backend. gestures( ) . len( ) , 4 , "{backend:?} narrowed the choices" ) ;
428+ }
429+ }
305430}
0 commit comments