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
This commit is contained in:
Jatin K Malik
2026-09-29 14:24:31 -07:00
committed by GitHub
parent eef2554f7c
commit c2654609ac
11 changed files with 1563 additions and 10 deletions
+10
View File
@@ -106,6 +106,16 @@ Vocalinux in action. Full gallery on the [website screenshots page](https://voca
<em>Advanced</em>
</td>
</tr>
<tr>
<td align="center">
<img src="web/public/screenshots/06-dictation-overlay-listening.png" alt="Dictation overlay listening" width="96"><br>
<em>Floating overlay while listening (green)</em>
</td>
<td align="center">
<img src="web/public/screenshots/07-dictation-overlay-processing.png" alt="Dictation overlay processing" width="96"><br>
<em>Floating overlay while processing (amber)</em>
</td>
</tr>
</table>
## Install
+18 -3
View File
@@ -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
+1
View File
@@ -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" "$@"
+9
View File
@@ -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.
+469
View File
@@ -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
+40 -7
View File
@@ -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)
+31
View File
@@ -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()
+888
View File
@@ -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()
+97
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB