aman/src/config_ui.py
Thales Maciel c6fc61c885
Some checks failed
ci / Unit Matrix (3.10) (push) Has been cancelled
ci / Unit Matrix (3.11) (push) Has been cancelled
ci / Unit Matrix (3.12) (push) Has been cancelled
ci / Portable Ubuntu Smoke (push) Has been cancelled
ci / Package Artifacts (push) Has been cancelled
Normalize native dependency ownership and split config UI
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.
2026-03-15 11:27:54 -03:00

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"