Skip to content

Commit a7d6a2c

Browse files
committed
refactor(ui): ponytail pass on dictation overlay
Drop the hand-drawn mic glyph, simplify the Cairo clear/pulse path, reuse ConfigManager get_bool/set, tighten tray overlay helpers, and delete string-grep structural tests that proved nothing.
1 parent 77ece25 commit a7d6a2c

5 files changed

Lines changed: 41 additions & 127 deletions

File tree

src/vocalinux/ui/config_manager.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -373,13 +373,11 @@ def set_sound_effects_enabled(self, enabled: bool):
373373

374374
def is_overlay_enabled(self) -> bool:
375375
"""Check if the floating dictation overlay is enabled (default True)."""
376-
return bool(self.config.get("ui", {}).get("show_overlay", True))
376+
return self.get_bool("ui", "show_overlay", True)
377377

378378
def set_overlay_enabled(self, enabled: bool):
379379
"""Enable or disable the floating dictation overlay."""
380-
if "ui" not in self.config:
381-
self.config["ui"] = {}
382-
self.config["ui"]["show_overlay"] = bool(enabled)
380+
self.set("ui", "show_overlay", bool(enabled))
383381

384382
def _update_dict_recursive(self, target: dict, source: dict):
385383
"""

src/vocalinux/ui/dictation_overlay.py

Lines changed: 30 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def __init__(self, enabled: bool = True):
118118
self._anim_id: Optional[int] = None
119119
self._phase = 0.0
120120
self._gtk_ready = False
121-
self._layer_shell = None
121+
self._use_layer_shell = False
122122

123123
try:
124124
self._init_gtk_window()
@@ -137,7 +137,6 @@ def _init_gtk_window(self) -> None:
137137
from gi.repository import Gdk, GLib, Gtk # noqa: F401
138138

139139
self._GLib = GLib
140-
self._Gtk = Gtk
141140
self._Gdk = Gdk
142141

143142
# TOPLEVEL (undecorated) positions more reliably than POPUP on Wayland
@@ -155,7 +154,6 @@ def _init_gtk_window(self) -> None:
155154
window.set_type_hint(Gdk.WindowTypeHint.NOTIFICATION)
156155
window.set_default_size(_OVERLAY_SIZE, _OVERLAY_SIZE)
157156
window.set_app_paintable(True)
158-
window.set_events(0) # no input events needed
159157

160158
# RGBA visual for true transparency when the compositor supports it.
161159
screen = window.get_screen()
@@ -164,25 +162,20 @@ def _init_gtk_window(self) -> None:
164162
window.set_visual(visual)
165163

166164
# Soft Wayland layer-shell (optional — never required).
167-
self._layer_shell = _try_import_layer_shell()
168-
self._use_layer_shell = False
169-
if self._layer_shell is not None:
165+
layer_shell = _try_import_layer_shell()
166+
if layer_shell is not None:
170167
try:
171-
if self._layer_shell.is_supported():
172-
self._layer_shell.init_for_window(window)
173-
self._layer_shell.set_layer(window, self._layer_shell.Layer.OVERLAY)
174-
self._layer_shell.set_anchor(window, self._layer_shell.Edge.BOTTOM, True)
175-
self._layer_shell.set_margin(
176-
window, self._layer_shell.Edge.BOTTOM, _BOTTOM_MARGIN
177-
)
178-
self._layer_shell.set_exclusive_zone(window, 0)
168+
if layer_shell.is_supported():
169+
layer_shell.init_for_window(window)
170+
layer_shell.set_layer(window, layer_shell.Layer.OVERLAY)
171+
layer_shell.set_anchor(window, layer_shell.Edge.BOTTOM, True)
172+
layer_shell.set_margin(window, layer_shell.Edge.BOTTOM, _BOTTOM_MARGIN)
173+
layer_shell.set_exclusive_zone(window, 0)
179174
# Do not grab keyboard — passive visual only.
180-
if hasattr(self._layer_shell, "set_keyboard_mode"):
181-
self._layer_shell.set_keyboard_mode(
182-
window, self._layer_shell.KeyboardMode.NONE
183-
)
184-
elif hasattr(self._layer_shell, "set_keyboard_interactivity"):
185-
self._layer_shell.set_keyboard_interactivity(window, False)
175+
if hasattr(layer_shell, "set_keyboard_mode"):
176+
layer_shell.set_keyboard_mode(window, layer_shell.KeyboardMode.NONE)
177+
elif hasattr(layer_shell, "set_keyboard_interactivity"):
178+
layer_shell.set_keyboard_interactivity(window, False)
186179
self._use_layer_shell = True
187180
logger.info("Dictation overlay using GtkLayerShell")
188181
except Exception as e:
@@ -313,12 +306,9 @@ def _on_draw(self, widget, cr) -> bool:
313306
width = widget.get_allocated_width()
314307
height = widget.get_allocated_height()
315308
cx, cy = width / 2.0, height / 2.0
316-
base_r, base_g, base_b = _COLORS.get(mode, _COLORS[MODE_LISTENING])
309+
base_r, base_g, base_b = _COLORS[mode]
317310

318-
# Clear to fully transparent.
319-
cr.set_operator(cr.get_operator()) # keep default
320-
cr.set_source_rgba(0, 0, 0, 0)
321-
cr.set_operator(1) # CAIRO_OPERATOR_SOURCE / CLEAR-like full clear
311+
# Clear to fully transparent (SOURCE then OVER for compositors with alpha).
322312
try:
323313
import cairo as _cairo
324314

@@ -327,67 +317,40 @@ def _on_draw(self, widget, cr) -> bool:
327317
cr.paint()
328318
cr.set_operator(_cairo.OPERATOR_OVER)
329319
except Exception:
320+
cr.set_source_rgba(0, 0, 0, 0)
330321
cr.paint()
331322

332-
# Pulse 0..1 → soft scale/alpha modulation.
333-
pulse = 0.5 + 0.5 * math.sin(self._phase * 2.0 * math.pi)
334-
# Processing uses a slower, subtler pulse.
323+
# Pulse 0..1 → soft scale/alpha modulation (quieter while processing).
335324
if mode == MODE_PROCESSING:
336325
pulse = 0.55 + 0.25 * math.sin(self._phase * 2.0 * math.pi)
326+
alpha_scale = 0.75
327+
core_a = 0.8
328+
else:
329+
pulse = 0.5 + 0.5 * math.sin(self._phase * 2.0 * math.pi)
330+
alpha_scale = 1.0
331+
core_a = 0.95
337332

338333
# Outer glow rings.
339-
for radius_scale, alpha_scale in ((0.95, 0.12), (0.72, 0.22), (0.52, 0.35)):
334+
for radius_scale, ring_a in ((0.95, 0.12), (0.72, 0.22), (0.52, 0.35)):
340335
radius = (width * 0.42) * radius_scale * (0.92 + 0.08 * pulse)
341-
alpha = alpha_scale * (0.55 + 0.45 * pulse)
342-
if mode == MODE_PROCESSING:
343-
alpha *= 0.75
336+
alpha = ring_a * (0.55 + 0.45 * pulse) * alpha_scale
344337
cr.set_source_rgba(base_r, base_g, base_b, alpha)
345338
cr.arc(cx, cy, radius, 0, 2 * math.pi)
346339
cr.fill()
347340

348-
# Expanding ripple ring (listening only — stronger “hot mic” cue).
341+
# Expanding ripple (listening only).
349342
if mode == MODE_LISTENING:
350-
ripple_t = self._phase
351-
ripple_r = (width * 0.28) + ripple_t * (width * 0.28)
352-
ripple_a = max(0.0, 0.45 * (1.0 - ripple_t))
343+
ripple_r = (width * 0.28) + self._phase * (width * 0.28)
344+
ripple_a = max(0.0, 0.45 * (1.0 - self._phase))
353345
cr.set_source_rgba(base_r, base_g, base_b, ripple_a)
354346
cr.set_line_width(2.0)
355347
cr.arc(cx, cy, ripple_r, 0, 2 * math.pi)
356348
cr.stroke()
357349

358-
# Solid core circle.
350+
# Solid core.
359351
core_r = width * 0.16 * (0.95 + 0.05 * pulse)
360-
cr.set_source_rgba(base_r, base_g, base_b, 0.95 if mode == MODE_LISTENING else 0.8)
352+
cr.set_source_rgba(base_r, base_g, base_b, core_a)
361353
cr.arc(cx, cy, core_r, 0, 2 * math.pi)
362354
cr.fill()
363355

364-
# Simple mic glyph (stem + capsule) in white for readability.
365-
cr.set_source_rgba(1, 1, 1, 0.95)
366-
mic_w = width * 0.06
367-
mic_h = height * 0.10
368-
# Capsule body
369-
cr.save()
370-
cr.translate(cx, cy - height * 0.02)
371-
cr.scale(1.0, 1.15)
372-
cr.arc(0, -mic_h * 0.15, mic_w, math.pi, 0)
373-
cr.arc(0, mic_h * 0.15, mic_w, 0, math.pi)
374-
cr.close_path()
375-
cr.fill()
376-
cr.restore()
377-
# Stem
378-
cr.set_line_width(max(1.5, width * 0.02))
379-
cr.set_line_cap(1) # round
380-
try:
381-
import cairo as _cairo
382-
383-
cr.set_line_cap(_cairo.LINE_CAP_ROUND)
384-
except Exception:
385-
pass
386-
cr.move_to(cx, cy + height * 0.06)
387-
cr.line_to(cx, cy + height * 0.14)
388-
cr.stroke()
389-
# Base arc under mic
390-
cr.arc(cx, cy + height * 0.04, width * 0.09, 0.15 * math.pi, 0.85 * math.pi)
391-
cr.stroke()
392-
393356
return False

src/vocalinux/ui/settings_dialog.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1328,7 +1328,6 @@ def _on_show_overlay_toggled(self, widget, state):
13281328
return False
13291329

13301330
enabled = bool(state)
1331-
logger.info(f"Dictation overlay toggled: {enabled}")
13321331
self.config_manager.set_overlay_enabled(enabled)
13331332
self.config_manager.save_settings()
13341333
if self.overlay_enabled_callback is not None:

src/vocalinux/ui/tray_indicator.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -445,24 +445,20 @@ def _update_ui(self, state: RecognitionState):
445445

446446
def _update_overlay(self, state: RecognitionState):
447447
"""Show/hide the floating dictation overlay for the given state."""
448-
if not hasattr(self, "overlay") or self.overlay is None:
448+
if getattr(self, "overlay", None) is None:
449449
return
450450
# Re-read config so Settings toggles apply without restart.
451451
self.overlay.set_enabled(self.config_manager.is_overlay_enabled())
452452
self.overlay.on_recognition_state(state)
453453

454454
def set_overlay_enabled(self, enabled: bool) -> None:
455-
"""
456-
Live-update overlay enabled state (called from Settings).
457-
458-
Args:
459-
enabled: Whether the floating overlay should be shown when listening.
460-
"""
455+
"""Live-update overlay enabled state (called from Settings)."""
461456
self.config_manager.set_overlay_enabled(enabled)
462-
if hasattr(self, "overlay") and self.overlay is not None:
463-
self.overlay.set_enabled(enabled)
464-
# Re-apply current recognition state so hide/show is immediate.
465-
self.overlay.on_recognition_state(self.speech_engine.state)
457+
if getattr(self, "overlay", None) is None:
458+
return
459+
self.overlay.set_enabled(enabled)
460+
# Re-apply current recognition state so hide/show is immediate.
461+
self.overlay.on_recognition_state(self.speech_engine.state)
466462

467463
def _set_menu_item_enabled(self, label: str, enabled: bool):
468464
"""
@@ -667,8 +663,7 @@ def _quit(self):
667663
# Stop the keyboard shortcut manager
668664
self.shortcut_manager.stop()
669665

670-
# Tear down floating overlay
671-
if hasattr(self, "overlay") and self.overlay is not None:
666+
if getattr(self, "overlay", None) is not None:
672667
self.overlay.destroy()
673668
self.overlay = None
674669

tests/test_dictation_overlay.py

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,7 @@
2424
DictationOverlayController,
2525
)
2626

27-
# Source paths for structural checks (avoid importing modules that load GTK).
2827
_REPO_ROOT = Path(__file__).resolve().parents[1]
29-
_OVERLAY_SRC = _REPO_ROOT / "src" / "vocalinux" / "ui" / "dictation_overlay.py"
30-
_TRAY_SRC = _REPO_ROOT / "src" / "vocalinux" / "ui" / "tray_indicator.py"
3128

3229

3330
def _ensure_test_config_dir(path: str):
@@ -237,43 +234,6 @@ def test_gtk_init_failure_keeps_controller_usable(self):
237234
overlay.destroy()
238235

239236

240-
class TestOverlayWindowPassiveProperties(unittest.TestCase):
241-
"""
242-
Structural checks against source files (no module import side effects).
243-
"""
244-
245-
def test_source_sets_non_focus_and_skip_taskbar(self):
246-
source = _OVERLAY_SRC.read_text(encoding="utf-8")
247-
self.assertIn("set_accept_focus(False)", source)
248-
self.assertIn("set_skip_taskbar_hint(True)", source)
249-
self.assertIn("set_keep_above(True)", source)
250-
self.assertIn("set_skip_pager_hint(True)", source)
251-
# Click-through so the overlay is passive.
252-
self.assertIn("input_shape_combine_region", source)
253-
254-
def test_source_has_glow_or_pulse_animation(self):
255-
source = _OVERLAY_SRC.read_text(encoding="utf-8")
256-
self.assertIn("_on_draw", source)
257-
self.assertIn("_on_anim_tick", source)
258-
self.assertIn("timeout_add", source)
259-
# Glow rings / pulse phase.
260-
self.assertIn("pulse", source)
261-
self.assertIn("sin", source)
262-
263-
def test_layer_shell_is_optional_soft_import(self):
264-
source = _OVERLAY_SRC.read_text(encoding="utf-8")
265-
self.assertIn("_try_import_layer_shell", source)
266-
self.assertIn("GtkLayerShell", source)
267-
# Soft failure path is present in the helper.
268-
self.assertIn("except Exception", source)
269-
270-
def test_tray_wires_overlay_to_state_updates(self):
271-
source = _TRAY_SRC.read_text(encoding="utf-8")
272-
self.assertIn("DictationOverlay", source)
273-
self.assertIn("_update_overlay", source)
274-
self.assertIn("on_recognition_state", source)
275-
276-
277237
class TestOverlayWithMockedGtkWindow(unittest.TestCase):
278238
"""
279239
Exercise DictationOverlay sync path when a mock window is attached,
@@ -516,11 +476,10 @@ def test_on_draw_listening_paints_rings_and_ripple(self):
516476
widget.get_allocated_width.return_value = 96
517477
widget.get_allocated_height.return_value = 96
518478
cr = MagicMock()
519-
# Simulate cairo import failure for the clear path then success for line cap
520479
self.assertFalse(overlay._on_draw(widget, cr))
521480
self.assertTrue(cr.arc.called)
522481
self.assertTrue(cr.fill.called)
523-
self.assertTrue(cr.stroke.called)
482+
self.assertTrue(cr.stroke.called) # ripple ring
524483

525484
def test_on_draw_processing_uses_muted_pulse(self):
526485
overlay = _overlay_without_gtk(enabled=True)

0 commit comments

Comments
 (0)