stability + settings: fix AT-SPI session freeze, markdown Manual, bigger emojis (v1.9.6)

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>
This commit is contained in:
mARTin-B78 2026-06-09 14:20:05 +02:00
parent 48be824a50
commit 9e291437bd
3 changed files with 199 additions and 21 deletions

View File

@ -4,14 +4,12 @@ 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 is no portable way to read the text caret of an arbitrary app, so we degrade
through a chain of decreasing precision: through a chain of decreasing precision:
1. AT-SPI caret the real text insertion point, when the focused app exposes 1. Mouse pointer `xdotool getmouselocation`. Default; always available on X11;
it over accessibility (native GTK/Qt apps do; many terminals / Electron / a reliable proxy since the pointer is usually near where you're typing.
web views do not). We track only the *focused* object via a11y events (cheap, 2. AT-SPI caret the real text insertion point (opt-in via overlay_anchor=caret).
low-frequency) and read its caret rectangle lazily, once, when the overlay Only native GTK/Qt apps expose it; many terminals / Electron / web views do
shows never from inside an event dispatch, since synchronous AT-SPI reads not. The blocking D-Bus read runs in a thread with a 300ms hard timeout so it
on the hot path can wedge the accessibility bus and freeze the session. can never stall the GTK main loop and freeze the session.
2. Mouse pointer `xdotool getmouselocation`. Always available on X11; a good
proxy since the pointer is usually near where you're typing.
3. Window / screen top-centre of the target window, else screen bottom-centre. 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 Everything here is defensive: any failure falls through to the next tier, and
@ -24,6 +22,7 @@ from __future__ import annotations
import shutil import shutil
import subprocess import subprocess
import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@ -155,17 +154,27 @@ class _CaretTracker:
return None return None
def rect(self) -> tuple[int, int, int, int] | None: def rect(self) -> tuple[int, int, int, int] | None:
# Called once when the overlay shows (not on the a11y hot path), so the # Called once when the overlay shows (not on the a11y hot path).
# single blocking extents read here is safe: at worst it briefly delays # We run the blocking D-Bus extents read in a worker thread and join it
# the overlay, it cannot storm or re-enter the bus. # 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: if not self._ok or self._focused is None:
return None return None
if time.time() - self._stamp > self.STALE_SECONDS: if time.time() - self._stamp > self.STALE_SECONDS:
return None return None
try: result: list[tuple[int, int, int, int] | None] = [None]
return self._caret_rect(self._focused) focused = self._focused
except Exception: # noqa: BLE001 - focused app may be gone/unresponsive
return None 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]
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #

View File

@ -50,7 +50,7 @@ class Config:
language: str = "de" # whisper hint; "" = autodetect language: str = "de" # whisper hint; "" = autodetect
# on-screen overlay (mic + live waveform + recognised-text bubble) # on-screen overlay (mic + live waveform + recognised-text bubble)
overlay_enabled: bool = True overlay_enabled: bool = True
overlay_anchor: str = "caret" # caret (best-effort AT-SPI) | pointer | corner overlay_anchor: str = "pointer" # pointer | caret (AT-SPI, may freeze) | corner
# input scheme # input scheme
input_mode: str = "modifiers" # "modifiers" (Ctrl+Win/Ctrl/Alt/Esc) | "hotkeys" (combos) input_mode: str = "modifiers" # "modifiers" (Ctrl+Win/Ctrl/Alt/Esc) | "hotkeys" (combos)
push_to_talk: bool = False push_to_talk: bool = False
@ -194,7 +194,7 @@ def load(path: Path = CONFIG_PATH) -> Config:
notify_routing=bool(g.get("notify_routing", True)), notify_routing=bool(g.get("notify_routing", True)),
language=g.get("language", "de"), language=g.get("language", "de"),
overlay_enabled=bool(g.get("overlay_enabled", True)), overlay_enabled=bool(g.get("overlay_enabled", True)),
overlay_anchor=g.get("overlay_anchor", "caret"), overlay_anchor=g.get("overlay_anchor", "pointer"),
model=w.get("model", "small"), model=w.get("model", "small"),
device=w.get("device", "auto"), device=w.get("device", "auto"),
compute_type=w.get("compute_type", "auto"), compute_type=w.get("compute_type", "auto"),
@ -428,7 +428,7 @@ notify = true # desktop notifications for each phase
notify_routing = true # announce which preset/keyword a voice command matched (shown even hands-free) notify_routing = true # announce which preset/keyword a voice command matched (shown even hands-free)
language = "de" # Whisper language hint; "" = autodetect language = "de" # Whisper language hint; "" = autodetect
overlay_enabled = true # on-screen mic + live waveform + recognised-text bubble at the cursor overlay_enabled = true # on-screen mic + live waveform + recognised-text bubble at the cursor
overlay_anchor = "caret" # caret (best-effort, follows the text cursor) | pointer | corner overlay_anchor = "pointer" # pointer (near the mouse — safe) | caret (AT-SPI, may freeze) | corner
[input] [input]
# How you start/stop dictation. # How you start/stop dictation.

View File

@ -536,6 +536,145 @@ def _app_paths() -> dict[str, list[Path]]:
} }
def _md_panel(text: str, height: int = 420) -> Gtk.ScrolledWindow:
"""Render a Markdown string into a read-only styled Gtk.TextView.
Handles: # h1/h2/h3, **bold**, *italic*, `inline code`,
> blockquotes, --- rules, bullet/numbered lists, and | tables |.
"""
import re
sw = Gtk.ScrolledWindow()
sw.set_min_content_height(height)
sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
view = Gtk.TextView()
view.set_editable(False)
view.set_cursor_visible(False)
view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
view.set_left_margin(14)
view.set_right_margin(14)
view.set_top_margin(10)
view.set_bottom_margin(10)
buf = view.get_buffer()
t_h1 = buf.create_tag("h1", weight=Pango.Weight.BOLD, size_points=17.0,
pixels_above_lines=14, pixels_below_lines=4)
t_h2 = buf.create_tag("h2", weight=Pango.Weight.BOLD, size_points=13.0,
pixels_above_lines=10, pixels_below_lines=3)
t_h3 = buf.create_tag("h3", weight=Pango.Weight.BOLD, size_points=11.0,
pixels_above_lines=8, pixels_below_lines=2)
t_bold = buf.create_tag("bold", weight=Pango.Weight.BOLD)
t_ital = buf.create_tag("ital", style=Pango.Style.ITALIC)
t_code = buf.create_tag("code", family="Monospace", size_points=10.0,
background="#f0f0f4", foreground="#c7254e")
t_quote = buf.create_tag("quote", left_margin=20, foreground="#666",
style=Pango.Style.ITALIC,
pixels_above_lines=2, pixels_below_lines=2)
t_hr = buf.create_tag("hr", foreground="#aaa",
pixels_above_lines=6, pixels_below_lines=6)
t_tbl = buf.create_tag("tbl", family="Monospace", size_points=10.0,
pixels_above_lines=1, pixels_below_lines=1)
t_thdr = buf.create_tag("thdr", family="Monospace", size_points=10.0,
weight=Pango.Weight.BOLD,
pixels_above_lines=2, pixels_below_lines=1)
_inline_re = re.compile(r'(\*\*.*?\*\*|\*.*?\*|`.*?`)')
def _insert_inline(line: str, end_iter, *extra_tags) -> None:
for part in _inline_re.split(line):
if part.startswith("**") and part.endswith("**") and len(part) > 4:
buf.insert_with_tags(end_iter, part[2:-2], t_bold, *extra_tags)
elif part.startswith("*") and part.endswith("*") and len(part) > 2:
buf.insert_with_tags(end_iter, part[1:-1], t_ital, *extra_tags)
elif part.startswith("`") and part.endswith("`") and len(part) > 2:
buf.insert_with_tags(end_iter, part[1:-1], t_code, *extra_tags)
elif extra_tags:
buf.insert_with_tags(end_iter, part, *extra_tags)
else:
buf.insert(end_iter, part)
# Collect lines, detect table blocks so we can measure column widths
lines = text.splitlines()
# Pre-scan: mark which lines are table rows vs separators
_tbl_sep = re.compile(r'^\|[-| :]+\|$')
def _is_table_row(s: str) -> bool:
return s.strip().startswith("|") and s.strip().endswith("|")
first_line = True
i = 0
while i < len(lines):
raw = lines[i].rstrip()
end = buf.get_end_iter()
if not first_line:
buf.insert(end, "\n")
end = buf.get_end_iter()
first_line = False
# --- Table block: gather all consecutive table rows ---
if _is_table_row(raw):
tbl_rows: list[list[str]] = []
while i < len(lines) and (_is_table_row(lines[i].rstrip()) or _tbl_sep.match(lines[i].rstrip())):
row_line = lines[i].rstrip()
if not _tbl_sep.match(row_line):
cells = [c.strip() for c in row_line.strip("|").split("|")]
tbl_rows.append(cells)
i += 1
if tbl_rows:
# Column widths: max of each column (plain text length, stripped of markup)
n_cols = max(len(r) for r in tbl_rows)
widths = [0] * n_cols
for row in tbl_rows:
for ci, cell in enumerate(row):
plain = re.sub(r'\*\*|`|\*', '', cell)
widths[ci] = max(widths[ci], len(plain))
for ri, row in enumerate(tbl_rows):
end = buf.get_end_iter()
tag = t_thdr if ri == 0 else t_tbl
line_text = ""
for ci in range(n_cols):
cell = row[ci] if ci < len(row) else ""
plain = re.sub(r'\*\*|`|\*', '', cell)
line_text += plain.ljust(widths[ci] + 2)
buf.insert_with_tags(end, line_text.rstrip(), tag)
if ri < len(tbl_rows) - 1:
end = buf.get_end_iter()
buf.insert(end, "\n")
continue # i already advanced
# --- Normal lines ---
if raw.startswith("### "):
buf.insert_with_tags(end, raw[4:], t_h3)
elif raw.startswith("## "):
buf.insert_with_tags(end, raw[3:], t_h2)
elif raw.startswith("# "):
buf.insert_with_tags(end, raw[2:], t_h1)
elif raw.startswith("> "):
_insert_inline(raw[2:], end, t_quote)
elif re.match(r'^-{3,}$', raw):
buf.insert_with_tags(end, "" * 64, t_hr)
elif raw.startswith("- ") or raw.startswith("* "):
buf.insert(end, "")
end = buf.get_end_iter()
_insert_inline(raw[2:], end)
elif re.match(r'^\d+\. ', raw):
m = re.match(r'^(\d+\. )(.*)', raw)
buf.insert(end, " " + m.group(1))
end = buf.get_end_iter()
_insert_inline(m.group(2), end)
else:
_insert_inline(raw, end)
i += 1
sw.add(view)
return sw
def _text_panel(text: str, *, monospace: bool = True, height: int = 180) -> Gtk.ScrolledWindow: def _text_panel(text: str, *, monospace: bool = True, height: int = 180) -> Gtk.ScrolledWindow:
sw = Gtk.ScrolledWindow() sw = Gtk.ScrolledWindow()
sw.set_min_content_height(height) sw.set_min_content_height(height)
@ -589,11 +728,37 @@ class SettingsDialog:
} }
.bt-infobox { .bt-infobox {
border-radius: 6px; border-radius: 6px;
border: 1px solid mix(@theme_fg_color, @theme_bg_color, 0.75); border: 1px solid rgba(66, 133, 244, 0.50);
background-color: mix(@theme_fg_color, @theme_bg_color, 0.05); background-color: rgba(66, 133, 244, 0.09);
padding: 4px; padding: 4px;
margin-bottom: 10px; margin-bottom: 10px;
} }
.bt-infobox label {
color: @theme_fg_color;
}
notebook.bt-nb tab {
padding: 7px 16px;
border-radius: 4px 4px 0 0;
}
notebook.bt-nb tab label {
font-size: 12px;
}
notebook.bt-nb tab:checked label {
font-weight: bold;
color: #1a73e8;
}
.bt-emoji-btn {
font-size: 20px;
min-width: 38px;
min-height: 38px;
padding: 2px;
}
.bt-emoji-cat-btn {
font-size: 18px;
min-width: 34px;
min-height: 34px;
padding: 2px;
}
""") """)
Gtk.StyleContext.add_provider_for_screen( Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(), _prov, Gdk.Screen.get_default(), _prov,
@ -601,6 +766,7 @@ class SettingsDialog:
) )
nb = Gtk.Notebook() nb = Gtk.Notebook()
nb.get_style_context().add_class("bt-nb")
self.dlg.get_content_area().pack_start(nb, True, True, 0) self.dlg.get_content_area().pack_start(nb, True, True, 0)
# Build each tab lazily on first view so the dialog opens instantly. The # Build each tab lazily on first view so the dialog opens instantly. The
# heavy bits are the Gtk.FileChooserButton pickers in Input/Benchmark # heavy bits are the Gtk.FileChooserButton pickers in Input/Benchmark
@ -867,6 +1033,7 @@ class SettingsDialog:
for emoji in emojis: for emoji in emojis:
eb = Gtk.Button(label=emoji) eb = Gtk.Button(label=emoji)
eb.set_relief(Gtk.ReliefStyle.NONE) eb.set_relief(Gtk.ReliefStyle.NONE)
eb.get_style_context().add_class("bt-emoji-btn")
eb.connect("clicked", lambda _b, e=emoji: (entry.set_text(e), pop.popdown())) eb.connect("clicked", lambda _b, e=emoji: (entry.set_text(e), pop.popdown()))
flow.add(eb) flow.add(eb)
sw = Gtk.ScrolledWindow() sw = Gtk.ScrolledWindow()
@ -876,6 +1043,7 @@ class SettingsDialog:
cb = Gtk.Button(label=cat_emoji) cb = Gtk.Button(label=cat_emoji)
cb.set_relief(Gtk.ReliefStyle.NONE) cb.set_relief(Gtk.ReliefStyle.NONE)
cb.get_style_context().add_class("bt-emoji-cat-btn")
cb.set_tooltip_text(cat_name) cb.set_tooltip_text(cat_name)
cb.connect("clicked", lambda _b, n=cat_name, b=cb: _select(n, b)) cb.connect("clicked", lambda _b, n=cat_name, b=cb: _select(n, b))
cat_bar.pack_start(cb, True, True, 0) cat_bar.pack_start(cb, True, True, 0)
@ -918,6 +1086,7 @@ class SettingsDialog:
if query in _keywords(emoji) or query in emoji: if query in _keywords(emoji) or query in emoji:
btn = Gtk.Button(label=emoji) btn = Gtk.Button(label=emoji)
btn.set_relief(Gtk.ReliefStyle.NONE) btn.set_relief(Gtk.ReliefStyle.NONE)
btn.get_style_context().add_class("bt-emoji-btn")
btn.connect("clicked", lambda _b, e=emoji: (entry.set_text(e), pop.popdown())) btn.connect("clicked", lambda _b, e=emoji: (entry.set_text(e), pop.popdown()))
search_flow.add(btn) search_flow.add(btn)
search_flow.show_all() search_flow.show_all()
@ -1906,7 +2075,7 @@ class SettingsDialog:
fallback="Manual not available in this install.\n\n" fallback="Manual not available in this install.\n\n"
"See the online documentation at " "See the online documentation at "
"github.com/mARTin-B78/blitztext-app-linux") "github.com/mARTin-B78/blitztext-app-linux")
page.pack_start(_text_panel(text, monospace=False, height=420), True, True, 0) page.pack_start(_md_panel(text, height=420), True, True, 0)
# ===== About ============================================================ # ===== About ============================================================
def _build_about(self, page: Gtk.Box) -> None: def _build_about(self, page: Gtk.Box) -> None: