Fix input forwarding: special chars, mouse events, and typing latency

Keyboard:
- Use xdotool type for printable characters (handles @, #, !, etc.)
- Use xdotool key only for non-printable keys and modifier combos
- Remove --sync from every keystroke — focus set once, not per-event
- Skip release events (xdotool key already handles press+release)

Mouse:
- Remove windowactivate --sync from every click — focus set once
- Fire-and-forget for mousemove events to reduce latency
- Add _xdotool_fire() for non-blocking subprocess calls

Performance:
- _ensure_focus() activates window once, skips on subsequent calls
- Eliminated redundant --sync flags that added 100-300ms per event
- Added _reap() helper to prevent zombie processes from fire-and-forget

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
roberts 2026-03-15 01:14:21 -05:00
parent 12c10d69f0
commit 8d2a228599

View file

@ -372,6 +372,9 @@ class ManagedGuiApp:
await self._xdotool("windowmove", "--sync", wid, "0", "0", env=env) await self._xdotool("windowmove", "--sync", wid, "0", "0", env=env)
# Resize to fill display (Xvfb is 1280x1024) # Resize to fill display (Xvfb is 1280x1024)
await self._xdotool("windowsize", "--sync", wid, "1280", "1024", env=env) await self._xdotool("windowsize", "--sync", wid, "1280", "1024", env=env)
# Focus the window for input
await self._xdotool("windowfocus", wid, env=env)
self._focused = True
# Some apps need a moment to redraw after resize # Some apps need a moment to redraw after resize
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
log.debug("Maximized window %s to 1280x1024 for %s", wid, self.app_id) log.debug("Maximized window %s to 1280x1024 for %s", wid, self.app_id)
@ -397,6 +400,16 @@ class ManagedGuiApp:
# ---- Input forwarding ---- # ---- Input forwarding ----
_focused: bool = False # tracks whether we've activated the window
async def _ensure_focus(self, env: dict):
"""Activate/focus the window once, then skip on subsequent calls."""
if not self._focused:
wid = str(self.window_id)
await self._xdotool("windowactivate", wid, env=env)
await self._xdotool("windowfocus", wid, env=env)
self._focused = True
async def send_input(self, msg: dict): async def send_input(self, msg: dict):
"""Forward mouse/keyboard input to the X11 window.""" """Forward mouse/keyboard input to the X11 window."""
if not self.window_id or not XDOTOOL_BIN: if not self.window_id or not XDOTOOL_BIN:
@ -418,23 +431,23 @@ class ManagedGuiApp:
wid = str(self.window_id) wid = str(self.window_id)
if action == "click": if action == "click":
await self._ensure_focus(env)
btn = str(msg.get("button", 1)) btn = str(msg.get("button", 1))
await self._xdotool( await self._xdotool(
"windowactivate", "--sync", wid,
"mousemove", "--window", wid, x, y, "mousemove", "--window", wid, x, y,
"click", "--window", wid, btn, "click", "--window", wid, btn,
env=env, env=env,
) )
elif action == "dblclick": elif action == "dblclick":
await self._ensure_focus(env)
btn = str(msg.get("button", 1)) btn = str(msg.get("button", 1))
await self._xdotool( await self._xdotool(
"windowactivate", "--sync", wid,
"mousemove", "--window", wid, x, y, "mousemove", "--window", wid, x, y,
"click", "--window", wid, "--repeat", "2", btn, "click", "--window", wid, "--repeat", "2", btn,
env=env, env=env,
) )
elif action == "move": elif action == "move":
await self._xdotool( await self._xdotool_fire(
"mousemove", "--window", wid, x, y, "mousemove", "--window", wid, x, y,
env=env, env=env,
) )
@ -452,34 +465,52 @@ class ManagedGuiApp:
key = msg.get("key", "") key = msg.get("key", "")
modifiers = msg.get("modifiers", []) modifiers = msg.get("modifiers", [])
# Translate key name # Skip release events — xdotool key handles press+release
xkey = _KEY_MAP.get(key, key) if action != "press":
return
# Skip standalone modifier key events # Skip standalone modifier key events
if key in ("Control", "Shift", "Alt", "Meta"):
return
await self._ensure_focus(env)
wid = str(self.window_id)
# Determine which modifiers are held (excluding Shift for printable chars)
active_mods = []
for m in modifiers:
xmod = _KEY_MAP.get(m, m.lower())
if xmod in ("ctrl", "alt", "super"):
active_mods.append(xmod)
# Single printable character with no ctrl/alt/super modifiers → use xdotool type
# This handles all special characters (@, #, !, etc.) correctly
if len(key) == 1 and not active_mods:
await self._xdotool(
"type", "--delay", "0", "--clearmodifiers", "--window", wid, key,
env=env,
)
return
# Non-printable key or key combo with modifiers → use xdotool key
xkey = _KEY_MAP.get(key, key)
if xkey in ("ctrl", "shift", "alt", "super"): if xkey in ("ctrl", "shift", "alt", "super"):
return return
# Build modifier prefix # For modifier combos, include shift too
mod_parts = [] mod_parts = []
for m in modifiers: for m in modifiers:
xmod = _KEY_MAP.get(m, m.lower()) xmod = _KEY_MAP.get(m, m.lower())
if xmod not in ("ctrl", "shift", "alt", "super"): if xmod in ("ctrl", "shift", "alt", "super"):
continue
mod_parts.append(xmod) mod_parts.append(xmod)
if mod_parts: if mod_parts:
xkey = "+".join(mod_parts) + "+" + xkey xkey = "+".join(mod_parts) + "+" + xkey
wid = str(self.window_id) await self._xdotool("key", "--window", wid, xkey, env=env)
if action == "press":
await self._xdotool(
"windowfocus", "--sync", wid,
"key", "--window", wid, xkey,
env=env,
)
# release events handled implicitly by xdotool key
async def _xdotool(self, *args, env=None): async def _xdotool(self, *args, env=None):
"""Run xdotool and wait for completion."""
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
XDOTOOL_BIN, *args, XDOTOOL_BIN, *args,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
@ -488,6 +519,28 @@ class ManagedGuiApp:
) )
await asyncio.wait_for(proc.communicate(), timeout=5) await asyncio.wait_for(proc.communicate(), timeout=5)
async def _xdotool_fire(self, *args, env=None):
"""Run xdotool without waiting — fire and forget for low-latency ops."""
proc = await asyncio.create_subprocess_exec(
XDOTOOL_BIN, *args,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
env=env,
)
# Don't await — let it complete in background
asyncio.create_task(self._reap(proc))
@staticmethod
async def _reap(proc):
"""Reap a fire-and-forget subprocess to avoid zombies."""
try:
await asyncio.wait_for(proc.wait(), timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
# ---- Lifecycle ---- # ---- Lifecycle ----
def kill(self): def kill(self):