caret: change default overlay_anchor from "caret" to "pointer" — AT-SPI's synchronous D-Bus extents read on the GTK main loop could freeze the entire X11 session when the focused app was slow to respond. Pointer anchor is a reliable fallback (the mouse is almost always near where you're typing) and avoids AT-SPI entirely by default. caret: wrap the AT-SPI rect() read in a daemon thread with a hard 300ms timeout — so even if overlay_anchor="caret" is configured manually, a frozen or unresponsive target app can never stall the GTK main loop. settings: replace the Manual tab's plain TextView with a styled markdown renderer (_md_panel): # h1/h2/h3 headings, **bold**, *italic*, `inline code`, > blockquotes, --- rules, bullet/numbered lists, and | tables |. settings: infobox background changed from theme-adaptive grey (unreadable on dark themes) to a fixed blue tint (rgba 66,133,244 @ 9%) with a matching blue border — readable in both light and dark themes. settings: active notebook tab now shows bold text with a #1a73e8 blue colour and extra padding, making the current tab obviously distinct. settings: emoji picker buttons enlarged to 20 px / 38×38 px minimum (grid) and 18 px / 34×34 px (category bar) via bt-emoji-btn / bt-emoji-cat-btn CSS classes — previously rendered at the default button label size (~11 px). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
285 lines
11 KiB
Python
285 lines
11 KiB
Python
"""Best-effort screen anchor for the overlay: *where* to point the bubble's tip.
|
|
|
|
The overlay wants to sit at "the cursor where the text will land". On X11 there
|
|
is no portable way to read the text caret of an arbitrary app, so we degrade
|
|
through a chain of decreasing precision:
|
|
|
|
1. Mouse pointer — `xdotool getmouselocation`. Default; always available on X11;
|
|
a reliable proxy since the pointer is usually near where you're typing.
|
|
2. AT-SPI caret — the real text insertion point (opt-in via overlay_anchor=caret).
|
|
Only native GTK/Qt apps expose it; many terminals / Electron / web views do
|
|
not. The blocking D-Bus read runs in a thread with a 300ms hard timeout so it
|
|
can never stall the GTK main loop and freeze the session.
|
|
3. Window / screen — top-centre of the target window, else screen bottom-centre.
|
|
|
|
Everything here is defensive: any failure falls through to the next tier, and
|
|
the whole module is optional — if AT-SPI isn't running you simply get the
|
|
pointer anchor. Returns a `(x, y, region)` anchor in **root/screen** pixels,
|
|
where `region` is the caret/line box so the overlay can avoid covering it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
|
|
from .logbuffer import log
|
|
|
|
|
|
@dataclass
|
|
class Anchor:
|
|
x: int # root-relative x to point the tail tip at
|
|
y: int # root-relative y (top of the caret/line box)
|
|
height: int = 0 # caret/line height, so the bubble can clear the line
|
|
source: str = "" # "caret" | "pointer" | "window" | "corner" — for logging
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Tier 1: AT-SPI caret tracking (best-effort, passive)
|
|
# --------------------------------------------------------------------------- #
|
|
class _CaretTracker:
|
|
"""Remember the most recently focused text object; read its caret lazily.
|
|
|
|
*Why so cautious.* AT-SPI queries (``get_character_extents`` and friends) are
|
|
**synchronous, blocking D-Bus round-trips into the target application**. The
|
|
earlier design subscribed to the high-frequency ``object:text-caret-moved``
|
|
signal and ran those blocking reads from *inside* the event handler. Two ways
|
|
that wedges a whole GNOME/X11 session:
|
|
|
|
• Calling a synchronous AT-SPI method from within an AT-SPI event dispatch
|
|
re-enters the a11y dispatcher and can deadlock the accessibility bus.
|
|
• ``text-caret-moved`` fires once *per character* — and delivering text is
|
|
exactly what this app does, typing via ``xdotool`` into the focused
|
|
field. So a single dictation became a storm of blocking round-trips on
|
|
the GTK main loop, congesting the a11y bus until the desktop froze.
|
|
|
|
So we now subscribe to **focus changes only** (rare, and never emitted by our
|
|
own synthetic typing), cache just the focused accessible, and do the one
|
|
blocking extents read **on demand** in :meth:`rect` — called once, when the
|
|
overlay shows, outside any event dispatch. Worst case is a slightly delayed
|
|
overlay placement, never a frozen session.
|
|
"""
|
|
|
|
STALE_SECONDS = 30.0 # ignore a focus older than this
|
|
|
|
def __init__(self) -> None:
|
|
self._ok = False
|
|
self._listener = None
|
|
self._focused = None # last focused accessible (read lazily)
|
|
self._stamp = 0.0
|
|
self._Atspi = None
|
|
|
|
def start(self) -> bool:
|
|
"""Register the AT-SPI focus listener on the running GLib main loop.
|
|
|
|
Safe to call when accessibility is disabled — it just returns False and
|
|
the anchor logic skips this tier from then on.
|
|
"""
|
|
try:
|
|
import gi
|
|
gi.require_version("Atspi", "2.0")
|
|
from gi.repository import Atspi # noqa: N813
|
|
except (ImportError, ValueError) as exc:
|
|
log(f"[overlay] AT-SPI unavailable, caret anchor disabled: {exc}")
|
|
return False
|
|
try:
|
|
# init() is idempotent; returns 0/1. Connects to the a11y registry.
|
|
Atspi.init()
|
|
self._Atspi = Atspi
|
|
self._listener = Atspi.EventListener.new(self._on_focus)
|
|
# Focus changes only. Deliberately NOT "object:text-caret-moved": that
|
|
# firehose (one event per typed character, including our own output)
|
|
# plus synchronous reads is what could freeze the session.
|
|
self._listener.register("object:state-changed:focused")
|
|
self._ok = True
|
|
log("[overlay] AT-SPI caret tracking active")
|
|
return True
|
|
except Exception as exc: # noqa: BLE001 - a11y bus may be down/locked
|
|
log(f"[overlay] AT-SPI init failed, caret anchor disabled: {exc}")
|
|
self._ok = False
|
|
return False
|
|
|
|
def stop(self) -> None:
|
|
try:
|
|
if self._listener is not None:
|
|
self._listener.deregister("object:state-changed:focused")
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
self._listener = None
|
|
self._focused = None
|
|
self._ok = False
|
|
|
|
def _on_focus(self, event) -> None:
|
|
# Runs on the GLib main thread (same loop GTK uses). Do the *minimum*:
|
|
# stash the focused accessible and stamp it. Crucially, make NO synchronous
|
|
# AT-SPI calls here — that would re-enter the a11y dispatcher and risk
|
|
# deadlocking the bus. The blocking extents read happens later, in rect().
|
|
try:
|
|
if not event.detail1:
|
|
return # a *de*focus event — nothing to track
|
|
self._focused = event.source
|
|
self._stamp = time.time()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
def _caret_rect(self, acc) -> tuple[int, int, int, int] | None:
|
|
Atspi = self._Atspi
|
|
text = None
|
|
try:
|
|
text = acc.get_text_iface()
|
|
except Exception: # noqa: BLE001
|
|
text = None
|
|
if text is None:
|
|
return None
|
|
try:
|
|
offset = text.get_caret_offset()
|
|
# Extents of the character at the caret, in absolute screen coords.
|
|
ext = text.get_character_extents(offset, Atspi.CoordType.SCREEN)
|
|
x, y, w, h = ext.x, ext.y, ext.width, ext.height
|
|
if w == 0 and h == 0:
|
|
# End-of-line / empty field: fall back to the component box so we
|
|
# at least anchor on the right widget.
|
|
comp = acc.get_component_iface()
|
|
if comp is None:
|
|
return None
|
|
cext = comp.get_extents(Atspi.CoordType.SCREEN)
|
|
return int(cext.x), int(cext.y), 2, int(cext.height) or 18
|
|
if x < 0 or y < 0:
|
|
return None
|
|
return int(x), int(y), int(w) or 2, int(h) or 18
|
|
except Exception: # noqa: BLE001
|
|
return None
|
|
|
|
def rect(self) -> tuple[int, int, int, int] | None:
|
|
# Called once when the overlay shows (not on the a11y hot path).
|
|
# We run the blocking D-Bus extents read in a worker thread and join it
|
|
# with a hard 300ms deadline — so even a frozen or slow target app can
|
|
# never stall the GTK main loop long enough to freeze the session.
|
|
if not self._ok or self._focused is None:
|
|
return None
|
|
if time.time() - self._stamp > self.STALE_SECONDS:
|
|
return None
|
|
result: list[tuple[int, int, int, int] | None] = [None]
|
|
focused = self._focused
|
|
|
|
def _read() -> None:
|
|
try:
|
|
result[0] = self._caret_rect(focused)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
t = threading.Thread(target=_read, daemon=True)
|
|
t.start()
|
|
t.join(timeout=0.3)
|
|
return result[0]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Tier 2 + 3: pointer and window fallbacks
|
|
# --------------------------------------------------------------------------- #
|
|
def _pointer() -> tuple[int, int] | None:
|
|
if not shutil.which("xdotool"):
|
|
return None
|
|
try:
|
|
out = subprocess.run(
|
|
["xdotool", "getmouselocation", "--shell"],
|
|
capture_output=True, text=True, check=True, timeout=0.5,
|
|
).stdout
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
vals: dict[str, str] = {}
|
|
for line in out.splitlines():
|
|
if "=" in line:
|
|
k, _, v = line.partition("=")
|
|
vals[k.strip()] = v.strip()
|
|
try:
|
|
return int(vals["X"]), int(vals["Y"])
|
|
except (KeyError, ValueError):
|
|
return None
|
|
|
|
|
|
def _window_box(window_id: str | None) -> tuple[int, int, int] | None:
|
|
"""Top-centre of the target window: (x, y, height_hint)."""
|
|
if not window_id or not shutil.which("xdotool"):
|
|
return None
|
|
try:
|
|
out = subprocess.run(
|
|
["xdotool", "getwindowgeometry", "--shell", window_id],
|
|
capture_output=True, text=True, check=True, timeout=0.5,
|
|
).stdout
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
vals: dict[str, str] = {}
|
|
for line in out.splitlines():
|
|
if "=" in line:
|
|
k, _, v = line.partition("=")
|
|
vals[k.strip()] = v.strip()
|
|
try:
|
|
x, y = int(vals["X"]), int(vals["Y"])
|
|
w = int(vals["WIDTH"])
|
|
return x + w // 2, y + 40, 0
|
|
except (KeyError, ValueError):
|
|
return None
|
|
|
|
|
|
def _screen_bottom_centre() -> tuple[int, int]:
|
|
try:
|
|
import gi
|
|
gi.require_version("Gdk", "3.0")
|
|
from gi.repository import Gdk
|
|
disp = Gdk.Display.get_default()
|
|
mon = disp.get_primary_monitor() or disp.get_monitor(0)
|
|
geo = mon.get_geometry()
|
|
return geo.x + geo.width // 2, geo.y + geo.height - 140
|
|
except Exception: # noqa: BLE001
|
|
return 960, 800 # last-ditch constant; better than crashing
|
|
|
|
|
|
_tracker: _CaretTracker | None = None
|
|
|
|
|
|
def start_tracking() -> None:
|
|
"""Begin passive caret tracking (call once, from the GTK main thread)."""
|
|
global _tracker
|
|
if _tracker is None:
|
|
_tracker = _CaretTracker()
|
|
_tracker.start()
|
|
|
|
|
|
def stop_tracking() -> None:
|
|
global _tracker
|
|
if _tracker is not None:
|
|
_tracker.stop()
|
|
_tracker = None
|
|
|
|
|
|
def resolve(anchor_mode: str, window_id: str | None) -> Anchor:
|
|
"""Resolve the on-screen anchor for the overlay, honouring `anchor_mode`.
|
|
|
|
anchor_mode:
|
|
"caret" -> caret → pointer → window → corner
|
|
"pointer" -> pointer → window → corner
|
|
"corner" -> window → corner
|
|
Always returns an Anchor (never None) so callers don't special-case failure.
|
|
"""
|
|
if anchor_mode == "caret" and _tracker is not None:
|
|
r = _tracker.rect()
|
|
if r is not None:
|
|
x, y, _w, h = r
|
|
return Anchor(x=x, y=y, height=h or 18, source="caret")
|
|
|
|
if anchor_mode in ("caret", "pointer"):
|
|
p = _pointer()
|
|
if p is not None:
|
|
return Anchor(x=p[0], y=p[1], height=0, source="pointer")
|
|
|
|
wb = _window_box(window_id)
|
|
if wb is not None:
|
|
return Anchor(x=wb[0], y=wb[1], height=0, source="window")
|
|
|
|
x, y = _screen_bottom_centre()
|
|
return Anchor(x=x, y=y, height=0, source="corner")
|