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
+
+
+ 
+ Floating overlay while listening (green)
+ |
+
+ 
+ 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 0000000..9d6e761
Binary files /dev/null and b/web/public/screenshots/06-dictation-overlay-listening.png differ
diff --git a/web/public/screenshots/07-dictation-overlay-processing.png b/web/public/screenshots/07-dictation-overlay-processing.png
new file mode 100644
index 0000000..38050ea
Binary files /dev/null and b/web/public/screenshots/07-dictation-overlay-processing.png differ