diff --git a/backend/display.py b/backend/display.py index f5c79e2..67cacf1 100644 --- a/backend/display.py +++ b/backend/display.py @@ -372,6 +372,9 @@ class ManagedGuiApp: await self._xdotool("windowmove", "--sync", wid, "0", "0", env=env) # Resize to fill display (Xvfb is 1280x1024) 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 await asyncio.sleep(0.3) log.debug("Maximized window %s to 1280x1024 for %s", wid, self.app_id) @@ -397,6 +400,16 @@ class ManagedGuiApp: # ---- 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): """Forward mouse/keyboard input to the X11 window.""" if not self.window_id or not XDOTOOL_BIN: @@ -418,23 +431,23 @@ class ManagedGuiApp: wid = str(self.window_id) if action == "click": + await self._ensure_focus(env) btn = str(msg.get("button", 1)) await self._xdotool( - "windowactivate", "--sync", wid, "mousemove", "--window", wid, x, y, "click", "--window", wid, btn, env=env, ) elif action == "dblclick": + await self._ensure_focus(env) btn = str(msg.get("button", 1)) await self._xdotool( - "windowactivate", "--sync", wid, "mousemove", "--window", wid, x, y, "click", "--window", wid, "--repeat", "2", btn, env=env, ) elif action == "move": - await self._xdotool( + await self._xdotool_fire( "mousemove", "--window", wid, x, y, env=env, ) @@ -452,34 +465,52 @@ class ManagedGuiApp: key = msg.get("key", "") modifiers = msg.get("modifiers", []) - # Translate key name - xkey = _KEY_MAP.get(key, key) + # Skip release events — xdotool key handles press+release + if action != "press": + return # 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"): return - # Build modifier prefix + # For modifier combos, include shift too mod_parts = [] for m in modifiers: xmod = _KEY_MAP.get(m, m.lower()) - if xmod not in ("ctrl", "shift", "alt", "super"): - continue - mod_parts.append(xmod) + if xmod in ("ctrl", "shift", "alt", "super"): + mod_parts.append(xmod) if mod_parts: xkey = "+".join(mod_parts) + "+" + xkey - wid = str(self.window_id) - if action == "press": - await self._xdotool( - "windowfocus", "--sync", wid, - "key", "--window", wid, xkey, - env=env, - ) - # release events handled implicitly by xdotool key + await self._xdotool("key", "--window", wid, xkey, env=env) async def _xdotool(self, *args, env=None): + """Run xdotool and wait for completion.""" proc = await asyncio.create_subprocess_exec( XDOTOOL_BIN, *args, stdout=asyncio.subprocess.PIPE, @@ -488,6 +519,28 @@ class ManagedGuiApp: ) 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 ---- def kill(self):