Add desktop adapters and extras
This commit is contained in:
parent
a83a843e1a
commit
fb1d0c07f9
10 changed files with 383 additions and 276 deletions
246
src/desktop_x11.py
Normal file
246
src/desktop_x11.py
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
import gi
|
||||
from Xlib import X, XK, display
|
||||
from Xlib.ext import xtest
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
try:
|
||||
gi.require_version("AppIndicator3", "0.1")
|
||||
from gi.repository import AppIndicator3 # type: ignore[import-not-found]
|
||||
except ValueError:
|
||||
AppIndicator3 = None
|
||||
|
||||
from gi.repository import GLib, Gdk, Gtk # type: ignore[import-not-found]
|
||||
|
||||
|
||||
ASSETS_DIR = Path(__file__).parent / "assets"
|
||||
TRAY_UPDATE_MS = 250
|
||||
|
||||
MOD_MAP = {
|
||||
"shift": X.ShiftMask,
|
||||
"ctrl": X.ControlMask,
|
||||
"control": X.ControlMask,
|
||||
"alt": X.Mod1Mask,
|
||||
"mod1": X.Mod1Mask,
|
||||
"super": X.Mod4Mask,
|
||||
"mod4": X.Mod4Mask,
|
||||
"cmd": X.Mod4Mask,
|
||||
"command": X.Mod4Mask,
|
||||
}
|
||||
|
||||
|
||||
class X11Adapter:
|
||||
def __init__(self):
|
||||
self.indicator = None
|
||||
self.status_icon = None
|
||||
self.menu = None
|
||||
if AppIndicator3 is not None:
|
||||
self.indicator = AppIndicator3.Indicator.new(
|
||||
"lel",
|
||||
self._icon_path("idle"),
|
||||
AppIndicator3.IndicatorCategory.APPLICATION_STATUS,
|
||||
)
|
||||
self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
|
||||
else:
|
||||
logging.warning("AppIndicator3 unavailable; falling back to deprecated Gtk.StatusIcon")
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message=".*Gtk.StatusIcon.*",
|
||||
category=DeprecationWarning,
|
||||
)
|
||||
self.status_icon = Gtk.StatusIcon()
|
||||
self.status_icon.set_visible(True)
|
||||
self.status_icon.connect("popup-menu", self._on_tray_menu)
|
||||
|
||||
def start_hotkey_listener(self, hotkey: str, callback: Callable[[], None]) -> None:
|
||||
thread = threading.Thread(target=self._listen, args=(hotkey, callback), daemon=True)
|
||||
thread.start()
|
||||
|
||||
def inject_text(self, text: str, backend: str) -> None:
|
||||
backend = (backend or "").strip().lower()
|
||||
if backend in ("", "clipboard"):
|
||||
self._write_clipboard(text)
|
||||
self._paste_clipboard()
|
||||
return
|
||||
if backend == "injection":
|
||||
self._type_text(text)
|
||||
return
|
||||
raise ValueError(f"unknown injection backend: {backend}")
|
||||
|
||||
def run_tray(self, state_getter: Callable[[], str], on_quit: Callable[[], None]) -> None:
|
||||
self.menu = Gtk.Menu()
|
||||
quit_item = Gtk.MenuItem(label="Quit")
|
||||
quit_item.connect("activate", lambda *_: on_quit())
|
||||
self.menu.append(quit_item)
|
||||
self.menu.show_all()
|
||||
if self.indicator is not None:
|
||||
self.indicator.set_menu(self.menu)
|
||||
|
||||
self._update_tray(state_getter)
|
||||
GLib.timeout_add(TRAY_UPDATE_MS, self._update_tray, state_getter)
|
||||
Gtk.main()
|
||||
|
||||
def _listen(self, hotkey: str, callback: Callable[[], None]) -> None:
|
||||
disp = display.Display()
|
||||
root = disp.screen().root
|
||||
mods, keysym = self._parse_hotkey(hotkey)
|
||||
keycode = self._grab_hotkey(disp, root, mods, keysym)
|
||||
try:
|
||||
while True:
|
||||
ev = disp.next_event()
|
||||
if ev.type == X.KeyPress and ev.detail == keycode:
|
||||
state = ev.state & ~(X.LockMask | X.Mod2Mask)
|
||||
if state == mods:
|
||||
callback()
|
||||
finally:
|
||||
try:
|
||||
root.ungrab_key(keycode, X.AnyModifier)
|
||||
disp.sync()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _parse_hotkey(self, hotkey: str):
|
||||
parts = [p.strip() for p in hotkey.split("+") if p.strip()]
|
||||
mods = 0
|
||||
key_part = None
|
||||
for p in parts:
|
||||
low = p.lower()
|
||||
if low in MOD_MAP:
|
||||
mods |= MOD_MAP[low]
|
||||
else:
|
||||
key_part = p
|
||||
if not key_part:
|
||||
raise ValueError("hotkey missing key")
|
||||
|
||||
keysym = XK.string_to_keysym(key_part)
|
||||
if keysym == 0 and len(key_part) == 1:
|
||||
keysym = ord(key_part)
|
||||
if keysym == 0:
|
||||
raise ValueError(f"unsupported key: {key_part}")
|
||||
return mods, keysym
|
||||
|
||||
def _grab_hotkey(self, disp, root, mods, keysym):
|
||||
keycode = disp.keysym_to_keycode(keysym)
|
||||
root.grab_key(keycode, mods, True, X.GrabModeAsync, X.GrabModeAsync)
|
||||
root.grab_key(keycode, mods | X.LockMask, True, X.GrabModeAsync, X.GrabModeAsync)
|
||||
root.grab_key(keycode, mods | X.Mod2Mask, True, X.GrabModeAsync, X.GrabModeAsync)
|
||||
root.grab_key(keycode, mods | X.LockMask | X.Mod2Mask, True, X.GrabModeAsync, X.GrabModeAsync)
|
||||
disp.sync()
|
||||
return keycode
|
||||
|
||||
def _write_clipboard(self, text: str) -> None:
|
||||
Gtk.init([])
|
||||
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||
clipboard.set_text(text, -1)
|
||||
clipboard.store()
|
||||
while Gtk.events_pending():
|
||||
Gtk.main_iteration()
|
||||
|
||||
def _paste_clipboard(self) -> None:
|
||||
dpy = display.Display()
|
||||
self._send_combo(dpy, ["Control_L", "Shift_L", "v"])
|
||||
|
||||
def _type_text(self, text: str) -> None:
|
||||
if not text:
|
||||
return
|
||||
dpy = display.Display()
|
||||
for ch in text:
|
||||
if ch == "\n":
|
||||
self._send_combo(dpy, ["Return"])
|
||||
continue
|
||||
keysym, needs_shift = self._keysym_for_char(ch)
|
||||
if keysym is None:
|
||||
continue
|
||||
if needs_shift:
|
||||
self._send_combo(dpy, ["Shift_L", keysym], already_keysym=True)
|
||||
else:
|
||||
self._send_combo(dpy, [keysym], already_keysym=True)
|
||||
|
||||
def _send_combo(self, dpy: display.Display, keys: Iterable[str], already_keysym: bool = False) -> None:
|
||||
keycodes: list[int] = []
|
||||
for key in keys:
|
||||
keysym = key if already_keysym else XK.string_to_keysym(key)
|
||||
if keysym == 0:
|
||||
continue
|
||||
keycode = dpy.keysym_to_keycode(keysym)
|
||||
if keycode == 0:
|
||||
continue
|
||||
keycodes.append(keycode)
|
||||
for code in keycodes:
|
||||
xtest.fake_input(dpy, X.KeyPress, code)
|
||||
for code in reversed(keycodes):
|
||||
xtest.fake_input(dpy, X.KeyRelease, code)
|
||||
dpy.flush()
|
||||
|
||||
def _keysym_for_char(self, ch: str) -> tuple[int | None, bool]:
|
||||
if ch.isupper():
|
||||
base = ch.lower()
|
||||
keysym = XK.string_to_keysym(base)
|
||||
return (keysym if keysym != 0 else None, True)
|
||||
if ch in _SHIFTED:
|
||||
keysym = XK.string_to_keysym(_SHIFTED[ch])
|
||||
return (keysym if keysym != 0 else None, True)
|
||||
if ch == " ":
|
||||
return (XK.string_to_keysym("space"), False)
|
||||
keysym = XK.string_to_keysym(ch)
|
||||
return (keysym if keysym != 0 else None, False)
|
||||
|
||||
def _icon_path(self, state: str) -> str:
|
||||
if state == "recording":
|
||||
return str(ASSETS_DIR / "recording.png")
|
||||
if state == "stt":
|
||||
return str(ASSETS_DIR / "stt.png")
|
||||
if state == "processing":
|
||||
return str(ASSETS_DIR / "processing.png")
|
||||
return str(ASSETS_DIR / "idle.png")
|
||||
|
||||
def _title(self, state: str) -> str:
|
||||
if state == "recording":
|
||||
return "Recording"
|
||||
if state == "stt":
|
||||
return "STT"
|
||||
if state == "processing":
|
||||
return "AI Processing"
|
||||
return "Idle"
|
||||
|
||||
def _update_tray(self, state_getter: Callable[[], str]):
|
||||
state = state_getter()
|
||||
icon_path = self._icon_path(state)
|
||||
if self.indicator is not None:
|
||||
self.indicator.set_icon_full(icon_path, self._title(state))
|
||||
self.indicator.set_label(self._title(state), "")
|
||||
elif self.status_icon is not None:
|
||||
self.status_icon.set_from_file(icon_path)
|
||||
self.status_icon.set_tooltip_text(self._title(state))
|
||||
return True
|
||||
|
||||
|
||||
_SHIFTED = {
|
||||
"!": "1",
|
||||
"@": "2",
|
||||
"#": "3",
|
||||
"$": "4",
|
||||
"%": "5",
|
||||
"^": "6",
|
||||
"&": "7",
|
||||
"*": "8",
|
||||
"(": "9",
|
||||
")": "0",
|
||||
"_": "-",
|
||||
"+": "=",
|
||||
"{": "[",
|
||||
"}": "]",
|
||||
"|": "\\",
|
||||
":": ";",
|
||||
"\"": "'",
|
||||
"<": ",",
|
||||
">": ".",
|
||||
"?": "/",
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue