refactor: systematic code quality improvements across 20 dimensions (#377)
* refactor: improve code quality across multiple dimensions Code quality improvements identified and driven by desloppify analysis: **action_handler.py** - Replace 9 repetitive shortcut forwarding methods with data-driven _SHORTCUT_ACTIONS class-level dispatch table - Add _make_shortcut_handler() factory for shortcut closures - Add explicit type annotation for action_handlers dict **config_manager.py** - Move 'import copy' from __init__ to module level (stdlib best practice) - Add _VALID_SECTIONS frozenset for section name validation in set() - Narrow broad Exception catch to (KeyError, TypeError) in set() - Add deprecation warning to save_settings() guiding toward save_config() **tray_indicator.py** - Consolidate all imports at module top level - Remove noqa E402 suppressions for mid-file imports - Move ResourceManager import alongside other local imports **recognition_manager.py** - Narrow broad 'except Exception' to 'except OSError' for audio device enumeration errors - Narrow 'except Exception' to 'except (FileNotFoundError, OSError)' for notification subprocess errors **Tests** - Update test assertions to use specific exception types matching narrowed catches (OSError, FileNotFoundError instead of Exception/RuntimeError) * refactor: improve type safety, callback contracts, and method decomposition **main.py** — Mid-level elegance - Document callback wiring contract: text_callback, action_callback, state_callback with threading context, argument types, and behavior - Add return type annotations to nested callback functions - Reorder: define callbacks before registration for readability **config_manager.py** — Type safety, error consistency - Add typed accessors: get_str(), get_bool(), get_int(), get_float(), get_optional_int() eliminating Any returns for common access patterns - Narrow load_config exception to (json.JSONDecodeError, OSError) - Narrow save_config exception to (OSError, TypeError) **recognition_manager.py** — Low-level elegance - Decompose _init_whispercpp (141 lines) into three focused methods: - _init_whispercpp: validation and download gating (35 lines) - _load_whispercpp_model: backend detection and model loading (55 lines) - _handle_gpu_fallback: GPU incompatibility recovery (40 lines) - Narrow exception catch from broad Exception to specific types **tray_indicator.py, settings_dialog.py** — Type safety - Migrate config access from untyped get() to typed get_str(), get_bool(), get_optional_int() across 8 call sites * refactor: narrow 11 more broad Exception catches to specific types Systematically replaced broad 'except Exception' with specific exception types across recognition_manager.py: - test_audio_input: (OSError, ValueError) for audio read errors - test_audio_input outer: (OSError, ValueError, RuntimeError) for unexpected - _init_whisper: (RuntimeError, OSError) for engine init failures - _transcribe_with_whisper: (RuntimeError, OSError, ValueError) for transcription - _download_whispercpp_model: (OSError, RuntimeError, ValueError) for downloads - _download_vosk_model: (OSError, RuntimeError, ValueError) for downloads - _download_whisper_model: (OSError, RuntimeError, ValueError) for downloads This brings the total narrowed exception catches to 18+ across the codebase, improving error consistency and making failure modes more predictable. * style: fix black formatting issues Auto-format with black to fix CI lint failures in recognition_manager.py, settings_dialog.py, and test_settings_shortcuts.py. * test: add coverage for typed config accessors Add TestTypedAccessors class with 15 tests covering: - get_str(): string values, defaults, non-string conversion, None handling - get_bool(): boolean values, defaults - get_int(): integer values, invalid input fallback, missing key defaults - get_float(): float values, invalid input fallback - get_optional_int(): int values, None values, missing keys, invalid input Fixes codecov/patch coverage requirement for new typed accessor methods.
This commit is contained in:
+36
-10
@@ -331,11 +331,37 @@ def main():
|
||||
# Initialize action handler
|
||||
action_handler = ActionHandler(text_system)
|
||||
|
||||
# Create a wrapper function to track injected text for action handler
|
||||
def text_callback_wrapper(text: str):
|
||||
"""Wrapper to track injected text and handle spacing between segments."""
|
||||
# Strip any leading/trailing whitespace from the incoming text as a
|
||||
# safety net (whisper tokenizer sometimes prepends spaces to tokens)
|
||||
# --- Callback wiring ---------------------------------------------------
|
||||
# The speech engine emits three kinds of events, each handled by a
|
||||
# dedicated callback registered below:
|
||||
#
|
||||
# text_callback(text: str)
|
||||
# Called on the recognition thread when a transcription segment
|
||||
# is finalised. The wrapper below strips whitespace, inserts
|
||||
# inter-segment spaces, injects the text, and records it so
|
||||
# "delete that" can undo it.
|
||||
#
|
||||
# action_callback(action: str) -> bool
|
||||
# Called when a voice command (e.g. "undo", "select all") is
|
||||
# recognised. Delegated directly to ActionHandler.handle_action.
|
||||
#
|
||||
# state_callback(state: RecognitionState)
|
||||
# Called whenever the engine transitions state (IDLE → LISTENING,
|
||||
# etc.). Used here to clear the "last injected" buffer when a
|
||||
# new listening session starts.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def text_callback_wrapper(text: str) -> None:
|
||||
"""Bridge between speech engine text events and the text injector.
|
||||
|
||||
Called on the recognition thread with each finalised transcription
|
||||
segment. Strips leading/trailing whitespace (whisper tokenizer
|
||||
sometimes prepends spaces), inserts a single space between
|
||||
consecutive segments, then injects via TextInjector.
|
||||
|
||||
Args:
|
||||
text: Raw transcription segment from the speech engine.
|
||||
"""
|
||||
text_to_inject = text.strip()
|
||||
if not text_to_inject:
|
||||
return
|
||||
@@ -351,14 +377,14 @@ def main():
|
||||
if success:
|
||||
action_handler.set_last_injected_text(text)
|
||||
|
||||
# Connect speech recognition to text injection and action handling
|
||||
speech_engine.register_text_callback(text_callback_wrapper)
|
||||
speech_engine.register_action_callback(action_handler.handle_action)
|
||||
|
||||
def on_state_change(state: RecognitionState):
|
||||
def on_state_change(state: RecognitionState) -> None:
|
||||
"""Reset the last-injected buffer when a new listening session starts."""
|
||||
if state == RecognitionState.LISTENING:
|
||||
action_handler.set_last_injected_text("")
|
||||
|
||||
# Connect speech recognition to text injection and action handling
|
||||
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)
|
||||
|
||||
# Initialize and start the system tray indicator
|
||||
|
||||
@@ -96,7 +96,7 @@ def get_audio_input_devices() -> list:
|
||||
audio.terminate()
|
||||
except ImportError:
|
||||
logger.error("PyAudio not installed, cannot enumerate audio devices")
|
||||
except Exception as e:
|
||||
except OSError as e:
|
||||
logger.error(f"Error enumerating audio devices: {e}")
|
||||
|
||||
return devices
|
||||
@@ -342,7 +342,7 @@ def test_audio_input(device_index: int = None, duration: float = 1.0) -> dict:
|
||||
audio_data = np.frombuffer(data, dtype=np.int16)
|
||||
amplitudes = np.abs(audio_data)
|
||||
all_amplitudes.extend(amplitudes)
|
||||
except Exception as e:
|
||||
except (OSError, ValueError) as e:
|
||||
result["error"] = f"Error reading audio: {e}"
|
||||
break
|
||||
|
||||
@@ -362,7 +362,7 @@ def test_audio_input(device_index: int = None, duration: float = 1.0) -> dict:
|
||||
|
||||
except ImportError as e:
|
||||
result["error"] = f"Missing dependency: {e}"
|
||||
except Exception as e:
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
result["error"] = f"Unexpected error: {e}"
|
||||
|
||||
return result
|
||||
@@ -433,7 +433,7 @@ def _show_notification(title: str, message: str, icon: str = "dialog-warning"):
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception as e:
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
logger.debug(f"Could not show notification: {e}")
|
||||
|
||||
|
||||
@@ -715,7 +715,7 @@ class SpeechRecognitionManager:
|
||||
logger.error("Please install with: pip install openai-whisper torch")
|
||||
self.state = RecognitionState.ERROR
|
||||
raise
|
||||
except Exception as e:
|
||||
except (RuntimeError, OSError) as e:
|
||||
logger.error(f"Failed to initialize Whisper engine: {e}")
|
||||
self.state = RecognitionState.ERROR
|
||||
raise
|
||||
@@ -786,16 +786,14 @@ class SpeechRecognitionManager:
|
||||
|
||||
return text
|
||||
|
||||
except Exception as e:
|
||||
except (RuntimeError, OSError, ValueError) as e:
|
||||
logger.error(f"Error in Whisper transcription: {e}", exc_info=True)
|
||||
return ""
|
||||
|
||||
def _init_whispercpp(self):
|
||||
"""Initialize the whisper.cpp speech recognition engine."""
|
||||
import time
|
||||
|
||||
try:
|
||||
from pywhispercpp.model import Model
|
||||
from pywhispercpp.model import Model # noqa: F401 — used in _load_whispercpp_model
|
||||
|
||||
# Validate model size for whisper.cpp
|
||||
valid_models = list(WHISPERCPP_MODEL_INFO.keys())
|
||||
@@ -821,105 +819,7 @@ class SpeechRecognitionManager:
|
||||
logger.info(f"Downloading whisper.cpp '{self.model_size}' model...")
|
||||
self._download_whispercpp_model()
|
||||
|
||||
# Detect and log compute backend
|
||||
from ..utils.whispercpp_model_info import (
|
||||
ComputeBackend,
|
||||
detect_compute_backend,
|
||||
get_backend_display_name,
|
||||
)
|
||||
|
||||
backend, backend_info = detect_compute_backend()
|
||||
logger.info(f"whisper.cpp backend selection priority: Vulkan -> CUDA -> CPU")
|
||||
logger.info(
|
||||
f"whisper.cpp using {get_backend_display_name(backend)} backend: {backend_info}"
|
||||
)
|
||||
|
||||
# Log hardware summary
|
||||
import psutil
|
||||
|
||||
total_ram_gb = psutil.virtual_memory().total // (1024**3)
|
||||
logger.info(f"whisper.cpp hardware: {backend} | {backend_info} | RAM: {total_ram_gb}GB")
|
||||
|
||||
# Validate model file exists and get size
|
||||
if os.path.exists(model_path):
|
||||
model_size_mb = os.path.getsize(model_path) / (1024 * 1024)
|
||||
logger.info(f"whisper.cpp model file: {model_path} ({model_size_mb:.1f} MB)")
|
||||
else:
|
||||
logger.error(f"whisper.cpp model file not found: {model_path}")
|
||||
raise FileNotFoundError(f"Model file not found: {model_path}")
|
||||
|
||||
logger.info(f"Loading whisper.cpp '{self.model_size}' model...")
|
||||
# Ensure previous model is released if re-initializing
|
||||
self.model = None
|
||||
|
||||
# Load model with pywhispercpp
|
||||
# It auto-detects the best backend (Vulkan, CUDA, or CPU)
|
||||
# Use all available CPU cores for best performance
|
||||
import multiprocessing
|
||||
|
||||
n_threads = multiprocessing.cpu_count()
|
||||
cpu_count = multiprocessing.cpu_count()
|
||||
|
||||
load_start_time = time.time()
|
||||
|
||||
loaded_backend = backend
|
||||
|
||||
# Attempt to load model with automatic backend selection
|
||||
# If Vulkan GPU fails (e.g., incompatible Intel GPU), fallback to CPU
|
||||
try:
|
||||
self.model = Model(
|
||||
model_path,
|
||||
n_threads=n_threads,
|
||||
suppress_blank=True,
|
||||
no_speech_thold=0.6,
|
||||
entropy_thold=2.4,
|
||||
)
|
||||
except RuntimeError as model_error:
|
||||
error_str = str(model_error).lower()
|
||||
# Check for Vulkan 16-bit storage incompatibility
|
||||
if (
|
||||
"16-bit storage" in error_str
|
||||
or "unsupported device" in error_str
|
||||
or "incompatible driver" in error_str
|
||||
):
|
||||
logger.warning(
|
||||
f"Vulkan GPU initialization failed: {model_error}. "
|
||||
"Falling back to CPU backend."
|
||||
)
|
||||
_show_notification(
|
||||
"Vocalinux: GPU Fallback",
|
||||
"Your GPU doesn't support whisper.cpp Vulkan.\n"
|
||||
"Switched to CPU mode - still fast!",
|
||||
"dialog-information",
|
||||
)
|
||||
# Force CPU backend by disabling GPU backends
|
||||
os.environ["GGML_VULKAN"] = "0"
|
||||
os.environ["GGML_CUDA"] = "0"
|
||||
# Retry with CPU-only backend
|
||||
self.model = Model(
|
||||
model_path,
|
||||
n_threads=n_threads,
|
||||
suppress_blank=True,
|
||||
no_speech_thold=0.6,
|
||||
entropy_thold=2.4,
|
||||
)
|
||||
loaded_backend = ComputeBackend.CPU
|
||||
backend_info = "CPU (fallback from incompatible Vulkan GPU)"
|
||||
logger.info("Successfully loaded model with CPU backend")
|
||||
else:
|
||||
raise
|
||||
|
||||
load_duration = time.time() - load_start_time
|
||||
|
||||
logger.info(
|
||||
f"whisper.cpp configured with n_threads={n_threads} (detected {cpu_count} CPUs)"
|
||||
)
|
||||
logger.info(
|
||||
f"whisper.cpp model loaded in {load_duration:.2f}s ({loaded_backend} backend)"
|
||||
)
|
||||
|
||||
self._model_initialized = True
|
||||
logger.info("whisper.cpp engine initialized successfully.")
|
||||
self._load_whispercpp_model(model_path)
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import pywhispercpp: {e}")
|
||||
@@ -927,11 +827,127 @@ class SpeechRecognitionManager:
|
||||
logger.error("Please install with: pip install pywhispercpp")
|
||||
self.state = RecognitionState.ERROR
|
||||
raise
|
||||
except Exception as e:
|
||||
except (FileNotFoundError, RuntimeError, OSError) as e:
|
||||
logger.error(f"Failed to initialize whisper.cpp engine: {e}", exc_info=True)
|
||||
self.state = RecognitionState.ERROR
|
||||
raise
|
||||
|
||||
def _load_whispercpp_model(self, model_path: str):
|
||||
"""Load the whisper.cpp model file and configure the compute backend.
|
||||
|
||||
Detects the optimal backend (Vulkan → CUDA → CPU), loads the model,
|
||||
and falls back to CPU if the GPU backend is incompatible.
|
||||
|
||||
Args:
|
||||
model_path: Filesystem path to the GGML model file.
|
||||
"""
|
||||
import multiprocessing
|
||||
import time
|
||||
|
||||
from pywhispercpp.model import Model
|
||||
|
||||
from ..utils.whispercpp_model_info import (
|
||||
ComputeBackend,
|
||||
detect_compute_backend,
|
||||
get_backend_display_name,
|
||||
)
|
||||
|
||||
# Detect and log compute backend
|
||||
backend, backend_info = detect_compute_backend()
|
||||
logger.info(f"whisper.cpp backend selection priority: Vulkan -> CUDA -> CPU")
|
||||
logger.info(
|
||||
f"whisper.cpp using {get_backend_display_name(backend)} backend: {backend_info}"
|
||||
)
|
||||
|
||||
# Log hardware summary
|
||||
import psutil
|
||||
|
||||
total_ram_gb = psutil.virtual_memory().total // (1024**3)
|
||||
logger.info(f"whisper.cpp hardware: {backend} | {backend_info} | RAM: {total_ram_gb}GB")
|
||||
|
||||
# Validate model file exists and get size
|
||||
if os.path.exists(model_path):
|
||||
model_size_mb = os.path.getsize(model_path) / (1024 * 1024)
|
||||
logger.info(f"whisper.cpp model file: {model_path} ({model_size_mb:.1f} MB)")
|
||||
else:
|
||||
raise FileNotFoundError(f"Model file not found: {model_path}")
|
||||
|
||||
logger.info(f"Loading whisper.cpp '{self.model_size}' model...")
|
||||
self.model = None # Release previous model if re-initializing
|
||||
|
||||
n_threads = multiprocessing.cpu_count()
|
||||
load_start_time = time.time()
|
||||
loaded_backend = backend
|
||||
|
||||
# Attempt to load model; fall back to CPU if GPU backend is incompatible
|
||||
try:
|
||||
self.model = Model(
|
||||
model_path,
|
||||
n_threads=n_threads,
|
||||
suppress_blank=True,
|
||||
no_speech_thold=0.6,
|
||||
entropy_thold=2.4,
|
||||
)
|
||||
except RuntimeError as model_error:
|
||||
loaded_backend = self._handle_gpu_fallback(
|
||||
model_error, model_path, n_threads, ComputeBackend.CPU
|
||||
)
|
||||
|
||||
load_duration = time.time() - load_start_time
|
||||
logger.info(
|
||||
f"whisper.cpp configured with n_threads={n_threads} "
|
||||
f"(detected {multiprocessing.cpu_count()} CPUs)"
|
||||
)
|
||||
logger.info(f"whisper.cpp model loaded in {load_duration:.2f}s ({loaded_backend} backend)")
|
||||
|
||||
self._model_initialized = True
|
||||
logger.info("whisper.cpp engine initialized successfully.")
|
||||
|
||||
def _handle_gpu_fallback(self, error, model_path: str, n_threads: int, cpu_backend):
|
||||
"""Handle GPU backend failure by falling back to CPU.
|
||||
|
||||
Args:
|
||||
error: The RuntimeError from model loading.
|
||||
model_path: Path to the GGML model file.
|
||||
n_threads: Number of CPU threads for the model.
|
||||
cpu_backend: The CPU ComputeBackend enum value.
|
||||
|
||||
Returns:
|
||||
The backend that was actually used.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the error is not a known GPU incompatibility.
|
||||
"""
|
||||
from pywhispercpp.model import Model
|
||||
|
||||
error_str = str(error).lower()
|
||||
gpu_incompatible = (
|
||||
"16-bit storage" in error_str
|
||||
or "unsupported device" in error_str
|
||||
or "incompatible driver" in error_str
|
||||
)
|
||||
if not gpu_incompatible:
|
||||
raise error
|
||||
|
||||
logger.warning(f"Vulkan GPU initialization failed: {error}. Falling back to CPU backend.")
|
||||
_show_notification(
|
||||
"Vocalinux: GPU Fallback",
|
||||
"Your GPU doesn't support whisper.cpp Vulkan.\n" "Switched to CPU mode - still fast!",
|
||||
"dialog-information",
|
||||
)
|
||||
# Force CPU backend by disabling GPU backends
|
||||
os.environ["GGML_VULKAN"] = "0"
|
||||
os.environ["GGML_CUDA"] = "0"
|
||||
self.model = Model(
|
||||
model_path,
|
||||
n_threads=n_threads,
|
||||
suppress_blank=True,
|
||||
no_speech_thold=0.6,
|
||||
entropy_thold=2.4,
|
||||
)
|
||||
logger.info("Successfully loaded model with CPU backend")
|
||||
return cpu_backend
|
||||
|
||||
def _transcribe_with_whispercpp(self, audio_buffer: list[bytes]) -> str:
|
||||
"""
|
||||
Transcribe audio buffer using whisper.cpp.
|
||||
@@ -1111,7 +1127,7 @@ class SpeechRecognitionManager:
|
||||
if os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
raise RuntimeError(f"Failed to download whisper.cpp model: {e}") from e
|
||||
except Exception as e:
|
||||
except (OSError, RuntimeError, ValueError) as e:
|
||||
logger.error(f"An error occurred during whisper.cpp model download: {e}")
|
||||
if os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
@@ -1274,7 +1290,7 @@ class SpeechRecognitionManager:
|
||||
if os.path.exists(zip_path):
|
||||
os.remove(zip_path)
|
||||
raise RuntimeError("Downloaded VOSK model file is corrupted.")
|
||||
except Exception as e:
|
||||
except (OSError, RuntimeError, ValueError) as e:
|
||||
logger.error(f"An error occurred during VOSK model download/extraction: {e}")
|
||||
# Clean up potentially corrupted extraction
|
||||
if os.path.exists(zip_path):
|
||||
@@ -1390,7 +1406,7 @@ class SpeechRecognitionManager:
|
||||
if os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
raise RuntimeError(f"Failed to download Whisper model: {e}") from e
|
||||
except Exception as e:
|
||||
except (OSError, RuntimeError, ValueError) as e:
|
||||
logger.error(f"An error occurred during Whisper model download: {e}")
|
||||
if os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
|
||||
@@ -5,7 +5,7 @@ This module handles special voice commands like "delete that", "undo", etc.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..text_injection.text_injector import TextInjector
|
||||
@@ -21,6 +21,20 @@ class ActionHandler:
|
||||
and performs the appropriate actions like deleting text, undoing, etc.
|
||||
"""
|
||||
|
||||
# Keyboard shortcut mappings for simple forwarding actions.
|
||||
# Actions not listed here have custom handlers defined as methods.
|
||||
_SHORTCUT_ACTIONS: dict[str, str] = {
|
||||
"undo": "ctrl+z",
|
||||
"redo": "ctrl+y",
|
||||
"select_all": "ctrl+a",
|
||||
"select_line": "Home+shift+End",
|
||||
"select_word": "ctrl+shift+Right",
|
||||
"select_paragraph": "ctrl+shift+Down",
|
||||
"cut": "ctrl+x",
|
||||
"copy": "ctrl+c",
|
||||
"paste": "ctrl+v",
|
||||
}
|
||||
|
||||
def __init__(self, text_injector: "TextInjector"):
|
||||
"""
|
||||
Initialize the action handler.
|
||||
@@ -31,19 +45,12 @@ class ActionHandler:
|
||||
self.text_injector = text_injector
|
||||
self.last_injected_text = ""
|
||||
|
||||
# Map actions to handler methods
|
||||
self.action_handlers = {
|
||||
# Build action dispatch table: custom handlers + shortcut-based actions
|
||||
self.action_handlers: dict[str, Callable[[], bool]] = {
|
||||
"delete_last": self._handle_delete_last,
|
||||
"undo": self._handle_undo,
|
||||
"redo": self._handle_redo,
|
||||
"select_all": self._handle_select_all,
|
||||
"select_line": self._handle_select_line,
|
||||
"select_word": self._handle_select_word,
|
||||
"select_paragraph": self._handle_select_paragraph,
|
||||
"cut": self._handle_cut,
|
||||
"copy": self._handle_copy,
|
||||
"paste": self._handle_paste,
|
||||
}
|
||||
for action, shortcut in self._SHORTCUT_ACTIONS.items():
|
||||
self.action_handlers[action] = self._make_shortcut_handler(shortcut)
|
||||
|
||||
def handle_action(self, action: str) -> bool:
|
||||
"""
|
||||
@@ -77,6 +84,14 @@ class ActionHandler:
|
||||
"""
|
||||
self.last_injected_text = text
|
||||
|
||||
def _make_shortcut_handler(self, shortcut: str) -> Callable[[], bool]:
|
||||
"""Create a handler that sends a keyboard shortcut via the text injector."""
|
||||
|
||||
def handler() -> bool:
|
||||
return self.text_injector._inject_keyboard_shortcut(shortcut)
|
||||
|
||||
return handler
|
||||
|
||||
def _handle_delete_last(self) -> bool:
|
||||
"""Handle 'delete that' command by sending backspace keys."""
|
||||
if not self.last_injected_text:
|
||||
@@ -92,39 +107,3 @@ class ActionHandler:
|
||||
self.last_injected_text = ""
|
||||
|
||||
return success
|
||||
|
||||
def _handle_undo(self) -> bool:
|
||||
"""Handle 'undo' command by sending Ctrl+Z."""
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+z")
|
||||
|
||||
def _handle_redo(self) -> bool:
|
||||
"""Handle 'redo' command by sending Ctrl+Y."""
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+y")
|
||||
|
||||
def _handle_select_all(self) -> bool:
|
||||
"""Handle 'select all' command by sending Ctrl+A."""
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+a")
|
||||
|
||||
def _handle_select_line(self) -> bool:
|
||||
"""Handle 'select line' command by sending Home+Shift+End."""
|
||||
return self.text_injector._inject_keyboard_shortcut("Home+shift+End")
|
||||
|
||||
def _handle_select_word(self) -> bool:
|
||||
"""Handle 'select word' command by sending Ctrl+Shift+Right."""
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+shift+Right")
|
||||
|
||||
def _handle_select_paragraph(self) -> bool:
|
||||
"""Handle 'select paragraph' command by sending Ctrl+Shift+Down."""
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+shift+Down")
|
||||
|
||||
def _handle_cut(self) -> bool:
|
||||
"""Handle 'cut' command by sending Ctrl+X."""
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+x")
|
||||
|
||||
def _handle_copy(self) -> bool:
|
||||
"""Handle 'copy' command by sending Ctrl+C."""
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+c")
|
||||
|
||||
def _handle_paste(self) -> bool:
|
||||
"""Handle 'paste' command by sending Ctrl+V."""
|
||||
return self.text_injector._inject_keyboard_shortcut("ctrl+v")
|
||||
|
||||
@@ -4,10 +4,11 @@ Configuration manager for Vocalinux.
|
||||
This module handles loading, saving, and accessing user preferences.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,10 +68,12 @@ class ConfigManager:
|
||||
preferences for the application.
|
||||
"""
|
||||
|
||||
# Valid configuration sections — prevents accidental typos from silently
|
||||
# creating new top-level config keys.
|
||||
_VALID_SECTIONS = frozenset(DEFAULT_CONFIG.keys())
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the configuration manager."""
|
||||
import copy
|
||||
|
||||
self.config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
self._ensure_config_dir()
|
||||
self.load_config()
|
||||
@@ -106,7 +109,7 @@ class ConfigManager:
|
||||
|
||||
self._migrate_shortcuts_config()
|
||||
|
||||
except Exception as e:
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.error(f"Failed to load config: {e}")
|
||||
|
||||
def _check_needs_migration(self, user_config: dict) -> bool:
|
||||
@@ -162,12 +165,22 @@ class ConfigManager:
|
||||
logger.info(f"Saved configuration to {CONFIG_FILE}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
except (OSError, TypeError) as e:
|
||||
logger.error(f"Failed to save config: {e}")
|
||||
return False
|
||||
|
||||
def save_settings(self):
|
||||
"""Save the current configuration to the config file."""
|
||||
"""Save the current configuration to the config file.
|
||||
|
||||
.. deprecated:: Use :meth:`save_config` instead.
|
||||
"""
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"save_settings() is deprecated, use save_config() instead",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self.save_config()
|
||||
|
||||
def get(self, section: str, key: str, default: Any = None) -> Any:
|
||||
@@ -187,12 +200,54 @@ class ConfigManager:
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
# -- Typed accessors -------------------------------------------------------
|
||||
# These provide compile-time type safety for commonly accessed config values,
|
||||
# avoiding the need for callers to cast the Any return from get().
|
||||
|
||||
def get_str(self, section: str, key: str, default: str = "") -> str:
|
||||
"""Get a configuration value as a string."""
|
||||
value = self.get(section, key, default)
|
||||
return str(value) if value is not None else default
|
||||
|
||||
def get_bool(self, section: str, key: str, default: bool = False) -> bool:
|
||||
"""Get a configuration value as a boolean."""
|
||||
value = self.get(section, key, default)
|
||||
return bool(value)
|
||||
|
||||
def get_int(self, section: str, key: str, default: int = 0) -> int:
|
||||
"""Get a configuration value as an integer."""
|
||||
value = self.get(section, key, default)
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def get_float(self, section: str, key: str, default: float = 0.0) -> float:
|
||||
"""Get a configuration value as a float."""
|
||||
value = self.get(section, key, default)
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def get_optional_int(
|
||||
self, section: str, key: str, default: Optional[int] = None
|
||||
) -> Optional[int]:
|
||||
"""Get a configuration value as an optional integer (allows None)."""
|
||||
value = self.get(section, key, default)
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def set(self, section: str, key: str, value: Any) -> bool:
|
||||
"""
|
||||
Set a configuration value.
|
||||
|
||||
Args:
|
||||
section: The configuration section
|
||||
section: The configuration section (must be a known section)
|
||||
key: The configuration key within the section
|
||||
value: The value to set
|
||||
|
||||
@@ -201,12 +256,17 @@ class ConfigManager:
|
||||
"""
|
||||
try:
|
||||
if section not in self.config:
|
||||
if section not in self._VALID_SECTIONS:
|
||||
logger.warning(
|
||||
f"Unknown config section '{section}' — valid sections: "
|
||||
f"{sorted(self._VALID_SECTIONS)}"
|
||||
)
|
||||
self.config[section] = {}
|
||||
|
||||
self.config[section][key] = value
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
except (KeyError, TypeError) as e:
|
||||
logger.error(f"Failed to set config value: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -1190,7 +1190,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self.shortcut_mode_combo.append(mode_id, display_name)
|
||||
|
||||
# Load current mode from config
|
||||
current_mode = self.config_manager.get("shortcuts", "mode", "toggle")
|
||||
current_mode = self.config_manager.get_str("shortcuts", "mode", "toggle")
|
||||
if not self.shortcut_mode_combo.set_active_id(current_mode):
|
||||
self.shortcut_mode_combo.set_active_id("toggle")
|
||||
|
||||
@@ -1217,7 +1217,9 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self.shortcut_combo.append(shortcut_id, display_name)
|
||||
|
||||
# Load current shortcut from config
|
||||
current_shortcut = self.config_manager.get("shortcuts", "toggle_recognition", "ctrl+ctrl")
|
||||
current_shortcut = self.config_manager.get_str(
|
||||
"shortcuts", "toggle_recognition", "ctrl+ctrl"
|
||||
)
|
||||
if not self.shortcut_combo.set_active_id(current_shortcut):
|
||||
self.shortcut_combo.set_active_id("ctrl+ctrl")
|
||||
|
||||
@@ -1321,7 +1323,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
# Ignore separator entries (used for grouped display)
|
||||
if shortcut_id.startswith("__separator_"):
|
||||
# Revert to the previously saved shortcut
|
||||
current = self.config_manager.get("shortcuts", "toggle_recognition", "ctrl+ctrl")
|
||||
current = self.config_manager.get_str("shortcuts", "toggle_recognition", "ctrl+ctrl")
|
||||
self.shortcut_combo.set_active_id(current)
|
||||
return
|
||||
|
||||
@@ -2207,7 +2209,7 @@ For now, the engine has been reverted to VOSK."""
|
||||
label += " (default)"
|
||||
self.audio_device_combo.append(str(device_index), label)
|
||||
|
||||
saved_device = self.config_manager.get("audio", "device_index", None)
|
||||
saved_device = self.config_manager.get_optional_int("audio", "device_index", None)
|
||||
|
||||
if saved_device is None:
|
||||
self.audio_device_combo.set_active_id("-1")
|
||||
|
||||
@@ -29,22 +29,17 @@ from gi.repository import GdkPixbuf, Gio, GLib, GObject, Gtk
|
||||
|
||||
# Import local modules - Use protocols to avoid circular imports
|
||||
from ..common_types import RecognitionState, SpeechRecognitionManagerProtocol, TextInjectorProtocol
|
||||
from ..suspend_handler import SuspendHandler # noqa: E402
|
||||
|
||||
# Import necessary components
|
||||
from .config_manager import ConfigManager # noqa: E402
|
||||
from .keyboard_shortcuts import KeyboardShortcutManager # noqa: E402
|
||||
from .settings_dialog import SettingsDialog # noqa: E402
|
||||
from ..suspend_handler import SuspendHandler
|
||||
from ..utils.resource_manager import ResourceManager
|
||||
from .config_manager import ConfigManager
|
||||
from .keyboard_shortcuts import KeyboardShortcutManager
|
||||
from .settings_dialog import SettingsDialog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Define constants
|
||||
APP_ID = "vocalinux"
|
||||
|
||||
|
||||
# Import the centralized resource manager
|
||||
from ..utils.resource_manager import ResourceManager # noqa: E402
|
||||
|
||||
# Initialize resource manager
|
||||
_resource_manager = ResourceManager()
|
||||
ICON_DIR = _resource_manager.icons_dir
|
||||
@@ -86,8 +81,8 @@ class TrayIndicator:
|
||||
self._syncing_autostart_menu = False
|
||||
|
||||
# Get configured shortcut and mode from config
|
||||
shortcut = self.config_manager.get("shortcuts", "toggle_recognition", "ctrl+ctrl")
|
||||
mode = self.config_manager.get("shortcuts", "mode", "toggle")
|
||||
shortcut = self.config_manager.get_str("shortcuts", "toggle_recognition", "ctrl+ctrl")
|
||||
mode = self.config_manager.get_str("shortcuts", "mode", "toggle")
|
||||
|
||||
# Initialize keyboard shortcut manager with configured shortcut and mode
|
||||
self.shortcut_manager = KeyboardShortcutManager(shortcut=shortcut, mode=mode)
|
||||
@@ -138,7 +133,7 @@ class TrayIndicator:
|
||||
self.shortcut_manager.register_release_callback(None)
|
||||
|
||||
# Get configured mode from config
|
||||
mode = self.config_manager.get("shortcuts", "mode", "toggle")
|
||||
mode = self.config_manager.get_str("shortcuts", "mode", "toggle")
|
||||
logger.info(f"Setting up keyboard shortcuts with mode: {mode}")
|
||||
|
||||
if mode == "toggle":
|
||||
@@ -294,7 +289,7 @@ class TrayIndicator:
|
||||
from . import autostart_manager
|
||||
|
||||
autostart_enabled = autostart_manager.is_autostart_enabled()
|
||||
config_enabled = self.config_manager.get("general", "autostart", False)
|
||||
config_enabled = self.config_manager.get_bool("general", "autostart", False)
|
||||
if config_enabled != autostart_enabled:
|
||||
self.config_manager.set("general", "autostart", autostart_enabled)
|
||||
self.config_manager.save_settings()
|
||||
|
||||
Reference in New Issue
Block a user