from __future__ import annotations import copy import logging import time from dataclasses import dataclass from pathlib import Path import gi from config import ( Config, DEFAULT_STT_PROVIDER, ) from constants import DEFAULT_CONFIG_PATH from languages import COMMON_STT_LANGUAGE_OPTIONS, stt_language_label from recorder import list_input_devices, resolve_input_device, start_recording, stop_recording gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") from gi.repository import Gdk, Gtk # type: ignore[import-not-found] RUNTIME_MODE_MANAGED = "aman_managed" RUNTIME_MODE_EXPERT = "expert_custom" @dataclass class ConfigUiResult: saved: bool config: Config | None closed_reason: str | None = None def infer_runtime_mode(cfg: Config) -> str: is_canonical = ( cfg.stt.provider.strip().lower() == DEFAULT_STT_PROVIDER and not bool(cfg.models.allow_custom_models) and not cfg.models.whisper_model_path.strip() ) return RUNTIME_MODE_MANAGED if is_canonical else RUNTIME_MODE_EXPERT def apply_canonical_runtime_defaults(cfg: Config) -> None: cfg.stt.provider = DEFAULT_STT_PROVIDER cfg.models.allow_custom_models = False cfg.models.whisper_model_path = "" 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._devices = 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." ) 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 = self._build_general_page() self._audio_page = self._build_audio_page() self._advanced_page = self._build_advanced_page() self._help_page = self._build_help_page() self._about_page = self._build_about_page() 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 _build_general_page(self) -> Gtk.Widget: grid = Gtk.Grid(column_spacing=12, row_spacing=10) grid.set_margin_start(14) grid.set_margin_end(14) grid.set_margin_top(14) grid.set_margin_bottom(14) hotkey_label = Gtk.Label(label="Trigger hotkey") hotkey_label.set_xalign(0.0) self._hotkey_entry = Gtk.Entry() self._hotkey_entry.set_placeholder_text("Super+m") self._hotkey_entry.connect("changed", lambda *_: self._validate_hotkey()) grid.attach(hotkey_label, 0, 0, 1, 1) grid.attach(self._hotkey_entry, 1, 0, 1, 1) self._hotkey_error = Gtk.Label(label="") self._hotkey_error.set_xalign(0.0) self._hotkey_error.set_line_wrap(True) grid.attach(self._hotkey_error, 1, 1, 1, 1) backend_label = Gtk.Label(label="Text injection") backend_label.set_xalign(0.0) self._backend_combo = Gtk.ComboBoxText() self._backend_combo.append("clipboard", "Clipboard paste (recommended)") self._backend_combo.append("injection", "Simulated typing") grid.attach(backend_label, 0, 2, 1, 1) grid.attach(self._backend_combo, 1, 2, 1, 1) self._remove_clipboard_check = Gtk.CheckButton( label="Remove transcription from clipboard after paste" ) self._remove_clipboard_check.set_hexpand(True) grid.attach(self._remove_clipboard_check, 1, 3, 1, 1) language_label = Gtk.Label(label="Transcription language") language_label.set_xalign(0.0) self._language_combo = Gtk.ComboBoxText() for code, label in COMMON_STT_LANGUAGE_OPTIONS: self._language_combo.append(code, label) grid.attach(language_label, 0, 4, 1, 1) grid.attach(self._language_combo, 1, 4, 1, 1) profile_label = Gtk.Label(label="Profile") profile_label.set_xalign(0.0) self._profile_combo = Gtk.ComboBoxText() self._profile_combo.append("default", "Default") self._profile_combo.append("fast", "Fast (lower latency)") self._profile_combo.append("polished", "Polished") grid.attach(profile_label, 0, 5, 1, 1) grid.attach(self._profile_combo, 1, 5, 1, 1) self._show_notifications_check = Gtk.CheckButton(label="Enable tray notifications") self._show_notifications_check.set_hexpand(True) grid.attach(self._show_notifications_check, 1, 6, 1, 1) return grid def _build_audio_page(self) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) box.set_margin_start(14) box.set_margin_end(14) box.set_margin_top(14) box.set_margin_bottom(14) input_label = Gtk.Label(label="Input device") input_label.set_xalign(0.0) box.pack_start(input_label, False, False, 0) self._mic_combo = Gtk.ComboBoxText() self._mic_combo.append("", "System default") for device in self._devices: self._mic_combo.append(str(device["index"]), f"{device['index']}: {device['name']}") box.pack_start(self._mic_combo, False, False, 0) test_button = Gtk.Button(label="Test microphone") test_button.connect("clicked", lambda *_: self._on_test_microphone()) box.pack_start(test_button, False, False, 0) self._mic_status = Gtk.Label(label="") self._mic_status.set_xalign(0.0) self._mic_status.set_line_wrap(True) box.pack_start(self._mic_status, False, False, 0) return box def _build_advanced_page(self) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) box.set_margin_start(14) box.set_margin_end(14) box.set_margin_top(14) box.set_margin_bottom(14) self._strict_startup_check = Gtk.CheckButton(label="Fail fast on startup validation errors") box.pack_start(self._strict_startup_check, False, False, 0) safety_title = Gtk.Label() safety_title.set_markup("Output safety") safety_title.set_xalign(0.0) box.pack_start(safety_title, False, False, 0) self._safety_enabled_check = Gtk.CheckButton( label="Enable fact-preservation guard (recommended)" ) self._safety_enabled_check.connect("toggled", lambda *_: self._on_safety_guard_toggled()) box.pack_start(self._safety_enabled_check, False, False, 0) self._safety_strict_check = Gtk.CheckButton( label="Strict mode: reject output when facts are changed" ) box.pack_start(self._safety_strict_check, False, False, 0) runtime_title = Gtk.Label() runtime_title.set_markup("Runtime management") runtime_title.set_xalign(0.0) box.pack_start(runtime_title, False, False, 0) runtime_copy = Gtk.Label( label=( "Aman-managed mode handles the canonical editor model lifecycle for you. " "Expert mode keeps Aman open-source friendly by letting you use custom Whisper paths." ) ) runtime_copy.set_xalign(0.0) runtime_copy.set_line_wrap(True) box.pack_start(runtime_copy, False, False, 0) mode_label = Gtk.Label(label="Runtime mode") mode_label.set_xalign(0.0) box.pack_start(mode_label, False, False, 0) self._runtime_mode_combo = Gtk.ComboBoxText() self._runtime_mode_combo.append(RUNTIME_MODE_MANAGED, "Aman-managed (recommended)") self._runtime_mode_combo.append(RUNTIME_MODE_EXPERT, "Expert mode (custom Whisper path)") self._runtime_mode_combo.connect("changed", lambda *_: self._on_runtime_mode_changed(user_initiated=True)) box.pack_start(self._runtime_mode_combo, False, False, 0) self._runtime_status_label = Gtk.Label(label="") self._runtime_status_label.set_xalign(0.0) self._runtime_status_label.set_line_wrap(True) box.pack_start(self._runtime_status_label, False, False, 0) self._expert_expander = Gtk.Expander(label="Expert options") self._expert_expander.set_expanded(False) box.pack_start(self._expert_expander, False, False, 0) expert_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) expert_box.set_margin_start(10) expert_box.set_margin_end(10) expert_box.set_margin_top(8) expert_box.set_margin_bottom(8) self._expert_expander.add(expert_box) expert_warning = Gtk.InfoBar() expert_warning.set_show_close_button(False) expert_warning.set_message_type(Gtk.MessageType.WARNING) warning_label = Gtk.Label( label=( "Expert mode is best-effort and may require manual troubleshooting. " "Aman-managed mode is the canonical supported path." ) ) warning_label.set_xalign(0.0) warning_label.set_line_wrap(True) expert_warning.get_content_area().pack_start(warning_label, True, True, 0) expert_box.pack_start(expert_warning, False, False, 0) self._allow_custom_models_check = Gtk.CheckButton( label="Allow custom local model paths" ) self._allow_custom_models_check.connect("toggled", lambda *_: self._on_runtime_widgets_changed()) expert_box.pack_start(self._allow_custom_models_check, False, False, 0) whisper_model_path_label = Gtk.Label(label="Custom Whisper model path") whisper_model_path_label.set_xalign(0.0) expert_box.pack_start(whisper_model_path_label, False, False, 0) self._whisper_model_path_entry = Gtk.Entry() self._whisper_model_path_entry.connect("changed", lambda *_: self._on_runtime_widgets_changed()) expert_box.pack_start(self._whisper_model_path_entry, False, False, 0) self._runtime_error = Gtk.Label(label="") self._runtime_error.set_xalign(0.0) self._runtime_error.set_line_wrap(True) expert_box.pack_start(self._runtime_error, False, False, 0) path_label = Gtk.Label(label="Config path") path_label.set_xalign(0.0) box.pack_start(path_label, False, False, 0) path_entry = Gtk.Entry() path_entry.set_editable(False) path_entry.set_text(str(self._config_path)) box.pack_start(path_entry, False, False, 0) note = Gtk.Label( label=( "Tip: after editing the file directly, use Reload Config from the tray to apply changes." ) ) note.set_xalign(0.0) note.set_line_wrap(True) box.pack_start(note, False, False, 0) return box def _build_help_page(self) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) box.set_margin_start(14) box.set_margin_end(14) box.set_margin_top(14) box.set_margin_bottom(14) help_text = Gtk.Label( label=( "Usage:\n" "- Press your hotkey to start recording.\n" "- Press the hotkey again to stop and process.\n" "- Press Esc while recording to cancel.\n\n" "Model/runtime tips:\n" "- Aman-managed mode (recommended) handles model lifecycle for you.\n" "- Expert mode lets you set custom Whisper model paths.\n\n" "Safety tips:\n" "- Keep fact guard enabled to prevent accidental name/number changes.\n" "- Strict safety blocks output on fact violations.\n\n" "Use the tray menu for pause/resume, config reload, and diagnostics." ) ) help_text.set_xalign(0.0) help_text.set_line_wrap(True) box.pack_start(help_text, False, False, 0) about_button = Gtk.Button(label="Open About Dialog") about_button.connect("clicked", lambda *_: _present_about_dialog(self._dialog)) box.pack_start(about_button, False, False, 0) return box def _build_about_page(self) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) box.set_margin_start(14) box.set_margin_end(14) box.set_margin_top(14) box.set_margin_bottom(14) title = Gtk.Label() title.set_markup("Aman") title.set_xalign(0.0) box.pack_start(title, False, False, 0) subtitle = Gtk.Label(label="Local amanuensis for desktop dictation and rewriting.") subtitle.set_xalign(0.0) subtitle.set_line_wrap(True) box.pack_start(subtitle, False, False, 0) about_button = Gtk.Button(label="About Aman") about_button.connect("clicked", lambda *_: _present_about_dialog(self._dialog)) box.pack_start(about_button, False, False, 0) return box 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._show_notifications_check.set_active(bool(self._config.ux.show_notifications)) 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 = 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() try: stream, record = start_recording(input_spec) time.sleep(0.35) audio = stop_recording(stream, record) if getattr(audio, "size", 0) > 0: self._mic_status.set_text("Microphone test successful.") return self._mic_status.set_text("No audio captured. Try another device.") except Exception as exc: self._mic_status.set_text(f"Microphone test failed: {exc}") 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.ux.show_notifications = self._show_notifications_check.get_active() 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. Keep fact guard enabled to prevent accidental fact changes. 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("pre-release") about.set_comments("Local amanuensis for desktop dictation and rewriting.") about.set_license("MIT") about.set_wrap_license(True) about.run() about.destroy()