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.
This commit is contained in:
Jatin K Malik
2026-02-05 14:32:43 -08:00
committed by GitHub
parent 2becfb5a98
commit 192b089c97
5 changed files with 1476 additions and 787 deletions
+364
View File
@@ -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 <jatinkrmalik@gmail.com>"])
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("<small>Built with VOSK, OpenAI Whisper, GTK 3, and Python</small>")
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(
'<a href="https://www.gnu.org/licenses/gpl-3.0.html">'
"GNU General Public License v3.0</a>"
)
footer.pack_start(license_label, False, False, 0)
return footer
+426 -91
View File
@@ -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("<b>Filters</b>")
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("<small>Buffer: --</small>")
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"<b>{stats['total']}</b> 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"<span foreground='{color}'>{level[0]}</span>:{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"<small>Buffer: <span foreground='#e5a50a'>{buffer_size}/1000</span></small>"
)
else:
self.memory_label.set_markup(f"<small>Buffer: {buffer_size}/1000</small>")
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,
File diff suppressed because it is too large Load Diff
+2 -43
View File
@@ -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."""
+5 -4
View File
@@ -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")