@@ -34,12 +34,29 @@ except ImportError:
3434 print ("Error: tkinter not available. Install python3-tk package." )
3535 sys .exit (1 )
3636
37- try :
38- from pynput import mouse
39- from pynput .mouse import Listener as MouseListener
40- except ImportError :
41- print ("Error: pynput not available. Install with: pip install pynput" )
42- sys .exit (1 )
37+ # Input backend: evdev on Linux (works on Wayland), pynput elsewhere
38+ _INPUT_BACKEND = None
39+
40+ if sys .platform == 'linux' :
41+ try :
42+ import evdev
43+ import select as _select
44+ _INPUT_BACKEND = 'evdev'
45+ except ImportError :
46+ pass
47+
48+ if _INPUT_BACKEND is None :
49+ try :
50+ from pynput import mouse
51+ from pynput .mouse import Listener as MouseListener
52+ _INPUT_BACKEND = 'pynput'
53+ except ImportError :
54+ if sys .platform == 'linux' :
55+ print ("Error: No input backend. Install: pip install evdev" )
56+ print (" Also add user to 'input' group: sudo usermod -aG input $USER" )
57+ else :
58+ print ("Error: pynput not available. Install with: pip install pynput" )
59+ sys .exit (1 )
4360
4461# Windows-specific imports
4562if sys .platform == 'win32' :
@@ -49,6 +66,79 @@ if sys.platform == 'win32':
4966 except ImportError :
5067 pass
5168
69+ # ─── Wayland cursor position via subprocess KWin D-Bus query ───
70+ _WAYLAND = sys .platform == 'linux' and bool (os .environ .get ('WAYLAND_DISPLAY' ))
71+ _CURSOR_QUERY_SCRIPT = '/tmp/_launcher_cursor_query.py'
72+ _KWIN_CURSOR_JS = '/tmp/_launcher_kwin_cursor.js'
73+
74+ _KWIN_JS_CONTENT = (
75+ 'var p = workspace.cursorPos;'
76+ 'callDBus("com.launcher.CursorHelper", "/CursorHelper", '
77+ '"com.launcher.CursorHelper", "ReportPosition", p.x, p.y);'
78+ )
79+
80+ _CURSOR_QUERY_CONTENT = '''\
81+ import sys, threading, subprocess, time
82+ try:
83+ import dbus, dbus.service
84+ from dbus.mainloop.glib import DBusGMainLoop
85+ from gi.repository import GLib
86+ except ImportError:
87+ sys.exit(1)
88+
89+ DBusGMainLoop(set_as_default=True)
90+ bus = dbus.SessionBus()
91+ bus_name = dbus.service.BusName("com.launcher.CursorHelper", bus)
92+ result = [None]
93+ done = threading.Event()
94+
95+ class Svc(dbus.service.Object):
96+ @dbus.service.method("com.launcher.CursorHelper", in_signature="ii", out_signature="")
97+ def ReportPosition(self, x, y):
98+ result[0] = (int(x), int(y))
99+ done.set()
100+
101+ svc = Svc(bus, "/CursorHelper")
102+ loop = GLib.MainLoop()
103+ threading.Thread(target=loop.run, daemon=True).start()
104+ name = f"_cursor_{int(time.time()*1000)}"
105+ try:
106+ subprocess.run(["gdbus", "call", "--session", "--dest", "org.kde.KWin",
107+ "--object-path", "/Scripting", "--method",
108+ "org.kde.kwin.Scripting.loadScript",
109+ "/tmp/_launcher_kwin_cursor.js", name],
110+ capture_output=True, timeout=1)
111+ subprocess.run(["gdbus", "call", "--session", "--dest", "org.kde.KWin",
112+ "--object-path", "/Scripting", "--method",
113+ "org.kde.kwin.Scripting.start"],
114+ capture_output=True, timeout=1)
115+ except Exception:
116+ sys.exit(1)
117+
118+ done.wait(timeout=0.3)
119+ if result[0]:
120+ print(f"{result[0][0]} {result[0][1]}")
121+ try:
122+ subprocess.run(["gdbus", "call", "--session", "--dest", "org.kde.KWin",
123+ "--object-path", "/Scripting", "--method",
124+ "org.kde.kwin.Scripting.unloadScript", name],
125+ capture_output=True, timeout=1)
126+ except Exception:
127+ pass
128+ else:
129+ sys.exit(1)
130+ '''
131+
132+
133+ def _write_helper_scripts ():
134+ """Write subprocess helper scripts to /tmp (Wayland only)."""
135+ if not _WAYLAND :
136+ return
137+ with open (_KWIN_CURSOR_JS , 'w' ) as f :
138+ f .write (_KWIN_JS_CONTENT )
139+ with open (_CURSOR_QUERY_SCRIPT , 'w' ) as f :
140+ f .write (_CURSOR_QUERY_CONTENT )
141+
52142
53143# ============== Configuration ==============
54144
@@ -123,7 +213,7 @@ class Config:
123213 max_clipboard_history = data .get ("max_clipboard_history" , 10000 ),
124214 simultaneous_threshold_ms = data .get ("trigger" , {}).get ("simultaneous_threshold_ms" , 50 ),
125215 debounce_ms = data .get ("trigger" , {}).get ("debounce_ms" , 500 ),
126- ui_width = data .get ("ui" , {}).get ("width" , 300 ),
216+ ui_width = int ( data .get ("ui" , {}).get ("width" , 300 ) ),
127217 dark_mode = data .get ("ui" , {}).get ("dark_mode" , True ),
128218 )
129219 except Exception as e :
@@ -613,6 +703,123 @@ class MouseInputListener:
613703 self .on_trigger (self .last_position )
614704
615705
706+ class EvdevMouseListener :
707+ """Detect simultaneous L+R mouse clicks using evdev (works on Wayland)"""
708+
709+ def __init__ (self , threshold_ms : int , debounce_ms : int , on_trigger : Callable [[tuple ], None ]):
710+ self .threshold = threshold_ms / 1000.0
711+ self .debounce = debounce_ms / 1000.0
712+ self .on_trigger = on_trigger
713+
714+ self .left_pressed : Optional [float ] = None
715+ self .right_pressed : Optional [float ] = None
716+ self .last_trigger : Optional [float ] = None
717+ self .last_position = (0 , 0 )
718+ self .last_press = (0.0 , (0 , 0 ))
719+
720+ self ._thread : Optional [threading .Thread ] = None
721+ self ._stop = False
722+ self ._xdisplay = None
723+
724+ def _get_cursor_position (self ) -> tuple :
725+ """Get cursor position. Subprocess KWin query on Wayland, Xlib fallback."""
726+ if _WAYLAND :
727+ try :
728+ r = subprocess .run (
729+ [sys .executable , _CURSOR_QUERY_SCRIPT ],
730+ capture_output = True , text = True , timeout = 0.5 )
731+ if r .returncode == 0 and r .stdout .strip ():
732+ parts = r .stdout .strip ().split ()
733+ return (int (parts [0 ]), int (parts [1 ]))
734+ except Exception :
735+ pass
736+ # Fallback: Xlib (works on X11, stale on Wayland)
737+ try :
738+ if self ._xdisplay is None :
739+ from Xlib import display
740+ self ._xdisplay = display .Display ()
741+ data = self ._xdisplay .screen ().root .query_pointer ()._data
742+ return (data ['root_x' ], data ['root_y' ])
743+ except Exception :
744+ return (400 , 300 )
745+
746+ def _find_mouse_devices (self ) -> list :
747+ """Find all input devices that have BTN_LEFT (mice, touchpads)"""
748+ devices = []
749+ for path in evdev .list_devices ():
750+ try :
751+ dev = evdev .InputDevice (path )
752+ caps = dev .capabilities ()
753+ # EV_KEY = 1; check if BTN_LEFT (272) is in the key capabilities
754+ if 1 in caps and 272 in caps [1 ]:
755+ print (f" Found mouse: { dev .name } " )
756+ devices .append (dev )
757+ except Exception :
758+ continue
759+ return devices
760+
761+ def start (self ):
762+ """Start listening for mouse events via evdev"""
763+ self ._thread = threading .Thread (target = self ._event_loop , daemon = True )
764+ self ._thread .start ()
765+
766+ def stop (self ):
767+ """Stop listening"""
768+ self ._stop = True
769+
770+ def _event_loop (self ):
771+ devices = self ._find_mouse_devices ()
772+ if not devices :
773+ print ("ERROR: No mouse devices found." )
774+ print (" Add user to 'input' group: sudo usermod -aG input $USER" )
775+ return
776+
777+ print (f" Monitoring { len (devices )} device(s)" )
778+
779+ while not self ._stop :
780+ r , _ , _ = _select .select (devices , [], [], 0.1 )
781+ for dev in r :
782+ try :
783+ for event in dev .read ():
784+ if event .type == evdev .ecodes .EV_KEY :
785+ self ._handle_button (event .code , event .value == 1 )
786+ except Exception :
787+ pass
788+
789+ def _handle_button (self , code : int , pressed : bool ):
790+ now = time .time ()
791+ if code == evdev .ecodes .BTN_LEFT :
792+ self .left_pressed = now if pressed else None
793+ elif code == evdev .ecodes .BTN_RIGHT :
794+ self .right_pressed = now if pressed else None
795+ else :
796+ return
797+
798+ if pressed :
799+ self .last_press = (now , self .last_position )
800+ self ._check_trigger ()
801+
802+ def _check_trigger (self ):
803+ if self .left_pressed is None or self .right_pressed is None :
804+ return
805+
806+ diff = abs (self .left_pressed - self .right_pressed )
807+ if diff > self .threshold :
808+ return
809+
810+ now = time .time ()
811+ if self .last_trigger and (now - self .last_trigger ) < self .debounce :
812+ return
813+
814+ self .last_trigger = now
815+ self .left_pressed = None
816+ self .right_pressed = None
817+
818+ pos = self ._get_cursor_position ()
819+ self .last_position = pos
820+ self .on_trigger (pos )
821+
822+
616823# ============== UI ==============
617824
618825class LauncherPopup :
@@ -635,6 +842,8 @@ class LauncherPopup:
635842 self .position = position
636843 self ._mouse_listener = mouse_listener
637844 self ._shown_time = 0.0
845+ self ._last_checked_press = 0.0
846+ self ._tkinter_click_time = 0.0
638847
639848 self .root : Optional [tk .Tk ] = None
640849 self .shortcut_num = 1
@@ -701,6 +910,8 @@ class LauncherPopup:
701910
702911 # Bindings
703912 self .root .bind ('<Escape>' , lambda e : self .close ())
913+ self .root .bind_all ('<Button-1>' , self ._on_tkinter_click )
914+ self .root .bind_all ('<Button-3>' , self ._on_tkinter_click )
704915
705916 # Number key bindings
706917 for i in range (1 , 10 ):
@@ -711,6 +922,7 @@ class LauncherPopup:
711922
712923 # Click-outside polling
713924 self ._shown_time = time .time ()
925+ self ._last_checked_press = 0.0
714926 self .root .after (100 , self ._check_click_outside )
715927
716928 # Start main loop
@@ -881,8 +1093,8 @@ class LauncherPopup:
8811093 relief = tk .FLAT ,
8821094 cursor = "hand2" ,
8831095 font = ('' , 8 ),
884- command = lambda : self ._pin_item (item ),
8851096 )
1097+ pin_btn .configure (command = lambda b = pin_btn : self ._pin_item (item , b ))
8861098 pin_btn .pack (side = tk .RIGHT , padx = 2 )
8871099
8881100 # Icon
@@ -1026,7 +1238,7 @@ class LauncherPopup:
10261238
10271239 self .close ()
10281240
1029- def _pin_item (self , item : LaunchItem ):
1241+ def _pin_item (self , item : LaunchItem , btn : tk . Button = None ):
10301242 """Pin an item to config"""
10311243 if item .item_type == 'document' :
10321244 if item .path not in [d .path for d in self .config .pinned_documents ]:
@@ -1035,6 +1247,9 @@ class LauncherPopup:
10351247 if item .path not in [p .path for p in self .config .pinned_programs ]:
10361248 self .config .pinned_programs .append (item )
10371249 self .config .save ()
1250+ # Visual feedback
1251+ if btn :
1252+ btn .configure (text = "\U0001F4CC " , fg = "#ffc832" , state = tk .DISABLED )
10381253
10391254 def _paste_clipboard (self , text : str ):
10401255 """Paste clipboard item"""
@@ -1055,29 +1270,41 @@ class LauncherPopup:
10551270 self .config .shortcuts .append (item )
10561271 self .config .save ()
10571272
1273+ def _on_tkinter_click (self , event ):
1274+ """Record timestamp of clicks on the popup window."""
1275+ self ._tkinter_click_time = time .time ()
1276+
10581277 def _check_click_outside (self ):
1059- """Poll for clicks outside the popup to close it"""
1278+ """Close popup when a click happens outside it.
1279+
1280+ Uses timestamp correlation: evdev sees ALL clicks globally, tkinter
1281+ only sees clicks ON the popup. If evdev has a fresh click but tkinter
1282+ didn't fire within 200ms, the click was outside -> close.
1283+ """
10601284 if not self .root :
10611285 return
1286+ # Grace period: ignore during the first 0.5s (trigger L+R click)
1287+ if time .time () - self ._shown_time < 0.5 :
1288+ self .root .after (100 , self ._check_click_outside )
1289+ return
1290+ # Need evdev listener for click detection
10621291 if not self ._mouse_listener :
10631292 self .root .after (200 , self ._check_click_outside )
10641293 return
1065- elapsed = time .time () - self ._shown_time
1066- if elapsed >= 0.3 :
1067- press_time , (px , py ) = self ._mouse_listener .last_press # single atomic read
1068- if press_time > self ._shown_time + 0.3 :
1069- try :
1070- wx = self .root .winfo_rootx ()
1071- wy = self .root .winfo_rooty ()
1072- ww = self .root .winfo_width ()
1073- wh = self .root .winfo_height ()
1074- if not (wx <= px <= wx + ww and wy <= py <= wy + wh ):
1075- self .close ()
1076- return
1077- except tk .TclError :
1078- pass
1079- if self .root :
1080- self .root .after (150 , self ._check_click_outside )
1294+ press_time , _ = self ._mouse_listener .last_press
1295+ if press_time <= self ._shown_time + 0.5 or press_time <= self ._last_checked_press :
1296+ if self .root :
1297+ self .root .after (80 , self ._check_click_outside )
1298+ return
1299+ self ._last_checked_press = press_time
1300+ # Timestamp correlation: tkinter click within 200ms of evdev click?
1301+ if abs (self ._tkinter_click_time - press_time ) < 0.2 :
1302+ # Click was inside the popup
1303+ if self .root :
1304+ self .root .after (80 , self ._check_click_outside )
1305+ return
1306+ # Click was outside -> close
1307+ self .close ()
10811308
10821309 def close (self ):
10831310 """Close the popup"""
@@ -1097,11 +1324,13 @@ class Launcher:
10971324 self .clipboard = ClipboardManager (self .config .max_clipboard_history )
10981325 self .popup : Optional [LauncherPopup ] = None
10991326
1100- self .input_listener = MouseInputListener (
1327+ ListenerClass = EvdevMouseListener if _INPUT_BACKEND == 'evdev' else MouseInputListener
1328+ self .input_listener = ListenerClass (
11011329 self .config .simultaneous_threshold_ms ,
11021330 self .config .debounce_ms ,
11031331 self ._on_trigger ,
11041332 )
1333+ print (f"Input backend: { _INPUT_BACKEND } " )
11051334
11061335 def _on_trigger (self , position : tuple ):
11071336 """Handle trigger event"""
@@ -1123,6 +1352,7 @@ class Launcher:
11231352 print (f"Trigger: L+R click (threshold: { self .config .simultaneous_threshold_ms } ms)" )
11241353 print ("Press Ctrl+C to exit" )
11251354
1355+ _write_helper_scripts ()
11261356 self .input_listener .start ()
11271357
11281358 try :
0 commit comments