Some checks failed
Make distro packages the single source of truth for GTK/X11 Python bindings instead of advertising them as wheel-managed runtime dependencies. Update the uv, CI, and packaging workflows to use system site packages, regenerate uv.lock, and keep portable and Arch metadata aligned with that contract. Pull runtime policy, audio probing, and page builders out of config_ui.py so the settings window becomes a coordinator instead of a single large mixed-concern module. Rename the config serialization and logging helpers, and stop startup logging from exposing raw vocabulary entries or custom model paths. Remove stale helper aliases and add regression coverage for safe startup logging, packaging metadata and module drift, portable requirements, and the extracted audio helper behavior. Validated with uv lock, python3 -m compileall -q src tests, python3 -m unittest discover -s tests -p 'test_*.py', make build, and make package-arch.
392 lines
15 KiB
Python
392 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import importlib.metadata
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import gi
|
|
|
|
from config import Config, DEFAULT_STT_PROVIDER
|
|
from config_ui_audio import AudioSettingsService
|
|
from config_ui_pages import (
|
|
build_about_page,
|
|
build_advanced_page,
|
|
build_audio_page,
|
|
build_general_page,
|
|
build_help_page,
|
|
)
|
|
from config_ui_runtime import (
|
|
RUNTIME_MODE_EXPERT,
|
|
RUNTIME_MODE_MANAGED,
|
|
apply_canonical_runtime_defaults,
|
|
infer_runtime_mode,
|
|
)
|
|
from constants import DEFAULT_CONFIG_PATH
|
|
from languages import stt_language_label
|
|
|
|
gi.require_version("Gdk", "3.0")
|
|
gi.require_version("Gtk", "3.0")
|
|
from gi.repository import Gdk, Gtk # type: ignore[import-not-found]
|
|
|
|
|
|
@dataclass
|
|
class ConfigUiResult:
|
|
saved: bool
|
|
config: Config | None
|
|
closed_reason: str | None = None
|
|
|
|
|
|
class ConfigWindow:
|
|
def __init__(
|
|
self,
|
|
initial_cfg: Config,
|
|
desktop,
|
|
*,
|
|
required: bool,
|
|
config_path: str | Path | None,
|
|
) -> None:
|
|
self._desktop = desktop
|
|
self._config = copy.deepcopy(initial_cfg)
|
|
self._required = required
|
|
self._config_path = Path(config_path) if config_path else DEFAULT_CONFIG_PATH
|
|
self._audio_settings = AudioSettingsService()
|
|
self._devices = self._audio_settings.list_input_devices()
|
|
self._device_by_id = {str(device["index"]): device for device in self._devices}
|
|
self._row_to_section: dict[Gtk.ListBoxRow, str] = {}
|
|
self._runtime_mode = infer_runtime_mode(self._config)
|
|
|
|
title = "Aman Settings (Required)" if required else "Aman Settings"
|
|
self._dialog = Gtk.Dialog(title=title, flags=Gtk.DialogFlags.MODAL)
|
|
self._dialog.set_default_size(880, 560)
|
|
self._dialog.set_modal(True)
|
|
self._dialog.set_keep_above(True)
|
|
self._dialog.set_position(Gtk.WindowPosition.CENTER_ALWAYS)
|
|
self._dialog.set_type_hint(Gdk.WindowTypeHint.DIALOG)
|
|
|
|
self._dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
|
|
self._apply_button = self._dialog.add_button("Apply", Gtk.ResponseType.APPLY)
|
|
self._dialog.set_default_response(Gtk.ResponseType.APPLY)
|
|
|
|
content = self._dialog.get_content_area()
|
|
content.set_border_width(12)
|
|
content.set_spacing(10)
|
|
|
|
if self._required:
|
|
banner = Gtk.InfoBar()
|
|
banner.set_show_close_button(False)
|
|
banner.set_message_type(Gtk.MessageType.WARNING)
|
|
banner_label = Gtk.Label(
|
|
label="Aman needs saved settings before it can start recording from the tray."
|
|
)
|
|
banner_label.set_xalign(0.0)
|
|
banner_label.set_line_wrap(True)
|
|
banner.get_content_area().pack_start(banner_label, True, True, 0)
|
|
content.pack_start(banner, False, False, 0)
|
|
|
|
body = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
|
content.pack_start(body, True, True, 0)
|
|
|
|
self._navigation = Gtk.ListBox()
|
|
self._navigation.set_selection_mode(Gtk.SelectionMode.SINGLE)
|
|
self._navigation.set_activate_on_single_click(True)
|
|
self._navigation.connect("row-selected", self._on_nav_selected)
|
|
|
|
nav_scroll = Gtk.ScrolledWindow()
|
|
nav_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
|
nav_scroll.set_min_content_width(210)
|
|
nav_scroll.add(self._navigation)
|
|
body.pack_start(nav_scroll, False, False, 0)
|
|
|
|
self._stack = Gtk.Stack()
|
|
self._stack.set_hexpand(True)
|
|
self._stack.set_vexpand(True)
|
|
self._stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
|
|
self._stack.set_transition_duration(120)
|
|
body.pack_start(self._stack, True, True, 0)
|
|
|
|
self._general_page = build_general_page(self)
|
|
self._audio_page = build_audio_page(self)
|
|
self._advanced_page = build_advanced_page(self)
|
|
self._help_page = build_help_page(self, present_about_dialog=_present_about_dialog)
|
|
self._about_page = build_about_page(self, present_about_dialog=_present_about_dialog)
|
|
|
|
self._add_section("general", "General", self._general_page)
|
|
self._add_section("audio", "Audio", self._audio_page)
|
|
self._add_section("advanced", "Runtime & Models", self._advanced_page)
|
|
self._add_section("help", "Help", self._help_page)
|
|
self._add_section("about", "About", self._about_page)
|
|
|
|
self._initialize_widget_values()
|
|
self._validate_hotkey()
|
|
first_row = self._navigation.get_row_at_index(0)
|
|
if first_row is not None:
|
|
self._navigation.select_row(first_row)
|
|
|
|
def run(self) -> ConfigUiResult:
|
|
self._dialog.show_all()
|
|
while True:
|
|
response = self._dialog.run()
|
|
if response == Gtk.ResponseType.APPLY:
|
|
if not self._validate_hotkey():
|
|
continue
|
|
if not self._validate_runtime_settings():
|
|
continue
|
|
cfg = self._build_result_config()
|
|
self._dialog.destroy()
|
|
return ConfigUiResult(saved=True, config=cfg, closed_reason="saved")
|
|
reason = "cancelled" if response == Gtk.ResponseType.CANCEL else "closed"
|
|
self._dialog.destroy()
|
|
return ConfigUiResult(saved=False, config=None, closed_reason=reason)
|
|
|
|
def _add_section(self, name: str, title: str, widget: Gtk.Widget) -> None:
|
|
row = Gtk.ListBoxRow()
|
|
row_label = Gtk.Label(label=title)
|
|
row_label.set_xalign(0.0)
|
|
row_label.set_margin_start(10)
|
|
row_label.set_margin_end(10)
|
|
row_label.set_margin_top(8)
|
|
row_label.set_margin_bottom(8)
|
|
row.add(row_label)
|
|
self._navigation.add(row)
|
|
self._row_to_section[row] = name
|
|
self._stack.add_titled(widget, name, title)
|
|
|
|
def _on_nav_selected(self, _listbox, row: Gtk.ListBoxRow | None) -> None:
|
|
if row is None:
|
|
return
|
|
section = self._row_to_section.get(row)
|
|
if section:
|
|
self._stack.set_visible_child_name(section)
|
|
|
|
def _initialize_widget_values(self) -> None:
|
|
hotkey = self._config.daemon.hotkey.strip() or "Super+m"
|
|
self._hotkey_entry.set_text(hotkey)
|
|
|
|
backend = (self._config.injection.backend or "clipboard").strip().lower()
|
|
if backend not in {"clipboard", "injection"}:
|
|
backend = "clipboard"
|
|
self._backend_combo.set_active_id(backend)
|
|
self._remove_clipboard_check.set_active(
|
|
bool(self._config.injection.remove_transcription_from_clipboard)
|
|
)
|
|
language = (self._config.stt.language or "auto").strip().lower()
|
|
if self._language_combo.get_active_id() is None:
|
|
self._language_combo.set_active_id("auto")
|
|
self._language_combo.set_active_id(language)
|
|
if self._language_combo.get_active_id() != language:
|
|
self._language_combo.append(language, stt_language_label(language))
|
|
self._language_combo.set_active_id(language)
|
|
|
|
profile = (self._config.ux.profile or "default").strip().lower()
|
|
if profile not in {"default", "fast", "polished"}:
|
|
profile = "default"
|
|
self._profile_combo.set_active_id(profile)
|
|
self._strict_startup_check.set_active(bool(self._config.advanced.strict_startup))
|
|
self._safety_enabled_check.set_active(bool(self._config.safety.enabled))
|
|
self._safety_strict_check.set_active(bool(self._config.safety.strict))
|
|
self._on_safety_guard_toggled()
|
|
self._allow_custom_models_check.set_active(bool(self._config.models.allow_custom_models))
|
|
self._whisper_model_path_entry.set_text(self._config.models.whisper_model_path)
|
|
self._runtime_mode_combo.set_active_id(self._runtime_mode)
|
|
self._sync_runtime_mode_ui(user_initiated=False)
|
|
self._validate_runtime_settings()
|
|
|
|
resolved = self._audio_settings.resolve_input_device(self._config.recording.input)
|
|
if resolved is None:
|
|
self._mic_combo.set_active_id("")
|
|
return
|
|
resolved_id = str(resolved)
|
|
self._mic_combo.set_active_id(resolved_id if resolved_id in self._device_by_id else "")
|
|
|
|
def _current_runtime_mode(self) -> str:
|
|
mode = (self._runtime_mode_combo.get_active_id() or "").strip().lower()
|
|
if mode in {RUNTIME_MODE_MANAGED, RUNTIME_MODE_EXPERT}:
|
|
return mode
|
|
return RUNTIME_MODE_MANAGED
|
|
|
|
def _on_runtime_mode_changed(self, *, user_initiated: bool) -> None:
|
|
self._sync_runtime_mode_ui(user_initiated=user_initiated)
|
|
self._validate_runtime_settings()
|
|
|
|
def _on_runtime_widgets_changed(self) -> None:
|
|
self._sync_runtime_mode_ui(user_initiated=False)
|
|
self._validate_runtime_settings()
|
|
|
|
def _on_safety_guard_toggled(self) -> None:
|
|
self._safety_strict_check.set_sensitive(self._safety_enabled_check.get_active())
|
|
|
|
def _sync_runtime_mode_ui(self, *, user_initiated: bool) -> None:
|
|
mode = self._current_runtime_mode()
|
|
self._runtime_mode = mode
|
|
if mode == RUNTIME_MODE_MANAGED:
|
|
if user_initiated:
|
|
self._apply_canonical_runtime_defaults_to_widgets()
|
|
self._runtime_status_label.set_text(
|
|
"Aman-managed mode is active. Aman handles model lifecycle and keeps supported defaults."
|
|
)
|
|
self._expert_expander.set_expanded(False)
|
|
self._expert_expander.set_visible(False)
|
|
self._set_expert_controls_sensitive(False)
|
|
self._runtime_error.set_text("")
|
|
return
|
|
|
|
self._runtime_status_label.set_text(
|
|
"Expert mode is active. You are responsible for custom Whisper path compatibility."
|
|
)
|
|
self._expert_expander.set_visible(True)
|
|
self._expert_expander.set_expanded(True)
|
|
self._set_expert_controls_sensitive(True)
|
|
|
|
def _set_expert_controls_sensitive(self, enabled: bool) -> None:
|
|
allow_custom = self._allow_custom_models_check.get_active()
|
|
custom_path_enabled = enabled and allow_custom
|
|
|
|
self._allow_custom_models_check.set_sensitive(enabled)
|
|
self._whisper_model_path_entry.set_sensitive(custom_path_enabled)
|
|
|
|
def _apply_canonical_runtime_defaults_to_widgets(self) -> None:
|
|
self._allow_custom_models_check.set_active(False)
|
|
self._whisper_model_path_entry.set_text("")
|
|
|
|
def _validate_runtime_settings(self) -> bool:
|
|
mode = self._current_runtime_mode()
|
|
if mode == RUNTIME_MODE_MANAGED:
|
|
self._runtime_error.set_text("")
|
|
return True
|
|
|
|
self._runtime_error.set_text("")
|
|
return True
|
|
|
|
def _selected_input_spec(self) -> str | int | None:
|
|
selected = self._mic_combo.get_active_id()
|
|
if not selected:
|
|
return ""
|
|
if selected.isdigit():
|
|
return int(selected)
|
|
return selected
|
|
|
|
def _on_test_microphone(self) -> None:
|
|
input_spec = self._selected_input_spec()
|
|
self._mic_status.set_text("Testing microphone...")
|
|
while Gtk.events_pending():
|
|
Gtk.main_iteration()
|
|
result = self._audio_settings.test_microphone(input_spec)
|
|
self._mic_status.set_text(result.message)
|
|
|
|
def _validate_hotkey(self) -> bool:
|
|
hotkey = self._hotkey_entry.get_text().strip()
|
|
if not hotkey:
|
|
self._hotkey_error.set_text("Hotkey is required.")
|
|
self._apply_button.set_sensitive(False)
|
|
return False
|
|
try:
|
|
self._desktop.validate_hotkey(hotkey)
|
|
except Exception as exc:
|
|
self._hotkey_error.set_text(f"Hotkey is not available: {exc}")
|
|
self._apply_button.set_sensitive(False)
|
|
return False
|
|
self._hotkey_error.set_text("")
|
|
self._apply_button.set_sensitive(True)
|
|
return True
|
|
|
|
def _build_result_config(self) -> Config:
|
|
cfg = copy.deepcopy(self._config)
|
|
cfg.daemon.hotkey = self._hotkey_entry.get_text().strip()
|
|
cfg.recording.input = self._selected_input_spec()
|
|
cfg.injection.backend = self._backend_combo.get_active_id() or "clipboard"
|
|
cfg.injection.remove_transcription_from_clipboard = self._remove_clipboard_check.get_active()
|
|
cfg.stt.language = self._language_combo.get_active_id() or "auto"
|
|
cfg.ux.profile = self._profile_combo.get_active_id() or "default"
|
|
cfg.advanced.strict_startup = self._strict_startup_check.get_active()
|
|
cfg.safety.enabled = self._safety_enabled_check.get_active()
|
|
cfg.safety.strict = self._safety_strict_check.get_active() and cfg.safety.enabled
|
|
if self._current_runtime_mode() == RUNTIME_MODE_MANAGED:
|
|
apply_canonical_runtime_defaults(cfg)
|
|
return cfg
|
|
|
|
cfg.stt.provider = DEFAULT_STT_PROVIDER
|
|
cfg.models.allow_custom_models = self._allow_custom_models_check.get_active()
|
|
if cfg.models.allow_custom_models:
|
|
cfg.models.whisper_model_path = self._whisper_model_path_entry.get_text().strip()
|
|
else:
|
|
cfg.models.whisper_model_path = ""
|
|
return cfg
|
|
|
|
|
|
def run_config_ui(
|
|
initial_cfg: Config,
|
|
desktop,
|
|
*,
|
|
required: bool,
|
|
config_path: str | Path | None = None,
|
|
) -> ConfigUiResult:
|
|
try:
|
|
Gtk.init([])
|
|
except Exception:
|
|
pass
|
|
logging.info("opening settings ui")
|
|
window = ConfigWindow(
|
|
initial_cfg,
|
|
desktop,
|
|
required=required,
|
|
config_path=config_path,
|
|
)
|
|
return window.run()
|
|
|
|
|
|
def show_help_dialog() -> None:
|
|
try:
|
|
Gtk.init([])
|
|
except Exception:
|
|
pass
|
|
dialog = Gtk.MessageDialog(
|
|
None,
|
|
Gtk.DialogFlags.MODAL,
|
|
Gtk.MessageType.INFO,
|
|
Gtk.ButtonsType.OK,
|
|
"Aman Help",
|
|
)
|
|
dialog.set_title("Aman Help")
|
|
dialog.format_secondary_text(
|
|
"Press your hotkey to record, press it again to process, and press Esc while recording to "
|
|
"cancel. Daily use runs through the tray and user service. Use Run Diagnostics or "
|
|
"the doctor -> self-check -> journalctl -> aman run --verbose flow when something breaks. "
|
|
"Aman-managed mode is the canonical supported path; expert mode exposes custom Whisper model paths "
|
|
"for advanced users."
|
|
)
|
|
dialog.run()
|
|
dialog.destroy()
|
|
|
|
|
|
def show_about_dialog() -> None:
|
|
try:
|
|
Gtk.init([])
|
|
except Exception:
|
|
pass
|
|
_present_about_dialog(None)
|
|
|
|
|
|
def _present_about_dialog(parent) -> None:
|
|
about = Gtk.AboutDialog(transient_for=parent, modal=True)
|
|
about.set_program_name("Aman")
|
|
about.set_version(_app_version())
|
|
about.set_comments("Local amanuensis for X11 desktop dictation and rewriting.")
|
|
about.set_license("MIT")
|
|
about.set_wrap_license(True)
|
|
about.run()
|
|
about.destroy()
|
|
|
|
|
|
def _app_version() -> str:
|
|
pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
|
if pyproject_path.exists():
|
|
for line in pyproject_path.read_text(encoding="utf-8").splitlines():
|
|
stripped = line.strip()
|
|
if stripped.startswith('version = "'):
|
|
return stripped.split('"')[1]
|
|
try:
|
|
return importlib.metadata.version("aman")
|
|
except importlib.metadata.PackageNotFoundError:
|
|
return "unknown"
|