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"