Skip to content

Commit f85aaa5

Browse files
RMANOVclaude
andcommitted
fix(wayland): click-outside detection and cursor position
Click-outside: use timestamp correlation between evdev (global clicks) and tkinter (popup clicks) instead of querying cursor position — eliminates XWayland stale position bug entirely. Cursor position: isolate KWin D-Bus query in subprocess to avoid GLib/tkinter mainloop conflicts. Only runs on L+R trigger (~50ms). Also adds evdev input backend for native Wayland mouse detection, pin button visual feedback, and UI width type safety. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a7b4e5f commit f85aaa5

1 file changed

Lines changed: 257 additions & 27 deletions

File tree

launcher.pyw

Lines changed: 257 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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
4562
if 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

618825
class 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

Comments
 (0)