feat: Add comprehensive logging system with UI for debugging text injection issues (#31)

* feat: Add comprehensive logging system with UI for debugging

🔍 New Logging Features:
- Add LoggingManager for centralized log capture and management
- Add LoggingDialog with real-time log viewing, filtering, and export
- Add 'View Logs' menu item to system tray for easy access
- Support log filtering by level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
- Support log filtering by module name
- Export logs to text files with applied filters
- Auto-scroll functionality for real-time log monitoring
- Log statistics and management (clear logs, view counts)

🐛 Enhanced Text Injection Debugging:
- Add detailed logging for text injection process
- Log target window information (name, class, PID, process)
- Log injection chunks and retry attempts
- Add timeout handling with detailed error messages
- Log environment detection and tool selection
- Enhanced error reporting with stack traces

🎯 UI Improvements:
- Integrated logging dialog with GTK interface
- Color-coded log levels for easy identification
- Real-time log updates in the UI
- Export functionality with file chooser dialog
- Status bar showing log statistics
- Non-modal dialog for continuous monitoring

📁 New Files:
- src/vocalinux/ui/logging_manager.py - Core logging management
- src/vocalinux/ui/logging_dialog.py - GTK logging viewer dialog

This will help debug text injection issues in applications like TextEdit
and terminal where voice typing may not work as expected.

* fix: Improve logging dialog UI layout and add clipboard functionality

🎨 UI Layout Improvements:
- Increase dialog default size from 900x600 to 900x700
- Set minimum content height for scrolled window to 400px
- Enable vertical and horizontal expansion for text view and scrolled window
- Ensure log text area takes up most of the available dialog space

📋 Clipboard Functionality:
- Add 'Copy All' button to dialog for easy log sharing
- Implement clipboard copy with formatted headers and timestamps
- Add user feedback messages for successful/failed copy operations
- Include line count information in success messages
- Handle empty log cases gracefully

🔧 Technical Improvements:
- Proper GTK clipboard integration using Gdk.SELECTION_CLIPBOARD
- Error handling for clipboard operations
- Formatted clipboard content with metadata headers
- Thread-safe clipboard operations

This fixes the issue where the log text area was too short and users
could barely see 2 lines despite having plenty of vertical space available.
The new layout ensures the text view expands to fill the available space,
and the clipboard functionality makes it easy to share logs for debugging.
This commit is contained in:
Jatin K Malik
2025-06-23 17:44:47 -05:00
committed by GitHub
parent f468be627d
commit d265607d40
7 changed files with 905 additions and 32 deletions
+4 -28
View File
@@ -1,29 +1,5 @@
#!/bin/bash
# Vocalinux Virtual Environment Activation Script
# This script activates the virtual environment and sets up the Python path
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Check if virtual environment exists
if [ ! -d "$SCRIPT_DIR/venv" ]; then
echo "Error: Virtual environment not found at $SCRIPT_DIR/venv"
echo "Please run the installer script first: ./install.sh"
return 1 2>/dev/null || exit 1
fi
# Activate the virtual environment
source "$SCRIPT_DIR/venv/bin/activate"
# Set up Python path for development
export PYTHONPATH="$SCRIPT_DIR/src:$PYTHONPATH"
echo "Vocalinux development environment activated!"
echo "Virtual environment: $SCRIPT_DIR/venv"
echo "Python path includes: $SCRIPT_DIR/src"
echo ""
echo "You can now:"
echo " - Run tests: pytest tests/"
echo " - Run Vocalinux: python -m vocalinux.main"
echo " - Install in development mode: pip install -e ."
echo ""
# This script activates the Vocalinux virtual environment
source "$(dirname "$(realpath "$0")")/venv/bin/activate"
echo "Vocalinux virtual environment activated."
echo "To start the application, run: vocalinux"
+5
View File
@@ -86,6 +86,11 @@ def main():
logging.getLogger().setLevel(logging.DEBUG)
logger.debug("Debug logging enabled")
# Initialize logging manager early
from .ui.logging_manager import initialize_logging
initialize_logging()
logger.info("Logging system initialized")
# Check dependencies first
if not check_dependencies():
logger.error("Cannot start Vocalinux due to missing dependencies")
+116 -3
View File
@@ -196,10 +196,15 @@ class TextInjector:
logger.debug("Empty text provided, skipping injection")
return True
logger.debug(f"Injecting text: {text}")
logger.info(f"Starting text injection: '{text}' (length: {len(text)})")
logger.debug(f"Environment: {self.environment}")
# Get information about the current window/application
self._log_current_window_info()
# Escape special characters for shell
escaped_text = self._escape_text(text)
logger.debug(f"Escaped text: '{escaped_text}'")
try:
if (
@@ -222,9 +227,10 @@ class TextInjector:
self._inject_with_xdotool(escaped_text)
else:
raise
logger.info(f"Text injection completed successfully")
return True
except Exception as e:
logger.error(f"Failed to inject text: {e}")
logger.error(f"Failed to inject text: {e}", exc_info=True)
try:
from ..ui.audio_feedback import play_error_sound
play_error_sound() # Play error sound when text injection fails
@@ -310,17 +316,25 @@ class TextInjector:
# Inject text using xdotool
try:
max_retries = 2
logger.debug(f"Starting xdotool injection with {max_retries} max retries")
for retry in range(max_retries + 1):
try:
# Inject in smaller chunks to avoid issues with very long text
chunk_size = 20 # Reduced chunk size for better reliability
total_chunks = (len(text) + chunk_size - 1) // chunk_size
logger.debug(f"Splitting text into {total_chunks} chunks of max {chunk_size} chars")
for i in range(0, len(text), chunk_size):
chunk = text[i : i + chunk_size]
chunk_num = (i // chunk_size) + 1
# First try with clearmodifiers
cmd = ["xdotool", "type", "--clearmodifiers", chunk]
logger.debug(f"Injecting chunk {chunk_num}/{total_chunks}: '{chunk}'")
result = subprocess.run(
cmd, env=env, check=True, stderr=subprocess.PIPE, text=True
cmd, env=env, check=True, stderr=subprocess.PIPE, text=True, timeout=5
)
# Add a larger delay between chunks
@@ -338,7 +352,15 @@ class TextInjector:
)
time.sleep(0.5) # Wait before retry
else:
logger.error(f"Final attempt failed: {chunk_error.stderr}")
raise # Re-raise on final attempt
except subprocess.TimeoutExpired:
if retry < max_retries:
logger.warning(f"Text injection timeout, retrying (attempt {retry+1}/{max_retries})")
time.sleep(0.5)
else:
logger.error("Text injection timed out on final attempt")
raise
# Try to reset any stuck modifiers
try:
@@ -450,3 +472,94 @@ class TextInjector:
else:
logger.warning(f"Keyboard shortcuts not supported with {self.wayland_tool}")
return False
def _log_current_window_info(self):
"""Log information about the current window/application for debugging."""
try:
if (
self.environment == DesktopEnvironment.X11
or self.environment == DesktopEnvironment.WAYLAND_XDOTOOL
):
self._log_x11_window_info()
else:
logger.debug("Window info logging not available for pure Wayland")
except Exception as e:
logger.debug(f"Could not get window info: {e}")
def _log_x11_window_info(self):
"""Log X11 window information."""
env = os.environ.copy()
if self.environment == DesktopEnvironment.WAYLAND_XDOTOOL:
env["GDK_BACKEND"] = "x11"
env["QT_QPA_PLATFORM"] = "xcb"
if "DISPLAY" not in env or not env["DISPLAY"]:
env["DISPLAY"] = ":0"
try:
# Get active window ID
result = subprocess.run(
["xdotool", "getactivewindow"],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=True,
timeout=2
)
window_id = result.stdout.strip()
logger.debug(f"Active window ID: {window_id}")
# Get window name
result = subprocess.run(
["xdotool", "getwindowname", window_id],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=True,
timeout=2
)
window_name = result.stdout.strip()
logger.info(f"Target window: '{window_name}' (ID: {window_id})")
# Get window class
result = subprocess.run(
["xdotool", "getwindowclassname", window_id],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=True,
timeout=2
)
window_class = result.stdout.strip()
logger.debug(f"Window class: {window_class}")
# Get window PID
result = subprocess.run(
["xdotool", "getwindowpid", window_id],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=True,
timeout=2
)
window_pid = result.stdout.strip()
logger.debug(f"Window PID: {window_pid}")
# Try to get process name
try:
with open(f"/proc/{window_pid}/comm", "r") as f:
process_name = f.read().strip()
logger.info(f"Target process: {process_name} (PID: {window_pid})")
except:
pass
except subprocess.TimeoutExpired:
logger.warning("Timeout getting window information")
except subprocess.CalledProcessError as e:
logger.debug(f"xdotool command failed: {e.stderr}")
except Exception as e:
logger.debug(f"Error getting window info: {e}")
+1 -1
View File
@@ -2,4 +2,4 @@
User interface module for Vocalinux
"""
from . import audio_feedback, config_manager, keyboard_shortcuts, tray_indicator
from . import audio_feedback, config_manager, keyboard_shortcuts, logging_manager, tray_indicator
+472
View File
@@ -0,0 +1,472 @@
"""
Logging viewer dialog for VocaLinux.
This module provides a GTK dialog for viewing, filtering, and exporting
application logs for debugging purposes.
"""
import logging
import os
import threading
from datetime import datetime
from typing import Optional
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import GLib, GObject, Gtk
from .logging_manager import LogRecord, get_logging_manager
logger = logging.getLogger(__name__)
class LoggingDialog(Gtk.Dialog):
"""GTK Dialog for viewing and managing application logs."""
def __init__(self, parent: Optional[Gtk.Window] = None):
super().__init__(
title="VocaLinux Logs",
transient_for=parent,
flags=Gtk.DialogFlags.DESTROY_WITH_PARENT,
modal=False
)
self.logging_manager = get_logging_manager()
self.auto_scroll = True
self.filter_level = None
self.filter_module = None
# Set dialog properties
self.set_default_size(900, 700)
self.set_border_width(10)
# 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)
# Create UI
self._create_ui()
# Register for new log records
self.logging_manager.register_callback(self._on_new_log_record)
# Load existing logs
self._refresh_logs()
# Connect signals
self.connect("response", self._on_response)
self.connect("destroy", self._on_destroy)
def _create_ui(self):
"""Create the user interface."""
content_area = self.get_content_area()
# Create main container
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
content_area.add(main_box)
# Create toolbar
toolbar_box = self._create_toolbar()
main_box.pack_start(toolbar_box, False, False, 0)
# Create log view (give it most of the space)
log_view_box = self._create_log_view()
main_box.pack_start(log_view_box, True, True, 0)
# Create status bar
status_box = self._create_status_bar()
main_box.pack_start(status_box, False, False, 0)
# 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)
# Level filter
level_label = Gtk.Label(label="Level:")
toolbar_box.pack_start(level_label, False, False, 0)
self.level_combo = Gtk.ComboBoxText()
self.level_combo.append("ALL", "All Levels")
self.level_combo.append("DEBUG", "Debug")
self.level_combo.append("INFO", "Info")
self.level_combo.append("WARNING", "Warning")
self.level_combo.append("ERROR", "Error")
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)
# Module filter
module_label = Gtk.Label(label="Module:")
toolbar_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.connect("changed", self._on_filter_changed)
toolbar_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)
# Refresh button
refresh_button = Gtk.Button(label="Refresh")
refresh_button.connect("clicked", self._on_refresh_clicked)
toolbar_box.pack_start(refresh_button, False, False, 0)
return toolbar_box
def _create_log_view(self):
"""Create the main log viewing area."""
# 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)
# Create text view
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_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)
# Get text buffer
self.text_buffer = self.text_view.get_buffer()
# Create text tags for different log levels
self._create_text_tags()
scrolled.add(self.text_view)
# Store reference to scrolled window for auto-scrolling
self.scrolled_window = scrolled
return scrolled
def _create_text_tags(self):
"""Create text tags for different log levels."""
# Debug - gray
debug_tag = self.text_buffer.create_tag("DEBUG")
debug_tag.set_property("foreground", "#666666")
# Info - default color
info_tag = self.text_buffer.create_tag("INFO")
# Warning - orange
warning_tag = self.text_buffer.create_tag("WARNING")
warning_tag.set_property("foreground", "#FF8C00")
warning_tag.set_property("weight", 600)
# Error - red
error_tag = self.text_buffer.create_tag("ERROR")
error_tag.set_property("foreground", "#DC143C")
error_tag.set_property("weight", 700)
# Critical - 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)
def _create_status_bar(self):
"""Create the status bar."""
status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self.status_label = Gtk.Label()
self.status_label.set_halign(Gtk.Align.START)
status_box.pack_start(self.status_label, True, True, 0)
# Update status
self._update_status()
return status_box
def _update_status(self):
"""Update the status bar with current log statistics."""
stats = self.logging_manager.get_log_stats()
if stats['total'] == 0:
status_text = "No log records"
else:
level_counts = []
for level in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
count = stats['by_level'].get(level, 0)
if count > 0:
level_counts.append(f"{level}: {count}")
status_text = f"Total: {stats['total']} | " + " | ".join(level_counts)
self.status_label.set_text(status_text)
def _on_filter_changed(self, widget):
"""Handle filter changes."""
# Get current filter values
level_id = self.level_combo.get_active_id()
self.filter_level = None if level_id == "ALL" else level_id
module_text = self.module_entry.get_text().strip()
self.filter_module = module_text if module_text else None
# Refresh logs with new filters
self._refresh_logs()
def _on_auto_scroll_toggled(self, widget):
"""Handle auto-scroll toggle."""
self.auto_scroll = widget.get_active()
def _on_refresh_clicked(self, widget):
"""Handle refresh button click."""
self._refresh_logs()
def _refresh_logs(self):
"""Refresh the log display with current filters."""
# Get filtered logs
records = self.logging_manager.get_logs(
level_filter=self.filter_level,
module_filter=self.filter_module
)
# Clear text buffer
self.text_buffer.set_text("")
# Add log records
for record in records:
self._append_log_record(record)
# Update status
self._update_status()
# Auto-scroll to bottom
if self.auto_scroll:
self._scroll_to_bottom()
def _append_log_record(self, record: LogRecord):
"""
Append a log record to the text view.
Args:
record: The log record to append
"""
# Format the log line
log_line = str(record) + "\n"
# Get end iterator
end_iter = self.text_buffer.get_end_iter()
# Insert text with appropriate tag
tag_name = record.level
if tag_name in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
self.text_buffer.insert_with_tags_by_name(end_iter, log_line, tag_name)
else:
self.text_buffer.insert(end_iter, log_line)
def _scroll_to_bottom(self):
"""Scroll the text view to the bottom."""
mark = self.text_buffer.get_insert()
end_iter = self.text_buffer.get_end_iter()
self.text_buffer.place_cursor(end_iter)
self.text_view.scroll_to_mark(mark, 0.0, False, 0.0, 0.0)
def _on_new_log_record(self, record: LogRecord):
"""
Handle new log record from the logging manager.
Args:
record: The new log record
"""
# Check if record matches current filters
if self.filter_level and record.level != self.filter_level:
return
if self.filter_module and self.filter_module.lower() not in record.logger_name.lower():
return
# Add to UI in main thread
GLib.idle_add(self._append_log_record_safe, record)
def _append_log_record_safe(self, record: LogRecord):
"""
Safely append a log record in the main thread.
Args:
record: The log record to append
"""
try:
self._append_log_record(record)
# Auto-scroll if enabled
if self.auto_scroll:
self._scroll_to_bottom()
# Update status
self._update_status()
except Exception as e:
print(f"Error appending log record: {e}")
return False # Remove from idle queue
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:
self.destroy()
def _export_logs(self):
"""Export logs to a file."""
# Create file chooser dialog
file_dialog = Gtk.FileChooserDialog(
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
)
# Set default filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
default_filename = f"vocalinux_logs_{timestamp}.txt"
file_dialog.set_current_name(default_filename)
# Add file filter
file_filter = Gtk.FileFilter()
file_filter.set_name("Text files")
file_filter.add_pattern("*.txt")
file_dialog.add_filter(file_filter)
response = file_dialog.run()
if response == Gtk.ResponseType.OK:
filepath = file_dialog.get_filename()
success = self.logging_manager.export_logs(
filepath,
level_filter=self.filter_level,
module_filter=self.filter_module
)
if success:
self._show_message("Export successful", f"Logs exported to:\n{filepath}")
else:
self._show_message("Export failed", "Failed to export logs. Check the logs for details.", Gtk.MessageType.ERROR)
file_dialog.destroy()
def _copy_logs_to_clipboard(self):
"""Copy all visible logs to clipboard."""
try:
# Get all text from the text buffer
start_iter = self.text_buffer.get_start_iter()
end_iter = self.text_buffer.get_end_iter()
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.")
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"
# Combine header and content
clipboard_content = header + text_content
# Set clipboard content
clipboard.set_text(clipboard_content, -1)
clipboard.store()
# 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."
)
except Exception as e:
logger.error(f"Failed to copy logs to clipboard: {e}")
self._show_message(
"Copy failed",
f"Failed to copy logs to clipboard: {e}",
Gtk.MessageType.ERROR
)
def _clear_logs(self):
"""Clear all logs after confirmation."""
# Show confirmation dialog
confirm_dialog = Gtk.MessageDialog(
transient_for=self,
flags=0,
message_type=Gtk.MessageType.QUESTION,
buttons=Gtk.ButtonsType.YES_NO,
text="Clear all logs?"
)
confirm_dialog.format_secondary_text(
"This will permanently remove all log records from memory. "
"This action cannot be undone."
)
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.")
def _show_message(self, title: str, message: str, message_type: Gtk.MessageType = Gtk.MessageType.INFO):
"""
Show a message dialog.
Args:
title: Dialog title
message: Message text
message_type: Type of message
"""
dialog = Gtk.MessageDialog(
transient_for=self,
flags=0,
message_type=message_type,
buttons=Gtk.ButtonsType.OK,
text=title
)
dialog.format_secondary_text(message)
dialog.run()
dialog.destroy()
def _on_destroy(self, widget):
"""Handle dialog destruction."""
# Unregister callback
self.logging_manager.unregister_callback(self._on_new_log_record)
+295
View File
@@ -0,0 +1,295 @@
"""
Logging manager for VocaLinux.
This module provides centralized logging functionality with UI integration,
allowing users to view, filter, and export application logs for debugging.
"""
import logging
import os
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Callable, List, Optional
logger = logging.getLogger(__name__)
class LogRecord:
"""Represents a single log record with additional metadata."""
def __init__(self, timestamp: datetime, level: str, logger_name: str, message: str, module: str = ""):
self.timestamp = timestamp
self.level = level
self.logger_name = logger_name
self.message = message
self.module = module
def to_dict(self):
"""Convert to dictionary for easy serialization."""
return {
'timestamp': self.timestamp.isoformat(),
'level': self.level,
'logger_name': self.logger_name,
'message': self.message,
'module': self.module
}
def __str__(self):
"""String representation for display."""
time_str = self.timestamp.strftime("%H:%M:%S.%f")[:-3] # Include milliseconds
return f"[{time_str}] {self.level:8} {self.logger_name:20} | {self.message}"
class LoggingManager:
"""
Centralized logging manager for VocaLinux.
This class captures all application logs, stores them in memory,
and provides functionality for viewing, filtering, and exporting logs.
"""
def __init__(self, max_records: int = 1000):
"""
Initialize the logging manager.
Args:
max_records: Maximum number of log records to keep in memory
"""
self.max_records = max_records
self.log_records: List[LogRecord] = []
self.log_callbacks: List[Callable[[LogRecord], None]] = []
self.lock = threading.Lock()
# Create logs directory
self.logs_dir = Path.home() / ".local" / "share" / "vocalinux" / "logs"
self.logs_dir.mkdir(parents=True, exist_ok=True)
# Set up custom log handler
self.handler = LoggingHandler(self)
self.handler.setLevel(logging.DEBUG)
# Create formatter
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
self.handler.setFormatter(formatter)
# Add handler to root logger
root_logger = logging.getLogger()
root_logger.addHandler(self.handler)
logger.info("Logging manager initialized")
def add_log_record(self, record: LogRecord):
"""
Add a new log record.
Args:
record: The log record to add
"""
with self.lock:
self.log_records.append(record)
# Trim old records if we exceed max_records
if len(self.log_records) > self.max_records:
self.log_records = self.log_records[-self.max_records:]
# Notify callbacks
for callback in self.log_callbacks:
try:
callback(record)
except Exception as e:
# Don't let callback errors break logging
print(f"Error in log callback: {e}")
def register_callback(self, callback: Callable[[LogRecord], None]):
"""
Register a callback to be called when new log records are added.
Args:
callback: Function to call with new log records
"""
with self.lock:
self.log_callbacks.append(callback)
def unregister_callback(self, callback: Callable[[LogRecord], None]):
"""
Unregister a log callback.
Args:
callback: The callback to remove
"""
with self.lock:
try:
self.log_callbacks.remove(callback)
except ValueError:
pass
def get_logs(self, level_filter: Optional[str] = None,
module_filter: Optional[str] = None,
last_n: Optional[int] = None) -> List[LogRecord]:
"""
Get log records with optional filtering.
Args:
level_filter: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
module_filter: Filter by module name (partial match)
last_n: Return only the last N records
Returns:
List of filtered log records
"""
with self.lock:
records = self.log_records.copy()
# Apply filters
if level_filter:
records = [r for r in records if r.level == level_filter]
if module_filter:
records = [r for r in records if module_filter.lower() in r.logger_name.lower()]
# Return last N records
if last_n:
records = records[-last_n:]
return records
def export_logs(self, filepath: str, level_filter: Optional[str] = None,
module_filter: Optional[str] = None) -> bool:
"""
Export logs to a file.
Args:
filepath: Path to save the log file
level_filter: Filter by log level
module_filter: Filter by module name
Returns:
True if export was successful, False otherwise
"""
try:
records = self.get_logs(level_filter, module_filter)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(f"VocaLinux Log Export\n")
f.write(f"Generated: {datetime.now().isoformat()}\n")
f.write(f"Total Records: {len(records)}\n")
if level_filter:
f.write(f"Level Filter: {level_filter}\n")
if module_filter:
f.write(f"Module Filter: {module_filter}\n")
f.write("=" * 80 + "\n\n")
for record in records:
f.write(str(record) + "\n")
logger.info(f"Exported {len(records)} log records to {filepath}")
return True
except Exception as e:
logger.error(f"Failed to export logs: {e}")
return False
def clear_logs(self):
"""Clear all stored log records."""
with self.lock:
self.log_records.clear()
logger.info("Log records cleared")
def get_log_stats(self) -> dict:
"""
Get statistics about the current logs.
Returns:
Dictionary with log statistics
"""
with self.lock:
records = self.log_records.copy()
if not records:
return {
'total': 0,
'by_level': {},
'by_module': {},
'oldest': None,
'newest': None
}
# Count by level
by_level = {}
for record in records:
by_level[record.level] = by_level.get(record.level, 0) + 1
# Count by module
by_module = {}
for record in records:
module = record.logger_name.split('.')[0] if '.' in record.logger_name else record.logger_name
by_module[module] = by_module.get(module, 0) + 1
return {
'total': len(records),
'by_level': by_level,
'by_module': by_module,
'oldest': records[0].timestamp if records else None,
'newest': records[-1].timestamp if records else None
}
class LoggingHandler(logging.Handler):
"""Custom logging handler that feeds records to the LoggingManager."""
def __init__(self, logging_manager: LoggingManager):
super().__init__()
self.logging_manager = logging_manager
def emit(self, record: logging.LogRecord):
"""
Emit a log record to the logging manager.
Args:
record: The logging record to emit
"""
try:
# Create our custom log record
log_record = LogRecord(
timestamp=datetime.fromtimestamp(record.created),
level=record.levelname,
logger_name=record.name,
message=record.getMessage(),
module=getattr(record, 'module', record.name.split('.')[0])
)
# Add to logging manager
self.logging_manager.add_log_record(log_record)
except Exception:
# Don't let logging errors break the application
self.handleError(record)
# Global logging manager instance
_logging_manager: Optional[LoggingManager] = None
def get_logging_manager() -> LoggingManager:
"""
Get the global logging manager instance.
Returns:
The global LoggingManager instance
"""
global _logging_manager
if _logging_manager is None:
_logging_manager = LoggingManager()
return _logging_manager
def initialize_logging():
"""Initialize the global logging manager."""
global _logging_manager
if _logging_manager is None:
_logging_manager = LoggingManager()
logger.info("Global logging manager initialized")
+12
View File
@@ -165,6 +165,7 @@ class TrayIndicator:
self._add_menu_item("Stop Voice Typing", self._on_stop_clicked)
self._add_menu_separator()
self._add_menu_item("Settings", self._on_settings_clicked)
self._add_menu_item("View Logs", self._on_logs_clicked)
self._add_menu_separator()
self._add_menu_item("About", self._on_about_clicked)
self._add_menu_item("Quit", self._on_quit_clicked)
@@ -283,6 +284,17 @@ class TrayIndicator:
# Show the dialog (non-modal)
dialog.show()
def _on_logs_clicked(self, widget):
"""Handle click on the View Logs menu item."""
logger.debug("View Logs clicked")
# Import here to avoid circular imports
from .logging_dialog import LoggingDialog
# Create and show the logging dialog
dialog = LoggingDialog(parent=None)
dialog.show()
def _on_settings_dialog_response(self, dialog, response):
"""Handle responses from the settings dialog."""
if response == Gtk.ResponseType.APPLY: