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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" "$@"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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 |
Reference in New Issue
Block a user