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:
parent
48be824a50
commit
9e291437bd
@ -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
|
||||
through a chain of decreasing precision:
|
||||
|
||||
1. AT-SPI caret — the real text insertion point, when the focused app exposes
|
||||
it over accessibility (native GTK/Qt apps do; many terminals / Electron /
|
||||
web views do not). We track only the *focused* object via a11y events (cheap,
|
||||
low-frequency) and read its caret rectangle lazily, once, when the overlay
|
||||
shows — never from inside an event dispatch, since synchronous AT-SPI reads
|
||||
on the hot path can wedge the accessibility bus 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.
|
||||
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
|
||||
@ -24,6 +22,7 @@ from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
@ -155,17 +154,27 @@ class _CaretTracker:
|
||||
return None
|
||||
|
||||
def rect(self) -> tuple[int, int, int, int] | None:
|
||||
# Called once when the overlay shows (not on the a11y hot path), so the
|
||||
# single blocking extents read here is safe: at worst it briefly delays
|
||||
# the overlay, it cannot storm or re-enter the bus.
|
||||
# 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:
|
||||
return self._caret_rect(self._focused)
|
||||
except Exception: # noqa: BLE001 - focused app may be gone/unresponsive
|
||||
return None
|
||||
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]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@ -50,7 +50,7 @@ class Config:
|
||||
language: str = "de" # whisper hint; "" = autodetect
|
||||
# on-screen overlay (mic + live waveform + recognised-text bubble)
|
||||
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_mode: str = "modifiers" # "modifiers" (Ctrl+Win/Ctrl/Alt/Esc) | "hotkeys" (combos)
|
||||
push_to_talk: bool = False
|
||||
@ -194,7 +194,7 @@ def load(path: Path = CONFIG_PATH) -> Config:
|
||||
notify_routing=bool(g.get("notify_routing", True)),
|
||||
language=g.get("language", "de"),
|
||||
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"),
|
||||
device=w.get("device", "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)
|
||||
language = "de" # Whisper language hint; "" = autodetect
|
||||
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]
|
||||
# How you start/stop dictation.
|
||||
|
||||
@ -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:
|
||||
sw = Gtk.ScrolledWindow()
|
||||
sw.set_min_content_height(height)
|
||||
@ -589,11 +728,37 @@ class SettingsDialog:
|
||||
}
|
||||
.bt-infobox {
|
||||
border-radius: 6px;
|
||||
border: 1px solid mix(@theme_fg_color, @theme_bg_color, 0.75);
|
||||
background-color: mix(@theme_fg_color, @theme_bg_color, 0.05);
|
||||
border: 1px solid rgba(66, 133, 244, 0.50);
|
||||
background-color: rgba(66, 133, 244, 0.09);
|
||||
padding: 4px;
|
||||
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(
|
||||
Gdk.Screen.get_default(), _prov,
|
||||
@ -601,6 +766,7 @@ class SettingsDialog:
|
||||
)
|
||||
|
||||
nb = Gtk.Notebook()
|
||||
nb.get_style_context().add_class("bt-nb")
|
||||
self.dlg.get_content_area().pack_start(nb, True, True, 0)
|
||||
# Build each tab lazily on first view so the dialog opens instantly. The
|
||||
# heavy bits are the Gtk.FileChooserButton pickers in Input/Benchmark
|
||||
@ -867,6 +1033,7 @@ class SettingsDialog:
|
||||
for emoji in emojis:
|
||||
eb = Gtk.Button(label=emoji)
|
||||
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()))
|
||||
flow.add(eb)
|
||||
sw = Gtk.ScrolledWindow()
|
||||
@ -876,6 +1043,7 @@ class SettingsDialog:
|
||||
|
||||
cb = Gtk.Button(label=cat_emoji)
|
||||
cb.set_relief(Gtk.ReliefStyle.NONE)
|
||||
cb.get_style_context().add_class("bt-emoji-cat-btn")
|
||||
cb.set_tooltip_text(cat_name)
|
||||
cb.connect("clicked", lambda _b, n=cat_name, b=cb: _select(n, b))
|
||||
cat_bar.pack_start(cb, True, True, 0)
|
||||
@ -918,6 +1086,7 @@ class SettingsDialog:
|
||||
if query in _keywords(emoji) or query in emoji:
|
||||
btn = Gtk.Button(label=emoji)
|
||||
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()))
|
||||
search_flow.add(btn)
|
||||
search_flow.show_all()
|
||||
@ -1906,7 +2075,7 @@ class SettingsDialog:
|
||||
fallback="Manual not available in this install.\n\n"
|
||||
"See the online documentation at "
|
||||
"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 ============================================================
|
||||
def _build_about(self, page: Gtk.Box) -> None:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user