From 192b089c972d643179348208e70badbacb9cf9e9 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Thu, 5 Feb 2026 14:32:43 -0800 Subject: [PATCH] feat(ui): redesign settings dialog with modern GNOME HIG styling (#133) * feat(ui): redesign settings dialog with modern GNOME HIG styling Completely redesign the settings dialog to follow GNOME Human Interface Guidelines with a modern, card-based preference page layout: UI/UX Improvements: - Add PreferencesGroup and PreferenceRow custom widgets mimicking libadwaita's AdwPreferencesPage pattern - Implement proper visual hierarchy with card-style grouped sections - Add comprehensive CSS stylesheet with theme-aware colors - Use proper spacing, border-radius, and typography following HIG - Add smooth reveal animations for settings applied notifications - Improve status indicators with semantic colors (success/warning/error) Technical Changes: - Replace flat grid layout with scrollable content area - Use ListBox rows for better accessibility and keyboard navigation - Add proper Gdk version requirement to prevent version conflicts - Consolidate model info display into a unified info card - Improve code organization with dedicated section builder methods Visual Features: - Card-like preference groups with subtle borders and shadows - Proper row hover states for interactive feedback - Status color coding: green for success, yellow for warnings, red for errors - Cleaner legend display for model download status - Modern flat button styling for auxiliary actions All 328 existing tests continue to pass. * refactor(settings): simplify to Close-only button pattern Remove Apply/Cancel buttons in favor of pure instant-apply UX: - Settings now apply immediately when changed (auto-apply) - Single Close button follows modern GNOME Settings pattern - Remove redundant _revert_settings() and _show_settings_applied_message() - Update docstring to reflect instant-apply design This resolves the UX inconsistency where settings auto-applied but also had Apply/Cancel buttons that were confusing. * feat(ui): redesign Logs and About dialogs with GNOME HIG styling Logs Dialog: - Modern card-based filter bar with grouped controls - Theme-aware log level colors (works in light/dark mode) - Toolbar with icon buttons for Copy, Export, Clear actions - Toast notifications for user feedback - Close-only button pattern (consistent with settings) About Dialog: - Extract to separate about_dialog.py module - Use standard Gtk.AboutDialog (GNOME recommended) - Clean credits section with contributors and tech stack - Remove emojis for cleaner professional look - Better organized credit sections Both dialogs now follow the same design language as the Settings dialog redesign. --- src/vocalinux/ui/about_dialog.py | 364 ++++++++ src/vocalinux/ui/logging_dialog.py | 517 +++++++++-- src/vocalinux/ui/settings_dialog.py | 1328 ++++++++++++++------------- src/vocalinux/ui/tray_indicator.py | 45 +- tests/test_tray_indicator.py | 9 +- 5 files changed, 1476 insertions(+), 787 deletions(-) create mode 100644 src/vocalinux/ui/about_dialog.py diff --git a/src/vocalinux/ui/about_dialog.py b/src/vocalinux/ui/about_dialog.py new file mode 100644 index 0000000..2ee2103 --- /dev/null +++ b/src/vocalinux/ui/about_dialog.py @@ -0,0 +1,364 @@ +""" +About Dialog for Vocalinux. + +A modern, GNOME HIG-compliant about dialog with: +- Clean card-based layout +- App icon and branding +- Version and description +- Credits section with tabs +- Links to GitHub and license + +UX Design Notes: +- Follows GNOME Human Interface Guidelines +- Uses standard Gtk.AboutDialog as base (GNOME apps do this) +- Customizes with better styling and organization +- Close-only button pattern +""" + +import logging +import os + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import GdkPixbuf, Gtk # noqa: E402 + +logger = logging.getLogger(__name__) + + +def show_about_dialog(parent: Gtk.Window = None): + """ + Show the About dialog for Vocalinux. + + Uses Gtk.AboutDialog which is the GNOME-standard way to show app info. + This is the recommended approach per GNOME HIG. + + Args: + parent: Parent window for the dialog + """ + from ..utils.resource_manager import ResourceManager + from ..version import __copyright__, __description__, __url__, __version__ + + resource_manager = ResourceManager() + + about_dialog = Gtk.AboutDialog() + about_dialog.set_transient_for(parent) + about_dialog.set_modal(True) + + # Basic info + about_dialog.set_program_name("Vocalinux") + about_dialog.set_version(__version__) + about_dialog.set_copyright(__copyright__) + + # Description with cleaner formatting (no emojis in main comments) + about_dialog.set_comments(__description__) + + # Website + about_dialog.set_website(__url__) + about_dialog.set_website_label("Star on GitHub") + + # License + about_dialog.set_license_type(Gtk.License.GPL_3_0) + + # Credits + about_dialog.set_authors(["Jatin K Malik "]) + about_dialog.set_documenters(["Jatin K Malik"]) + about_dialog.set_artists(["Jatin K Malik"]) + + # Additional credits section + about_dialog.add_credit_section( + "Contributors", + [ + "Open to contributions!", + "github.com/jatinkrmalik/vocalinux", + ], + ) + + about_dialog.add_credit_section( + "Built With", + [ + "VOSK Speech Recognition", + "OpenAI Whisper", + "GTK 3 / PyGObject", + ], + ) + + # Set the logo + logo_path = resource_manager.get_icon_path("vocalinux") + if os.path.exists(logo_path): + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(logo_path) + # Scale to appropriate size for about dialog + scaled_pixbuf = pixbuf.scale_simple(128, 128, GdkPixbuf.InterpType.BILINEAR) + about_dialog.set_logo(scaled_pixbuf) + except Exception as e: + logger.warning(f"Failed to load or scale logo: {e}") + + # Run and clean up + about_dialog.run() + about_dialog.destroy() + + +class AboutDialog(Gtk.Dialog): + """ + Custom About Dialog with modern GNOME styling. + + This is an alternative to Gtk.AboutDialog for apps that want more + customization. Uses card-based layout with tabs for different sections. + """ + + # CSS for modern styling + ABOUT_CSS = """ + .about-dialog { + background-color: @theme_bg_color; + } + + .about-header { + padding: 32px 24px 24px 24px; + } + + .about-icon { + margin-bottom: 16px; + } + + .about-title { + font-size: 1.5em; + font-weight: bold; + margin-bottom: 4px; + } + + .about-version { + font-size: 0.9em; + color: @theme_unfocused_fg_color; + margin-bottom: 12px; + } + + .about-description { + color: @theme_unfocused_fg_color; + margin-bottom: 16px; + } + + .about-links { + margin-top: 8px; + } + + .about-link-button { + padding: 8px 16px; + border-radius: 8px; + background-color: @theme_selected_bg_color; + color: @theme_selected_fg_color; + } + + .about-link-button:hover { + background-color: shade(@theme_selected_bg_color, 1.1); + } + + .about-section { + background-color: @theme_base_color; + border-radius: 12px; + padding: 16px; + margin: 8px 24px; + border: 1px solid alpha(@borders, 0.5); + } + + .about-section-title { + font-weight: 600; + font-size: 0.85em; + color: @theme_unfocused_fg_color; + margin-bottom: 8px; + } + + .about-credit { + padding: 4px 0; + } + + .about-credit-name { + font-weight: 500; + } + + .about-credit-role { + font-size: 0.85em; + color: @theme_unfocused_fg_color; + } + + .about-footer { + padding: 16px 24px; + color: @theme_unfocused_fg_color; + font-size: 0.85em; + } + """ + + def __init__(self, parent: Gtk.Window = None): + from ..utils.resource_manager import ResourceManager + from ..version import __copyright__, __description__, __url__, __version__ + + super().__init__( + title="About Vocalinux", + transient_for=parent, + flags=Gtk.DialogFlags.MODAL, + ) + + self.resource_manager = ResourceManager() + self.version = __version__ + self.description = __description__ + self.url = __url__ + self.copyright = __copyright__ + + # Setup + self._setup_css() + self.set_default_size(400, -1) + self.set_resizable(False) + self.get_style_context().add_class("about-dialog") + + # Only Close button + self.add_button("_Close", Gtk.ResponseType.CLOSE) + + # Build UI + self._build_ui() + + # Connect response + self.connect("response", lambda d, r: d.destroy()) + + self.show_all() + + def _setup_css(self): + """Set up CSS styling.""" + from gi.repository import Gdk + + css_provider = Gtk.CssProvider() + css_provider.load_from_data(self.ABOUT_CSS.encode()) + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + def _build_ui(self): + """Build the dialog UI.""" + content_area = self.get_content_area() + + # Main container + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + content_area.add(main_box) + + # Header section (icon, name, version, description) + header = self._build_header() + main_box.pack_start(header, False, False, 0) + + # Links section + links = self._build_links() + main_box.pack_start(links, False, False, 0) + + # Credits section + credits_section = self._build_credits() + main_box.pack_start(credits_section, False, False, 0) + + # Footer (copyright, license) + footer = self._build_footer() + main_box.pack_start(footer, False, False, 0) + + def _build_header(self): + """Build the header with icon, name, version.""" + header = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + header.get_style_context().add_class("about-header") + header.set_halign(Gtk.Align.CENTER) + + # App icon + logo_path = self.resource_manager.get_icon_path("vocalinux") + if os.path.exists(logo_path): + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(logo_path) + scaled = pixbuf.scale_simple(96, 96, GdkPixbuf.InterpType.BILINEAR) + icon = Gtk.Image.new_from_pixbuf(scaled) + icon.get_style_context().add_class("about-icon") + header.pack_start(icon, False, False, 0) + except Exception as e: + logger.warning(f"Failed to load logo: {e}") + + # App name + name_label = Gtk.Label(label="Vocalinux") + name_label.get_style_context().add_class("about-title") + header.pack_start(name_label, False, False, 0) + + # Version + version_label = Gtk.Label(label=self.version) + version_label.get_style_context().add_class("about-version") + header.pack_start(version_label, False, False, 0) + + # Description + desc_label = Gtk.Label(label=self.description) + desc_label.get_style_context().add_class("about-description") + desc_label.set_line_wrap(True) + desc_label.set_max_width_chars(40) + desc_label.set_justify(Gtk.Justification.CENTER) + header.pack_start(desc_label, False, False, 0) + + return header + + def _build_links(self): + """Build the links section.""" + links_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + links_box.set_halign(Gtk.Align.CENTER) + links_box.get_style_context().add_class("about-links") + links_box.set_margin_bottom(16) + + # GitHub button + github_button = Gtk.LinkButton.new_with_label(self.url, "Star on GitHub") + github_button.get_style_context().add_class("about-link-button") + links_box.pack_start(github_button, False, False, 0) + + return links_box + + def _build_credits(self): + """Build the credits section.""" + section = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + section.get_style_context().add_class("about-section") + + # Title + title = Gtk.Label(label="CREDITS", xalign=0) + title.get_style_context().add_class("about-section-title") + section.pack_start(title, False, False, 0) + + # Author + author_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + author_box.get_style_context().add_class("about-credit") + + author_name = Gtk.Label(label="Jatin K Malik", xalign=0) + author_name.get_style_context().add_class("about-credit-name") + author_box.pack_start(author_name, False, False, 0) + + author_role = Gtk.Label(label="Author & Maintainer", xalign=0) + author_role.get_style_context().add_class("about-credit-role") + author_box.pack_start(author_role, False, False, 0) + + section.pack_start(author_box, False, False, 0) + + # Technologies + tech_label = Gtk.Label(xalign=0) + tech_label.set_markup("Built with VOSK, OpenAI Whisper, GTK 3, and Python") + tech_label.get_style_context().add_class("about-credit-role") + tech_label.set_margin_top(8) + section.pack_start(tech_label, False, False, 0) + + return section + + def _build_footer(self): + """Build the footer with copyright and license.""" + footer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + footer.get_style_context().add_class("about-footer") + footer.set_halign(Gtk.Align.CENTER) + + # Copyright + copyright_label = Gtk.Label(label=self.copyright) + copyright_label.set_line_wrap(True) + footer.pack_start(copyright_label, False, False, 0) + + # License + license_label = Gtk.Label() + license_label.set_markup( + '' + "GNU General Public License v3.0" + ) + footer.pack_start(license_label, False, False, 0) + + return footer diff --git a/src/vocalinux/ui/logging_dialog.py b/src/vocalinux/ui/logging_dialog.py index 42816ff..8eb73a0 100644 --- a/src/vocalinux/ui/logging_dialog.py +++ b/src/vocalinux/ui/logging_dialog.py @@ -3,6 +3,12 @@ Logging viewer dialog for VocaLinux. This module provides a GTK dialog for viewing, filtering, and exporting application logs for debugging purposes. + +UX Design Notes: +- Follows GNOME Human Interface Guidelines for modern desktop look +- Uses card-based layout with grouped filter controls +- Theme-aware colors for log levels (works in light/dark mode) +- Close-only button pattern (instant-apply for filters) """ import logging @@ -12,19 +18,174 @@ from typing import Optional import gi gi.require_version("Gtk", "3.0") -from gi.repository import GLib, Gtk # noqa: E402 +gi.require_version("Gdk", "3.0") +from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402 from .logging_manager import LogRecord, get_logging_manager # noqa: E402 logger = logging.getLogger(__name__) +# CSS for modern styling +LOGGING_CSS = """ +/* Modern GNOME-style logging dialog */ +.logging-dialog { + background-color: @theme_bg_color; +} + +/* Filter bar styling - card-like appearance */ +.filter-bar { + background-color: @theme_base_color; + border-radius: 12px; + padding: 12px 16px; + margin-bottom: 8px; + border: 1px solid alpha(@borders, 0.5); +} + +.filter-bar label { + color: @theme_unfocused_fg_color; + font-size: 0.9em; +} + +.filter-bar entry { + min-height: 32px; + border-radius: 6px; +} + +.filter-bar combobox button { + min-height: 32px; + border-radius: 6px; +} + +/* Log view styling */ +.log-view-container { + background-color: @theme_base_color; + border-radius: 12px; + border: 1px solid alpha(@borders, 0.5); +} + +.log-view { + font-family: "JetBrains Mono", "Fira Code", "Source Code Pro", monospace; + font-size: 9pt; + padding: 12px; + background-color: transparent; +} + +/* Status bar styling */ +.status-bar { + background-color: @theme_base_color; + border-radius: 8px; + padding: 8px 16px; + margin-top: 8px; + border: 1px solid alpha(@borders, 0.3); +} + +.status-bar label { + font-size: 0.85em; + color: @theme_unfocused_fg_color; +} + +.status-count { + font-weight: 500; + color: @theme_fg_color; +} + +/* Level indicators */ +.level-debug { + color: alpha(@theme_fg_color, 0.6); +} + +.level-info { + color: @theme_fg_color; +} + +.level-warning { + color: #e5a50a; + font-weight: 600; +} + +.level-error { + color: #c01c28; + font-weight: 700; +} + +.level-critical { + color: #ffffff; + background-color: #c01c28; + font-weight: 700; + border-radius: 3px; + padding: 1px 4px; +} + +/* Action buttons in header */ +.header-button { + border-radius: 6px; + padding: 6px 12px; + min-height: 28px; +} + +.header-button.destructive { + color: #c01c28; +} + +.header-button.destructive:hover { + background-color: alpha(#c01c28, 0.1); +} + +/* Toolbar styling */ +.toolbar-box { + margin-bottom: 8px; +} + +.toolbar-box button { + border-radius: 6px; + min-height: 32px; +} + +.toolbar-box button.suggested-action { + background-color: @theme_selected_bg_color; + color: @theme_selected_fg_color; +} + +/* Empty state */ +.empty-state { + padding: 48px; +} + +.empty-state-icon { + opacity: 0.4; + margin-bottom: 12px; +} + +.empty-state-title { + font-size: 1.2em; + font-weight: 600; + margin-bottom: 4px; +} + +.empty-state-subtitle { + color: @theme_unfocused_fg_color; + font-size: 0.9em; +} +""" + + +def _setup_css(): + """Set up CSS styling for the logging dialog.""" + css_provider = Gtk.CssProvider() + css_provider.load_from_data(LOGGING_CSS.encode()) + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + class LoggingDialog(Gtk.Dialog): - """GTK Dialog for viewing and managing application logs.""" + """Modern GTK Dialog for viewing and managing application logs.""" def __init__(self, parent: Optional[Gtk.Window] = None): super().__init__( - title="VocaLinux Logs", + title="Logs", transient_for=parent, flags=Gtk.DialogFlags.DESTROY_WITH_PARENT, modal=False, @@ -35,15 +196,15 @@ class LoggingDialog(Gtk.Dialog): self.filter_level = None self.filter_module = None + # Setup CSS styling + _setup_css() + # Set dialog properties self.set_default_size(900, 700) - self.set_border_width(10) + self.get_style_context().add_class("logging-dialog") - # Add buttons - self.add_button("Close", Gtk.ResponseType.CLOSE) - self.add_button("Copy All", Gtk.ResponseType.HELP) - self.add_button("Export", Gtk.ResponseType.APPLY) - self.add_button("Clear", Gtk.ResponseType.REJECT) + # Add only Close button (actions are in header bar style) + self.add_button("_Close", Gtk.ResponseType.CLOSE) # Create UI self._create_ui() @@ -61,14 +222,22 @@ class LoggingDialog(Gtk.Dialog): def _create_ui(self): """Create the user interface.""" content_area = self.get_content_area() + content_area.set_margin_top(16) + content_area.set_margin_bottom(8) + content_area.set_margin_start(16) + content_area.set_margin_end(16) # Create main container - main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) content_area.add(main_box) - # Create toolbar - toolbar_box = self._create_toolbar() - main_box.pack_start(toolbar_box, False, False, 0) + # Create filter bar (card-style) + filter_bar = self._create_filter_bar() + main_box.pack_start(filter_bar, False, False, 0) + + # Create action toolbar + toolbar = self._create_toolbar() + main_box.pack_start(toolbar, False, False, 0) # Create log view (give it most of the space) log_view_box = self._create_log_view() @@ -81,13 +250,32 @@ class LoggingDialog(Gtk.Dialog): # Show all widgets main_box.show_all() - def _create_toolbar(self): - """Create the toolbar with filters and controls.""" - toolbar_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + def _create_filter_bar(self): + """Create the filter bar with modern card styling.""" + filter_frame = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + filter_frame.get_style_context().add_class("filter-bar") + + # Title row + title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + title_label = Gtk.Label(label="Filters", xalign=0) + title_label.set_markup("Filters") + title_box.pack_start(title_label, False, False, 0) + + # Auto-scroll toggle (moved to right of title) + self.auto_scroll_check = Gtk.CheckButton(label="Auto-scroll") + self.auto_scroll_check.set_active(True) + self.auto_scroll_check.connect("toggled", self._on_auto_scroll_toggled) + title_box.pack_end(self.auto_scroll_check, False, False, 0) + + filter_frame.pack_start(title_box, False, False, 0) + + # Filter controls row + controls_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) # Level filter - level_label = Gtk.Label(label="Level:") - toolbar_box.pack_start(level_label, False, False, 0) + level_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + level_label = Gtk.Label(label="Level") + level_box.pack_start(level_label, False, False, 0) self.level_combo = Gtk.ComboBoxText() self.level_combo.append("ALL", "All Levels") @@ -98,37 +286,98 @@ class LoggingDialog(Gtk.Dialog): self.level_combo.append("CRITICAL", "Critical") self.level_combo.set_active(0) self.level_combo.connect("changed", self._on_filter_changed) - toolbar_box.pack_start(self.level_combo, False, False, 0) + level_box.pack_start(self.level_combo, False, False, 0) + + controls_box.pack_start(level_box, False, False, 0) + + # Separator + sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) + controls_box.pack_start(sep, False, False, 4) # Module filter - module_label = Gtk.Label(label="Module:") - toolbar_box.pack_start(module_label, False, False, 0) + module_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + module_label = Gtk.Label(label="Module") + module_box.pack_start(module_label, False, False, 0) self.module_entry = Gtk.Entry() self.module_entry.set_placeholder_text("Filter by module name...") + self.module_entry.set_icon_from_icon_name( + Gtk.EntryIconPosition.PRIMARY, "edit-find-symbolic" + ) self.module_entry.connect("changed", self._on_filter_changed) - toolbar_box.pack_start(self.module_entry, True, True, 0) + module_box.pack_start(self.module_entry, True, True, 0) - # Auto-scroll toggle - self.auto_scroll_check = Gtk.CheckButton(label="Auto-scroll") - self.auto_scroll_check.set_active(True) - self.auto_scroll_check.connect("toggled", self._on_auto_scroll_toggled) - toolbar_box.pack_start(self.auto_scroll_check, False, False, 0) + controls_box.pack_start(module_box, True, True, 0) + + filter_frame.pack_start(controls_box, False, False, 0) + + return filter_frame + + def _create_toolbar(self): + """Create the action toolbar.""" + toolbar_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + toolbar_box.get_style_context().add_class("toolbar-box") # Refresh button - refresh_button = Gtk.Button(label="Refresh") + refresh_button = Gtk.Button() + refresh_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + refresh_icon = Gtk.Image.new_from_icon_name("view-refresh-symbolic", Gtk.IconSize.BUTTON) + refresh_box.pack_start(refresh_icon, False, False, 0) + refresh_box.pack_start(Gtk.Label(label="Refresh"), False, False, 0) + refresh_button.add(refresh_box) + refresh_button.set_tooltip_text("Refresh log view") refresh_button.connect("clicked", self._on_refresh_clicked) toolbar_box.pack_start(refresh_button, False, False, 0) + # Spacer + toolbar_box.pack_start(Gtk.Box(), True, True, 0) + + # Copy button + copy_button = Gtk.Button() + copy_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + copy_icon = Gtk.Image.new_from_icon_name("edit-copy-symbolic", Gtk.IconSize.BUTTON) + copy_box.pack_start(copy_icon, False, False, 0) + copy_box.pack_start(Gtk.Label(label="Copy"), False, False, 0) + copy_button.add(copy_box) + copy_button.set_tooltip_text("Copy all logs to clipboard") + copy_button.connect("clicked", lambda w: self._copy_logs_to_clipboard()) + toolbar_box.pack_start(copy_button, False, False, 0) + + # Export button + export_button = Gtk.Button() + export_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + export_icon = Gtk.Image.new_from_icon_name("document-save-symbolic", Gtk.IconSize.BUTTON) + export_box.pack_start(export_icon, False, False, 0) + export_box.pack_start(Gtk.Label(label="Export"), False, False, 0) + export_button.add(export_box) + export_button.set_tooltip_text("Export logs to file") + export_button.connect("clicked", lambda w: self._export_logs()) + toolbar_box.pack_start(export_button, False, False, 0) + + # Clear button (destructive action) + clear_button = Gtk.Button() + clear_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + clear_icon = Gtk.Image.new_from_icon_name("user-trash-symbolic", Gtk.IconSize.BUTTON) + clear_box.pack_start(clear_icon, False, False, 0) + clear_box.pack_start(Gtk.Label(label="Clear"), False, False, 0) + clear_button.add(clear_box) + clear_button.get_style_context().add_class("header-button") + clear_button.get_style_context().add_class("destructive") + clear_button.set_tooltip_text("Clear all logs") + clear_button.connect("clicked", lambda w: self._clear_logs()) + toolbar_box.pack_start(clear_button, False, False, 0) + return toolbar_box def _create_log_view(self): """Create the main log viewing area.""" + # Container with rounded corners + container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + container.get_style_context().add_class("log-view-container") + # Create scrolled window scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - scrolled.set_shadow_type(Gtk.ShadowType.IN) - # Set minimum height to ensure the text view takes up most of the dialog space scrolled.set_min_content_height(400) scrolled.set_vexpand(True) scrolled.set_hexpand(True) @@ -137,62 +386,93 @@ class LoggingDialog(Gtk.Dialog): self.text_view = Gtk.TextView() self.text_view.set_editable(False) self.text_view.set_cursor_visible(False) - self.text_view.set_wrap_mode(Gtk.WrapMode.WORD) - # Ensure the text view expands to fill available space + self.text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) self.text_view.set_vexpand(True) self.text_view.set_hexpand(True) - - # Set monospace font - font_desc = self.text_view.get_pango_context().get_font_description() - font_desc.set_family("monospace") - font_desc.set_size(9 * 1024) # 9pt - self.text_view.override_font(font_desc) + self.text_view.set_left_margin(12) + self.text_view.set_right_margin(12) + self.text_view.set_top_margin(12) + self.text_view.set_bottom_margin(12) + self.text_view.get_style_context().add_class("log-view") # Get text buffer self.text_buffer = self.text_view.get_buffer() - # Create text tags for different log levels + # Create text tags for different log levels (theme-aware) self._create_text_tags() scrolled.add(self.text_view) + container.pack_start(scrolled, True, True, 0) # Store reference to scrolled window for auto-scrolling self.scrolled_window = scrolled - return scrolled + return container def _create_text_tags(self): - """Create text tags for different log levels.""" - # Debug - gray + """Create text tags for different log levels with theme-aware colors.""" + # Get the style context for theme colors + style_context = self.text_view.get_style_context() + + # Debug - muted/gray (theme-aware) debug_tag = self.text_buffer.create_tag("DEBUG") - debug_tag.set_property("foreground", "#666666") + debug_tag.set_property("foreground", "#888888") + debug_tag.set_property("style", Pango.Style.ITALIC) - # Info - default color - self.text_buffer.create_tag("INFO") + # Info - default foreground (theme-aware) + info_tag = self.text_buffer.create_tag("INFO") + # No foreground set - uses default theme color - # Warning - orange + # Warning - orange (visible in both light/dark) warning_tag = self.text_buffer.create_tag("WARNING") - warning_tag.set_property("foreground", "#FF8C00") - warning_tag.set_property("weight", 600) + warning_tag.set_property("foreground", "#e5a50a") + warning_tag.set_property("weight", Pango.Weight.SEMIBOLD) - # Error - red + # Error - red (visible in both light/dark) error_tag = self.text_buffer.create_tag("ERROR") - error_tag.set_property("foreground", "#DC143C") - error_tag.set_property("weight", 700) + error_tag.set_property("foreground", "#e01b24") + error_tag.set_property("weight", Pango.Weight.BOLD) - # Critical - red background + # Critical - white on red background critical_tag = self.text_buffer.create_tag("CRITICAL") - critical_tag.set_property("foreground", "#FFFFFF") - critical_tag.set_property("background", "#DC143C") - critical_tag.set_property("weight", 700) + critical_tag.set_property("foreground", "#ffffff") + critical_tag.set_property("background", "#c01c28") + critical_tag.set_property("weight", Pango.Weight.BOLD) + + # Timestamp tag - subtle + timestamp_tag = self.text_buffer.create_tag("timestamp") + timestamp_tag.set_property("foreground", "#888888") + timestamp_tag.set_property("scale", 0.95) + + # Module name tag - slightly emphasized + module_tag = self.text_buffer.create_tag("module") + module_tag.set_property("weight", Pango.Weight.MEDIUM) def _create_status_bar(self): - """Create the status bar.""" - status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + """Create the status bar with statistics.""" + status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16) + status_box.get_style_context().add_class("status-bar") - self.status_label = Gtk.Label() - self.status_label.set_halign(Gtk.Align.START) - status_box.pack_start(self.status_label, True, True, 0) + # Left side - total count + self.total_label = Gtk.Label() + self.total_label.get_style_context().add_class("status-count") + status_box.pack_start(self.total_label, False, False, 0) + + # Separator + sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) + status_box.pack_start(sep, False, False, 0) + + # Level breakdown + self.level_stats_label = Gtk.Label() + status_box.pack_start(self.level_stats_label, False, False, 0) + + # Spacer + status_box.pack_start(Gtk.Box(), True, True, 0) + + # Memory indicator (optional info) + self.memory_label = Gtk.Label() + self.memory_label.set_markup("Buffer: --") + status_box.pack_end(self.memory_label, False, False, 0) # Update status self._update_status() @@ -203,18 +483,37 @@ class LoggingDialog(Gtk.Dialog): """Update the status bar with current log statistics.""" stats = self.logging_manager.get_log_stats() + # Total count + self.total_label.set_markup(f"{stats['total']} records") + + # Level breakdown with colored indicators if stats["total"] == 0: - status_text = "No log records" + self.level_stats_label.set_text("No logs") else: - level_counts = [] + parts = [] + level_colors = { + "DEBUG": "#888888", + "INFO": "#3584e4", + "WARNING": "#e5a50a", + "ERROR": "#e01b24", + "CRITICAL": "#c01c28", + } for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: count = stats["by_level"].get(level, 0) if count > 0: - level_counts.append(f"{level}: {count}") + color = level_colors.get(level, "#888888") + parts.append(f"{level[0]}:{count}") - status_text = f"Total: {stats['total']} | " + " | ".join(level_counts) + self.level_stats_label.set_markup(" ".join(parts)) - self.status_label.set_text(status_text) + # Buffer size indicator + buffer_size = stats["total"] + if buffer_size > 500: + self.memory_label.set_markup( + f"Buffer: {buffer_size}/1000" + ) + else: + self.memory_label.set_markup(f"Buffer: {buffer_size}/1000") def _on_filter_changed(self, widget): """Handle filter changes.""" @@ -325,13 +624,7 @@ class LoggingDialog(Gtk.Dialog): def _on_response(self, dialog, response_id): """Handle dialog responses.""" - if response_id == Gtk.ResponseType.APPLY: - self._export_logs() - elif response_id == Gtk.ResponseType.REJECT: - self._clear_logs() - elif response_id == Gtk.ResponseType.HELP: - self._copy_logs_to_clipboard() - elif response_id == Gtk.ResponseType.CLOSE: + if response_id == Gtk.ResponseType.CLOSE: self.destroy() def _export_logs(self): @@ -341,9 +634,12 @@ class LoggingDialog(Gtk.Dialog): title="Export Logs", parent=self, action=Gtk.FileChooserAction.SAVE ) - file_dialog.add_buttons( - Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.OK - ) + file_dialog.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Save", Gtk.ResponseType.OK) + + # Style the save button + save_button = file_dialog.get_widget_for_response(Gtk.ResponseType.OK) + if save_button: + save_button.get_style_context().add_class("suggested-action") # Set default filename timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -365,7 +661,7 @@ class LoggingDialog(Gtk.Dialog): ) if success: - self._show_message("Export successful", f"Logs exported to:\n{filepath}") + self._show_toast("Logs exported successfully") else: self._show_message( "Export failed", @@ -384,17 +680,13 @@ class LoggingDialog(Gtk.Dialog): text_content = self.text_buffer.get_text(start_iter, end_iter, False) if not text_content.strip(): - self._show_message("No logs to copy", "There are no logs to copy to clipboard.") + self._show_toast("No logs to copy") return # Get the clipboard - from gi.repository import Gdk - clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) # Create header for clipboard content - from datetime import datetime - header = f"VocaLinux Logs - Copied at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" header += "=" * 80 + "\n\n" @@ -407,11 +699,7 @@ class LoggingDialog(Gtk.Dialog): # Count lines for user feedback line_count = len(text_content.strip().split("\n")) - self._show_message( - "Logs copied to clipboard", - f"Successfully copied {line_count} log lines to clipboard.\n\n" - "You can now paste them into any text editor or document.", - ) + self._show_toast(f"Copied {line_count} log lines to clipboard") except Exception as e: logger.error(f"Failed to copy logs to clipboard: {e}") @@ -424,9 +712,9 @@ class LoggingDialog(Gtk.Dialog): # Show confirmation dialog confirm_dialog = Gtk.MessageDialog( transient_for=self, - flags=0, - message_type=Gtk.MessageType.QUESTION, - buttons=Gtk.ButtonsType.YES_NO, + flags=Gtk.DialogFlags.MODAL, + message_type=Gtk.MessageType.WARNING, + buttons=Gtk.ButtonsType.NONE, text="Clear all logs?", ) confirm_dialog.format_secondary_text( @@ -434,13 +722,60 @@ class LoggingDialog(Gtk.Dialog): "This action cannot be undone." ) + confirm_dialog.add_button("_Cancel", Gtk.ResponseType.CANCEL) + clear_btn = confirm_dialog.add_button("_Clear", Gtk.ResponseType.YES) + clear_btn.get_style_context().add_class("destructive-action") + response = confirm_dialog.run() confirm_dialog.destroy() if response == Gtk.ResponseType.YES: self.logging_manager.clear_logs() self._refresh_logs() - self._show_message("Logs cleared", "All log records have been cleared.") + self._show_toast("Logs cleared") + + def _show_toast(self, message: str): + """Show a brief toast-style notification.""" + # For GTK3, we'll use an in-dialog notification + # Create a revealer for smooth animation + revealer = Gtk.Revealer() + revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN) + revealer.set_transition_duration(200) + + toast_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + toast_box.get_style_context().add_class("app-notification") + toast_box.set_halign(Gtk.Align.CENTER) + + # Style inline since CSS may not be loaded + toast_box.override_background_color(Gtk.StateFlags.NORMAL, Gdk.RGBA(0.2, 0.2, 0.2, 0.9)) + + icon = Gtk.Image.new_from_icon_name("emblem-ok-symbolic", Gtk.IconSize.BUTTON) + toast_box.pack_start(icon, False, False, 8) + + label = Gtk.Label(label=message) + label.set_margin_top(8) + label.set_margin_bottom(8) + label.set_margin_end(12) + toast_box.pack_start(label, False, False, 0) + + revealer.add(toast_box) + + # Add to content area at top + content_area = self.get_content_area() + content_area.pack_start(revealer, False, False, 0) + content_area.reorder_child(revealer, 0) + revealer.show_all() + + # Reveal with animation + GLib.idle_add(revealer.set_reveal_child, True) + + # Hide and remove after 2 seconds + def hide_toast(): + revealer.set_reveal_child(False) + GLib.timeout_add(200, lambda: (content_area.remove(revealer), False)[1]) + return False + + GLib.timeout_add(2000, hide_toast) def _show_message( self, title: str, message: str, message_type: Gtk.MessageType = Gtk.MessageType.INFO @@ -455,7 +790,7 @@ class LoggingDialog(Gtk.Dialog): """ dialog = Gtk.MessageDialog( transient_for=self, - flags=0, + flags=Gtk.DialogFlags.MODAL, message_type=message_type, buttons=Gtk.ButtonsType.OK, text=title, diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 8963fde..ec4b68b 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -5,11 +5,13 @@ Allows users to configure speech recognition engine, model size, and other relevant parameters. UX Design Notes: -- Implements Apply/Cancel/Close button pattern per GNOME HIG +- Follows GNOME Human Interface Guidelines (HIG) for modern desktop look +- Uses preference-page style layout with clearly grouped sections +- Implements instant-apply pattern: settings apply immediately when changed +- Close button only (no Apply/Cancel) - consistent with GNOME Settings app - Provides real-time progress feedback for recognition state - Multi-modal feedback (text + icon + audio level) for accessibility -- Auto-apply for safe settings, modal dialog for model downloads -- See RESEARCH.md for full UX rationale and research citations +- Modal dialog for model downloads (explicit confirmation for large downloads) """ import logging @@ -21,8 +23,9 @@ from typing import TYPE_CHECKING import gi gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") # Need GLib for idle_add -from gi.repository import GLib, Gtk # noqa: E402 +from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402 from ..common_types import RecognitionState # noqa: E402 from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO # noqa: E402 @@ -66,6 +69,207 @@ SYSTEM_MODELS_DIRS = [ "/usr/share/vocalinux/models", ] +# CSS for modern styling +SETTINGS_CSS = """ +/* Modern GNOME-style settings dialog */ +.settings-dialog { + background-color: @theme_bg_color; +} + +/* Preference group styling - card-like appearance */ +.preferences-group { + background-color: @theme_base_color; + border-radius: 12px; + padding: 0; + margin: 6px 0; + border: 1px solid alpha(@borders, 0.5); +} + +.preferences-group-title { + font-weight: bold; + font-size: 0.9em; + color: @theme_unfocused_fg_color; + padding: 12px 16px 6px 16px; + margin: 0; +} + +/* Row styling */ +.preference-row { + padding: 12px 16px; + min-height: 32px; + border-bottom: 1px solid alpha(@borders, 0.3); +} + +.preference-row:last-child { + border-bottom: none; +} + +.preference-row:hover { + background-color: alpha(@theme_selected_bg_color, 0.1); +} + +.preference-row-title { + font-weight: 500; +} + +.preference-row-subtitle { + font-size: 0.85em; + color: @theme_unfocused_fg_color; +} + +/* Status indicators */ +.status-success { + color: #26a269; +} + +.status-warning { + color: #e5a50a; +} + +.status-error { + color: #c01c28; +} + +.status-info { + color: @theme_unfocused_fg_color; +} + +/* Test area styling */ +.test-area { + background-color: @theme_base_color; + border-radius: 8px; + padding: 12px; + border: 1px solid alpha(@borders, 0.5); +} + +.test-textview { + font-family: monospace; + font-size: 0.95em; + padding: 8px; + background-color: alpha(@theme_bg_color, 0.5); + border-radius: 6px; +} + +/* Level bars */ +levelbar block.filled { + background-color: @theme_selected_bg_color; + border-radius: 3px; +} + +levelbar block.empty { + background-color: alpha(@theme_fg_color, 0.1); + border-radius: 3px; +} + +/* Combo boxes and spin buttons */ +combobox button, +spinbutton { + min-height: 32px; + border-radius: 6px; +} + +/* Section headers */ +.section-header { + font-size: 1.1em; + font-weight: bold; + margin-top: 12px; + margin-bottom: 6px; +} + +/* Info box styling */ +.info-box { + background-color: alpha(@theme_selected_bg_color, 0.1); + border-radius: 8px; + padding: 12px; + border-left: 4px solid @theme_selected_bg_color; +} + +.info-box-warning { + background-color: alpha(#e5a50a, 0.1); + border-left-color: #e5a50a; +} + +/* Recognition status */ +.recognition-idle { + color: @theme_unfocused_fg_color; +} + +.recognition-listening { + color: #26a269; +} + +.recognition-processing { + color: #e5a50a; +} + +.recognition-error { + color: #c01c28; +} + +/* Buttons */ +.suggested-action { + background-color: @theme_selected_bg_color; + color: @theme_selected_fg_color; +} + +.flat-button { + background: transparent; + border: none; + padding: 8px; + border-radius: 6px; +} + +.flat-button:hover { + background-color: alpha(@theme_fg_color, 0.1); +} + +/* Scrolled content */ +.scrolled-content { + background-color: transparent; +} + +/* Model info card */ +.model-info-card { + background-color: alpha(@theme_base_color, 0.8); + border-radius: 8px; + padding: 12px 16px; + margin: 8px 0; +} + +.model-info-title { + font-weight: bold; + font-size: 1.0em; +} + +.model-info-subtitle { + font-size: 0.9em; + color: @theme_unfocused_fg_color; +} + +/* Tip styling */ +.tip-label { + font-size: 0.85em; + color: @theme_unfocused_fg_color; + font-style: italic; +} + +.tip-highlight { + font-weight: bold; + color: @theme_selected_bg_color; +} +""" + + +def _setup_css(): + """Set up CSS styling for the settings dialog.""" + css_provider = Gtk.CssProvider() + css_provider.load_from_data(SETTINGS_CSS.encode()) + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + def _get_whisper_cache_dir() -> str: """Get the Whisper model cache directory.""" @@ -168,6 +372,89 @@ def _get_recommended_vosk_model() -> tuple: return "small", "Default recommendation" +class PreferencesGroup(Gtk.Box): + """A card-style group of preferences, similar to libadwaita's AdwPreferencesGroup.""" + + def __init__(self, title: str = "", description: str = ""): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.get_style_context().add_class("preferences-group") + + # Header with title + if title: + header_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + header_box.set_margin_top(12) + header_box.set_margin_bottom(4) + header_box.set_margin_start(16) + header_box.set_margin_end(16) + + title_label = Gtk.Label(label=title, xalign=0) + title_label.get_style_context().add_class("preferences-group-title") + header_box.pack_start(title_label, False, False, 0) + + if description: + desc_label = Gtk.Label(label=description, xalign=0, wrap=True) + desc_label.get_style_context().add_class("preference-row-subtitle") + header_box.pack_start(desc_label, False, False, 0) + + self.pack_start(header_box, False, False, 0) + + # Content area with listbox for rows + self.listbox = Gtk.ListBox() + self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) + self.listbox.set_activate_on_single_click(False) + self.pack_start(self.listbox, False, False, 0) + + def add_row(self, widget): + """Add a widget as a row in the preferences group.""" + self.listbox.add(widget) + + +class PreferenceRow(Gtk.ListBoxRow): + """A single preference row with title, subtitle, and a control widget.""" + + def __init__( + self, + title: str, + subtitle: str = "", + widget: Gtk.Widget = None, + activatable: bool = False, + ): + super().__init__() + self.set_activatable(activatable) + self.get_style_context().add_class("preference-row") + + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + hbox.set_margin_top(12) + hbox.set_margin_bottom(12) + hbox.set_margin_start(16) + hbox.set_margin_end(16) + + # Text container (title + subtitle) + text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + text_box.set_valign(Gtk.Align.CENTER) + + title_label = Gtk.Label(label=title, xalign=0) + title_label.get_style_context().add_class("preference-row-title") + text_box.pack_start(title_label, False, False, 0) + + if subtitle: + subtitle_label = Gtk.Label(label=subtitle, xalign=0, wrap=True) + subtitle_label.get_style_context().add_class("preference-row-subtitle") + subtitle_label.set_max_width_chars(40) + subtitle_label.set_line_wrap(True) + subtitle_label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR) + text_box.pack_start(subtitle_label, False, False, 0) + + hbox.pack_start(text_box, True, True, 0) + + # Control widget on the right + if widget: + widget.set_valign(Gtk.Align.CENTER) + hbox.pack_end(widget, False, False, 0) + + self.add(hbox) + + class ModelDownloadDialog(Gtk.Dialog): """Dialog showing model download progress with cancel support.""" @@ -184,7 +471,7 @@ class ModelDownloadDialog(Gtk.Dialog): transient_for=parent, flags=Gtk.DialogFlags.MODAL, ) - self.set_default_size(450, 180) + self.set_default_size(450, 200) self.set_deletable(False) # Prevent closing during download self.cancelled = False @@ -194,11 +481,11 @@ class ModelDownloadDialog(Gtk.Dialog): engine_display = engine.upper() if engine == "vosk" else engine.capitalize() box = self.get_content_area() - box.set_spacing(12) - box.set_margin_start(20) - box.set_margin_end(20) - box.set_margin_top(20) - box.set_margin_bottom(15) + box.set_spacing(16) + box.set_margin_start(24) + box.set_margin_end(24) + box.set_margin_top(24) + box.set_margin_bottom(20) # Info label self.info_label = Gtk.Label( @@ -212,18 +499,19 @@ class ModelDownloadDialog(Gtk.Dialog): self.progress_bar = Gtk.ProgressBar() self.progress_bar.set_show_text(True) self.progress_bar.set_text("Connecting...") - box.pack_start(self.progress_bar, False, False, 5) + box.pack_start(self.progress_bar, False, False, 8) # Status label (shows speed and ETA) self.status_label = Gtk.Label(label="") self.status_label.set_markup("Please wait...") + self.status_label.get_style_context().add_class("status-info") box.pack_start(self.status_label, False, False, 0) # Cancel button self.cancel_button = Gtk.Button(label="Cancel") self.cancel_button.connect("clicked", self._on_cancel_clicked) self.cancel_button.set_halign(Gtk.Align.CENTER) - self.cancel_button.set_margin_top(10) + self.cancel_button.set_margin_top(12) box.pack_start(self.cancel_button, False, False, 0) self.show_all() @@ -274,14 +562,18 @@ class ModelDownloadDialog(Gtk.Dialog): if success: self.progress_bar.set_fraction(1.0) self.progress_bar.set_text("Complete!") - self.status_label.set_markup("✓ Model ready to use") + self.status_label.set_markup( + "✓ Model ready to use" + ) else: self.progress_bar.set_fraction(0) self.progress_bar.set_text("Failed") if "cancelled" in message.lower(): - self.status_label.set_markup("✗ Download cancelled") + self.status_label.set_markup( + "✗ Download cancelled" + ) else: - self.status_label.set_markup(f"✗ {message}") + self.status_label.set_markup(f"✗ {message}") # Allow closing now self.set_deletable(True) @@ -289,7 +581,7 @@ class ModelDownloadDialog(Gtk.Dialog): class SettingsDialog(Gtk.Dialog): - """GTK Dialog for configuring Vocalinux settings.""" + """Modern GTK Dialog for configuring Vocalinux settings.""" def __init__( self, @@ -309,308 +601,309 @@ class SettingsDialog(Gtk.Dialog): ) self._applying_settings = False # Flag to prevent recursive settings application - # UX: Button placement follows GNOME Human Interface Guidelines (HIG) - # Order: Cancel (left, esc-bound) -> Apply (center, return-bound) -> Close (right) - # Rationale: Separates "apply and continue" from "apply and close" actions - # Reference: https://developer.gnome.org/hig/patterns/feedback/dialogs.html - # See RESEARCH.md Section 2.1 for full GNOME HIG compliance details - self.add_buttons( - Gtk.STOCK_CANCEL, - Gtk.ResponseType.CANCEL, - Gtk.STOCK_APPLY, - Gtk.ResponseType.APPLY, - Gtk.STOCK_CLOSE, - Gtk.ResponseType.CLOSE, - ) - self.set_default_size(450, 400) - self.set_border_width(10) + # Setup CSS styling + _setup_css() - # --- UI Elements --- - self.grid = Gtk.Grid(column_spacing=10, row_spacing=8) - self.get_content_area().add(self.grid) + # Dialog configuration - Close button only (instant-apply pattern) + self.add_button("_Close", Gtk.ResponseType.CLOSE) + self.set_default_size(520, 680) + self.get_style_context().add_class("settings-dialog") - row = 0 + # Main scrolled window for content + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled.get_style_context().add_class("scrolled-content") - # ==================== AUDIO INPUT SECTION (TOP) ==================== - audio_label = Gtk.Label(label="Audio Input", use_markup=True, halign=Gtk.Align.START) - self.grid.attach(audio_label, 0, row, 2, 1) - row += 1 + # Main content box + self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + self.content_box.set_margin_top(16) + self.content_box.set_margin_bottom(16) + self.content_box.set_margin_start(16) + self.content_box.set_margin_end(16) - # Audio device selection - audio_device_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - audio_device_box.pack_start( - Gtk.Label(label="Input Device:", halign=Gtk.Align.START), False, False, 0 - ) + scrolled.add(self.content_box) + self.get_content_area().pack_start(scrolled, True, True, 0) + # Build UI sections + self._build_audio_section() + self._build_engine_section() + self._build_recognition_section() + self._build_test_section() + + # Load settings and populate UI + self._load_and_apply_settings() + + # Show everything first + self.show_all() + + # Then update visibility of engine-specific elements + self._update_engine_specific_ui() + + # Initialize recognition progress UI + self.update_recognition_progress("Idle") + + # Connect to recognition manager for progress updates + self.connect_to_recognition_manager() + + # Initialization complete - enable auto-apply + self._initializing = False + + def _build_audio_section(self): + """Build the Audio Input section.""" + group = PreferencesGroup(title="Audio Input") + + # Device selection row + device_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) self.audio_device_combo = Gtk.ComboBoxText() self.audio_device_combo.set_tooltip_text( - "Select the microphone to use for voice recognition.\n" - "If you're having issues with audio detection, try different devices." + "Select the microphone to use for voice recognition" ) - self._populate_audio_devices() - self.audio_device_combo.connect("changed", self._on_audio_device_changed) - audio_device_box.pack_start(self.audio_device_combo, True, True, 0) + self.audio_device_combo.set_size_request(250, -1) + device_box.pack_start(self.audio_device_combo, True, True, 0) - # Refresh button - refresh_btn = Gtk.Button() - refresh_btn.set_image(Gtk.Image.new_from_icon_name("view-refresh", Gtk.IconSize.BUTTON)) - refresh_btn.set_tooltip_text("Refresh audio device list") + refresh_btn = Gtk.Button.new_from_icon_name("view-refresh-symbolic", Gtk.IconSize.BUTTON) + refresh_btn.set_tooltip_text("Refresh device list") + refresh_btn.get_style_context().add_class("flat-button") refresh_btn.connect("clicked", self._on_refresh_audio_devices) - audio_device_box.pack_start(refresh_btn, False, False, 0) + device_box.pack_start(refresh_btn, False, False, 0) - self.grid.attach(audio_device_box, 0, row, 2, 1) - row += 1 - - # Audio level indicator and test button - audio_test_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - - level_label = Gtk.Label(label="Level:", halign=Gtk.Align.START) - audio_test_box.pack_start(level_label, False, False, 0) + device_row = PreferenceRow( + title="Input Device", + subtitle="Select the microphone for voice recognition", + widget=device_box, + ) + group.add_row(device_row) + # Audio level test row + level_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) self.audio_level_bar = Gtk.LevelBar() self.audio_level_bar.set_min_value(0) self.audio_level_bar.set_max_value(100) self.audio_level_bar.set_value(0) self.audio_level_bar.set_size_request(150, -1) - audio_test_box.pack_start(self.audio_level_bar, True, True, 0) + level_box.pack_start(self.audio_level_bar, True, True, 0) - self.test_audio_btn = Gtk.Button(label="Test Mic") - self.test_audio_btn.set_tooltip_text( - "Test the selected microphone for 2 seconds.\n" - "Speak into your microphone to verify it's working." - ) + self.test_audio_btn = Gtk.Button(label="Test") + self.test_audio_btn.set_tooltip_text("Test the microphone for 2 seconds") self.test_audio_btn.connect("clicked", self._on_test_audio_clicked) - audio_test_box.pack_start(self.test_audio_btn, False, False, 0) + level_box.pack_start(self.test_audio_btn, False, False, 0) - self.grid.attach(audio_test_box, 0, row, 2, 1) - row += 1 - - # Audio test status label - self.audio_test_status = Gtk.Label(label="", use_markup=True, halign=Gtk.Align.START) - self.grid.attach(self.audio_test_status, 0, row, 2, 1) - row += 1 - - # ==================== SEPARATOR ==================== - self.grid.attach(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), 0, row, 2, 1) - row += 1 - - # ==================== SPEECH ENGINE SECTION ==================== - engine_label = Gtk.Label( - label="Speech Engine", use_markup=True, halign=Gtk.Align.START + level_row = PreferenceRow( + title="Audio Level", + subtitle="Test your microphone", + widget=level_box, ) - self.grid.attach(engine_label, 0, row, 2, 1) - row += 1 + group.add_row(level_row) - # Engine Selection - self.grid.attach(Gtk.Label(label="Engine:", halign=Gtk.Align.START), 0, row, 1, 1) + # Status label for audio testing (added below the group) + self.audio_test_status = Gtk.Label(label="", use_markup=True, xalign=0) + self.audio_test_status.set_margin_start(16) + self.audio_test_status.set_margin_top(4) + self.audio_test_status.get_style_context().add_class("status-info") + + self.content_box.pack_start(group, False, False, 0) + self.content_box.pack_start(self.audio_test_status, False, False, 0) + + # Populate devices + self._populate_audio_devices() + self.audio_device_combo.connect("changed", self._on_audio_device_changed) + + def _build_engine_section(self): + """Build the Speech Engine section.""" + group = PreferencesGroup(title="Speech Engine") + + # Engine selection self.engine_combo = Gtk.ComboBoxText() - self.grid.attach(self.engine_combo, 1, row, 1, 1) - row += 1 + self.engine_combo.set_size_request(180, -1) + engine_row = PreferenceRow( + title="Engine", + subtitle="Speech recognition backend", + widget=self.engine_combo, + ) + group.add_row(engine_row) - # Model Size Selection - self.grid.attach(Gtk.Label(label="Model Size:", halign=Gtk.Align.START), 0, row, 1, 1) + # Model size selection self.model_combo = Gtk.ComboBoxText() - self.grid.attach(self.model_combo, 1, row, 1, 1) - row += 1 + self.model_combo.set_size_request(180, -1) + model_row = PreferenceRow( + title="Model Size", + subtitle="Larger models are more accurate but slower", + widget=self.model_combo, + ) + group.add_row(model_row) - # Language Selection - self.grid.attach(Gtk.Label(label="Language:", halign=Gtk.Align.START), 0, row, 1, 1) + # Language selection self.language_combo = Gtk.ComboBoxText() - self.language_combo.set_tooltip_text("Select primary language for speech recognition") - self.grid.attach(self.language_combo, 1, row, 1, 1) - row += 1 - - # Language warning label (shown for auto/unsupported) - self.language_warning = Gtk.Label(label="", use_markup=True, halign=Gtk.Align.START) - self.language_warning.set_margin_bottom(5) - self.grid.attach(self.language_warning, 0, row, 2, 1) - row += 1 - - # Model legend (applies to both engines) - model_legend = Gtk.Label( - label="✓ = Downloaded ↓ = Will download ★ = Recommended", - use_markup=True, - halign=Gtk.Align.END, + self.language_combo.set_size_request(180, -1) + self.language_combo.set_tooltip_text("Primary language for speech recognition") + language_row = PreferenceRow( + title="Language", + subtitle="Primary language for recognition", + widget=self.language_combo, ) - self.grid.attach(model_legend, 0, row, 2, 1) - row += 1 + group.add_row(language_row) - # Whisper Info Box (initially hidden) - self.whisper_info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) - self.whisper_info_box.pack_start( - Gtk.Label( - label="Whisper Model Info", - use_markup=True, - halign=Gtk.Align.START, - ), - False, - False, - 5, - ) + self.content_box.pack_start(group, False, False, 0) - # Model info label (will be updated based on selection) - self.whisper_model_info_label = Gtk.Label( - label="", - use_markup=True, - halign=Gtk.Align.START, - wrap=True, - ) - self.whisper_info_box.pack_start(self.whisper_model_info_label, False, False, 0) + # Model info card (shown below the group) + self.model_info_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.model_info_card.get_style_context().add_class("model-info-card") + self.model_info_card.set_margin_start(4) + self.model_info_card.set_margin_end(4) - # Recommendation label - self.whisper_recommendation_label = Gtk.Label( - label="", - use_markup=True, - halign=Gtk.Align.START, - wrap=True, - ) - self.whisper_info_box.pack_start(self.whisper_recommendation_label, False, False, 5) + self.model_info_title = Gtk.Label(xalign=0) + self.model_info_title.get_style_context().add_class("model-info-title") + self.model_info_card.pack_start(self.model_info_title, False, False, 0) - self.grid.attach(self.whisper_info_box, 0, row, 2, 1) - row += 1 + self.model_info_subtitle = Gtk.Label(xalign=0, wrap=True) + self.model_info_subtitle.get_style_context().add_class("model-info-subtitle") + self.model_info_card.pack_start(self.model_info_subtitle, False, False, 0) - # Add model change handler + self.model_recommendation = Gtk.Label(xalign=0, wrap=True) + self.model_recommendation.get_style_context().add_class("tip-label") + self.model_info_card.pack_start(self.model_recommendation, False, False, 0) + + self.content_box.pack_start(self.model_info_card, False, False, 0) + + # Language warning (for auto-detect) + self.language_warning = Gtk.Label(label="", use_markup=True, xalign=0) + self.language_warning.set_margin_start(16) + self.language_warning.get_style_context().add_class("status-warning") + self.content_box.pack_start(self.language_warning, False, False, 0) + + # Legend + legend_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16) + legend_box.set_halign(Gtk.Align.CENTER) + legend_box.set_margin_top(4) + legend_box.set_margin_bottom(4) + + for symbol, text in [("✓", "Downloaded"), ("↓", "Will download"), ("★", "Recommended")]: + item = Gtk.Label(label=f"{symbol} {text}") + item.get_style_context().add_class("status-info") + legend_box.pack_start(item, False, False, 0) + + self.content_box.pack_start(legend_box, False, False, 0) + + # Connect signals + self.engine_combo.connect("changed", self._on_engine_changed) self.model_combo.connect("changed", self._on_model_changed) - - # Add language change handler self.language_combo.connect("changed", self._on_language_changed) - # ==================== SEPARATOR ==================== - self.grid.attach(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), 0, row, 2, 1) - row += 1 + def _build_recognition_section(self): + """Build the Recognition Settings section.""" + group = PreferencesGroup(title="Recognition Settings") - # ==================== RECOGNITION SETTINGS SECTION ==================== - recognition_label = Gtk.Label( - label="Recognition Settings", use_markup=True, halign=Gtk.Align.START - ) - self.grid.attach(recognition_label, 0, row, 2, 1) - row += 1 - - # VAD Sensitivity (controls how sensitive the mic is to speech vs silence) - self.grid.attach( - Gtk.Label(label="VAD Sensitivity (1-5):", halign=Gtk.Align.START), 0, row, 1, 1 - ) + # VAD Sensitivity self.vad_spin = Gtk.SpinButton.new_with_range(1, 5, 1) self.vad_spin.set_tooltip_text("Higher = more sensitive to quiet speech") - self.vad_spin.connect("value-changed", self._on_vad_changed) - self.grid.attach(self.vad_spin, 1, row, 1, 1) - row += 1 - - # Silence Timeout (how long to wait before processing) - self.grid.attach( - Gtk.Label(label="Silence Timeout (sec):", halign=Gtk.Align.START), 0, row, 1, 1 + vad_row = PreferenceRow( + title="VAD Sensitivity", + subtitle="Voice Activity Detection sensitivity (1-5)", + widget=self.vad_spin, ) + group.add_row(vad_row) + + # Silence Timeout self.silence_spin = Gtk.SpinButton.new_with_range(0.5, 5.0, 0.1) self.silence_spin.set_digits(1) self.silence_spin.set_tooltip_text("Wait time after silence before processing speech") + silence_row = PreferenceRow( + title="Silence Timeout", + subtitle="Seconds of silence before processing", + widget=self.silence_spin, + ) + group.add_row(silence_row) + + self.content_box.pack_start(group, False, False, 0) + + # Connect signals + self.vad_spin.connect("value-changed", self._on_vad_changed) self.silence_spin.connect("value-changed", self._on_silence_changed) - self.grid.attach(self.silence_spin, 1, row, 1, 1) - row += 1 - # VOSK Model info label (shown only for VOSK) - self.vosk_model_info_label = Gtk.Label( - label="", - use_markup=True, - halign=Gtk.Align.START, - wrap=True, - ) - self.grid.attach(self.vosk_model_info_label, 0, row, 2, 1) - row += 1 + def _build_test_section(self): + """Build the Test Recognition section.""" + group = PreferencesGroup(title="Test Recognition") - # VOSK Recommendation label (shown only for VOSK) - self.vosk_recommendation_label = Gtk.Label( - label="", - use_markup=True, - halign=Gtk.Align.START, - wrap=True, - ) - self.grid.attach(self.vosk_recommendation_label, 0, row, 2, 1) - row += 1 + # Test area inside the group's listbox as a custom row + test_container = Gtk.ListBoxRow() + test_container.set_activatable(False) + test_container.get_style_context().add_class("preference-row") - # Legacy recognition settings box (for compatibility) - self.recognition_settings_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - self.recognition_grid = Gtk.Grid(column_spacing=10, row_spacing=10) + test_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + test_box.set_margin_top(12) + test_box.set_margin_bottom(12) + test_box.set_margin_start(16) + test_box.set_margin_end(16) - # ==================== SEPARATOR ==================== - self.grid.attach(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), 0, row, 2, 1) - row += 1 - - # ==================== TEST RECOGNITION SECTION ==================== - test_label = Gtk.Label( - label="Test Recognition", use_markup=True, halign=Gtk.Align.START - ) - self.grid.attach(test_label, 0, row, 2, 1) - row += 1 - - scrolled_window = Gtk.ScrolledWindow(hexpand=True, vexpand=True) + # Text view for test results + scrolled_window = Gtk.ScrolledWindow() scrolled_window.set_min_content_height(80) - self.test_textview = Gtk.TextView( - editable=False, cursor_visible=False, wrap_mode=Gtk.WrapMode.WORD - ) + scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled_window.get_style_context().add_class("test-area") + + self.test_textview = Gtk.TextView() + self.test_textview.set_editable(False) + self.test_textview.set_cursor_visible(False) + self.test_textview.set_wrap_mode(Gtk.WrapMode.WORD) + self.test_textview.get_style_context().add_class("test-textview") self.test_buffer = self.test_textview.get_buffer() scrolled_window.add(self.test_textview) - self.grid.attach(scrolled_window, 0, row, 2, 1) - row += 1 + test_box.pack_start(scrolled_window, True, True, 0) + # Test button self.test_button = Gtk.Button(label="Start Test (3 seconds)") + self.test_button.get_style_context().add_class("suggested-action") self.test_button.connect("clicked", self._on_test_clicked) - self.grid.attach(self.test_button, 0, row, 2, 1) - row += 1 + test_box.pack_start(self.test_button, False, False, 0) - # UX: Multi-modal progress feedback for speech recognition (per HCI research) - # Provides: (1) Text status, (2) Icon indicator, (3) Audio level bar - # Rationale: Addresses user anxiety during processing delays (typical 300-800ms) - # WCAG Compliance: Multiple perception channels for accessibility - # Reference: RESEARCH.md Section 4 (Progress Feedback in Speech Recognition) - progress_label = Gtk.Label( - label="Recognition Progress", use_markup=True, halign=Gtk.Align.START + test_container.add(test_box) + group.listbox.add(test_container) + + self.content_box.pack_start(group, False, False, 0) + + # Recognition Progress section + progress_group = PreferencesGroup(title="Recognition Status") + + # Status row + status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self.recognition_status_label = Gtk.Label(label="Idle", xalign=0) + status_box.pack_start(self.recognition_status_label, True, True, 0) + + self.recognition_indicator = Gtk.Image.new_from_icon_name( + "media-record-symbolic", Gtk.IconSize.MENU ) - self.grid.attach(progress_label, 0, row, 2, 1) - row += 1 + self.recognition_indicator.set_opacity(0.3) + status_box.pack_end(self.recognition_indicator, False, False, 0) - # Recognition status - Text label for screen reader accessibility - self.recognition_status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - self.recognition_status_label = Gtk.Label(label="Status: Idle", halign=Gtk.Align.START) - self.recognition_status_box.pack_start(self.recognition_status_label, False, False, 0) - - # UX: LED-like icon using Gtk.Image sensitivity state (color/gray) - # Provides visual cue without flashing (WCAG 2.3.1 - Three Flashes requirement) - # Green=Listening, Orange=Processing, Red=Error, Gray=Idle - self.recognition_indicator = Gtk.Image() - self.recognition_indicator.set_from_icon_name("media-record", Gtk.IconSize.MENU) - self.recognition_indicator.set_sensitive(False) # Makes it grayed out (Idle state) - self.recognition_status_box.pack_start(self.recognition_indicator, False, False, 0) - - self.grid.attach(self.recognition_status_box, 0, row, 2, 1) - row += 1 - - # UX: Audio level bar for real-time feedback - # Rationale: Confirms system is detecting user's voice, aids troubleshooting - # Industry Standard: Both Dragon NaturallySpeaking and Google Docs use similar feedback - # Reference: RESEARCH.md Section 3.2 (Industry Patterns comparison) - audio_progress_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - audio_progress_box.pack_start( - Gtk.Label(label="Audio Level:", halign=Gtk.Align.START), False, False, 0 + status_row = PreferenceRow( + title="Status", + widget=status_box, ) + progress_group.add_row(status_row) + # Audio level row self.recognition_audio_level = Gtk.LevelBar() self.recognition_audio_level.set_min_value(0) self.recognition_audio_level.set_max_value(100) self.recognition_audio_level.set_value(0) self.recognition_audio_level.set_size_request(150, -1) - audio_progress_box.pack_start(self.recognition_audio_level, True, True, 0) - self.grid.attach(audio_progress_box, 0, row, 2, 1) - row += 1 + level_row = PreferenceRow( + title="Audio Level", + widget=self.recognition_audio_level, + ) + progress_group.add_row(level_row) - # Progress info - self.progress_info_label = Gtk.Label(label="", use_markup=True, halign=Gtk.Align.START) - self.progress_info_label.set_margin_bottom(10) - self.grid.attach(self.progress_info_label, 0, row, 2, 1) - row += 1 + self.content_box.pack_start(progress_group, False, False, 0) - # ---- CRITICAL CHANGE ---- - # Load settings FIRST before creating UI connections + # Progress info label + self.progress_info_label = Gtk.Label(label="", use_markup=True, xalign=0) + self.progress_info_label.set_margin_start(16) + self.progress_info_label.set_margin_bottom(8) + self.content_box.pack_start(self.progress_info_label, False, False, 0) + + def _load_and_apply_settings(self): + """Load current settings and populate the UI.""" settings = self._get_current_settings() self.current_engine = settings["engine"] self.language = settings["language"] @@ -627,16 +920,11 @@ class SettingsDialog(Gtk.Dialog): capitalized_engine = engine.capitalize() self.engine_combo.append(capitalized_engine, capitalized_engine) - # Add engine change handler AFTER populating, but before setting active - self.engine_combo.connect("changed", self._on_engine_changed) - self.grid.attach(self.engine_combo, 1, 0, 1, 1) - # Set engine active engine_text = self.current_engine.capitalize() logger.info(f"Setting active engine to: {engine_text}") if not self.engine_combo.set_active_id(engine_text): logger.warning("Could not set engine by ID, trying by index") - # Fallback to setting by index if self.current_engine == "vosk": self.engine_combo.set_active(0) elif self.current_engine == "whisper": @@ -647,44 +935,24 @@ class SettingsDialog(Gtk.Dialog): # Populate language options self._populate_language_options() - # Set the current language from settings if self.language: if not self.language_combo.set_active_id(self.language): - # Fallback to auto if language not found logger.warning(f"Language '{self.language}' not found in options, using auto") self.language_combo.set_active_id("auto") self.language = "auto" - # Set non-dependent widgets directly + # Set spin button values self.vad_spin.set_value(self.current_vad) self.silence_spin.set_value(self.current_silence) - # Show everything first - self.show_all() - - # Then show/hide engine-specific sections (must be after show_all) - self._update_engine_specific_ui() - - # Initialize recognition progress UI - self.update_recognition_progress("Idle") - - # Connect to recognition manager for progress updates - self.connect_to_recognition_manager() - - # Initialization complete - enable auto-apply - self._initializing = False - def _get_current_settings(self): """Get current settings from config manager.""" - # Always reload config from disk to reflect latest saved settings self.config_manager.load_config() settings = self.config_manager.get_settings() - # Use .get with defaults for robustness sr_settings = settings.get("speech_recognition", {}) engine = sr_settings.get("engine", "vosk") language = sr_settings.get("language", "en-us") - # Get model size for the specific engine model_size = self.config_manager.get_model_size_for_engine(engine) vad_sensitivity = sr_settings.get("vad_sensitivity", 3) silence_timeout = sr_settings.get("silence_timeout", 2.0) @@ -704,13 +972,10 @@ class SettingsDialog(Gtk.Dialog): def _populate_model_options(self): """Populate model options based on the current engine selection.""" - # Set flag to prevent model change handler from firing during population self._populating_models = True try: - # Clear existing items self.model_combo.remove_all() - # Get the current engine text and convert to lowercase for lookup engine_text = self.engine_combo.get_active_text() if not engine_text: logger.warning("No engine selected during model options population") @@ -719,95 +984,66 @@ class SettingsDialog(Gtk.Dialog): engine = engine_text.lower() logger.info(f"Populating model options for engine: {engine}") - # Get the saved model size for THIS specific engine (not the generic one) saved_model_for_engine = self.config_manager.get_model_size_for_engine(engine) logger.info(f"Saved model for {engine}: {saved_model_for_engine}") - # Track which models are downloaded and find smallest downloaded downloaded_models = [] smallest_model = None + recommended_model, _ = ( + _get_recommended_whisper_model() + if engine == "whisper" + else _get_recommended_vosk_model() + ) - # Add model sizes for this engine if engine in ENGINE_MODELS: - # Add all options for this engine for size in ENGINE_MODELS[engine]: if engine == "whisper" and size in WHISPER_MODEL_INFO: info = WHISPER_MODEL_INFO[size] is_downloaded = _is_whisper_model_downloaded(size) - status = "✓" if is_downloaded else "↓" - display_text = ( - f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}" - ) - if is_downloaded: - downloaded_models.append(size) - if smallest_model is None: - smallest_model = size elif engine == "vosk" and size in VOSK_MODEL_INFO: info = VOSK_MODEL_INFO[size] is_downloaded = _is_vosk_model_downloaded(size, self.language) - status = "✓" if is_downloaded else "↓" - display_text = ( - f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}" - ) - if is_downloaded: - downloaded_models.append(size) - if smallest_model is None: - smallest_model = size else: - display_text = size.capitalize() - # Use lowercase as ID, display text with info + is_downloaded = False + info = {"size_mb": 0} + + status = "✓" if is_downloaded else "↓" + star = " ★" if size == recommended_model else "" + display_text = f"{size.capitalize()} ({_format_size(info.get('size_mb', 0))}) {status}{star}" + + if is_downloaded: + downloaded_models.append(size) + if smallest_model is None: + smallest_model = size + self.model_combo.append(size.capitalize(), display_text) - # Determine which model to select: - # 1. If saved model for this engine is downloaded, use it - # 2. Else if saved model for this engine exists (even if not downloaded), use it - # (user will be prompted to download when applying) - # 3. Else if any model is downloaded, use the smallest downloaded - # 4. Else use the smallest model for this engine + # Determine which model to select saved_model = saved_model_for_engine.lower() - - # Check if the saved model is valid for this engine valid_models = [m.lower() for m in ENGINE_MODELS.get(engine, [])] if saved_model in valid_models: - # Use the saved model for this engine (whether downloaded or not) model_to_set = saved_model.capitalize() elif downloaded_models: - # Saved model isn't valid for this engine, use smallest downloaded model_to_set = downloaded_models[0].capitalize() else: - # No downloaded models, use the smallest model for this engine model_to_set = smallest_model.capitalize() if smallest_model else "Small" - logger.info( - f"Setting active model to: {model_to_set} (saved_for_engine={saved_model}, " - f"valid={saved_model in valid_models}, downloaded={downloaded_models})" - ) + logger.info(f"Setting active model to: {model_to_set}") - # Try to set by ID if not self.model_combo.set_active_id(model_to_set): - logger.warning(f"Could not set model by ID '{model_to_set}', trying by text") - # Find by text as fallback + logger.warning(f"Could not set model by ID '{model_to_set}'") model = self.model_combo.get_model() - model_found = False for i, row in enumerate(model): if row[0].lower() == model_to_set.lower(): self.model_combo.set_active(i) - model_found = True - logger.info(f"Set model by index {i}") break + else: + if len(ENGINE_MODELS.get(engine, [])) > 0: + self.model_combo.set_active(0) - # If still not found, default to first - if not model_found and len(ENGINE_MODELS[engine]) > 0: - logger.warning( - f"Model '{model_to_set}' not found in options, defaulting to first" - ) - self.model_combo.set_active(0) - - # Log final selection logger.info(f"Final selected model: {self.model_combo.get_active_text()}") finally: - # Clear flag to re-enable model change handler self._populating_models = False def _on_engine_changed(self, widget): @@ -818,41 +1054,27 @@ class SettingsDialog(Gtk.Dialog): engine = engine_text.lower() - # Check if current language is supported by the new engine current_lang = self.language_combo.get_active_id() if current_lang: if engine == "vosk" and ( current_lang == "auto" or not SUPPORTED_LANGUAGES.get(current_lang, {}).get("vosk") ): - # Switching to VOSK with unsupported language, fall back to en-us self.language = "en-us" elif engine == "whisper" and not current_lang: - # No language selected, default to auto for Whisper self.language = "auto" self._populate_model_options() self._populate_language_options() - - # Set the active language in the combo after repopulating - # This will trigger _on_language_changed which will apply settings self.language_combo.set_active_id(self.language) - self._update_engine_specific_ui() - self._update_whisper_info() + self._update_model_info() def _on_model_changed(self, widget): """Handle changes in the selected model.""" - # Don't process if we're populating models (prevents recursive calls) if self._populating_models: return - engine_text = self.engine_combo.get_active_text() - if engine_text: - engine = engine_text.lower() - if engine == "whisper": - self._update_whisper_info() - elif engine == "vosk": - self._update_vosk_info() + self._update_model_info() self._auto_apply_settings() def _on_vad_changed(self, widget): @@ -875,26 +1097,22 @@ class SettingsDialog(Gtk.Dialog): for lang_code, lang_info in SUPPORTED_LANGUAGES.items(): display_text = lang_info["name"] - # Filter languages based on engine support if engine == "vosk": - # VOSK: only show languages with available models (skip "auto" since VOSK doesn't support it) has_model = lang_info["vosk"] is not None if not has_model or lang_code == "auto": - continue # Skip languages not supported by VOSK + continue is_downloaded = _is_vosk_model_downloaded("small", lang_code) display_text += " ✓" if is_downloaded else " ↓" elif engine == "whisper": - # Whisper: show all languages, add warning for auto-detect if lang_code == "auto": display_text += " ⚠" else: - continue # Unknown engine + continue self.language_combo.append(lang_code, display_text) def _on_language_changed(self, widget): """Handle language selection change.""" - # Guard against recursive calls if self._processing_language_change: return @@ -911,38 +1129,95 @@ class SettingsDialog(Gtk.Dialog): engine = engine.lower() lang_info = SUPPORTED_LANGUAGES.get(lang_code, {}) - # Update warning label for auto-detect only if lang_info.get("warning"): self.language_warning.set_markup( - f"⚠ {lang_info['warning']}" + f"⚠ {lang_info['warning']}" ) + self.language_warning.show() else: self.language_warning.set_markup("") + self.language_warning.hide() - # Update self.language for VOSK model download checks self.language = lang_code - - # Refresh model options to update download status indicators self._populate_model_options() - self._auto_apply_settings() finally: self._processing_language_change = False + def _update_engine_specific_ui(self): + """Show/hide UI elements specific to the selected engine.""" + self._update_model_info() + + def _update_model_info(self): + """Update the model info card display.""" + engine_text = self.engine_combo.get_active_text() + if not engine_text: + self.model_info_card.hide() + return + + engine = engine_text.lower() + model_id = self.model_combo.get_active_id() + if not model_id: + self.model_info_card.hide() + return + + model_name = model_id.lower() + + if engine == "whisper": + if model_name not in WHISPER_MODEL_INFO: + self.model_info_card.hide() + return + info = WHISPER_MODEL_INFO[model_name] + is_downloaded = _is_whisper_model_downloaded(model_name) + recommended, reason = _get_recommended_whisper_model() + extra_info = f"Parameters: {info['params']}" + elif engine == "vosk": + if model_name not in VOSK_MODEL_INFO: + self.model_info_card.hide() + return + info = VOSK_MODEL_INFO[model_name] + is_downloaded = _is_vosk_model_downloaded(model_name, self.language) + recommended, reason = _get_recommended_vosk_model() + extra_info = f"Size: {_format_size(info['size_mb'])}" + else: + self.model_info_card.hide() + return + + # Update title + self.model_info_title.set_markup(f"{model_name.capitalize()}: {info['desc']}") + + # Update subtitle with status + if is_downloaded: + status = "✓ Downloaded and ready" + else: + status = f"↓ Will download ~{_format_size(info['size_mb'])}" + self.model_info_subtitle.set_markup(f"{extra_info} • {status}") + + # Update recommendation + if model_name == recommended: + self.model_recommendation.set_markup( + f"★ Recommended for your system ({reason})" + ) + else: + self.model_recommendation.set_markup( + f"Tip: {recommended.capitalize()} is recommended for your system ({reason})" + ) + + self.model_info_card.show_all() + def _auto_apply_settings(self): """Automatically apply settings when changed.""" - # Guard against recursive calls if self._applying_settings: return if self._initializing: - return # Don't auto-apply during initialization + return if self._test_active: - return # Don't apply during testing + return if self._populating_models: - return # Don't apply during model population + return self._applying_settings = True try: @@ -952,6 +1227,7 @@ class SettingsDialog(Gtk.Dialog): # Check if model needs to be downloaded needs_download = False + model_info = {"size_mb": 100} # Default if engine == "whisper" and not _is_whisper_model_downloaded(model_name): needs_download = True model_info = WHISPER_MODEL_INFO.get(model_name, {"size_mb": 500}) @@ -960,7 +1236,6 @@ class SettingsDialog(Gtk.Dialog): model_info = VOSK_MODEL_INFO.get(model_name, {"size_mb": 50}) if needs_download: - # Show download dialog for models that need downloading logger.info(f"Model {model_name} needs download, showing progress dialog") download_dialog = ModelDownloadDialog( self, @@ -971,27 +1246,22 @@ class SettingsDialog(Gtk.Dialog): ) def progress_callback(fraction, speed, status): - """Update UI with download progress.""" GLib.idle_add(download_dialog.update_progress, fraction, speed, status) def download_and_apply(): try: - # Set up progress callback for downloads (both VOSK and Whisper) self.speech_engine.set_download_progress_callback(progress_callback) - # Check for cancellation periodically def check_cancelled(): if download_dialog.cancelled: self.speech_engine.cancel_download() return not download_dialog.cancelled - # Start cancellation checker cancel_check_id = GLib.timeout_add(100, check_cancelled) try: self._apply_settings_internal(settings) GLib.idle_add(download_dialog.set_complete, True, "") - # Refresh model list after download to update icons GLib.idle_add(self._populate_model_options) finally: GLib.source_remove(cancel_check_id) @@ -1014,14 +1284,10 @@ class SettingsDialog(Gtk.Dialog): download_dialog.destroy() return - # Model already downloaded, just apply settings directly logger.info(f"Auto-applying settings: {settings}") - # Update config manager self.config_manager.update_speech_recognition_settings(settings) self.config_manager.save_settings() - - # Reconfigure speech engine (don't stop/start if idle) self.speech_engine.reconfigure(**settings) logger.info("Settings auto-applied successfully") except Exception as e: @@ -1029,138 +1295,20 @@ class SettingsDialog(Gtk.Dialog): finally: self._applying_settings = False - def _update_engine_specific_ui(self): - """Show/hide UI elements specific to the selected engine.""" - selected_engine_text = self.engine_combo.get_active_text() - if not selected_engine_text: - self.recognition_settings_box.hide() - self.whisper_info_box.hide() - return - - selected_engine = selected_engine_text.lower() - - # Recognition settings (VAD, silence timeout) apply to both engines - self.recognition_settings_box.show() - - if selected_engine == "vosk": - # Show VOSK-specific info labels - self.vosk_model_info_label.show() - self.vosk_recommendation_label.show() - self.whisper_info_box.hide() - self._update_vosk_info() - elif selected_engine == "whisper": - # Hide VOSK-specific info labels, show Whisper info - self.vosk_model_info_label.hide() - self.vosk_recommendation_label.hide() - self.whisper_info_box.show() - self._update_whisper_info() - else: - self.recognition_settings_box.hide() - self.whisper_info_box.hide() - - def _update_whisper_info(self): - """Update the Whisper model info display.""" - engine_text = self.engine_combo.get_active_text() - if not engine_text or engine_text.lower() != "whisper": - return - - model_id = self.model_combo.get_active_id() - if not model_id: - return - - model_name = model_id.lower() - if model_name not in WHISPER_MODEL_INFO: - return - - info = WHISPER_MODEL_INFO[model_name] - is_downloaded = _is_whisper_model_downloaded(model_name) - - # Build info text - if is_downloaded: - status_text = "✓ Downloaded and ready" - else: - status_text = ( - f"↓ Will download ~{_format_size(info['size_mb'])}" - ) - - info_text = ( - f"{model_name.capitalize()}: {info['desc']}\n" - f"Parameters: {info['params']} • {status_text}" - ) - self.whisper_model_info_label.set_markup(info_text) - - # Update recommendation - recommended, reason = _get_recommended_whisper_model() - if model_name == recommended: - self.whisper_recommendation_label.set_markup( - f"★ Recommended for your system: {reason}" - ) - else: - self.whisper_recommendation_label.set_markup( - f"Tip: {recommended.capitalize()} is recommended for your system ({reason})" - ) - - def _update_vosk_info(self): - """Update the VOSK model info display.""" - engine_text = self.engine_combo.get_active_text() - if not engine_text or engine_text.lower() != "vosk": - return - - model_id = self.model_combo.get_active_id() - if not model_id: - return - - model_name = model_id.lower() - if model_name not in VOSK_MODEL_INFO: - return - - info = VOSK_MODEL_INFO[model_name] - is_downloaded = _is_vosk_model_downloaded(model_name, self.language) - - # Build info text - if is_downloaded: - status_text = "✓ Downloaded and ready" - else: - status_text = ( - f"↓ Will download ~{_format_size(info['size_mb'])}" - ) - - info_text = ( - f"{model_name.capitalize()}: {info['desc']}\n" - f"Size: {_format_size(info['size_mb'])} • {status_text}" - ) - self.vosk_model_info_label.set_markup(info_text) - - # Update recommendation - recommended, reason = _get_recommended_vosk_model() - if model_name == recommended: - self.vosk_recommendation_label.set_markup( - f"★ Recommended for your system: {reason}" - ) - else: - self.vosk_recommendation_label.set_markup( - f"Tip: {recommended.capitalize()} is recommended for your system ({reason})" - ) - def get_selected_settings(self) -> dict: """Return the currently selected settings from the UI.""" engine_text = self.engine_combo.get_active_text() - # Get model by ID (which is the capitalized name) not the display text model_id = self.model_combo.get_active_id() - # Get language by ID language_id = self.language_combo.get_active_id() - # Handle cases where combo boxes might be empty (shouldn't happen with defaults) engine = engine_text.lower() if engine_text else "vosk" - # Extract model name from ID (which is the capitalized name like "Small") model_size = model_id.lower() if model_id else "small" - # Get language code (use "auto" as default) language = language_id if language_id else "auto" vad = int(self.vad_spin.get_value()) silence = self.silence_spin.get_value() - settings = { + return { "engine": engine, "model_size": model_size, "language": language, @@ -1168,21 +1316,15 @@ class SettingsDialog(Gtk.Dialog): "silence_timeout": silence, } - return settings - def _on_test_clicked(self, widget): """Handle click on the test button.""" if self._test_active: logger.warning("Test already in progress.") return - # Ensure settings are applied before testing - # TODO @violog: add language selection into GUI and here current_config = self.config_manager.get_settings().get("speech_recognition", {}) selected_settings = self.get_selected_settings() - # Check if settings in dialog differ from saved config - # This is a basic check; a more robust diff might be needed settings_differ = False if current_config.get("engine") != selected_settings.get("engine") or current_config.get( "model_size" @@ -1193,10 +1335,8 @@ class SettingsDialog(Gtk.Dialog): "vad_sensitivity" ) or current_config.get("silence_timeout") != selected_settings.get("silence_timeout"): settings_differ = True - # Add checks for other engines if they get specific settings if settings_differ: - # Auto-apply settings before testing self.test_buffer.set_text("Applying settings...") if not self.apply_settings(): self.test_buffer.set_text("Failed to apply settings. Please try again.") @@ -1206,58 +1346,44 @@ class SettingsDialog(Gtk.Dialog): self._test_active = True self.test_button.set_sensitive(False) self.test_button.set_label("Testing... Speak Now!") - self.test_buffer.set_text("") # Clear previous results + self.test_buffer.set_text("") self._test_result = "" - # Connect to recognition manager for progress feedback self.connect_to_recognition_manager() - - # Update progress UI to show we're starting self.update_recognition_progress("Listening", info="Starting recognition test...") - # Save existing text callbacks and replace with test-only callback - # This prevents the text injector from typing into the dialog during testing self._saved_text_callbacks = self.speech_engine.get_text_callbacks() self.speech_engine.set_text_callbacks([self._test_text_callback]) - # Start recognition self.speech_engine.start_recognition() - - # Stop recognition after a delay (e.g., 3 seconds) in a separate thread threading.Thread(target=self._stop_test_after_delay, args=(3,)).start() def _test_text_callback(self, text: str): """Callback specifically for the test recognition.""" - # Append text in the GTK main thread GLib.idle_add(self._append_test_result, text) def _append_test_result(self, text: str): current_text = self.test_buffer.get_text( self.test_buffer.get_start_iter(), self.test_buffer.get_end_iter(), False ) - # Add a space if there's existing text - separator = " " if current_text.strip() else "" # Check strip() to avoid leading space + separator = " " if current_text.strip() else "" self.test_buffer.insert(self.test_buffer.get_end_iter(), separator + text) - # Ensure the text view scrolls to the end mark = self.test_buffer.get_insert() self.test_textview.scroll_to_mark(mark, 0.0, True, 0.0, 1.0) - return False # Remove idle callback + return False def _stop_test_after_delay(self, delay: int): """Stops the recognition test after a specified delay.""" time.sleep(delay) - # Stop recognition in the main GTK thread if possible, or directly - # Using GLib.idle_add ensures it runs in the correct thread GLib.idle_add(self._finalize_test) def _finalize_test(self): """Finalize the test state and UI updates.""" if not self._test_active: - return False # Already finalized + return False self.speech_engine.stop_recognition() - # Restore the original text callbacks (including text injector) if hasattr(self, "_saved_text_callbacks"): self.speech_engine.set_text_callbacks(self._saved_text_callbacks) del self._saved_text_callbacks @@ -1266,14 +1392,10 @@ class SettingsDialog(Gtk.Dialog): self.test_button.set_sensitive(True) self.test_button.set_label("Start Test (3 seconds)") - # Reset progress UI self.update_recognition_progress("Idle") - - # Schedule the "no speech" check after a short delay to allow - # any pending callbacks to complete first GLib.timeout_add(200, self._check_test_result) - return False # Remove idle callback + return False def _check_test_result(self): """Check if any text was captured after all callbacks have run.""" @@ -1282,7 +1404,7 @@ class SettingsDialog(Gtk.Dialog): ) if not final_text.strip(): self.test_buffer.set_text("(No speech detected during test)") - return False # Don't repeat + return False def _show_whisper_install_dialog(self): """Show a dialog with instructions for installing Whisper.""" @@ -1316,7 +1438,6 @@ For now, the engine has been reverted to VOSK.""" dialog.run() dialog.destroy() - # Revert the engine selection back to VOSK self.engine_combo.set_active_id("Vosk") self._populate_model_options() self._update_engine_specific_ui() @@ -1329,8 +1450,8 @@ For now, the engine has been reverted to VOSK.""" engine = settings.get("engine", "vosk") model_name = settings.get("model_size", "small") - # Check if we need to download a model needs_download = False + model_info = {"size_mb": 100} # Default if engine == "whisper" and not _is_whisper_model_downloaded(model_name): needs_download = True model_info = WHISPER_MODEL_INFO.get(model_name, {"size_mb": 500}) @@ -1339,7 +1460,6 @@ For now, the engine has been reverted to VOSK.""" model_info = VOSK_MODEL_INFO.get(model_name, {"size_mb": 50}) if needs_download: - # Show download dialog download_dialog = ModelDownloadDialog( self, model_name, @@ -1349,15 +1469,12 @@ For now, the engine has been reverted to VOSK.""" ) def progress_callback(fraction, speed, status): - """Update UI with download progress.""" GLib.idle_add(download_dialog.update_progress, fraction, speed, status) def download_and_apply(): try: - # Set up progress callback for downloads (both VOSK and Whisper) self.speech_engine.set_download_progress_callback(progress_callback) - # Check for cancellation periodically def check_cancelled(): if download_dialog.cancelled: self.speech_engine.cancel_download() @@ -1386,7 +1503,6 @@ For now, the engine has been reverted to VOSK.""" download_dialog.run() download_dialog.destroy() - # Refresh the model list to show updated download status self._populate_model_options() return True @@ -1395,35 +1511,24 @@ For now, the engine has been reverted to VOSK.""" def _apply_settings_internal(self, settings: dict) -> bool: """Internal method to apply settings.""" try: - # 1. Update Config Manager self.config_manager.update_speech_recognition_settings(settings) self.config_manager.save_settings() - # 2. Reconfigure Speech Engine - # Stop engine before reconfiguring if it's running was_running = self.speech_engine.state != RecognitionState.IDLE if was_running: self.speech_engine.stop_recognition() - # Give it a moment to fully stop time.sleep(0.5) self.speech_engine.reconfigure(**settings) - # Restart if it was running before - # if was_running: - # self.speech_engine.start_recognition() # Maybe don't auto-restart? - logger.info("Settings applied successfully.") - # Optionally show a confirmation message return True except Exception as e: logger.error(f"Failed to apply settings: {e}", exc_info=True) - # Check if this is a Whisper import error if "whisper" in str(e).lower() and "no module named" in str(e).lower(): self._show_whisper_install_dialog() else: - # Show generic error dialog error_dialog = Gtk.MessageDialog( transient_for=self, flags=0, @@ -1438,15 +1543,12 @@ For now, the engine has been reverted to VOSK.""" def _populate_audio_devices(self): """Populate the audio device dropdown with available input devices.""" - # Lazy import to avoid circular dependency from ..speech_recognition.recognition_manager import get_audio_input_devices self.audio_device_combo.remove_all() - # Add "System Default" option first self.audio_device_combo.append("-1", "System Default") - # Get available devices devices = get_audio_input_devices() for device_index, device_name, is_default in devices: @@ -1455,14 +1557,12 @@ For now, the engine has been reverted to VOSK.""" label += " (default)" self.audio_device_combo.append(str(device_index), label) - # Get saved device from config saved_device = self.config_manager.get("audio", "device_index", None) if saved_device is None: self.audio_device_combo.set_active_id("-1") else: if not self.audio_device_combo.set_active_id(str(saved_device)): - # Saved device no longer available, fall back to default logger.warning(f"Saved audio device {saved_device} no longer available") self.audio_device_combo.set_active_id("-1") @@ -1485,7 +1585,6 @@ For now, the engine has been reverted to VOSK.""" device_index = int(device_id) device_name = self.audio_device_combo.get_active_text() - # Save to config if device_index == -1: self.config_manager.set("audio", "device_index", None) self.config_manager.set("audio", "device_name", None) @@ -1495,7 +1594,6 @@ For now, the engine has been reverted to VOSK.""" self.config_manager.save_settings() - # Update speech engine if device_index == -1: self.speech_engine.set_audio_device(None) else: @@ -1511,12 +1609,10 @@ For now, the engine has been reverted to VOSK.""" self.audio_test_status.set_markup("Recording... speak into your microphone") self.audio_level_bar.set_value(0) - # Get selected device device_id = self.audio_device_combo.get_active_id() device_index = None if device_id == "-1" else int(device_id) def run_test(): - # Lazy import to avoid circular dependency from ..speech_recognition.recognition_manager import test_audio_input result = test_audio_input(device_index=device_index, duration=2.0) @@ -1527,142 +1623,81 @@ For now, the engine has been reverted to VOSK.""" def _handle_audio_test_result(self, result: dict): """Handle the result of an audio test.""" self.test_audio_btn.set_sensitive(True) - self.test_audio_btn.set_label("Test Mic") + self.test_audio_btn.set_label("Test") if result.get("success"): max_level = result.get("max_amplitude", 0) has_signal = result.get("has_signal", False) - # Update level bar with max level (normalized to 0-100) level_percent = min(100, (max_level / 327.68)) self.audio_level_bar.set_value(level_percent) if has_signal: self.audio_test_status.set_markup( - f"✓ Audio detected! " + f"✓ Audio detected! " f"Peak level: {level_percent:.0f}%" ) else: self.audio_test_status.set_markup( - f"⚠ Very low audio level " + f"⚠ Very low audio level " f"(peak: {level_percent:.1f}%)\n" "Check if microphone is muted or try a different device" ) else: error_msg = result.get("error", "Unknown error") self.audio_test_status.set_markup( - f"✗ Test failed: {error_msg}" + f"✗ Test failed: {error_msg}" ) - return False # Don't repeat + return False def do_response(self, response_id): """Handle dialog button responses.""" - if response_id == Gtk.ResponseType.APPLY: - # Apply button clicked - logger.info("Apply button clicked - applying settings") - if self.apply_settings(): - # Show success feedback - self._show_settings_applied_message() - elif response_id == Gtk.ResponseType.CANCEL: - # Cancel button clicked - revert changes - logger.info("Cancel button clicked - reverting changes") - self._revert_settings() - elif response_id == Gtk.ResponseType.CLOSE: - # Close button clicked + if response_id == Gtk.ResponseType.CLOSE: logger.info("Close button clicked") self.destroy() - return True # We handled the response - - def _show_settings_applied_message(self): - """Show a brief message that settings were applied successfully.""" - # Create a temporary label to show success message - success_label = Gtk.Label(label="✓ Settings applied successfully") - success_label.set_halign(Gtk.Align.CENTER) - success_label.set_margin_top(10) - success_label.get_style_context().add_class("success-label") - - # Add it to the grid temporarily - self.grid.attach(success_label, 0, self.grid.get_property("n-rows"), 2, 1) - success_label.show() - - # Remove it after 2 seconds - GLib.timeout_add(2000, lambda: (self.grid.remove(success_label), False)[1]) - - def _revert_settings(self): - """Revert settings to last saved values.""" - try: - # Reload settings from config - settings = self._get_current_settings() - - # Update UI to reflect saved settings - engine_text = settings["engine"].capitalize() - self.engine_combo.set_active_id(engine_text) - - # This will trigger the engine change handler which updates models and languages - self.vad_spin.set_value(settings["vad_sensitivity"]) - self.silence_spin.set_value(settings["silence_timeout"]) - - logger.info("Settings reverted to last saved values") - except Exception as e: - logger.error(f"Failed to revert settings: {e}") - - # Show error message - error_label = Gtk.Label(label="✗ Failed to revert settings") - error_label.set_halign(Gtk.Align.CENTER) - error_label.set_margin_top(10) - error_label.get_style_context().add_class("error-label") - - self.grid.attach(error_label, 0, self.grid.get_property("n-rows"), 2, 1) - error_label.show() - - GLib.timeout_add(3000, lambda: (self.grid.remove(error_label), False)[1]) + return True def update_recognition_progress(self, state: str, audio_level: float = 0.0, info: str = ""): - """ - Update the recognition progress feedback UI. + """Update the recognition progress feedback UI.""" + self.recognition_status_label.set_text(state) - UX Design: Implements multi-modal feedback (text + icon + color + level bar) - State Machine: IDLE → LISTENING → PROCESSING → IDLE (with ERROR branch) + # Remove existing state classes + for css_class in [ + "recognition-idle", + "recognition-listening", + "recognition-processing", + "recognition-error", + ]: + self.recognition_status_label.get_style_context().remove_class(css_class) - Accessibility: - - Text label: Screen reader friendly (WCAG 2.4.3 Focus Order) - - Icon sensitivity: Visual cue without flashing (WCAG 2.3.1 Three Flashes) - - Color coding: Paired with text for colorblind users (WCAG 1.4.1 Use of Color) - - Reference: RESEARCH.md Section 4 (Progress Feedback in Speech Recognition) - """ - # Update status - Primary text feedback for screen readers - self.recognition_status_label.set_text(f"Status: {state}") - - # Update indicator - Visual cue using icon sensitivity (color vs gray) - # UX: Color-coded states provide quick recognition for sighted users - # Green=Listening (good), Orange=Processing (wait), Red=Error (problem), Gray=Idle if state == "Listening": - self.recognition_indicator.set_sensitive(True) # Make it bright/colored - self.progress_info_label.set_markup("● Listening...") + self.recognition_indicator.set_opacity(1.0) + self.recognition_status_label.get_style_context().add_class("recognition-listening") + self.progress_info_label.set_markup("● Listening...") elif state == "Processing": - self.recognition_indicator.set_sensitive(True) + self.recognition_indicator.set_opacity(1.0) + self.recognition_status_label.get_style_context().add_class("recognition-processing") self.progress_info_label.set_markup( - "● Processing speech..." + "● Processing speech..." ) elif state == "Idle": - self.recognition_indicator.set_sensitive(False) # Make it grayed out + self.recognition_indicator.set_opacity(0.3) + self.recognition_status_label.get_style_context().add_class("recognition-idle") self.progress_info_label.set_text("") elif state == "Error": - self.recognition_indicator.set_sensitive(False) - self.progress_info_label.set_markup(f"✗ Error: {info}") + self.recognition_indicator.set_opacity(0.3) + self.recognition_status_label.get_style_context().add_class("recognition-error") + self.progress_info_label.set_markup( + f"✗ Error: {info}" + ) else: - self.recognition_indicator.set_sensitive(False) + self.recognition_indicator.set_opacity(0.3) if info: self.progress_info_label.set_text(info) - # Update audio level - Real-time feedback for user confidence - # Rationale: Shows system is detecting voice, aids microphone troubleshooting - # Industry Standard: Matches Dragon NaturallySpeaking and Google Docs Voice Typing if audio_level > 0: - # Normalize audio level to 0-100 range for LevelBar widget normalized_level = min(100, max(0, audio_level)) self.recognition_audio_level.set_value(normalized_level) elif state == "Idle": @@ -1671,26 +1706,21 @@ For now, the engine has been reverted to VOSK.""" def connect_to_recognition_manager(self): """Connect to speech recognition manager for progress updates.""" if hasattr(self, "speech_engine") and self.speech_engine: - # Add state change callback using proper API methods if not hasattr(self, "_callbacks_registered"): self.speech_engine.state_callbacks.append(self._on_recognition_state_changed) self.speech_engine.register_audio_level_callback(self._on_audio_level_changed) self._callbacks_registered = True - # Connect to dialog destroy signal for cleanup self.connect("destroy", self._on_dialog_destroy) def _on_dialog_destroy(self, widget): """Clean up callbacks when dialog is destroyed.""" if hasattr(self, "speech_engine") and self.speech_engine: - # Remove state callback if self._on_recognition_state_changed in self.speech_engine.state_callbacks: self.speech_engine.state_callbacks.remove(self._on_recognition_state_changed) - # Remove audio level callback using proper API self.speech_engine.unregister_audio_level_callback(self._on_audio_level_changed) def _on_recognition_state_changed(self, state): """Handle recognition state changes.""" - # Convert state to readable string state_map = { RecognitionState.IDLE: "Idle", RecognitionState.LISTENING: "Listening", diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 655fc6c..3e37dc1 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -306,51 +306,10 @@ class TrayIndicator: def _on_about_clicked(self, widget): """Handle click on the About menu item.""" - from ..version import __copyright__, __description__, __url__, __version__ + from .about_dialog import show_about_dialog logger.debug("About clicked") - - about_dialog = Gtk.AboutDialog() - about_dialog.set_program_name("Vocalinux") - about_dialog.set_version(__version__) - about_dialog.set_copyright(__copyright__) - - # Authors and credits - about_dialog.set_authors(["Jatin K Malik"]) - about_dialog.set_documenters(["Jatin K Malik"]) - about_dialog.set_artists(["Jatin K Malik"]) - - # Add credits section with social links - about_dialog.add_credit_section( - "Connect", - [ - "GitHub: github.com/jatinkrmalik", - "Twitter/X: @jatinkrmalik", - ], - ) - - # Comments with better formatting - about_dialog.set_comments( - f"{__description__}\n\n" "🌟 Open Source Project\n" "Contributions Welcome!" - ) - - about_dialog.set_website(__url__) - about_dialog.set_website_label("⭐ Star on GitHub") - about_dialog.set_license_type(Gtk.License.GPL_3_0) - - # Set the logo using our custom icon - logo_path = _resource_manager.get_icon_path("vocalinux") - if os.path.exists(logo_path): - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file(logo_path) - scaled_pixbuf = pixbuf.scale_simple(128, 128, GdkPixbuf.InterpType.BILINEAR) - about_dialog.set_logo(scaled_pixbuf) - except Exception as e: - logger.warning(f"Failed to load or scale logo: {e}") - - # Run the dialog - about_dialog.run() - about_dialog.destroy() + show_about_dialog(parent=None) def _on_quit_clicked(self, widget): """Handle click on the Quit menu item.""" diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index 793020c..d7bc713 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -251,12 +251,13 @@ class TestTrayIndicator(unittest.TestCase): def test_about_dialog(self): """Test about dialog creation.""" - with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk: + with patch("vocalinux.ui.about_dialog.Gtk") as patched_gtk: mock_about_dialog = MagicMock() patched_gtk.AboutDialog.return_value = mock_about_dialog mock_about_dialog.run.side_effect = lambda: None + patched_gtk.License.GPL_3_0 = 1 - with patch("vocalinux.ui.tray_indicator.GdkPixbuf") as patched_pixbuf: + with patch("vocalinux.ui.about_dialog.GdkPixbuf") as patched_pixbuf: mock_pixbuf = MagicMock() patched_pixbuf.Pixbuf.new_from_file.return_value = mock_pixbuf @@ -429,13 +430,13 @@ class TestTrayIndicator(unittest.TestCase): def test_about_dialog_logo_scaling_error(self): """Test about dialog handles logo scaling errors gracefully.""" - with patch("vocalinux.ui.tray_indicator.Gtk") as patched_gtk: + with patch("vocalinux.ui.about_dialog.Gtk") as patched_gtk: mock_about_dialog = MagicMock() patched_gtk.AboutDialog.return_value = mock_about_dialog mock_about_dialog.run.return_value = None patched_gtk.License.GPL_3_0 = 1 - with patch("vocalinux.ui.tray_indicator.GdkPixbuf") as patched_pixbuf: + with patch("vocalinux.ui.about_dialog.GdkPixbuf") as patched_pixbuf: # Simulate error loading pixbuf patched_pixbuf.Pixbuf.new_from_file.side_effect = Exception("Load error")