From c2654609ac12a23332b6b1741f2653480b564eed Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Tue, 29 Sep 2026 14:24:31 -0700 Subject: [PATCH] feat(ui): floating glowing dictation overlay (#516) * feat(ui): add floating glowing dictation overlay Show a bottom-center non-focus overlay while recognition is LISTENING or PROCESSING so users get a clear visual cue beyond the tray icon. Optional GtkLayerShell on Wayland; plain always-on-top GTK fallback elsewhere. Configurable via ui.show_overlay (default on) and Settings. * fix(test): isolate dictation overlay tests from GTK pollution Overlay unit tests no longer construct real GTK windows or import tray_indicator, which was loading gi.repository early and breaking later tests that mock GI (single-instance and suspend GLib checks). * docs: add dictation overlay screenshots Capture the floating glowing orb in listening (green) and processing (amber) states for the PR and README screenshots section. * test(ui): raise dictation overlay coverage for Codecov patch gate Exercise GTK init, draw/animation, positioning, layer-shell fallbacks, tray overlay wiring, settings toggle, and config edge cases so patch coverage on the floating overlay clears the 80% Codecov target. * test(tray): call overlay helpers via unbound methods Avoid instance-level MagicMock shadowing of set_overlay_enabled under some import/mock orderings (seen on CI Python 3.10). * test(tray): inject explicit config mock for overlay helpers Assign a MagicMock config_manager on the instance so assertions do not depend on the constructor patch (which can leave a real ConfigManager under some Python versions / import orders). * 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. * fix(ui): pin overlay to bottom strip and stop stealing focus Use a full-width bottom strip (layer-shell left/right/bottom anchors, or resize+move fallback) so the orb is bottom-center on Wayland. Map the window once and toggle opacity instead of hide/show so compositors do not re-activate it and yank focus out of the editor (which broke IBus inject). * fix(appimage): seed GtkLayerShell typelib for dictation overlay * fix(ui): address greptile findings on dictation overlay * fix(appimage): keep layer-shell typelib optional for direct builds --- README.md | 10 + packaging/appimage/build.sh | 21 +- packaging/appimage/container-build.sh | 1 + src/vocalinux/ui/config_manager.py | 9 + src/vocalinux/ui/dictation_overlay.py | 469 +++++++++ src/vocalinux/ui/settings_dialog.py | 47 +- src/vocalinux/ui/tray_indicator.py | 31 + tests/test_dictation_overlay.py | 888 ++++++++++++++++++ tests/test_tray_indicator.py | 97 ++ .../06-dictation-overlay-listening.png | Bin 0 -> 4706 bytes .../07-dictation-overlay-processing.png | Bin 0 -> 2181 bytes 11 files changed, 1563 insertions(+), 10 deletions(-) create mode 100644 src/vocalinux/ui/dictation_overlay.py create mode 100644 tests/test_dictation_overlay.py create mode 100644 web/public/screenshots/06-dictation-overlay-listening.png create mode 100644 web/public/screenshots/07-dictation-overlay-processing.png diff --git a/README.md b/README.md index fd9e6cb..f4c6cd7 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,16 @@ Vocalinux in action. Full gallery on the [website screenshots page](https://voca Advanced + + + Dictation overlay listening
+ Floating overlay while listening (green) + + + Dictation overlay processing
+ Floating overlay while processing (amber) + + ## Install diff --git a/packaging/appimage/build.sh b/packaging/appimage/build.sh index 9b3f18c..59a4386 100755 --- a/packaging/appimage/build.sh +++ b/packaging/appimage/build.sh @@ -117,7 +117,7 @@ TYPELIBS=( GModule-2.0 Pango-1.0 PangoCairo-1.0 cairo-1.0 HarfBuzz-0.0 Atk-1.0 freetype2-2.0 fontconfig-2.0 xlib-2.0 AyatanaAppIndicator3-0.1 AyatanaAppindicator3-0.1 AppIndicator3-0.1 - Dbusmenu-0.4 Notify-0.7 IBus-1.0 Rsvg-2.0 + Dbusmenu-0.4 Notify-0.7 IBus-1.0 Rsvg-2.0 GtkLayerShell-0.1 ) # Runtime only needs one tray stack (same order as tray_indicator.py). Prefer @@ -126,6 +126,13 @@ INDICATOR_TYPELIBS=( AyatanaAppIndicator3-0.1 AyatanaAppindicator3-0.1 AppIndicator3-0.1 ) +# Soft dependencies the app itself falls back from: dictation_overlay.py uses a +# plain GTK window when layer shell is absent, so these are copied when found +# but must never block a direct build on a host without them. +OPTIONAL_TYPELIBS=( + GtkLayerShell-0.1 +) + # Shared libs loaded via GI at runtime (not linked into python3), so # linuxdeploy will not discover them from -e python3 alone. # verify_typelib_libraries below fails the build when one is missing. @@ -136,6 +143,7 @@ GI_RUNTIME_LIBS=( libdbusmenu-gtk3.so.4 libnotify.so.4 libibus-1.0.so.5 + libgtk-layer-shell.so.0 # Nothing imports PangoXft, but linuxdeploy keeps its typelib, and a typelib # without its library is the trap that cost us IBus. 30 KB is cheaper than an # exception to the rule. @@ -147,6 +155,10 @@ GI_RUNTIME_LIBS=( HOST_PROVIDED_LIBS=( libharfbuzz.so.0 libharfbuzz-gobject.so.0 + # Layer shell is optional end to end (see OPTIONAL_TYPELIBS): when its + # library cannot be bundled, the host's copy is the right one — the app + # falls back to a plain GTK window when no host copy exists either. + libgtk-layer-shell.so.0 ) # On the excludelist, but something we bundle links them, so the host cannot be @@ -264,7 +276,7 @@ copy_typelibs() { done # Tray indicator typelibs are alternates; drop them from the hard-fail list - # when at least one copied successfully. + # when at least one copied successfully. Optional typelibs never hard-fail. local hard_missing=() for typelib in "${missing[@]}"; do case " ${INDICATOR_TYPELIBS[*]} " in @@ -274,7 +286,10 @@ copy_typelibs() { fi ;; *) - hard_missing+=("$typelib") + case " ${OPTIONAL_TYPELIBS[*]} " in + *" ${typelib} "*) ;; + *) hard_missing+=("$typelib") ;; + esac ;; esac done diff --git a/packaging/appimage/container-build.sh b/packaging/appimage/container-build.sh index e15d55c..47ab221 100755 --- a/packaging/appimage/container-build.sh +++ b/packaging/appimage/container-build.sh @@ -25,6 +25,7 @@ apt-get install -y -qq --no-install-recommends \ gir1.2-notify-0.7 libnotify4 \ gir1.2-dbusmenu-glib-0.4 libdbusmenu-gtk3-4 \ gir1.2-ibus-1.0 gir1.2-rsvg-2.0 librsvg2-common \ + gir1.2-gtklayershell-0.1 libgtk-layer-shell0 \ libvulkan-dev bash "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/build.sh" "$@" diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 75377da..4ed41bd 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -157,6 +157,7 @@ DEFAULT_CONFIG = { "start_minimized": False, "show_notifications": True, "show_missing_tray_warning": True, + "show_overlay": True, # Floating on-screen dictation indicator }, "general": { "autostart": False, @@ -785,6 +786,14 @@ class ConfigManager: self.config["text_injection"] = {} self.config["text_injection"]["paste_shortcut"] = normalize_paste_shortcut(shortcut) + def is_overlay_enabled(self) -> bool: + """Check if the floating dictation overlay is enabled (default True).""" + return self.get_bool("ui", "show_overlay", True) + + def set_overlay_enabled(self, enabled: bool) -> None: + """Enable or disable the floating dictation overlay.""" + self.set("ui", "show_overlay", bool(enabled)) + def _update_dict_recursive(self, target: dict, source: dict): """ Update a dictionary recursively. diff --git a/src/vocalinux/ui/dictation_overlay.py b/src/vocalinux/ui/dictation_overlay.py new file mode 100644 index 0000000..0bc38f1 --- /dev/null +++ b/src/vocalinux/ui/dictation_overlay.py @@ -0,0 +1,469 @@ +""" +Floating on-screen dictation overlay for Vocalinux. + +Shows a compact glowing indicator while recognition is active so users get a +clear visual cue beyond the system tray icon (similar to SuperWhisper-style +apps). The window is passive: it does not take keyboard focus or steal clicks. +""" + +from __future__ import annotations + +import logging +import math +from types import ModuleType +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from gi.repository import Gtk + +from ..common_types import RecognitionState + +logger = logging.getLogger(__name__) + +# Visual modes the overlay can display while visible. +MODE_LISTENING = "listening" +MODE_PROCESSING = "processing" + +# Colors as (r, g, b) in 0..1 — listening = green (hot mic), processing = amber. +_COLORS = { + MODE_LISTENING: (0.18, 0.76, 0.45), # #2ec273 + MODE_PROCESSING: (0.95, 0.65, 0.15), # #f2a626 +} + +# Animation / layout +# Thin full-width strip along the bottom; orb is drawn at horizontal center. +_STRIP_HEIGHT = 120 +_ORB_SIZE = 96 +_PULSE_PERIOD_MS = 1400 +_ANIMATION_TICK_MS = 33 # ~30 fps +_BOTTOM_MARGIN = 48 + + +class DictationOverlayController: + """ + Pure state machine: enabled flag + RecognitionState → visible + mode. + + Separated from GTK so unit tests can drive show/hide without a display. + """ + + def __init__(self, enabled: bool = True) -> None: + self._enabled = bool(enabled) + self._state = RecognitionState.IDLE + + @property + def enabled(self) -> bool: + """Whether the overlay feature is allowed to show.""" + return self._enabled + + def set_enabled(self, enabled: bool) -> None: + """Enable or disable the overlay feature (does not change recognition state).""" + self._enabled = bool(enabled) + + @property + def state(self) -> RecognitionState: + """Last recognition state applied to the controller.""" + return self._state + + def set_state(self, state: RecognitionState) -> None: + """Apply a recognition state transition.""" + self._state = state + + @property + def visible(self) -> bool: + """True when the overlay should be shown on screen.""" + if not self._enabled: + return False + return self._state in (RecognitionState.LISTENING, RecognitionState.PROCESSING) + + @property + def mode(self) -> Optional[str]: + """ + Active visual mode, or None when the overlay should be hidden. + + Returns: + ``"listening"``, ``"processing"``, or ``None``. + """ + if not self.visible: + return None + if self._state == RecognitionState.LISTENING: + return MODE_LISTENING + # visible is only True for LISTENING or PROCESSING + return MODE_PROCESSING + + +def _try_import_layer_shell() -> Optional[ModuleType]: + """ + Soft-import GtkLayerShell when available (Wayland, wlroots-based). + + Returns the GtkLayerShell module or None. Never raises — missing packages + must not break installs on X11 or GNOME without the library. + """ + try: + import gi + + gi.require_version("GtkLayerShell", "0.1") + from gi.repository import GtkLayerShell # type: ignore + + module: ModuleType = GtkLayerShell + return module + except Exception: + return None + + +class DictationOverlay: + """ + Floating glowing dictation indicator backed by a GTK window. + + Uses a full-width bottom strip so the orb is always bottom-center of the + monitor (Wayland often ignores move() on a small centered popup). The + window is passive: no keyboard focus, click-through input region. + """ + + def __init__(self, enabled: bool = True) -> None: + self.controller = DictationOverlayController(enabled=enabled) + self._window: Optional[Gtk.Window] = None + self._drawing_area: Optional[Gtk.DrawingArea] = None + self._anim_id: Optional[int] = None + self._phase = 0.0 + self._gtk_ready = False + self._use_layer_shell = False + + try: + self._init_gtk_window() + self._gtk_ready = True + except Exception as e: + # Headless / missing display: keep controller usable for tests. + logger.warning("Dictation overlay window unavailable: %s", e) + self._gtk_ready = False + + def _init_gtk_window(self) -> None: + """Construct the floating transparent always-on-top bottom strip.""" + import gi + + gi.require_version("Gtk", "3.0") + gi.require_version("Gdk", "3.0") + from gi.repository import Gdk, GLib, Gtk # noqa: F401 + + self._GLib = GLib + self._Gdk = Gdk + self._Gtk = Gtk + + # TOPLEVEL is required by gtk-layer-shell; passive flags below avoid + # keyboard focus so the active editor keeps receiving injected text. + # Size/position are applied after map via _layout_bottom_strip. + window = Gtk.Window(type=Gtk.WindowType.TOPLEVEL) + window.set_title("Vocalinux Dictation") + window.set_decorated(False) + window.set_resizable(False) + window.set_accept_focus(False) + window.set_can_focus(False) + window.set_focus_on_map(False) + window.set_skip_taskbar_hint(True) + window.set_skip_pager_hint(True) + # NOTIFICATION/TOOLTIP hints: not a normal app window, less likely to + # activate and steal focus from the dictation target. + window.set_type_hint(Gdk.WindowTypeHint.NOTIFICATION) + window.set_keep_above(True) + window.set_app_paintable(True) + window.set_modal(False) + + # RGBA visual for true transparency when the compositor supports it. + screen = window.get_screen() + visual = screen.get_rgba_visual() + if visual is not None: + window.set_visual(visual) + + # Soft Wayland layer-shell (optional — never required). + layer_shell = _try_import_layer_shell() + if layer_shell is not None: + try: + if layer_shell.is_supported(): + layer_shell.init_for_window(window) + layer_shell.set_layer(window, layer_shell.Layer.OVERLAY) + # Full-width bottom strip → orb drawn at horizontal center. + layer_shell.set_anchor(window, layer_shell.Edge.BOTTOM, True) + layer_shell.set_anchor(window, layer_shell.Edge.LEFT, True) + layer_shell.set_anchor(window, layer_shell.Edge.RIGHT, True) + layer_shell.set_margin(window, layer_shell.Edge.BOTTOM, _BOTTOM_MARGIN) + layer_shell.set_exclusive_zone(window, 0) + # Do not grab keyboard — critical so the focused app keeps input. + if hasattr(layer_shell, "set_keyboard_mode"): + layer_shell.set_keyboard_mode(window, layer_shell.KeyboardMode.NONE) + elif hasattr(layer_shell, "set_keyboard_interactivity"): + layer_shell.set_keyboard_interactivity(window, False) + self._use_layer_shell = True + logger.info("Dictation overlay using GtkLayerShell") + except Exception as e: + logger.debug("GtkLayerShell setup failed, using fallback: %s", e) + self._use_layer_shell = False + + drawing = Gtk.DrawingArea() + drawing.set_size_request(1, _STRIP_HEIGHT) + drawing.set_can_focus(False) + drawing.connect("draw", self._on_draw) + window.add(drawing) + window.connect("size-allocate", self._on_size_allocate) + window.connect("realize", self._on_realize) + window.connect("map-event", self._on_map_event) + # Fallback placement is stale the moment monitor geometry or the + # compositor changes; layer shell re-anchors itself on these signals. + screen.connect("size-changed", self._on_screen_geometry_changed) + screen.connect("monitors-changed", self._on_screen_geometry_changed) + screen.connect("composited-changed", self._on_composited_changed) + + self._window = window + self._drawing_area = drawing + + def _on_realize(self, widget) -> None: + """Make the window click-through and non-focus once it has a Gdk window.""" + try: + import cairo + + gdk_win = widget.get_window() + if gdk_win is None: + return + # Empty input region → pointer events pass through to apps below. + region = cairo.Region() + gdk_win.input_shape_combine_region(region, 0, 0) + # Also clear the shape region so the surface is fully transparent to hits. + try: + gdk_win.shape_combine_region(None, 0, 0) + except Exception: + pass + try: + # X11: override-redirect popups do not take focus; no-op on Wayland. + if hasattr(gdk_win, "set_override_redirect"): + gdk_win.set_override_redirect(True) + except Exception: + pass + try: + gdk_win.set_accept_focus(False) + except Exception: + pass + except Exception as e: + logger.debug("Could not set click-through region: %s", e) + + def _on_map_event(self, widget, event) -> bool: + """Re-apply layout after map (Wayland often ignores pre-show move()).""" + if not self._use_layer_shell: + self._layout_bottom_strip() + self._on_realize(widget) + return False + + def _on_size_allocate(self, widget, allocation) -> None: + """Keep the empty input shape after size changes.""" + if widget.get_realized(): + self._on_realize(widget) + + def _on_screen_geometry_changed(self, *_args: object) -> None: + """Re-run fallback placement when monitors or resolution change.""" + if not self._use_layer_shell and self._window is not None and self._window.get_visible(): + self._layout_bottom_strip() + + def _on_composited_changed(self, *_args: object) -> None: + """Re-evaluate the hide strategy when a compositor appears or leaves.""" + self._sync_window() + + def _is_wayland(self) -> bool: + """True when the default display is a Wayland one.""" + try: + wayland_cls = getattr(self._Gdk, "WaylandDisplay", None) + if wayland_cls is None: + return False + return isinstance(self._Gdk.Display.get_default(), wayland_cls) + except Exception: + return False + + def set_enabled(self, enabled: bool) -> None: + """Enable/disable the feature and sync the window.""" + self.controller.set_enabled(enabled) + self._sync_window() + + def on_recognition_state(self, state: RecognitionState) -> None: + """Handle a recognition state change (same path as the tray icon).""" + self.controller.set_state(state) + self._sync_window() + + def destroy(self) -> None: + """Tear down animation and window.""" + self._stop_animation() + if self._window is not None: + try: + self._window.destroy() + except Exception: + pass + self._window = None + + def _sync_window(self) -> None: + """Show/hide and restyle the GTK window from controller state.""" + if not self._gtk_ready or self._window is None: + return + + mode = self.controller.mode + if mode is None: + self._stop_animation() + # Opacity 0 instead of hide() — hide/show cycles re-activate the + # window on many Wayland compositors and steal editor focus (which + # breaks IBus/wtype injection into the previously focused app). + # On a non-composited display opacity is a no-op, so hide the + # window outright rather than leave a stale orb claiming the mic + # is live. + try: + composited = self._window.is_composited() + except Exception: + composited = False + try: + self._window.set_opacity(0.0) + except Exception: + composited = False + if not composited and self._window.get_visible(): + self._window.hide() + return + + # Layout before first map so the first frame is already bottom-center. + if not self._use_layer_shell: + self._layout_bottom_strip() + + if not self._window.get_visible(): + # Map once; never call present() (steals keyboard focus). + self._window.set_accept_focus(False) + self._window.set_can_focus(False) + self._window.set_focus_on_map(False) + self._window.show_all() + if not self._use_layer_shell: + self._GLib.idle_add(self._layout_bottom_strip_idle) + + try: + self._window.set_opacity(1.0) + except Exception: + pass + + self._start_animation() + if self._drawing_area is not None: + self._drawing_area.queue_draw() + + def _layout_bottom_strip_idle(self) -> bool: + self._layout_bottom_strip() + return False # one-shot idle + + def _layout_bottom_strip(self) -> None: + """ + Size the window as a full-width bottom strip and place it on the primary monitor. + + Drawing the orb at the strip's horizontal center always yields bottom-center + of the screen, even when the compositor only roughly honors move(). + On Wayland without layer shell, move() is a hint the compositor can + ignore entirely, so ask for a centered compact window instead of + promising a bottom position it cannot place. + """ + if self._window is None: + return + try: + display = self._Gdk.Display.get_default() + if display is None: + return + monitor = display.get_primary_monitor() + if monitor is None and display.get_n_monitors() > 0: + monitor = display.get_monitor(0) + if monitor is None: + return + geom = monitor.get_geometry() + height = _STRIP_HEIGHT + wayland = self._is_wayland() + if wayland: + self._window.set_position(self._Gtk.WindowPosition.CENTER_ALWAYS) + width = _ORB_SIZE + else: + width = max(geom.width, _ORB_SIZE) + self._window.resize(width, height) + if self._drawing_area is not None: + self._drawing_area.set_size_request(width, height) + if not wayland: + x = geom.x + y = geom.y + geom.height - height - _BOTTOM_MARGIN + self._window.move(x, y) + except Exception as e: + logger.debug("Could not layout overlay strip: %s", e) + + def _start_animation(self) -> None: + if self._anim_id is not None: + return + self._phase = 0.0 + self._anim_id = self._GLib.timeout_add(_ANIMATION_TICK_MS, self._on_anim_tick) + + def _stop_animation(self) -> None: + if self._anim_id is not None: + try: + self._GLib.source_remove(self._anim_id) + except Exception: + pass + self._anim_id = None + + def _on_anim_tick(self) -> bool: + self._phase = (self._phase + _ANIMATION_TICK_MS / _PULSE_PERIOD_MS) % 1.0 + if self._drawing_area is not None: + self._drawing_area.queue_draw() + # Keep ticking while visible. + return self.controller.visible + + def _on_draw(self, widget, cr) -> bool: + """Cairo draw: soft multi-ring glow + solid core at bottom-center of strip.""" + mode = self.controller.mode + if mode is None: + return False + + width = widget.get_allocated_width() + height = widget.get_allocated_height() + # Orb sits in the horizontal center of the full-width strip. + cx = width / 2.0 + cy = height / 2.0 + # Draw relative to a fixed orb diameter so a wide strip does not stretch it. + orb = float(min(_ORB_SIZE, width, height)) + base_r, base_g, base_b = _COLORS[mode] + + # Clear to fully transparent (SOURCE then OVER for compositors with alpha). + try: + import cairo as _cairo + + cr.set_operator(_cairo.OPERATOR_SOURCE) + cr.set_source_rgba(0, 0, 0, 0) + cr.paint() + cr.set_operator(_cairo.OPERATOR_OVER) + except Exception: + cr.set_source_rgba(0, 0, 0, 0) + cr.paint() + + # Pulse 0..1 → soft scale/alpha modulation (quieter while processing). + if mode == MODE_PROCESSING: + pulse = 0.55 + 0.25 * math.sin(self._phase * 2.0 * math.pi) + alpha_scale = 0.75 + core_a = 0.8 + else: + pulse = 0.5 + 0.5 * math.sin(self._phase * 2.0 * math.pi) + alpha_scale = 1.0 + core_a = 0.95 + + # Outer glow rings. + for radius_scale, ring_a in ((0.95, 0.12), (0.72, 0.22), (0.52, 0.35)): + radius = (orb * 0.42) * radius_scale * (0.92 + 0.08 * pulse) + alpha = ring_a * (0.55 + 0.45 * pulse) * alpha_scale + cr.set_source_rgba(base_r, base_g, base_b, alpha) + cr.arc(cx, cy, radius, 0, 2 * math.pi) + cr.fill() + + # Expanding ripple (listening only). + if mode == MODE_LISTENING: + ripple_r = (orb * 0.28) + self._phase * (orb * 0.28) + ripple_a = max(0.0, 0.45 * (1.0 - self._phase)) + cr.set_source_rgba(base_r, base_g, base_b, ripple_a) + cr.set_line_width(2.0) + cr.arc(cx, cy, ripple_r, 0, 2 * math.pi) + cr.stroke() + + # Solid core. + core_r = orb * 0.16 * (0.95 + 0.05 * pulse) + cr.set_source_rgba(base_r, base_g, base_b, core_a) + cr.arc(cx, cy, core_r, 0, 2 * math.pi) + cr.fill() + + return False diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 449d010..6e50067 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -35,9 +35,6 @@ from gi.repository import Gdk, GLib, GObject, Gtk, Pango # noqa: E402 from ..common_types import RecognitionState, _EvdevCaptureDevice # noqa: E402 from ..speech_recognition.silero_vad import is_silero_available # noqa: E402 from ..utils import parakeet_model_info as parakeet # noqa: E402 -from ..utils.faster_whisper_model_info import ( - FASTER_WHISPER_MODEL_INFO, -) from ..utils.faster_whisper_model_info import delete_model as delete_faster_whisper_model from ..utils.faster_whisper_model_info import ( # noqa: E402 get_recommended_model as get_recommended_faster_whisper_model, @@ -81,10 +78,6 @@ from ..utils.whisper_model_info import ( # noqa: E402 whisper_model_file, ) from ..utils.whispercpp_model_info import MODEL_SIZES as WHISPERCPP_MODEL_SIZES -from ..utils.whispercpp_model_info import ( - WHISPERCPP_MODEL_INFO, - default_variant_for_size, -) from ..utils.whispercpp_model_info import delete_model as delete_whispercpp_model from ..utils.whispercpp_model_info import ( detect_compute_backend, @@ -121,6 +114,14 @@ from .keyboard_backends import ( # noqa: E402 ) from .keyboard_backends.evdev_backend import MODIFIER_KEY_CODES # noqa: E402 +from ..utils.faster_whisper_model_info import ( # isort:skip + FASTER_WHISPER_MODEL_INFO, +) +from ..utils.whispercpp_model_info import ( # isort:skip + WHISPERCPP_MODEL_INFO, + default_variant_for_size, +) + # Avoid circular imports for type checking if TYPE_CHECKING: from ..speech_recognition.recognition_manager import SpeechRecognitionManager # noqa: E402 @@ -2421,6 +2422,7 @@ class SettingsDialog(Gtk.Dialog): initial_page: Optional[str] = None, pending_update: Optional[ReleaseInfo] = None, update_status_callback: callable = None, + overlay_enabled_callback: Optional[Callable[[bool], None]] = None, ): super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0) # Force window decorations (title-bar close) on all WMs. An in-window @@ -2433,6 +2435,7 @@ class SettingsDialog(Gtk.Dialog): self.speech_engine = speech_engine self.shortcut_update_callback = shortcut_update_callback self.update_status_callback = update_status_callback + self.overlay_enabled_callback = overlay_enabled_callback self._test_active = False self._test_result = "" self._initializing = True # Flag to prevent auto-apply during initialization @@ -3033,11 +3036,23 @@ class SettingsDialog(Gtk.Dialog): ) group.add_row(missing_tray_warning_row) + self.show_overlay_switch = Gtk.Switch() + self.show_overlay_switch.set_tooltip_text( + "Show a floating glowing indicator on screen while the microphone is active" + ) + show_overlay_row = PreferenceRow( + title="Show Dictation Overlay", + subtitle="Floating on-screen glow when listening or processing", + widget=self.show_overlay_switch, + ) + group.add_row(show_overlay_row) + self.general_tab.pack_start(group, False, False, 0) self.autostart_switch.connect("state-set", self._on_autostart_toggled) self.start_minimized_switch.connect("state-set", self._on_start_minimized_toggled) self.missing_tray_warning_switch.connect("state-set", self._on_missing_tray_warning_toggled) + self.show_overlay_switch.connect("state-set", self._on_show_overlay_toggled) def _build_auto_pause_section(self): """Build Auto-Pause settings: enable toggle + process name list.""" @@ -3422,6 +3437,22 @@ class SettingsDialog(Gtk.Dialog): self.config_manager.save_settings() return False + def _on_show_overlay_toggled(self, widget: Gtk.Switch, state: bool) -> bool: + """Handle toggle of the floating dictation overlay switch.""" + if self._initializing or self._applying_settings: + return False + + enabled = bool(state) + self.config_manager.set_overlay_enabled(enabled) + self.config_manager.save_settings() + if self.overlay_enabled_callback is not None: + try: + self.overlay_enabled_callback(enabled) + except Exception as e: + logger.warning(f"Overlay enabled callback failed: {e}") + logger.info(f"Dictation overlay {'enabled' if enabled else 'disabled'}") + return False + def _on_copy_to_clipboard_toggled(self, widget, state): """Handle toggle of the copy to clipboard switch.""" if self._initializing or self._applying_settings: @@ -5567,6 +5598,7 @@ class SettingsDialog(Gtk.Dialog): autostart_enabled = general_settings.get("autostart", False) start_minimized = ui_settings.get("start_minimized", False) show_missing_tray_warning = ui_settings.get("show_missing_tray_warning", True) + show_overlay = ui_settings.get("show_overlay", True) copy_to_clipboard = text_injection_settings.get("copy_to_clipboard", False) auto_capitalize = text_injection_settings.get("auto_capitalize", True) append_trailing_space = text_injection_settings.get("append_trailing_space", True) @@ -5575,6 +5607,7 @@ class SettingsDialog(Gtk.Dialog): self.autostart_switch.set_active(autostart_enabled) self.start_minimized_switch.set_active(start_minimized) self.missing_tray_warning_switch.set_active(show_missing_tray_warning) + self.show_overlay_switch.set_active(show_overlay) self.copy_to_clipboard_switch.set_active(copy_to_clipboard) self.auto_capitalize_switch.set_active(auto_capitalize) self.append_trailing_space_switch.set_active(append_trailing_space) diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 21d6a81..c0a783a 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -175,6 +175,12 @@ class TrayIndicator: if hasattr(self.speech_engine, "set_model_missing_handler"): self.speech_engine.set_model_missing_handler(self._offer_recommended_model) + # Floating on-screen indicator (glow while listening); gated by config. + # Lazy import keeps tray import light for tests that mock gi. + from .dictation_overlay import DictationOverlay + + self.overlay = DictationOverlay(enabled=self.config_manager.is_overlay_enabled()) + # Initialize the icon files and validate resources self._init_icons() self._validate_resources() @@ -747,8 +753,28 @@ class TrayIndicator: self._set_menu_item_enabled("Start Voice Typing", True) self._set_menu_item_enabled("Stop Voice Typing", False) + # Keep floating overlay in sync with tray (same RecognitionState path). + self._update_overlay(state) + return False # Remove idle callback + def _update_overlay(self, state: RecognitionState): + """Show/hide the floating dictation overlay for the given state.""" + if getattr(self, "overlay", None) is None: + return + # Re-read config so Settings toggles apply without restart. + self.overlay.set_enabled(self.config_manager.is_overlay_enabled()) + self.overlay.on_recognition_state(state) + + def set_overlay_enabled(self, enabled: bool) -> None: + """Live-update overlay enabled state (called from Settings).""" + self.config_manager.set_overlay_enabled(enabled) + if getattr(self, "overlay", None) is None: + return + self.overlay.set_enabled(enabled) + # Re-apply current recognition state so hide/show is immediate. + self.overlay.on_recognition_state(self.speech_engine.state) + def _set_menu_item_enabled(self, label: str, enabled: bool): """ Set the enabled state of a menu item by its label. @@ -804,6 +830,7 @@ class TrayIndicator: update_status_callback=lambda available, release: self._apply_update_status( available, release, notify=False ), + overlay_enabled_callback=self.set_overlay_enabled, ) dialog.connect("response", self._on_settings_dialog_response) dialog.connect("destroy", self._on_settings_dialog_destroyed) @@ -1164,6 +1191,10 @@ class TrayIndicator: # Stop the keyboard shortcut manager self.shortcut_manager.stop() + if getattr(self, "overlay", None) is not None: + self.overlay.destroy() + self.overlay = None + # Stop the text injector (restores previous IBus engine) if hasattr(self, "text_injector") and self.text_injector is not None: self.text_injector.stop() diff --git a/tests/test_dictation_overlay.py b/tests/test_dictation_overlay.py new file mode 100644 index 0000000..ff45d2b --- /dev/null +++ b/tests/test_dictation_overlay.py @@ -0,0 +1,888 @@ +""" +Tests for the floating dictation overlay. + +Drives the shipped DictationOverlayController (and config helpers) so +LISTENING/PROCESSING show the cue and IDLE/ERROR/disabled hide it. + +Important: these tests must not import real GTK / tray_indicator into +sys.modules — that breaks later tests that mock gi (see CI isolation). +""" + +import importlib +import logging +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from vocalinux.common_types import RecognitionState +from vocalinux.ui.config_manager import DEFAULT_CONFIG, ConfigManager +from vocalinux.ui.dictation_overlay import ( + MODE_LISTENING, + MODE_PROCESSING, + DictationOverlay, + DictationOverlayController, +) + +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _ensure_test_config_dir(path: str): + parent_dir = os.path.dirname(path) + if not os.path.exists(parent_dir): + os.mkdir(parent_dir) + if not os.path.exists(path): + os.mkdir(path) + + +def _overlay_without_gtk(enabled: bool = True) -> DictationOverlay: + """ + Build DictationOverlay without initializing a real GTK window. + + Patches window construction so tests never load gi.repository (which + would pollute the process for later gi-mocked tests). + """ + with patch.object( + DictationOverlay, + "_init_gtk_window", + side_effect=RuntimeError("gtk disabled in unit tests"), + ): + return DictationOverlay(enabled=enabled) + + +class TestDictationOverlayController(unittest.TestCase): + """Unit tests for the pure overlay visibility controller.""" + + def test_default_hidden_when_idle(self): + ctrl = DictationOverlayController(enabled=True) + self.assertEqual(ctrl.state, RecognitionState.IDLE) + self.assertFalse(ctrl.visible) + self.assertIsNone(ctrl.mode) + + def test_listening_shows_listening_mode(self): + ctrl = DictationOverlayController(enabled=True) + ctrl.set_state(RecognitionState.LISTENING) + self.assertTrue(ctrl.visible) + self.assertEqual(ctrl.mode, MODE_LISTENING) + + def test_processing_shows_processing_mode(self): + ctrl = DictationOverlayController(enabled=True) + ctrl.set_state(RecognitionState.PROCESSING) + self.assertTrue(ctrl.visible) + self.assertEqual(ctrl.mode, MODE_PROCESSING) + + def test_idle_hides_after_listening(self): + ctrl = DictationOverlayController(enabled=True) + ctrl.set_state(RecognitionState.LISTENING) + self.assertTrue(ctrl.visible) + ctrl.set_state(RecognitionState.IDLE) + self.assertFalse(ctrl.visible) + self.assertIsNone(ctrl.mode) + + def test_error_hides_overlay(self): + ctrl = DictationOverlayController(enabled=True) + ctrl.set_state(RecognitionState.LISTENING) + ctrl.set_state(RecognitionState.ERROR) + self.assertFalse(ctrl.visible) + self.assertIsNone(ctrl.mode) + + def test_disabled_keeps_hidden_while_listening(self): + ctrl = DictationOverlayController(enabled=False) + ctrl.set_state(RecognitionState.LISTENING) + self.assertFalse(ctrl.visible) + self.assertIsNone(ctrl.mode) + + def test_disable_while_listening_hides(self): + ctrl = DictationOverlayController(enabled=True) + ctrl.set_state(RecognitionState.LISTENING) + self.assertTrue(ctrl.visible) + ctrl.set_enabled(False) + self.assertFalse(ctrl.visible) + self.assertIsNone(ctrl.mode) + + def test_enable_while_listening_shows(self): + ctrl = DictationOverlayController(enabled=False) + ctrl.set_state(RecognitionState.LISTENING) + self.assertFalse(ctrl.visible) + ctrl.set_enabled(True) + self.assertTrue(ctrl.visible) + self.assertEqual(ctrl.mode, MODE_LISTENING) + + def test_full_state_cycle_matches_tray_semantics(self): + """Same RecognitionState path as the tray: idle → listen → process → idle.""" + ctrl = DictationOverlayController(enabled=True) + transitions = [ + (RecognitionState.IDLE, False, None), + (RecognitionState.LISTENING, True, MODE_LISTENING), + (RecognitionState.PROCESSING, True, MODE_PROCESSING), + (RecognitionState.IDLE, False, None), + (RecognitionState.ERROR, False, None), + ] + for state, expect_visible, expect_mode in transitions: + ctrl.set_state(state) + self.assertEqual( + ctrl.visible, + expect_visible, + f"visible mismatch for {state}", + ) + self.assertEqual(ctrl.mode, expect_mode, f"mode mismatch for {state}") + + +class TestOverlayConfig(unittest.TestCase): + """ConfigManager helpers for the show_overlay preference.""" + + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.temp_config_dir = os.path.join(self.temp_dir.name, ".config/vocalinux") + _ensure_test_config_dir(self.temp_config_dir) + self.temp_config_file = os.path.join(self.temp_config_dir, "config.json") + + self.config_dir_patcher = patch( + "vocalinux.ui.config_manager.CONFIG_DIR", self.temp_config_dir + ) + self.config_file_patcher = patch( + "vocalinux.ui.config_manager.CONFIG_FILE", self.temp_config_file + ) + self.makedirs_patcher = patch( + "vocalinux.ui.config_manager.os.makedirs", + side_effect=lambda path, exist_ok=True: _ensure_test_config_dir(path), + ) + self.config_dir_patcher.start() + self.config_file_patcher.start() + self.makedirs_patcher.start() + _ensure_test_config_dir(self.temp_config_dir) + + def tearDown(self): + self.config_dir_patcher.stop() + self.config_file_patcher.stop() + self.makedirs_patcher.stop() + self.temp_dir.cleanup() + + def test_default_overlay_enabled(self): + self.assertTrue(DEFAULT_CONFIG["ui"]["show_overlay"]) + cm = ConfigManager() + self.assertTrue(cm.is_overlay_enabled()) + + def test_set_overlay_disabled_persists(self): + cm = ConfigManager() + cm.set_overlay_enabled(False) + self.assertFalse(cm.is_overlay_enabled()) + cm.save_config() + + cm2 = ConfigManager() + self.assertFalse(cm2.is_overlay_enabled()) + + def test_set_overlay_enabled_true(self): + cm = ConfigManager() + cm.set_overlay_enabled(False) + cm.set_overlay_enabled(True) + self.assertTrue(cm.is_overlay_enabled()) + + +class TestDictationOverlayFacade(unittest.TestCase): + """ + Drive the shipped DictationOverlay API (real controller path). + + Window construction is patched out so these tests never load GTK. + """ + + def test_on_recognition_state_drives_controller(self): + overlay = _overlay_without_gtk(enabled=True) + try: + overlay.on_recognition_state(RecognitionState.LISTENING) + self.assertTrue(overlay.controller.visible) + self.assertEqual(overlay.controller.mode, MODE_LISTENING) + + overlay.on_recognition_state(RecognitionState.PROCESSING) + self.assertTrue(overlay.controller.visible) + self.assertEqual(overlay.controller.mode, MODE_PROCESSING) + + overlay.on_recognition_state(RecognitionState.IDLE) + self.assertFalse(overlay.controller.visible) + + overlay.on_recognition_state(RecognitionState.LISTENING) + overlay.on_recognition_state(RecognitionState.ERROR) + self.assertFalse(overlay.controller.visible) + finally: + overlay.destroy() + + def test_disabled_overlay_stays_hidden_on_listening(self): + overlay = _overlay_without_gtk(enabled=False) + try: + overlay.on_recognition_state(RecognitionState.LISTENING) + self.assertFalse(overlay.controller.visible) + self.assertIsNone(overlay.controller.mode) + finally: + overlay.destroy() + + def test_set_enabled_false_hides_while_listening(self): + overlay = _overlay_without_gtk(enabled=True) + try: + overlay.on_recognition_state(RecognitionState.LISTENING) + self.assertTrue(overlay.controller.visible) + overlay.set_enabled(False) + self.assertFalse(overlay.controller.visible) + finally: + overlay.destroy() + + def test_gtk_init_failure_keeps_controller_usable(self): + """If the display/GTK is unavailable, controller still tracks state.""" + overlay = _overlay_without_gtk(enabled=True) + self.assertFalse(overlay._gtk_ready) + overlay.on_recognition_state(RecognitionState.LISTENING) + self.assertTrue(overlay.controller.visible) + overlay.destroy() + + +class TestOverlayWithMockedGtkWindow(unittest.TestCase): + """ + Exercise DictationOverlay sync path when a mock window is attached, + proving show/hide follows the real controller without a compositor. + """ + + def test_sync_shows_on_listening_hides_on_idle(self): + overlay = _overlay_without_gtk(enabled=True) + mock_window = MagicMock() + mock_window.get_visible.return_value = False + overlay._window = mock_window + overlay._gtk_ready = True + overlay._use_layer_shell = True # skip positioning + overlay._GLib = MagicMock() + overlay._GLib.timeout_add.return_value = 42 + overlay._drawing_area = MagicMock() + + try: + overlay.on_recognition_state(RecognitionState.LISTENING) + self.assertTrue(overlay.controller.visible) + mock_window.show_all.assert_called() + # Must not call present() — that steals keyboard focus. + mock_window.present.assert_not_called() + mock_window.set_opacity.assert_called_with(1.0) + + mock_window.get_visible.return_value = True + mock_window.set_opacity.reset_mock() + overlay.on_recognition_state(RecognitionState.IDLE) + self.assertFalse(overlay.controller.visible) + # Idle uses opacity 0 (not hide) so later show cycles do not refocus. + mock_window.set_opacity.assert_called_with(0.0) + mock_window.hide.assert_not_called() + finally: + overlay._window = None + overlay._anim_id = None + overlay.destroy() + + def test_disabled_does_not_show_window(self): + overlay = _overlay_without_gtk(enabled=False) + mock_window = MagicMock() + mock_window.get_visible.return_value = False + overlay._window = mock_window + overlay._gtk_ready = True + overlay._use_layer_shell = True + overlay._GLib = MagicMock() + + try: + overlay.on_recognition_state(RecognitionState.LISTENING) + mock_window.show_all.assert_not_called() + self.assertFalse(overlay.controller.visible) + finally: + overlay._window = None + overlay.destroy() + + def test_processing_mode_shows_and_animates(self): + overlay = _overlay_without_gtk(enabled=True) + mock_window = MagicMock() + mock_window.get_visible.return_value = False + overlay._window = mock_window + overlay._gtk_ready = True + overlay._use_layer_shell = True + overlay._GLib = MagicMock() + overlay._GLib.timeout_add.return_value = 7 + overlay._drawing_area = MagicMock() + + try: + overlay.on_recognition_state(RecognitionState.PROCESSING) + self.assertEqual(overlay.controller.mode, MODE_PROCESSING) + mock_window.show_all.assert_called() + overlay._GLib.timeout_add.assert_called() + self.assertEqual(overlay._anim_id, 7) + finally: + overlay._window = None + overlay._anim_id = None + overlay.destroy() + + def test_start_animation_is_idempotent(self): + overlay = _overlay_without_gtk(enabled=True) + overlay._gtk_ready = True + overlay._window = MagicMock() + overlay._window.get_visible.return_value = True + overlay._use_layer_shell = True + overlay._GLib = MagicMock() + overlay._GLib.timeout_add.return_value = 99 + overlay._drawing_area = MagicMock() + overlay._anim_id = 99 # already running + + overlay.controller.set_state(RecognitionState.LISTENING) + overlay._sync_window() + overlay._GLib.timeout_add.assert_not_called() + + def test_stop_animation_swallows_source_remove_errors(self): + overlay = _overlay_without_gtk(enabled=True) + overlay._GLib = MagicMock() + overlay._GLib.source_remove.side_effect = RuntimeError("bad id") + overlay._anim_id = 5 + overlay._stop_animation() + self.assertIsNone(overlay._anim_id) + + def test_destroy_swallows_window_destroy_errors(self): + overlay = _overlay_without_gtk(enabled=True) + mock_window = MagicMock() + mock_window.destroy.side_effect = RuntimeError("gone") + overlay._window = mock_window + overlay._GLib = MagicMock() + overlay.destroy() + self.assertIsNone(overlay._window) + + def test_layout_bottom_strip_full_width(self): + from vocalinux.ui.dictation_overlay import _BOTTOM_MARGIN, _STRIP_HEIGHT + + overlay = _overlay_without_gtk(enabled=True) + mock_window = MagicMock() + overlay._window = mock_window + overlay._drawing_area = MagicMock() + geom = MagicMock(x=100, y=50, width=1920, height=1080) + monitor = MagicMock() + monitor.get_geometry.return_value = geom + display = MagicMock() + display.get_primary_monitor.return_value = monitor + overlay._Gdk = MagicMock() + overlay._Gdk.Display.get_default.return_value = display + + overlay._layout_bottom_strip() + # Full-width strip along the bottom of the monitor. + mock_window.resize.assert_called_once_with(1920, _STRIP_HEIGHT) + mock_window.move.assert_called_once_with(100, 50 + 1080 - _STRIP_HEIGHT - _BOTTOM_MARGIN) + + def test_layout_falls_back_to_first_monitor(self): + overlay = _overlay_without_gtk(enabled=True) + mock_window = MagicMock() + overlay._window = mock_window + geom = MagicMock(x=0, y=0, width=800, height=600) + monitor = MagicMock() + monitor.get_geometry.return_value = geom + display = MagicMock() + display.get_primary_monitor.return_value = None + display.get_n_monitors.return_value = 1 + display.get_monitor.return_value = monitor + overlay._Gdk = MagicMock() + overlay._Gdk.Display.get_default.return_value = display + + overlay._layout_bottom_strip() + mock_window.move.assert_called_once() + mock_window.resize.assert_called_once() + + def test_layout_noop_without_display(self): + overlay = _overlay_without_gtk(enabled=True) + overlay._window = MagicMock() + overlay._Gdk = MagicMock() + overlay._Gdk.Display.get_default.return_value = None + overlay._layout_bottom_strip() + overlay._window.move.assert_not_called() + + def test_layout_swallows_errors(self): + overlay = _overlay_without_gtk(enabled=True) + overlay._window = MagicMock() + overlay._Gdk = MagicMock() + overlay._Gdk.Display.get_default.side_effect = RuntimeError("no gdk") + overlay._layout_bottom_strip() # must not raise + + def test_layout_skipped_when_no_window(self): + overlay = _overlay_without_gtk(enabled=True) + overlay._window = None + overlay._layout_bottom_strip() # must not raise + + def test_sync_layouts_when_layer_shell_unused(self): + overlay = _overlay_without_gtk(enabled=True) + overlay._window = MagicMock() + overlay._window.get_visible.return_value = True + overlay._gtk_ready = True + overlay._use_layer_shell = False + overlay._GLib = MagicMock() + overlay._GLib.timeout_add.return_value = 1 + overlay._drawing_area = MagicMock() + with patch.object(overlay, "_layout_bottom_strip") as mock_layout: + overlay.on_recognition_state(RecognitionState.LISTENING) + mock_layout.assert_called() + + def test_on_anim_tick_updates_phase_and_returns_visibility(self): + overlay = _overlay_without_gtk(enabled=True) + overlay._drawing_area = MagicMock() + overlay._phase = 0.0 + overlay.controller.set_state(RecognitionState.LISTENING) + result = overlay._on_anim_tick() + self.assertTrue(result) + self.assertGreater(overlay._phase, 0.0) + overlay._drawing_area.queue_draw.assert_called() + + overlay.controller.set_state(RecognitionState.IDLE) + self.assertFalse(overlay._on_anim_tick()) + + def test_on_realize_sets_click_through_region(self): + overlay = _overlay_without_gtk(enabled=True) + mock_gdk_win = MagicMock() + widget = MagicMock() + widget.get_window.return_value = mock_gdk_win + mock_region = MagicMock() + with patch.dict( + "sys.modules", {"cairo": MagicMock(Region=MagicMock(return_value=mock_region))} + ): + # Force a fresh cairo import inside the method + import sys + + sys.modules["cairo"].Region.return_value = mock_region + overlay._on_realize(widget) + mock_gdk_win.input_shape_combine_region.assert_called_once_with(mock_region, 0, 0) + + def test_on_realize_noop_without_gdk_window(self): + overlay = _overlay_without_gtk(enabled=True) + widget = MagicMock() + widget.get_window.return_value = None + with patch.dict("sys.modules", {"cairo": MagicMock()}): + overlay._on_realize(widget) # must not raise + + def test_on_realize_swallows_errors(self): + overlay = _overlay_without_gtk(enabled=True) + widget = MagicMock() + widget.get_window.side_effect = RuntimeError("no surface") + overlay._on_realize(widget) + + def test_on_size_allocate_reapplies_shape_when_realized(self): + overlay = _overlay_without_gtk(enabled=True) + widget = MagicMock() + widget.get_realized.return_value = True + with patch.object(overlay, "_on_realize") as mock_realize: + overlay._on_size_allocate(widget, MagicMock()) + mock_realize.assert_called_once_with(widget) + + def test_on_size_allocate_skips_when_not_realized(self): + overlay = _overlay_without_gtk(enabled=True) + widget = MagicMock() + widget.get_realized.return_value = False + with patch.object(overlay, "_on_realize") as mock_realize: + overlay._on_size_allocate(widget, MagicMock()) + mock_realize.assert_not_called() + + def test_on_draw_hidden_mode_returns_false(self): + overlay = _overlay_without_gtk(enabled=True) + widget = MagicMock() + cr = MagicMock() + self.assertFalse(overlay._on_draw(widget, cr)) + + def test_on_draw_listening_paints_rings_and_ripple(self): + overlay = _overlay_without_gtk(enabled=True) + overlay.controller.set_state(RecognitionState.LISTENING) + overlay._phase = 0.25 + widget = MagicMock() + widget.get_allocated_width.return_value = 96 + widget.get_allocated_height.return_value = 96 + cr = MagicMock() + self.assertFalse(overlay._on_draw(widget, cr)) + self.assertTrue(cr.arc.called) + self.assertTrue(cr.fill.called) + self.assertTrue(cr.stroke.called) # ripple ring + + def test_on_draw_processing_uses_muted_pulse(self): + overlay = _overlay_without_gtk(enabled=True) + overlay.controller.set_state(RecognitionState.PROCESSING) + overlay._phase = 0.5 + widget = MagicMock() + widget.get_allocated_width.return_value = 96 + widget.get_allocated_height.return_value = 96 + cr = MagicMock() + self.assertFalse(overlay._on_draw(widget, cr)) + self.assertTrue(cr.fill.called) + + def test_on_draw_works_when_cairo_import_fails(self): + """Drawing still works if the cairo module is unavailable.""" + overlay = _overlay_without_gtk(enabled=True) + overlay.controller.set_state(RecognitionState.LISTENING) + overlay._phase = 0.1 + widget = MagicMock() + widget.get_allocated_width.return_value = 96 + widget.get_allocated_height.return_value = 96 + cr = MagicMock() + + import builtins + + real_import = builtins.__import__ + + def blocked_import(name, *args, **kwargs): + if name == "cairo" or name.startswith("cairo."): + raise ImportError("no cairo") + return real_import(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=blocked_import): + self.assertFalse(overlay._on_draw(widget, cr)) + self.assertTrue(cr.paint.called) + self.assertTrue(cr.fill.called) + + def test_sync_without_drawing_area_still_starts_animation(self): + overlay = _overlay_without_gtk(enabled=True) + overlay._window = MagicMock() + overlay._window.get_visible.return_value = True + overlay._gtk_ready = True + overlay._use_layer_shell = True + overlay._GLib = MagicMock() + overlay._GLib.timeout_add.return_value = 3 + overlay._drawing_area = None + overlay.on_recognition_state(RecognitionState.LISTENING) + overlay._GLib.timeout_add.assert_called() + + def test_anim_tick_without_drawing_area(self): + overlay = _overlay_without_gtk(enabled=True) + overlay._drawing_area = None + overlay.controller.set_state(RecognitionState.LISTENING) + self.assertTrue(overlay._on_anim_tick()) + + def test_layout_when_no_monitors(self): + overlay = _overlay_without_gtk(enabled=True) + overlay._window = MagicMock() + display = MagicMock() + display.get_primary_monitor.return_value = None + display.get_n_monitors.return_value = 0 + overlay._Gdk = MagicMock() + overlay._Gdk.Display.get_default.return_value = display + overlay._layout_bottom_strip() + overlay._window.move.assert_not_called() + + def test_controller_enabled_property(self): + ctrl = DictationOverlayController(enabled=True) + self.assertTrue(ctrl.enabled) + ctrl.set_enabled(False) + self.assertFalse(ctrl.enabled) + + +class _FakeLayerShell: + """Minimal layer-shell stand-in so hasattr() works like real GI.""" + + class Layer: + OVERLAY = "OVERLAY" + + class Edge: + BOTTOM = "BOTTOM" + LEFT = "LEFT" + RIGHT = "RIGHT" + + class KeyboardMode: + NONE = "NONE" + + def __init__(self, *, supported=True, fail_init=False, keyboard_mode=True): + self._supported = supported + self._fail_init = fail_init + self._keyboard_mode = keyboard_mode + self.init_for_window = MagicMock(side_effect=RuntimeError("nope") if fail_init else None) + self.set_layer = MagicMock() + self.set_anchor = MagicMock() + self.set_margin = MagicMock() + self.set_exclusive_zone = MagicMock() + if keyboard_mode: + self.set_keyboard_mode = MagicMock() + else: + self.set_keyboard_interactivity = MagicMock() + + def is_supported(self): + return self._supported + + +class TestTryImportLayerShell(unittest.TestCase): + """Cover soft GtkLayerShell import without requiring the package.""" + + def test_returns_none_when_require_version_fails(self): + from vocalinux.ui.dictation_overlay import _try_import_layer_shell + + mock_gi = MagicMock() + mock_gi.require_version.side_effect = ValueError("no namespace") + with patch.dict("sys.modules", {"gi": mock_gi, "gi.repository": MagicMock()}): + self.assertIsNone(_try_import_layer_shell()) + + def test_returns_module_when_available(self): + from vocalinux.ui.dictation_overlay import _try_import_layer_shell + + fake_shell = object() + mock_gi = MagicMock() + mock_repo = MagicMock() + mock_repo.GtkLayerShell = fake_shell + with patch.dict("sys.modules", {"gi": mock_gi, "gi.repository": mock_repo}): + result = _try_import_layer_shell() + self.assertIs(result, fake_shell) + + +class TestInitGtkWindowWithMocks(unittest.TestCase): + """ + Drive the real _init_gtk_window path with fully mocked GI modules. + Restores sys.modules afterwards so other tests stay isolated. + """ + + def setUp(self): + import sys + + self._sys = sys + self._saved = {k: sys.modules.get(k) for k in ("gi", "gi.repository", "cairo")} + + def tearDown(self): + import sys + + for key, val in self._saved.items(): + if val is None: + sys.modules.pop(key, None) + else: + sys.modules[key] = val + + def _install_gi_mocks(self): + import sys + + mock_gi = MagicMock() + mock_gtk = MagicMock() + mock_gdk = MagicMock() + mock_glib = MagicMock() + mock_window = MagicMock() + mock_drawing = MagicMock() + mock_screen = MagicMock() + mock_visual = MagicMock() + + mock_gtk.Window.return_value = mock_window + mock_gtk.DrawingArea.return_value = mock_drawing + mock_gtk.WindowType.TOPLEVEL = "TOPLEVEL" + mock_gdk.WindowTypeHint.NOTIFICATION = "NOTIFICATION" + mock_window.get_screen.return_value = mock_screen + mock_screen.get_rgba_visual.return_value = mock_visual + + mock_repo = MagicMock() + mock_repo.Gtk = mock_gtk + mock_repo.Gdk = mock_gdk + mock_repo.GLib = mock_glib + + sys.modules["gi"] = mock_gi + sys.modules["gi.repository"] = mock_repo + + return { + "gi": mock_gi, + "gtk": mock_gtk, + "gdk": mock_gdk, + "glib": mock_glib, + "window": mock_window, + "drawing": mock_drawing, + "screen": mock_screen, + "visual": mock_visual, + "repo": mock_repo, + } + + def test_init_sets_passive_window_flags(self): + mocks = self._install_gi_mocks() + with patch("vocalinux.ui.dictation_overlay._try_import_layer_shell", return_value=None): + overlay = DictationOverlay(enabled=True) + + self.assertTrue(overlay._gtk_ready) + win = mocks["window"] + win.set_decorated.assert_called_with(False) + win.set_keep_above.assert_called_with(True) + win.set_accept_focus.assert_called_with(False) + win.set_can_focus.assert_called_with(False) + win.set_focus_on_map.assert_called_with(False) + win.set_skip_taskbar_hint.assert_called_with(True) + win.set_skip_pager_hint.assert_called_with(True) + win.set_app_paintable.assert_called_with(True) + win.set_visual.assert_called_with(mocks["visual"]) + win.add.assert_called_with(mocks["drawing"]) + mocks["drawing"].connect.assert_any_call("draw", overlay._on_draw) + win.connect.assert_any_call("realize", overlay._on_realize) + self.assertFalse(overlay._use_layer_shell) + overlay.destroy() + + def test_init_with_layer_shell_keyboard_mode(self): + shell = _FakeLayerShell(keyboard_mode=True) + self._install_gi_mocks() + with patch("vocalinux.ui.dictation_overlay._try_import_layer_shell", return_value=shell): + overlay = DictationOverlay(enabled=True) + + self.assertTrue(overlay._use_layer_shell) + shell.init_for_window.assert_called_once() + shell.set_layer.assert_called_once() + # Bottom + left + right anchors → full-width strip, orb drawn at center. + self.assertGreaterEqual(shell.set_anchor.call_count, 3) + shell.set_keyboard_mode.assert_called_once() + overlay.destroy() + + def test_init_with_layer_shell_keyboard_interactivity_fallback(self): + shell = _FakeLayerShell(keyboard_mode=False) + self._install_gi_mocks() + with patch("vocalinux.ui.dictation_overlay._try_import_layer_shell", return_value=shell): + overlay = DictationOverlay(enabled=True) + + self.assertTrue(overlay._use_layer_shell) + shell.set_keyboard_interactivity.assert_called_once_with(overlay._window, False) + overlay.destroy() + + def test_init_layer_shell_setup_failure_falls_back(self): + shell = _FakeLayerShell(fail_init=True) + self._install_gi_mocks() + with patch("vocalinux.ui.dictation_overlay._try_import_layer_shell", return_value=shell): + overlay = DictationOverlay(enabled=True) + + self.assertTrue(overlay._gtk_ready) + self.assertFalse(overlay._use_layer_shell) + overlay.destroy() + + def test_init_without_rgba_visual(self): + mocks = self._install_gi_mocks() + mocks["screen"].get_rgba_visual.return_value = None + with patch("vocalinux.ui.dictation_overlay._try_import_layer_shell", return_value=None): + overlay = DictationOverlay(enabled=True) + + mocks["window"].set_visual.assert_not_called() + self.assertTrue(overlay._gtk_ready) + overlay.destroy() + + def test_layer_shell_not_supported(self): + shell = _FakeLayerShell(supported=False) + self._install_gi_mocks() + with patch("vocalinux.ui.dictation_overlay._try_import_layer_shell", return_value=shell): + overlay = DictationOverlay(enabled=True) + self.assertFalse(overlay._use_layer_shell) + shell.init_for_window.assert_not_called() + overlay.destroy() + + +def _load_settings_method(method_name: str): + """ + Load a SettingsDialog method with the module's real globals. + + conftest replaces gi.repository with a MagicMock, so Gtk's widget classes + are not subclassable and the module cannot be imported as-is. Swapping in + real (empty) classes for just the bases it subclasses lets the import run + the same way CI executes the method — with every module-level name in + scope — then the imported module is dropped again so later tests still see + only the conftest mocks. + """ + repository = sys.modules["gi.repository"] + previous_gtk = repository.Gtk + gtk_stub = MagicMock() + gtk_stub.Box = type("Box", (), {}) + gtk_stub.ListBoxRow = type("ListBoxRow", (), {}) + gtk_stub.Dialog = type("Dialog", (), {}) + repository.Gtk = gtk_stub + try: + module = importlib.import_module("vocalinux.ui.settings_dialog") + finally: + repository.Gtk = previous_gtk + sys.modules.pop("vocalinux.ui.settings_dialog", None) + return getattr(module.SettingsDialog, method_name) + + +# Loaded once at import time so it is not turned into a bound method on the +# test class (which would inject the TestCase as an extra argument). +_SETTINGS_OVERLAY_TOGGLE = _load_settings_method("_on_show_overlay_toggled") + + +class TestShowOverlaySettingsToggle(unittest.TestCase): + """Cover SettingsDialog._on_show_overlay_toggled (shipped method body).""" + + def _make_dialog(self, callback=None): + dialog = MagicMock() + dialog._initializing = False + dialog._applying_settings = False + dialog.config_manager = MagicMock() + dialog.overlay_enabled_callback = callback + return dialog + + def test_toggle_saves_config_and_invokes_callback(self): + callback = MagicMock() + dialog = self._make_dialog(callback=callback) + + result = _SETTINGS_OVERLAY_TOGGLE(dialog, None, True) + + dialog.config_manager.set_overlay_enabled.assert_called_once_with(True) + dialog.config_manager.save_settings.assert_called_once() + callback.assert_called_once_with(True) + self.assertFalse(result) + + def test_toggle_disable_without_callback(self): + dialog = self._make_dialog(callback=None) + + result = _SETTINGS_OVERLAY_TOGGLE(dialog, None, False) + + dialog.config_manager.set_overlay_enabled.assert_called_once_with(False) + dialog.config_manager.save_settings.assert_called_once() + self.assertFalse(result) + + def test_toggle_ignored_while_initializing(self): + dialog = self._make_dialog(callback=MagicMock()) + dialog._initializing = True + + result = _SETTINGS_OVERLAY_TOGGLE(dialog, None, True) + + dialog.config_manager.set_overlay_enabled.assert_not_called() + self.assertFalse(result) + + def test_toggle_ignored_while_applying(self): + dialog = self._make_dialog(callback=MagicMock()) + dialog._applying_settings = True + + result = _SETTINGS_OVERLAY_TOGGLE(dialog, None, True) + + dialog.config_manager.set_overlay_enabled.assert_not_called() + self.assertFalse(result) + + def test_toggle_swallows_callback_errors(self): + callback = MagicMock(side_effect=RuntimeError("overlay boom")) + dialog = self._make_dialog(callback=callback) + + result = _SETTINGS_OVERLAY_TOGGLE(dialog, None, True) + self.assertFalse(result) + callback.assert_called_once_with(True) + + +class TestOverlayConfigMissingUiSection(unittest.TestCase): + """Cover set_overlay_enabled when the ui section is absent.""" + + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.temp_config_dir = os.path.join(self.temp_dir.name, ".config/vocalinux") + _ensure_test_config_dir(self.temp_config_dir) + self.temp_config_file = os.path.join(self.temp_config_dir, "config.json") + self.config_dir_patcher = patch( + "vocalinux.ui.config_manager.CONFIG_DIR", self.temp_config_dir + ) + self.config_file_patcher = patch( + "vocalinux.ui.config_manager.CONFIG_FILE", self.temp_config_file + ) + self.makedirs_patcher = patch( + "vocalinux.ui.config_manager.os.makedirs", + side_effect=lambda path, exist_ok=True: _ensure_test_config_dir(path), + ) + self.config_dir_patcher.start() + self.config_file_patcher.start() + self.makedirs_patcher.start() + + def tearDown(self): + self.config_dir_patcher.stop() + self.config_file_patcher.stop() + self.makedirs_patcher.stop() + self.temp_dir.cleanup() + + def test_set_overlay_creates_ui_section(self): + cm = ConfigManager() + del cm.config["ui"] + cm.set_overlay_enabled(False) + self.assertIn("ui", cm.config) + self.assertFalse(cm.is_overlay_enabled()) + + def test_is_overlay_enabled_defaults_true_without_key(self): + cm = ConfigManager() + cm.config["ui"] = {} + self.assertTrue(cm.is_overlay_enabled()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index d56fd0a..d5a30b9 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -885,6 +885,103 @@ class TestTrayIndicator(unittest.TestCase): self.tray_indicator._set_menu_item_enabled("Start Voice Typing", True) + def test_update_overlay_reads_config_and_forwards_state(self): + """_update_overlay re-reads config and drives the floating overlay.""" + from vocalinux.ui.tray_indicator import TrayIndicator + + mock_overlay = MagicMock() + mock_cm = MagicMock() + mock_cm.is_overlay_enabled.return_value = True + self.tray_indicator.overlay = mock_overlay + self.tray_indicator.config_manager = mock_cm + + TrayIndicator._update_overlay(self.tray_indicator, self.RecognitionState.LISTENING) + + mock_overlay.set_enabled.assert_called_once_with(True) + mock_overlay.on_recognition_state.assert_called_once_with(self.RecognitionState.LISTENING) + + def test_update_overlay_noop_when_overlay_missing(self): + from vocalinux.ui.tray_indicator import TrayIndicator + + self.tray_indicator.overlay = None + self.tray_indicator.config_manager = MagicMock() + # Must not raise + TrayIndicator._update_overlay(self.tray_indicator, self.RecognitionState.LISTENING) + + def test_set_overlay_enabled_updates_config_and_live_overlay(self): + from vocalinux.ui.tray_indicator import TrayIndicator + + mock_overlay = MagicMock() + mock_cm = MagicMock() + self.tray_indicator.overlay = mock_overlay + self.tray_indicator.config_manager = mock_cm + self.mock_speech_engine.state = self.RecognitionState.LISTENING + + TrayIndicator.set_overlay_enabled(self.tray_indicator, False) + + mock_cm.set_overlay_enabled.assert_called_once_with(False) + mock_overlay.set_enabled.assert_called_once_with(False) + mock_overlay.on_recognition_state.assert_called_once_with(self.RecognitionState.LISTENING) + + def test_set_overlay_enabled_without_overlay_still_saves_config(self): + from vocalinux.ui.tray_indicator import TrayIndicator + + mock_cm = MagicMock() + self.tray_indicator.overlay = None + self.tray_indicator.config_manager = mock_cm + + TrayIndicator.set_overlay_enabled(self.tray_indicator, True) + mock_cm.set_overlay_enabled.assert_called_once_with(True) + + def test_update_ui_forwards_state_to_overlay(self): + """_update_ui keeps the floating overlay in sync with tray icon state.""" + self.tray_indicator.indicator = MagicMock() + self.tray_indicator.icon_names = { + "default": "off", + "active": "on", + "processing": "proc", + } + with patch.object(self.tray_indicator, "_set_menu_item_enabled"): + with patch.object(self.tray_indicator, "_update_overlay") as mock_overlay: + for state in ( + self.RecognitionState.IDLE, + self.RecognitionState.LISTENING, + self.RecognitionState.PROCESSING, + self.RecognitionState.ERROR, + ): + mock_overlay.reset_mock() + # _update_ui prefers the engine's live state over the hint. + self.mock_speech_engine.state = state + self.tray_indicator._update_ui(state) + mock_overlay.assert_called_once_with(state) + + def test_settings_dialog_receives_overlay_enabled_callback(self): + """Settings dialog is wired with the live overlay toggle callback.""" + import vocalinux.ui.tray_indicator as tray_module + + mock_dialog_instance = MagicMock() + mock_dialog_class = MagicMock(return_value=mock_dialog_instance) + + with patch.object(tray_module, "SettingsDialog", mock_dialog_class): + self.tray_indicator._on_settings_clicked(None) + kwargs = mock_dialog_class.call_args.kwargs + self.assertIn("overlay_enabled_callback", kwargs) + self.assertEqual( + kwargs["overlay_enabled_callback"], + self.tray_indicator.set_overlay_enabled, + ) + + def test_quit_destroys_overlay(self): + mock_overlay = MagicMock() + self.tray_indicator.overlay = mock_overlay + self.tray_indicator._suspend_handler = None + with patch.object(self.tray_indicator, "_cleanup_input_monitor"): + with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk: + self.tray_indicator._quit() + mock_overlay.destroy.assert_called_once() + self.assertIsNone(self.tray_indicator.overlay) + patched_gtk.main_quit.assert_called_once() + class TestTrayIndicatorFlatpakIcons(unittest.TestCase): """Tray icon names must adapt to the Flatpak runtime. diff --git a/web/public/screenshots/06-dictation-overlay-listening.png b/web/public/screenshots/06-dictation-overlay-listening.png new file mode 100644 index 0000000000000000000000000000000000000000..9d6e7614a37b40caa12c0a4d0aea43c24701806a GIT binary patch literal 4706 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 Hu0mjflnK{R literal 0 HcmV?d00001