feat(tray): add recent dictation snippets history menu 🤖🤖🤖 (#487)

* feat(tray): add recent dictation snippets history menu

Add a "Recent Snippets" submenu to the tray indicator that lists recent
dictation sessions. Clicking a snippet copies its full text to the
clipboard for easy re-pasting; a "Clear History" entry empties the list.

Snippets are recorded on recognition, independent of whether text
injection succeeds, so text can be recovered even when injection silently
no-ops (e.g. on some Wayland compositors). One snippet corresponds to one
dictation session (everything said between start and stop); voice commands
are not recorded since they take a separate callback path.

History is kept in memory only (newest-first, capped, never written to
disk) and clears on quit — a deliberate privacy choice for a tool that
sees everything dictated.

Adds a "history" config section (enabled, max_items) and matching
Settings UI under General. Includes unit tests for the new
TranscriptionHistory store.

* fix(history): sanitize saved max_items to keep startup robust

A hand-edited config.json can store a non-numeric or out-of-range
history.max_items. Coerce the value on load (and in TranscriptionHistory
itself) with a positive-int fallback so a malformed preference warns and
falls back to the default instead of aborting startup or crashing the
Settings dialog spin button.

* fix(history): fold late-arriving segments into their originating session

stop_recognition() only waits on the recognition worker for a bounded
time before reporting IDLE, so a slow final segment can still fire its
text callback afterwards — landing in the next session's pending
snippet, or starting a phantom entry of its own.

Track the open session (state transitions, its worker thread, and its
pending segments) under a lock and file each segment by origin: while a
session is open only its own worker's segments accumulate into it; a
segment arriving after finalization extends that session's committed
snippet via the new TranscriptionHistory.extend_latest(), and a segment
from a session that produced nothing forms its own entry instead of
polluting the next one. ERROR now also finalizes the session, so a
partial result is still recorded and the tracker never wedges open.

Covered by new tests driving the callback and state wiring directly:
mid-session LISTENING/PROCESSING transitions, back-to-back sessions,
late callbacks from a lingering worker thread, disabled history, and
invalid saved limits.

* style(ui): type the new history handlers in tray and settings

* fix(history): keep cleared snippets from reappearing via late segments

The recognition worker can still emit a final segment after the manager's
bounded stop wait reported IDLE. Once the user cleared the history, that
late text could not extend the now-empty history and was appended as a
new entry — resurrecting snippets the user explicitly removed.

Give TranscriptionHistory a clear epoch: clear() (and disabling, which
clears) bumps it even when the store is already empty. add() and
extend_latest() accept an expected_epoch and refuse stale epochs
atomically under the store lock, so the check cannot race the clear.

main.py tracks the epoch each session opened under and the epoch of the
most recently ended session. Session commits and every late-segment
write are guarded by them, so text produced before a clear — whether a
session's whole snippet, its straggler segments, or leftovers committed
on the next session's start — cannot re-enter history afterwards.

* test(history): cover clear-epoch rejection and type new signatures

Add regression tests for the cleared-history race: a late segment after
clear() must not reappear, a session ending after a mid-session clear
leaves no snippet, and add/extend_latest refuse a stale expected_epoch
while unguarded writes keep working.

Annotate the new signatures per the repo guide: _boot's extra_settings
and return tuple, every test in the new TestSessionHistoryRecording and
history-menu tray classes, and all methods of the history store tests.

* fix(main): keep post-clear dictation by rebinding the session epoch

* fix(history): drop the boundary segment when a clear lands mid-session

* fix: scope dictation history to post-clear audio via capture-time stamps

The engine now tags each queued audio segment with the monotonic time
capture began and reports it to a new segment_callbacks hook. History
recording drops segments whose capture started before the last clear,
so pre-clear audio that decodes late can no longer resurrect cleared
entries or leak into a later session, while text dictated after a
mid-session clear is kept instead of being discarded by a stale epoch
check.

* fix(settings): keep Test Dictation speech out of transcription history

The mic test swaps text callbacks for its preview, but history recording
runs on segment callbacks, so test speech still landed in Recent
Snippets. The test now saves and clears the segment callback list
alongside the text list — restoring both on success and on the
start-failure path. register/unregister_segment_callback also gain the
missing return annotations and the engine gets symmetric
get/set_segment_callbacks accessors.

* fix(settings): floor test-dictation speech instead of swapping callbacks

Instead of clearing the segment callback list during the mic test —
which would have dropped leftovers from a still-finishing dictation —
the engine exposes test_capture_floor: segments captured at or after
the stamp are test speech and stay out of Recent Snippets, while
earlier-session leftovers continue to file normally.

* refactor(history): narrow change-callback exception surface

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
LuigiKraken
2026-09-30 00:06:51 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent f36c3f6ae5
commit b71164a214
10 changed files with 1201 additions and 17 deletions
+4
View File
@@ -72,6 +72,10 @@ class SpeechRecognitionManagerProtocol(Protocol):
"""Register a callback for recognized text."""
...
def register_segment_callback(self, callback: Callable[[str, float], None]) -> None:
"""Register a callback for recognized text with its audio-capture start time."""
...
class TextInjectorProtocol(Protocol):
"""Protocol defining the interface for TextInjector."""
+185 -13
View File
@@ -7,6 +7,7 @@ import argparse
import atexit
import logging
import sys
import threading
from typing import Optional
from .utils.vosk_model_info import SUPPORTED_LANGUAGES
@@ -388,6 +389,11 @@ def main():
from .ui.action_handler import ActionHandler
from .ui.config_manager import get_shared_config_manager
from .ui.logging_manager import initialize_logging
from .ui.transcription_history import (
DEFAULT_MAX_ITEMS,
TranscriptionHistory,
sanitize_max_items,
)
# Initialize logging manager early
initialize_logging()
@@ -483,6 +489,12 @@ def main():
advanced_settings = config_manager.get_settings().get("advanced", {})
history_settings = config_manager.get_settings().get("history", {})
history_enabled = bool(history_settings.get("enabled", True))
# A hand-edited config.json can hold a non-numeric or out-of-range value;
# sanitize on load so a bad preference cannot abort startup.
history_max_items = sanitize_max_items(history_settings.get("max_items", DEFAULT_MAX_ITEMS))
logger.info(f"Final settings: engine={engine}, language={language}, model={model_size}")
if audio_device_index is not None:
logger.info(
@@ -530,10 +542,49 @@ def main():
# Initialize action handler
action_handler = ActionHandler(text_system)
# Transcription history: an in-memory, newest-first list of recent
# dictation snippets surfaced in the tray menu. One snippet == one
# dictation session (everything said between start and stop).
transcription_history = TranscriptionHistory(
max_items=history_max_items, enabled=history_enabled
)
# Segments dictated during the open session, joined and committed to
# history when the session ends (state returns to IDLE). Each entry
# keeps the monotonic time its audio capture began: a history.clear()
# lands mid-decode, so capture time — not arrival order — decides
# whether a segment counts as pre-clear speech (dropped) or new
# dictation (kept).
#
# Session association: stop_recognition() emits IDLE after only a
# bounded wait on the recognition worker, so a slow final segment can
# still fire its text callback afterwards. Each segment must land in
# the session that produced it: while a session is open, segments
# accumulate in session_segments; a segment arriving on a worker that
# is not the open session's worker (or while no session is open) is a
# leftover of the just-ended session and is folded into its snippet
# instead of leaking into the next one.
session_lock = threading.Lock()
session_segments: list[tuple[str, float]] = []
session_open = False
# Worker thread that produced the open session's segments; used to
# detect callbacks from a previous session's still-running worker.
session_worker: Optional[threading.Thread] = None
# True when the newest history entry is the just-closed session's
# snippet, so late segments can still merge into it.
latest_snippet_extendable = False
# Clear epoch the most-recently-ended session was committed under;
# late segments merging into its snippet are judged against it.
ended_session_epoch = transcription_history.epoch
# --- Callback wiring ---------------------------------------------------
# The speech engine emits three kinds of events, each handled by a
# The speech engine emits four kinds of events, each handled by a
# dedicated callback registered below:
#
# segment_callback(text: str, started_at: float)
# Fires for each finalised segment with the monotonic time its
# audio capture began, just before the text callbacks. Records
# the segment into the transcription history.
#
# text_callback(text: str)
# Called on the recognition thread when a transcription segment
# is finalised. The wrapper below strips whitespace, optionally
@@ -550,6 +601,82 @@ def main():
# listening session ends.
# ------------------------------------------------------------------
def _normalize_segment_text(text: str) -> str:
"""Segment text as it is injected: stripped, auto-capitalized (Vosk)."""
normalized = text.lstrip().rstrip(" \t")
# Auto-capitalize sentences if enabled (Vosk only - Whisper outputs proper casing)
if (
normalized
and config_manager.get("text_injection", "auto_capitalize")
and speech_engine.engine == "vosk"
):
from vocalinux.speech_recognition.command_processor import capitalize_sentences
normalized = capitalize_sentences(normalized)
return normalized
def record_history_segment(segment: str, started_at: float) -> None:
"""File a recognized segment under the dictation session it came from.
Runs on the recognition worker thread as a segment callback, so
``started_at`` is the moment the segment's audio capture began.
While a session is open, segments accumulate into that session's
pending snippet; a segment delivered after the session was
finalized — the worker can outlive the manager's bounded stop
wait and emit text after IDLE — is folded into its own session's
snippet rather than the next one.
Segments whose capture began before the most recent
history.clear() are refused: their audio can only contain
pre-clear speech, however late the decode finishes. Segments
captured after the clear are kept, so dictation that continues
across a clear is still recoverable.
"""
nonlocal session_worker, latest_snippet_extendable
if not transcription_history.enabled:
return
segment = _normalize_segment_text(segment)
if not segment:
return
worker = threading.current_thread()
# The engine's live worker, when it exposes one: a segment from
# any other thread is a leftover from an older session.
current_worker = getattr(speech_engine, "recognition_thread", None)
with session_lock:
if started_at <= transcription_history.cleared_at:
# Captured before the last clear — must not re-enter.
return
# Segments captured while the Settings mic test runs are test
# speech, not dictation; the floor is stamped before the test
# starts recognition, so leftovers from an earlier session —
# whose capture began before it — still file normally.
test_floor = getattr(speech_engine, "test_capture_floor", None)
if isinstance(test_floor, (int, float)) and started_at >= test_floor:
return
if session_open and (
worker is session_worker
or worker is current_worker
# When the engine exposes no worker (tests, mocks), the
# first segment of a session tags it.
or (session_worker is None and not isinstance(current_worker, threading.Thread))
):
session_worker = worker
session_segments.append((segment, started_at))
return
# Late segment from a session that already ended: merge into
# its committed snippet when there is one. Both writes are
# guarded by the ended session's epoch, so a clear() landing
# between that session's commit and this delivery still
# refuses the text.
if latest_snippet_extendable and transcription_history.extend_latest(
segment, expected_epoch=ended_session_epoch
):
return
# Otherwise the late segments are the session's only output
# and form their own snippet.
if transcription_history.add(segment, expected_epoch=ended_session_epoch):
latest_snippet_extendable = True
def text_callback_wrapper(text: str) -> None:
"""Bridge between speech engine text events and the text injector.
@@ -564,17 +691,10 @@ def main():
"""
# Preserve trailing newlines ("new line" / "new paragraph"); only
# strip spaces/tabs that whisper sometimes wraps around tokens.
text_to_inject = text.lstrip().rstrip(" \t")
text_to_inject = _normalize_segment_text(text)
if not text_to_inject:
return
# Auto-capitalize sentences if enabled (Vosk only - Whisper outputs proper casing)
auto_capitalize = config_manager.get("text_injection", "auto_capitalize")
if auto_capitalize and speech_engine.engine == "vosk":
from vocalinux.speech_recognition.command_processor import capitalize_sentences
text_to_inject = capitalize_sentences(text_to_inject)
# Read from disk so the Settings toggle applies without restart.
append_trailing_space = _should_append_trailing_space()
@@ -596,11 +716,62 @@ def main():
action_handler.set_last_injected_text(text_to_inject)
def on_state_change(state: RecognitionState) -> None:
"""Reset the last-injected buffer when a listening session ends."""
if state == RecognitionState.IDLE:
action_handler.set_last_injected_text("")
"""Reset the last-injected buffer when a listening session ends.
# Connect speech recognition to text injection and action handling
Also commits the just-finished dictation session to the
transcription history as a single snippet.
"""
nonlocal session_open, session_worker, latest_snippet_extendable
nonlocal ended_session_epoch
if state in (RecognitionState.IDLE, RecognitionState.ERROR):
if state == RecognitionState.IDLE:
action_handler.set_last_injected_text("")
with session_lock:
session_open = False
session_worker = None
ended_session_epoch = transcription_history.epoch
# Only segments captured after the last clear() join the
# snippet — speech captured before it is gone for good,
# while dictation continued across the clear is kept.
cleared_at = transcription_history.cleared_at
joined = " ".join(
text for text, started_at in session_segments if started_at > cleared_at
)
session_segments.clear()
# Guarded by the epoch observed here: a clear() landing
# between this read and the add still refuses the
# snippet. The committed snippet stays open to late
# segments still trickling out of the worker; a session
# that produced no text leaves no entry to merge into.
latest_snippet_extendable = transcription_history.add(
joined, expected_epoch=ended_session_epoch
)
else:
with session_lock:
if not session_open:
session_open = True
session_worker = None
# Segments left over by a session that ended without a
# closing state commit as their own snippet rather
# than leaking into the new session's — still only
# those captured after the last clear().
if session_segments:
cleared_at = transcription_history.cleared_at
latest_snippet_extendable = transcription_history.add(
" ".join(
text
for text, started_at in session_segments
if started_at > cleared_at
),
expected_epoch=transcription_history.epoch,
)
session_segments.clear()
# Connect speech recognition to text injection and action handling.
# Segments reach history with their capture-start time so a mid-
# session clear() can separate pre-clear speech from new dictation;
# injection keeps the plain text callback.
speech_engine.register_segment_callback(record_history_segment)
speech_engine.register_text_callback(text_callback_wrapper)
speech_engine.register_action_callback(action_handler.handle_action)
speech_engine.register_state_callback(on_state_change)
@@ -609,6 +780,7 @@ def main():
indicator = tray_indicator.TrayIndicator(
speech_engine=speech_engine,
text_injector=text_system,
transcription_history=transcription_history,
)
# Start the GTK main loop
@@ -73,6 +73,21 @@ from .command_processor import CommandProcessor
from .silero_vad import SILERO_CHUNK_SIZE, load_silero_vad
class _AudioSegment(list):
"""A queued audio segment that knows when its capture began.
Stays a plain ``list`` of chunks so consumers (including tests that put
or compare raw buffers) see an ordinary segment; ``started_at`` is the
``time.monotonic()`` timestamp of its first captured chunk. That stamp
is what lets a listener tell speech captured before a point in time
from speech captured after it, however late the decode finishes.
"""
def __init__(self, chunks: list[bytes], started_at: Optional[float]) -> None:
super().__init__(chunks)
self.started_at = started_at
def _pywhispercpp_distribution_version() -> Optional[tuple[int, ...]]:
"""Installed pywhispercpp version, or None if it cannot be read.
@@ -487,6 +502,12 @@ class SpeechRecognitionManager:
self._voice_commands_enabled = self._resolve_voice_commands_enabled()
self.text_callbacks: list[Callable[[str], None]] = []
# (text, capture-started_at) consumers, e.g. transcription history.
self.segment_callbacks: list[Callable[[str, float], None]] = []
# While the Settings microphone test runs, segments whose capture
# began at or after this monotonic time are test speech; consumers
# (e.g. history) read the floor to skip them.
self.test_capture_floor: Optional[float] = None
self.state_callbacks: list[Callable[[RecognitionState], None]] = []
self.action_callbacks: list[Callable[[str], None]] = []
@@ -560,6 +581,9 @@ class SpeechRecognitionManager:
self.should_record = False
self._recognition_mode = "toggle" # "toggle" or "push_to_talk"
self.audio_buffer = []
# Monotonic time the current buffer's first chunk was captured,
# propagated onto each enqueued _AudioSegment.
self._segment_started_at: Optional[float] = None
self._recording_segment_has_speech = False
self._buffer_lock = threading.Lock() # Thread safety for audio_buffer
self._model_lock = threading.Lock() # Thread safety for model/recognizer access
@@ -2654,6 +2678,32 @@ class SpeechRecognitionManager:
"""Set the text callbacks list (used for temporarily replacing callbacks)."""
self.text_callbacks = list(callbacks)
def register_segment_callback(self, callback: Callable[[str, float], None]) -> None:
"""
Register a callback invoked with ``(text, started_at)`` per segment.
``started_at`` is the ``time.monotonic()`` timestamp when the
segment's audio capture began. Segment callbacks run on the
recognition thread just before the text callbacks.
Args:
callback: A function taking (recognized_text, capture_started_at)
"""
self.segment_callbacks.append(callback)
def unregister_segment_callback(self, callback: Callable[[str, float], None]) -> None:
"""
Unregister a segment callback function.
Args:
callback: The callback function to remove.
"""
try:
self.segment_callbacks.remove(callback)
logger.debug(f"Unregistered segment callback: {callback}")
except ValueError:
logger.warning(f"Callback {callback} not found in segment_callbacks.")
def register_state_callback(self, callback: Callable[[RecognitionState], None]):
"""
Register a callback function that will be called when the recognition state changes.
@@ -2909,6 +2959,7 @@ class SpeechRecognitionManager:
self.should_record = True
self._recognition_mode = mode
self.audio_buffer = []
self._segment_started_at = None
self._segment_queue = queue.Queue(maxsize=32)
# Start the audio recording thread
@@ -3160,6 +3211,12 @@ class SpeechRecognitionManager:
data = source.read_chunk()
if not self.audio_buffer:
# First chunk of a new segment: remember when its
# capture began. A mid-session history clear
# compares against this to separate speech
# captured before it from speech captured after.
self._segment_started_at = time.monotonic()
self.audio_buffer.append(data)
# Voice Activity Detection (VAD)
@@ -3407,6 +3464,11 @@ class SpeechRecognitionManager:
f"processed_text='{processed_text[:50] if processed_text else '(empty)'}...', callbacks={len(self.text_callbacks)}"
)
if processed_text:
started_at = getattr(audio_buffer, "started_at", None)
if started_at is None:
started_at = time.monotonic()
for segment_callback in self.segment_callbacks:
segment_callback(processed_text, started_at)
for callback in self.text_callbacks:
logger.debug(
f"invoking text callback: {callback.__name__ if hasattr(callback, '__name__') else callback}"
@@ -3478,7 +3540,7 @@ class SpeechRecognitionManager:
def _enqueue_audio_segment(self, audio_buffer: list[bytes]):
"""Queue an audio segment for asynchronous transcription."""
segment = audio_buffer.copy()
segment = _AudioSegment(audio_buffer.copy(), self._segment_started_at)
if not segment:
logger.warning("_enqueue_audio_segment called with empty buffer")
return
+4
View File
@@ -196,6 +196,10 @@ DEFAULT_CONFIG = {
# VOCALINUX_FORCE_BACKEND overrides this for a single run.
"backend": "auto",
},
"history": {
"enabled": True, # Keep recent dictation snippets in the tray menu
"max_items": 10, # How many snippets to retain (in-memory only, cleared on quit)
},
"advanced": {
"power_user_mode": False,
"debug_logging": False,
+69
View File
@@ -115,6 +115,7 @@ from .keyboard_backends import ( # noqa: E402
parse_shortcut_spec,
)
from .keyboard_backends.evdev_backend import MODIFIER_KEY_CODES # noqa: E402
from .transcription_history import sanitize_max_items # noqa: E402
from ..utils.faster_whisper_model_info import ( # isort:skip
FASTER_WHISPER_MODEL_INFO,
@@ -3220,10 +3221,43 @@ class SettingsDialog(Gtk.Dialog):
self.general_tab.pack_start(group, False, False, 0)
# Transcription history group
history_group = PreferencesGroup(
title="Transcription History",
description="Recent dictation snippets are kept in memory and shown "
"in the tray menu. Nothing is written to disk; history clears on quit. "
"Changes take effect after restarting Vocalinux.",
)
self.history_enabled_switch = Gtk.Switch()
self.history_enabled_switch.set_tooltip_text(
"Keep recent dictation snippets in the tray menu for quick re-copying"
)
history_enabled_row = PreferenceRow(
title="Keep History",
subtitle="Show recent snippets in the tray menu",
widget=self.history_enabled_switch,
)
history_group.add_row(history_enabled_row)
self.history_max_items_spin = Gtk.SpinButton.new_with_range(1, 50, 1)
self.history_max_items_spin.set_tooltip_text("How many recent snippets to keep")
_prevent_scroll_on_hover(self.history_max_items_spin)
history_max_items_row = PreferenceRow(
title="Snippets to Keep",
subtitle="Number of recent snippets retained",
widget=self.history_max_items_spin,
)
history_group.add_row(history_max_items_row)
self.general_tab.pack_start(history_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)
self.history_enabled_switch.connect("state-set", self._on_history_enabled_toggled)
self.history_max_items_spin.connect("value-changed", self._on_history_max_items_changed)
def _build_auto_pause_section(self):
"""Build Auto-Pause settings: enable toggle + process name list."""
@@ -3580,6 +3614,27 @@ class SettingsDialog(Gtk.Dialog):
self.config_manager.set("model_keepalive", "idle_timeout_seconds", seconds)
self.config_manager.save_settings()
def _on_history_enabled_toggled(self, widget: Gtk.Switch, state: bool) -> bool:
"""Handle toggle of the keep-history switch."""
if self._initializing or self._applying_settings:
return False
enabled = bool(state)
logger.info(f"Transcription history toggled: {enabled}")
self.config_manager.set("history", "enabled", enabled)
self.config_manager.save_settings()
return False
def _on_history_max_items_changed(self, widget: Gtk.SpinButton) -> None:
"""Handle change of the snippets-to-keep spin button."""
if self._initializing or self._applying_settings:
return
max_items = widget.get_value_as_int()
logger.info(f"Transcription history max items: {max_items}")
self.config_manager.set("history", "max_items", max_items)
self.config_manager.save_settings()
def _on_autostart_toggled(self, widget, state):
"""Handle toggle of the autostart switch."""
if _handlers_suppressed(self):
@@ -6096,6 +6151,7 @@ class SettingsDialog(Gtk.Dialog):
general_settings = self.config_manager.get_settings().get("general", {})
ui_settings = self.config_manager.get_settings().get("ui", {})
text_injection_settings = self.config_manager.get_settings().get("text_injection", {})
history_settings = self.config_manager.get_settings().get("history", {})
autostart_enabled = general_settings.get("autostart", False)
start_minimized = ui_settings.get("start_minimized", False)
@@ -6105,6 +6161,10 @@ class SettingsDialog(Gtk.Dialog):
auto_capitalize = text_injection_settings.get("auto_capitalize", True)
append_trailing_space = text_injection_settings.get("append_trailing_space", True)
paste_shortcut = self.config_manager.get_paste_shortcut()
history_enabled = bool(history_settings.get("enabled", True))
# A hand-edited config.json can hold a non-numeric value; sanitize so a
# bad preference cannot crash the settings dialog.
history_max_items = sanitize_max_items(history_settings.get("max_items", 10))
self.autostart_switch.set_active(autostart_enabled)
self.start_minimized_switch.set_active(start_minimized)
@@ -6145,6 +6205,9 @@ class SettingsDialog(Gtk.Dialog):
if not self.model_keepalive_timeout_combo.set_active_id(str(timeout_seconds)):
self.model_keepalive_timeout_combo.set_active_id("300")
self.history_enabled_switch.set_active(history_enabled)
self.history_max_items_spin.set_value(history_max_items)
disable_internal_hotkey = self.config_manager.get_bool(
"shortcuts", "disable_internal_hotkey", False
)
@@ -7927,9 +7990,14 @@ class SettingsDialog(Gtk.Dialog):
self._saved_text_callbacks = self.speech_engine.get_text_callbacks()
self.speech_engine.set_text_callbacks([self._test_text_callback])
# History recording runs on segment callbacks keyed by capture time:
# stamp the floor so test speech stays out of Recent Snippets while
# leftover segments from a dictation still file normally.
self.speech_engine.test_capture_floor = time.monotonic()
if not self.speech_engine.start_recognition():
self.speech_engine.set_text_callbacks(self._saved_text_callbacks)
self.speech_engine.test_capture_floor = None
del self._saved_text_callbacks
self.test_output_revealer.set_reveal_child(True)
if getattr(self.speech_engine, "is_auto_paused", False):
@@ -8135,6 +8203,7 @@ class SettingsDialog(Gtk.Dialog):
if hasattr(self, "_saved_text_callbacks"):
self.speech_engine.set_text_callbacks(self._saved_text_callbacks)
del self._saved_text_callbacks
self.speech_engine.test_capture_floor = None
# Check result after giving time for final callbacks to complete
GLib.timeout_add(300, self._check_test_result)
+212
View File
@@ -0,0 +1,212 @@
"""In-memory transcription history for Vocalinux.
Keeps a bounded, newest-first list of recent dictation snippets so the user
can review and re-copy recent voice input from the tray menu.
The history lives only for the lifetime of the running process — nothing is
written to disk — so dictated text never persists past the current session.
This is a deliberate privacy choice: a dictation tool sees everything the user
types by voice, and that should not silently accumulate in a file.
"""
import logging
import threading
import time
from collections import deque
from typing import Any, Callable, List, Optional
logger = logging.getLogger(__name__)
# Default number of snippets to retain. Kept small so the tray menu stays
# readable; configurable via the "history" config section.
DEFAULT_MAX_ITEMS = 10
def sanitize_max_items(value: Any) -> int:
"""Coerce a configured snippet cap to a positive int.
config.json is user-editable, so a saved ``history.max_items`` may be
missing, non-numeric, or out of range. An unusable value falls back to
``DEFAULT_MAX_ITEMS`` rather than raising: a malformed preference must
never prevent the app from starting.
"""
try:
return max(1, int(value))
except (TypeError, ValueError):
logger.warning(
"Invalid transcription history max_items %r; falling back to %d",
value,
DEFAULT_MAX_ITEMS,
)
return DEFAULT_MAX_ITEMS
class TranscriptionHistory:
"""A bounded, thread-safe, in-memory store of recent dictation snippets.
A "snippet" is the text of a single dictation session (everything said
between starting and stopping voice typing). Entries are stored oldest to
newest internally and returned newest-first for display.
Recording happens on the speech-recognition thread while the tray menu is
rebuilt on the GTK main thread, so all access is guarded by a lock. The
optional change callback lets the UI refresh when entries are added,
cleared, or trimmed; callers are responsible for marshalling that callback
onto the correct thread (e.g. via ``GLib.idle_add``).
"""
def __init__(self, max_items: int = DEFAULT_MAX_ITEMS, enabled: bool = True) -> None:
self._max_items = sanitize_max_items(max_items)
self._enabled = bool(enabled)
self._entries: deque = deque(maxlen=self._max_items)
self._lock = threading.Lock()
self._change_callback: Optional[Callable[[], None]] = None
# Bumped every time the entries are wiped; lets callers refuse text
# produced before a clear so it cannot reappear afterwards.
self._epoch = 0
# Monotonic time of the last wipe. Segments carry the moment their
# audio capture began, so text decoded from speech captured before
# this point can be refused however late it arrives.
self._cleared_at = 0.0
def set_change_callback(self, callback: Optional[Callable[[], None]]) -> None:
"""Register a callback invoked whenever the history changes."""
self._change_callback = callback
@property
def enabled(self) -> bool:
"""Whether new snippets are being recorded."""
return self._enabled
@property
def max_items(self) -> int:
"""The maximum number of snippets retained."""
return self._max_items
@property
def epoch(self) -> int:
"""Clear generation, incremented each time the entries are wiped.
``add`` and ``extend_latest`` take an ``expected_epoch`` and check
it atomically under the lock, so a caller holding the epoch from
before a ``clear`` can be refused instead of re-entering history
as fresh text.
"""
with self._lock:
return self._epoch
@property
def cleared_at(self) -> float:
"""``time.monotonic()`` of the most recent wipe, or 0.0 if never.
A recognized segment whose audio capture began at or before this
timestamp can only contain pre-clear speech and must not re-enter
history; anything captured afterwards is genuinely new dictation.
"""
with self._lock:
return self._cleared_at
def set_max_items(self, max_items: int) -> None:
"""Change the retained-snippet cap, trimming oldest entries if needed."""
max_items = sanitize_max_items(max_items)
with self._lock:
if max_items == self._max_items:
return
self._max_items = max_items
# deque(maxlen=...) keeps the rightmost (newest) items on trim.
self._entries = deque(self._entries, maxlen=max_items)
self._notify()
def set_enabled(self, enabled: bool) -> None:
"""Enable or disable recording. Disabling also clears existing entries."""
enabled = bool(enabled)
with self._lock:
if enabled == self._enabled:
return
self._enabled = enabled
if not enabled:
self._entries.clear()
self._epoch += 1
self._cleared_at = time.monotonic()
self._notify()
def add(self, text: str, *, expected_epoch: Optional[int] = None) -> bool:
"""Add a snippet, returning True when it was recorded.
No-op when disabled or when text is empty. With ``expected_epoch``
the add is also refused once the epoch has advanced — i.e. the
history was cleared since that epoch was observed — so text
produced before the clear cannot reappear as a new entry.
"""
if not text:
return False
text = text.strip()
if not text:
return False
with self._lock:
if not self._enabled:
return False
if expected_epoch is not None and expected_epoch != self._epoch:
return False
self._entries.append(text)
self._notify()
return True
def extend_latest(self, text: str, *, expected_epoch: Optional[int] = None) -> bool:
"""Append a late-arriving segment to the most recent snippet.
The recognition worker can emit a final segment after its session
already ended (the manager stops waiting for it after a bounded
timeout and reports IDLE anyway). That text belongs to the just-ended
session's snippet, so it is merged into the newest entry instead of
becoming a snippet of its own or leaking into the next session.
Returns False when there is nothing to extend (empty or disabled
history, or empty text) or when ``expected_epoch`` no longer matches
— the history was cleared since the caller observed that epoch, and
the cleared snippet must not grow back.
"""
if not text or not text.strip():
return False
with self._lock:
if not self._enabled or not self._entries:
return False
if expected_epoch is not None and expected_epoch != self._epoch:
return False
self._entries[-1] = f"{self._entries[-1]} {text.strip()}"
self._notify()
return True
def get_all(self) -> List[str]:
"""Return all snippets, newest first."""
with self._lock:
return list(reversed(self._entries))
def clear(self) -> None:
"""Remove all snippets.
The epoch advances even when the history is already empty: the
call still expresses "forget everything dictated so far", so
text still in flight from before it must not re-enter.
"""
with self._lock:
self._epoch += 1
self._cleared_at = time.monotonic()
if not self._entries:
return
self._entries.clear()
self._notify()
def __len__(self) -> int:
with self._lock:
return len(self._entries)
def _notify(self) -> None:
callback = self._change_callback
if callback is None:
return
try:
callback()
except (RuntimeError, TypeError, ValueError) as e:
# A misbehaving UI callback must never break recording.
logger.exception("Transcription history change callback failed: %s", e)
+80 -1
View File
@@ -50,6 +50,7 @@ from .config_manager import get_shared_config_manager
from .keyboard_backends import DEFAULT_SHORTCUT, DEFAULT_SHORTCUT_MODE
from .keyboard_shortcuts import KeyboardShortcutManager
from .settings_dialog import SettingsDialog, recommended_model_for_engine
from .transcription_history import TranscriptionHistory
logger = logging.getLogger(__name__)
@@ -101,6 +102,9 @@ def _themed_icon_names() -> dict:
}
# Maximum characters shown for a history snippet label before truncation.
_HISTORY_LABEL_MAX_CHARS = 50
# /dev/input settle-detection tuning (used after resume)
_INPUT_SETTLE_SECONDS = 2
_INPUT_MONITOR_CAP_SECONDS = 10
@@ -119,16 +123,20 @@ class TrayIndicator:
self,
speech_engine: SpeechRecognitionManagerProtocol,
text_injector: TextInjectorProtocol,
):
transcription_history: Optional[TranscriptionHistory] = None,
) -> None:
"""
Initialize the system tray indicator.
Args:
speech_engine: The speech recognition manager instance
text_injector: The text injector instance
transcription_history: Optional in-memory store of recent dictation
snippets. When provided, a "Recent Snippets" submenu is shown.
"""
self.speech_engine = speech_engine
self.text_injector = text_injector
self.transcription_history = transcription_history
# Shared with main() and the settings dialog: separate instances would
# overwrite each other's saves with stale in-memory copies.
self.config_manager = get_shared_config_manager()
@@ -138,6 +146,15 @@ class TrayIndicator:
# than reapplying a disable_internal_hotkey setting D-Bus cannot serve.
self._external_activation_unavailable = False
self._syncing_autostart_menu = False
self._history_menu_item = None
# Refresh the history submenu whenever the history changes. The change
# callback fires on the recognition thread, so marshal onto the GTK
# main thread before touching widgets.
if self.transcription_history is not None:
self.transcription_history.set_change_callback(
lambda: GLib.idle_add(self._refresh_history_menu)
)
# Get configured shortcut and mode from config
shortcut = self.config_manager.get_str("shortcuts", "toggle_recognition", DEFAULT_SHORTCUT)
@@ -404,6 +421,13 @@ class TrayIndicator:
self._add_menu_item("Stop Voice Typing", self._on_stop_clicked)
self._add_menu_separator()
# Recent snippets submenu (only when history is enabled)
if self.transcription_history is not None and self.transcription_history.enabled:
self._history_menu_item = Gtk.MenuItem.new_with_label("Recent Snippets")
self.menu.append(self._history_menu_item)
self._refresh_history_menu()
self._add_menu_separator()
self._autostart_menu_item = self._add_menu_checkbox(
"Start on Login", self._on_autostart_toggled
)
@@ -896,6 +920,61 @@ class TrayIndicator:
logger.debug("Stop Voice Typing clicked")
self.speech_engine.stop_recognition()
def _refresh_history_menu(self) -> bool:
"""Rebuild the Recent Snippets submenu from the current history."""
if self._history_menu_item is None or self.transcription_history is None:
return False # Remove idle callback
submenu = Gtk.Menu()
entries = self.transcription_history.get_all()
if not entries:
empty_item = Gtk.MenuItem.new_with_label("(no snippets yet)")
empty_item.set_sensitive(False)
submenu.append(empty_item)
else:
for entry in entries:
item = Gtk.MenuItem.new_with_label(self._truncate_label(entry))
item.set_tooltip_text(entry)
# The full snippet is passed as connect user-data, so each item
# copies its own text (no late-binding closure pitfall).
item.connect("activate", self._on_history_item_clicked, entry)
submenu.append(item)
submenu.append(Gtk.SeparatorMenuItem())
clear_item = Gtk.MenuItem.new_with_label("Clear History")
clear_item.connect("activate", self._on_clear_history_clicked)
submenu.append(clear_item)
submenu.show_all()
self._history_menu_item.set_submenu(submenu)
return False # Remove idle callback
@staticmethod
def _truncate_label(text: str) -> str:
"""Collapse whitespace and truncate a snippet for menu display."""
single_line = " ".join(text.split())
if len(single_line) > _HISTORY_LABEL_MAX_CHARS:
return single_line[: _HISTORY_LABEL_MAX_CHARS - 1].rstrip() + "…"
return single_line
def _on_history_item_clicked(self, widget: Gtk.MenuItem, text: str) -> None:
"""Copy the selected snippet to the clipboard."""
logger.debug("History snippet clicked, copying to clipboard")
# Lazy import keeps the module importable when gi.repository is a
# minimal test stand-in (e.g. the AppIndicator fallback tests).
from gi.repository import Gdk
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard.set_text(text, -1)
clipboard.store()
def _on_clear_history_clicked(self, widget: Gtk.MenuItem) -> None:
"""Clear all stored snippets."""
logger.debug("Clear history clicked")
if self.transcription_history is not None:
self.transcription_history.clear()
def _on_settings_clicked(self, widget):
"""Handle click on the Settings menu item."""
logger.debug("Settings clicked")
+262 -2
View File
@@ -4,8 +4,12 @@ Tests for the main module functionality.
import argparse
import sys
import threading
import time
import unittest
from unittest.mock import MagicMock, patch
from contextlib import ExitStack
from typing import Any, Callable, Dict, Optional, Tuple
from unittest.mock import ANY, MagicMock, patch
# Mock GTK modules before importing vocalinux.main
sys.modules["gi"] = MagicMock()
@@ -14,6 +18,7 @@ sys.modules["gi.repository"] = MagicMock()
# Update import to use the new package structure
from vocalinux.common_types import RecognitionState
from vocalinux.main import check_dependencies, main, parse_arguments
from vocalinux.ui.transcription_history import TranscriptionHistory
class TestMainModule(unittest.TestCase):
@@ -244,11 +249,14 @@ class TestMainModule(unittest.TestCase):
mock_text.assert_called_once_with(wayland_mode=True)
mock_action_handler.assert_called_once_with(mock_text_instance)
mock_tray.assert_called_once_with(
speech_engine=mock_speech_instance, text_injector=mock_text_instance
speech_engine=mock_speech_instance,
text_injector=mock_text_instance,
transcription_history=ANY,
)
# Verify callbacks were registered
mock_speech_instance.register_text_callback.assert_called_once()
mock_speech_instance.register_segment_callback.assert_called_once()
mock_speech_instance.register_action_callback.assert_called_once_with(
mock_action_instance.handle_action
)
@@ -1353,5 +1361,257 @@ class TestMainCallbackTrailingSpaceEdges(unittest.TestCase):
stack.close()
class TestSessionHistoryRecording(unittest.TestCase):
"""Segments commit to transcription history as one snippet per session."""
def _boot(self, *, extra_settings: Optional[Dict[str, Any]] = None) -> Tuple[
ExitStack,
Callable[[str], None],
Callable[[str, float], None],
Callable[[RecognitionState], None],
TranscriptionHistory,
]:
"""Boot main() under mocks; return (stack, text_cb, segment_cb, state_cb, history).
The history is the real TranscriptionHistory instance handed to the
(mocked) TrayIndicator, so assertions observe actual recording.
segment_cb(text, started_at) delivers a recognized segment with the
monotonic time its audio capture began — history recording consumes
capture time so tests control pre-/post-clear placement precisely.
"""
stack = ExitStack()
stack.enter_context(patch("vocalinux.main.check_dependencies", return_value=True))
mock_config_cls = stack.enter_context(
patch("vocalinux.ui.config_manager.get_shared_config_manager")
)
mock_config = MagicMock()
settings = {
"speech_recognition": {},
"general": {"first_run": False},
}
if extra_settings:
settings.update(extra_settings)
mock_config.get_settings.return_value = settings
mock_config.get.return_value = False # auto_capitalize off
mock_config_cls.return_value = mock_config
mock_speech_cls = stack.enter_context(
patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
)
mock_speech = MagicMock()
mock_speech.engine = "whisper_cpp"
mock_speech_cls.return_value = mock_speech
mock_text_cls = stack.enter_context(
patch("vocalinux.text_injection.text_injector.TextInjector")
)
mock_text = MagicMock()
mock_text.inject_text.return_value = True
mock_text_cls.return_value = mock_text
mock_tray_cls = stack.enter_context(patch("vocalinux.ui.tray_indicator.TrayIndicator"))
mock_tray_cls.return_value = MagicMock()
stack.enter_context(patch("vocalinux.ui.logging_manager.initialize_logging"))
mock_parse = stack.enter_context(patch("vocalinux.main.parse_arguments"))
stack.enter_context(patch("sys.argv", ["vocalinux"]))
mock_args = MagicMock()
mock_args.debug = False
mock_args.model = "tiny"
mock_args.engine = "whisper_cpp"
mock_args.language = "en-us"
mock_args.wayland = False
mock_args.start_minimized = False
mock_parse.return_value = mock_args
try:
main()
text_cb = mock_speech.register_text_callback.call_args.args[0]
segment_cb = mock_speech.register_segment_callback.call_args.args[0]
state_cb = mock_speech.register_state_callback.call_args.args[0]
history = mock_tray_cls.call_args.kwargs["transcription_history"]
except BaseException:
stack.close()
raise
return stack, text_cb, segment_cb, state_cb, history
def test_session_segments_commit_as_single_snippet(self) -> None:
stack, _, segment_cb, state_cb, history = self._boot()
try:
state_cb(RecognitionState.LISTENING)
segment_cb("Hello.", time.monotonic())
state_cb(RecognitionState.PROCESSING)
state_cb(RecognitionState.LISTENING)
segment_cb("World", time.monotonic())
state_cb(RecognitionState.IDLE)
self.assertEqual(history.get_all(), ["Hello. World"])
finally:
stack.close()
def test_successive_sessions_record_separate_snippets_newest_first(self) -> None:
stack, _, segment_cb, state_cb, history = self._boot()
try:
state_cb(RecognitionState.LISTENING)
segment_cb("first session", time.monotonic())
state_cb(RecognitionState.IDLE)
state_cb(RecognitionState.LISTENING)
segment_cb("second session", time.monotonic())
state_cb(RecognitionState.IDLE)
self.assertEqual(history.get_all(), ["second session", "first session"])
finally:
stack.close()
def test_late_segment_merges_into_its_own_sessions_snippet(self) -> None:
"""A worker that outlives the bounded stop wait lands in its snippet."""
stack, _, segment_cb, state_cb, history = self._boot()
try:
state_cb(RecognitionState.LISTENING)
segment_cb("hello", time.monotonic())
segment_cb("world", time.monotonic())
# IDLE fires while the worker still holds a final segment.
state_cb(RecognitionState.IDLE)
segment_cb("late tail", time.monotonic())
self.assertEqual(history.get_all(), ["hello world late tail"])
# The next session's snippet is not contaminated.
state_cb(RecognitionState.LISTENING)
segment_cb("next session", time.monotonic())
state_cb(RecognitionState.IDLE)
self.assertEqual(history.get_all(), ["next session", "hello world late tail"])
finally:
stack.close()
def test_late_segment_from_old_worker_during_next_session(self) -> None:
"""A leftover worker delivering on its own thread stays out of the open session."""
stack, _, segment_cb, state_cb, history = self._boot()
try:
state_cb(RecognitionState.LISTENING)
segment_cb("one", time.monotonic())
state_cb(RecognitionState.IDLE)
state_cb(RecognitionState.LISTENING)
segment_cb("two", time.monotonic())
# The previous session's worker finally delivers on its own thread.
t = threading.Thread(target=segment_cb, args=("trailing", time.monotonic()))
t.start()
t.join()
self.assertEqual(history.get_all(), ["one trailing"])
state_cb(RecognitionState.IDLE)
self.assertEqual(history.get_all(), ["two", "one trailing"])
finally:
stack.close()
def test_session_without_segments_creates_no_snippet(self) -> None:
stack, _, segment_cb, state_cb, history = self._boot()
try:
state_cb(RecognitionState.LISTENING)
state_cb(RecognitionState.IDLE)
self.assertEqual(history.get_all(), [])
# A segment arriving now belongs to that empty session: it becomes
# its own snippet rather than merging into an older entry.
segment_cb("orphan", time.monotonic())
self.assertEqual(history.get_all(), ["orphan"])
finally:
stack.close()
def test_error_state_also_commits_partial_snippet(self) -> None:
stack, _, segment_cb, state_cb, history = self._boot()
try:
state_cb(RecognitionState.LISTENING)
segment_cb("partial", time.monotonic())
state_cb(RecognitionState.ERROR)
self.assertEqual(history.get_all(), ["partial"])
finally:
stack.close()
def test_history_disabled_records_nothing(self) -> None:
stack, _, segment_cb, state_cb, history = self._boot(
extra_settings={"history": {"enabled": False}}
)
try:
state_cb(RecognitionState.LISTENING)
segment_cb("hello", time.monotonic())
state_cb(RecognitionState.IDLE)
self.assertEqual(history.get_all(), [])
finally:
stack.close()
def test_invalid_history_max_items_uses_default(self) -> None:
"""A corrupted saved limit must not prevent startup."""
stack, _, _, _, history = self._boot(extra_settings={"history": {"max_items": "abc"}})
try:
self.assertEqual(history.max_items, 10)
finally:
stack.close()
def test_late_segment_after_clear_does_not_reappear(self) -> None:
"""A straggler from an ended session must not resurrect cleared history."""
stack, _, segment_cb, state_cb, history = self._boot()
try:
state_cb(RecognitionState.LISTENING)
segment_cb("before clear", time.monotonic())
state_cb(RecognitionState.IDLE)
self.assertEqual(history.get_all(), ["before clear"])
history.clear()
# Its audio was captured before the clear; the decode only
# finished afterwards, so it must still be refused.
segment_cb("late tail", history.cleared_at - 1.0)
self.assertEqual(history.get_all(), [])
finally:
stack.close()
def test_late_orphan_segment_after_clear_does_not_reappear(self) -> None:
"""Late-only output of an empty session is refused after a clear too."""
stack, _, segment_cb, state_cb, history = self._boot()
try:
state_cb(RecognitionState.LISTENING)
state_cb(RecognitionState.IDLE) # empty session: no snippet
history.clear()
segment_cb("orphan", history.cleared_at - 1.0)
self.assertEqual(history.get_all(), [])
finally:
stack.close()
def test_clear_during_session_drops_its_snippet(self) -> None:
"""A clear issued while a session runs keeps its whole snippet out."""
stack, _, segment_cb, state_cb, history = self._boot()
try:
state_cb(RecognitionState.LISTENING)
segment_cb("dictated", time.monotonic())
history.clear()
state_cb(RecognitionState.IDLE)
self.assertEqual(history.get_all(), [])
finally:
stack.close()
def test_dictation_after_mid_session_clear_is_kept(self) -> None:
"""A mid-session clear splits segments by capture time, not arrival.
Speech captured before the clear is refused no matter when its
decode lands; speech captured after it joins the session's snippet.
"""
stack, _, segment_cb, state_cb, history = self._boot()
try:
state_cb(RecognitionState.LISTENING)
segment_cb("before", time.monotonic())
history.clear()
boundary = history.cleared_at
# A decode of pre-clear audio finishing after the clear is still
# refused — this is the segment the epoch check used to keep or
# drop wholesale.
segment_cb("pre-clear tail", boundary - 1.0)
segment_cb("after clear", time.monotonic())
state_cb(RecognitionState.IDLE)
self.assertEqual(history.get_all(), ["after clear"])
finally:
stack.close()
if __name__ == "__main__":
unittest.main()
+241
View File
@@ -0,0 +1,241 @@
"""
Tests for the in-memory transcription history.
"""
import unittest
from vocalinux.ui.transcription_history import DEFAULT_MAX_ITEMS, TranscriptionHistory
class TestTranscriptionHistory(unittest.TestCase):
"""Test cases for the TranscriptionHistory store."""
def test_defaults(self) -> None:
history = TranscriptionHistory()
self.assertEqual(history.max_items, DEFAULT_MAX_ITEMS)
self.assertTrue(history.enabled)
self.assertEqual(len(history), 0)
self.assertEqual(history.get_all(), [])
def test_add_and_newest_first(self) -> None:
history = TranscriptionHistory()
history.add("first")
history.add("second")
history.add("third")
self.assertEqual(history.get_all(), ["third", "second", "first"])
self.assertEqual(len(history), 3)
def test_add_strips_whitespace(self) -> None:
history = TranscriptionHistory()
history.add(" hello world ")
self.assertEqual(history.get_all(), ["hello world"])
def test_empty_and_whitespace_ignored(self) -> None:
history = TranscriptionHistory()
history.add("")
history.add(" ")
history.add(None) # type: ignore[arg-type]
self.assertEqual(len(history), 0)
def test_max_items_cap_drops_oldest(self) -> None:
history = TranscriptionHistory(max_items=3)
for text in ["a", "b", "c", "d"]:
history.add(text)
# "a" dropped; newest first.
self.assertEqual(history.get_all(), ["d", "c", "b"])
self.assertEqual(len(history), 3)
def test_set_max_items_trims_keeping_newest(self) -> None:
history = TranscriptionHistory(max_items=5)
for text in ["a", "b", "c", "d", "e"]:
history.add(text)
history.set_max_items(2)
self.assertEqual(history.max_items, 2)
self.assertEqual(history.get_all(), ["e", "d"])
def test_max_items_floor_is_one(self) -> None:
history = TranscriptionHistory(max_items=0)
self.assertEqual(history.max_items, 1)
history.add("a")
history.add("b")
self.assertEqual(history.get_all(), ["b"])
def test_invalid_max_items_falls_back_to_default(self) -> None:
self.assertEqual(TranscriptionHistory(max_items="abc").max_items, DEFAULT_MAX_ITEMS)
self.assertEqual(TranscriptionHistory(max_items=None).max_items, DEFAULT_MAX_ITEMS)
self.assertEqual(TranscriptionHistory(max_items=[1]).max_items, DEFAULT_MAX_ITEMS)
def test_numeric_string_max_items_accepted(self) -> None:
history = TranscriptionHistory(max_items="4")
self.assertEqual(history.max_items, 4)
def test_set_max_items_invalid_falls_back_to_default(self) -> None:
history = TranscriptionHistory(max_items=3)
history.set_max_items("bogus")
self.assertEqual(history.max_items, DEFAULT_MAX_ITEMS)
def test_extend_latest_appends_to_newest_entry(self) -> None:
history = TranscriptionHistory()
history.add("one")
self.assertTrue(history.extend_latest("late tail"))
self.assertEqual(history.get_all(), ["one late tail"])
def test_extend_latest_only_touches_newest_entry(self) -> None:
history = TranscriptionHistory()
history.add("one")
history.add("two")
history.extend_latest("tail")
self.assertEqual(history.get_all(), ["two tail", "one"])
def test_extend_latest_strips_segment_whitespace(self) -> None:
history = TranscriptionHistory()
history.add("one")
history.extend_latest(" tail ")
self.assertEqual(history.get_all(), ["one tail"])
def test_extend_latest_empty_history_returns_false(self) -> None:
self.assertFalse(TranscriptionHistory().extend_latest("x"))
def test_extend_latest_disabled_returns_false(self) -> None:
self.assertFalse(TranscriptionHistory(enabled=False).extend_latest("x"))
def test_extend_latest_blank_text_returns_false(self) -> None:
history = TranscriptionHistory()
history.add("one")
self.assertFalse(history.extend_latest(" "))
self.assertEqual(history.get_all(), ["one"])
def test_extend_latest_after_clear_returns_false(self) -> None:
history = TranscriptionHistory()
history.add("a")
history.clear()
self.assertFalse(history.extend_latest("x"))
def test_extend_latest_fires_change_callback(self) -> None:
history = TranscriptionHistory()
history.add("a")
calls = []
history.set_change_callback(lambda: calls.append(1))
history.extend_latest("b")
self.assertEqual(calls, [1])
def test_clear(self) -> None:
history = TranscriptionHistory()
history.add("a")
history.add("b")
history.clear()
self.assertEqual(history.get_all(), [])
self.assertEqual(len(history), 0)
def test_disabled_does_not_record(self) -> None:
history = TranscriptionHistory(enabled=False)
self.assertFalse(history.enabled)
history.add("ignored")
self.assertEqual(len(history), 0)
def test_set_enabled_false_clears_entries(self) -> None:
history = TranscriptionHistory()
history.add("a")
history.set_enabled(False)
self.assertFalse(history.enabled)
self.assertEqual(len(history), 0)
history.add("b") # still ignored while disabled
self.assertEqual(len(history), 0)
history.set_enabled(True)
history.add("c")
self.assertEqual(history.get_all(), ["c"])
def test_change_callback_fires_on_mutations(self) -> None:
history = TranscriptionHistory()
calls = []
history.set_change_callback(lambda: calls.append(1))
history.add("a") # fires
history.clear() # fires
history.clear() # no-op, already empty -> no fire
self.assertEqual(len(calls), 2)
def test_change_callback_not_fired_when_disabled_add(self) -> None:
history = TranscriptionHistory(enabled=False)
calls = []
history.set_change_callback(lambda: calls.append(1))
history.add("a")
self.assertEqual(calls, [])
def test_change_callback_exception_is_swallowed(self) -> None:
history = TranscriptionHistory()
def boom() -> None:
raise RuntimeError("callback failure")
history.set_change_callback(boom)
# Must not propagate.
history.add("a")
self.assertEqual(history.get_all(), ["a"])
# --- Clear epoch -------------------------------------------------------
def test_epoch_advances_on_every_clear(self) -> None:
history = TranscriptionHistory()
first = history.epoch
history.clear()
self.assertNotEqual(history.epoch, first)
second = history.epoch
history.clear()
self.assertNotEqual(history.epoch, second)
def test_epoch_advances_on_disable(self) -> None:
history = TranscriptionHistory()
epoch = history.epoch
history.set_enabled(False)
self.assertNotEqual(history.epoch, epoch)
def test_add_refused_from_stale_epoch(self) -> None:
"""Text produced before a clear must not re-enter afterwards."""
history = TranscriptionHistory()
epoch = history.epoch
history.clear()
self.assertFalse(history.add("stale", expected_epoch=epoch))
self.assertEqual(history.get_all(), [])
def test_add_accepted_within_same_epoch(self) -> None:
history = TranscriptionHistory()
self.assertTrue(history.add("a", expected_epoch=history.epoch))
self.assertEqual(history.get_all(), ["a"])
def test_extend_latest_refused_from_stale_epoch(self) -> None:
"""A cleared snippet must not grow back via a stale epoch."""
history = TranscriptionHistory()
history.add("a")
epoch = history.epoch
history.clear()
history.add("b")
self.assertFalse(history.extend_latest("tail", expected_epoch=epoch))
self.assertEqual(history.get_all(), ["b"])
def test_unguarded_writes_ignore_epoch(self) -> None:
history = TranscriptionHistory()
history.add("a")
history.clear()
self.assertTrue(history.add("b"))
self.assertEqual(history.get_all(), ["b"])
# --- Clear timestamp ---------------------------------------------------
def test_cleared_at_advances_on_clear(self) -> None:
history = TranscriptionHistory()
self.assertEqual(history.cleared_at, 0.0)
history.clear()
self.assertGreater(history.cleared_at, 0.0)
first = history.cleared_at
history.clear()
self.assertGreaterEqual(history.cleared_at, first)
def test_cleared_at_advances_on_disable(self) -> None:
history = TranscriptionHistory()
history.set_enabled(False)
self.assertGreater(history.cleared_at, 0.0)
if __name__ == "__main__":
unittest.main()
+81
View File
@@ -982,6 +982,87 @@ class TestTrayIndicator(unittest.TestCase):
self.assertIsNone(self.tray_indicator.overlay)
patched_gtk.main_quit.assert_called_once()
# --- Recent Snippets history menu -------------------------------------
def test_truncate_label_collapses_and_truncates(self) -> None:
from vocalinux.ui.tray_indicator import TrayIndicator
self.assertEqual(TrayIndicator._truncate_label(" a b\nc "), "a b c")
long = "x" * 200
truncated = TrayIndicator._truncate_label(long)
self.assertTrue(truncated.endswith("…"))
self.assertEqual(len(truncated), 50)
def test_refresh_history_menu_populated(self) -> None:
from vocalinux.ui.transcription_history import TranscriptionHistory
history = TranscriptionHistory()
history.add("first snippet")
history.add("second snippet")
self.tray_indicator.transcription_history = history
self.tray_indicator._history_menu_item = MagicMock()
result = self.tray_indicator._refresh_history_menu()
self.assertFalse(result)
self.tray_indicator._history_menu_item.set_submenu.assert_called_once()
def test_refresh_history_menu_empty(self) -> None:
from vocalinux.ui.transcription_history import TranscriptionHistory
self.tray_indicator.transcription_history = TranscriptionHistory()
self.tray_indicator._history_menu_item = MagicMock()
result = self.tray_indicator._refresh_history_menu()
self.assertFalse(result)
self.tray_indicator._history_menu_item.set_submenu.assert_called_once()
def test_refresh_history_menu_noop_without_item(self) -> None:
self.tray_indicator.transcription_history = None
self.tray_indicator._history_menu_item = None
# Should return False and not raise.
self.assertFalse(self.tray_indicator._refresh_history_menu())
def test_on_history_item_clicked_copies_to_clipboard(self) -> None:
self.tray_indicator._on_history_item_clicked(MagicMock(), "some snippet")
clipboard = mock_gtk.Clipboard.get.return_value
clipboard.set_text.assert_called_once_with("some snippet", -1)
clipboard.store.assert_called_once()
def test_on_clear_history_clicked_clears(self) -> None:
from vocalinux.ui.transcription_history import TranscriptionHistory
history = TranscriptionHistory()
history.add("a")
history.add("b")
self.tray_indicator.transcription_history = history
self.tray_indicator._on_clear_history_clicked(MagicMock())
self.assertEqual(len(history), 0)
def test_construct_with_history_creates_submenu_and_wires_callback(self) -> None:
from vocalinux.ui.transcription_history import TranscriptionHistory
from vocalinux.ui.tray_indicator import TrayIndicator
history = TranscriptionHistory()
tray = TrayIndicator(
speech_engine=self.mock_speech_engine,
text_injector=self.mock_text_injector,
transcription_history=history,
)
tray.shortcut_manager = self.mock_ksm
# The submenu item is created during _init_indicator.
self.assertIsNotNone(tray._history_menu_item)
# Adding a snippet fires the change callback, which refreshes the menu
# (GLib.idle_add runs synchronously under the test mocks).
history.add("live snippet")
self.assertTrue(tray._history_menu_item.set_submenu.called)
class TestTrayIndicatorFlatpakIcons(unittest.TestCase):
"""Tray icon names must adapt to the Flatpak runtime.