diff --git a/font_control_widget.py b/font_control_widget.py new file mode 100644 index 0000000..ea7ab03 --- /dev/null +++ b/font_control_widget.py @@ -0,0 +1,336 @@ +""" +Unified font control widget clustering all font-related manipulators. +Self-contained widget for easy layout management. +""" + +import logging +from typing import List, Dict, Optional +from PyQt5.QtWidgets import (QWidget, QComboBox, QVBoxLayout, QHBoxLayout, + QLabel, QSlider, QPushButton, QGroupBox) +from PyQt5.QtCore import pyqtSignal, Qt + +from font_manager import get_font_manager + + +class FontControlWidget(QWidget): + """ + Unified font control widget containing all font-related manipulators. + + Provides clustered interface for: + - Font selection (unified for Qt and matplotlib) + - Qt font size adjustment + - Plot regeneration controls + """ + + # Signals + fontChanged = pyqtSignal(str, str) # (font_name, font_type) + fontSizeChanged = pyqtSignal(int) # font_size + plotRefreshRequested = pyqtSignal() # manual refresh request + + def __init__(self, parent=None): + """Initialize the font control widget.""" + super().__init__(parent) + self.logger = logging.getLogger(__name__) + self.font_manager = get_font_manager() + self.available_fonts: Dict[str, str] = {} # display_name -> actual_font_name + + # Font size bounds (reasonable range for GUI fonts) + self.min_font_size = 8 + self.max_font_size = 12 + self.default_font_size = 10 + + self.initUI() + self.refresh_font_list() + + def initUI(self): + """Initialize the user interface.""" + # Main layout + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) # Minimal margins + + # Group box for visual clustering + group_box = QGroupBox("Font Settings") + group_layout = QVBoxLayout(group_box) + + # Font selector section + font_section = self._create_font_selector_section() + group_layout.addWidget(font_section) + + # Font size section + size_section = self._create_font_size_section() + group_layout.addWidget(size_section) + + # Control buttons section + button_section = self._create_button_section() + group_layout.addWidget(button_section) + + layout.addWidget(group_box) + + def _create_font_selector_section(self) -> QWidget: + """Create the font selector section.""" + section = QWidget() + layout = QVBoxLayout(section) + layout.setContentsMargins(0, 0, 0, 0) + + # Font label and dropdown + font_label = QLabel("Font:") + layout.addWidget(font_label) + + self.font_combo = QComboBox() + self.font_combo.currentTextChanged.connect(self.on_font_changed) + layout.addWidget(self.font_combo) + + return section + + def _create_font_size_section(self) -> QWidget: + """Create the font size control section.""" + section = QWidget() + layout = QVBoxLayout(section) + layout.setContentsMargins(0, 0, 0, 0) + + # Size label + self.size_label = QLabel(f"Qt Font Size: {self.default_font_size}pt") + layout.addWidget(self.size_label) + + # Size slider with value labels + slider_layout = QHBoxLayout() + + # Min label + min_label = QLabel(str(self.min_font_size)) + min_label.setFixedWidth(20) + slider_layout.addWidget(min_label) + + # Slider + self.size_slider = QSlider(Qt.Horizontal) + self.size_slider.setMinimum(self.min_font_size) + self.size_slider.setMaximum(self.max_font_size) + self.size_slider.setValue(self.default_font_size) + self.size_slider.setTickPosition(QSlider.TicksBelow) + self.size_slider.setTickInterval(2) + self.size_slider.valueChanged.connect(self.on_font_size_changed) + slider_layout.addWidget(self.size_slider) + + # Max label + max_label = QLabel(str(self.max_font_size)) + max_label.setFixedWidth(20) + slider_layout.addWidget(max_label) + + layout.addLayout(slider_layout) + + return section + + def _create_button_section(self) -> QWidget: + """Create the control buttons section.""" + section = QWidget() + layout = QHBoxLayout(section) + layout.setContentsMargins(0, 0, 0, 0) + + # Refresh plot button + self.refresh_button = QPushButton("Refresh Plot") + self.refresh_button.setToolTip("Regenerate current plot with new font settings") + self.refresh_button.clicked.connect(self.on_refresh_plot_clicked) + layout.addWidget(self.refresh_button) + + return section + + def refresh_font_list(self): + """Refresh the list of available fonts.""" + self.logger.debug("Refreshing font list...") + self.available_fonts.clear() + + try: + # Get custom fonts from font manager + custom_fonts = self.font_manager.loaded_fonts + + # Get system font candidates + system_fonts = self.font_manager.get_available_system_fonts()[:10] + + # Clear combo box + self.font_combo.clear() + + # Add custom fonts first (highest priority) + if custom_fonts: + for font_family, font_path in custom_fonts.items(): + display_name = f"{font_family} (Custom)" + self.available_fonts[display_name] = font_family + self.font_combo.addItem(display_name) + self.logger.debug(f"Added custom font: {display_name}") + + # Add system fonts + for font_name in system_fonts: + display_name = f"{font_name} (System)" + self.available_fonts[display_name] = font_name + self.font_combo.addItem(display_name) + self.logger.debug(f"Added system font: {display_name}") + + # Add default option with actual system font name + system_font_name = self.font_manager.get_default_system_font_name() + default_name = f"Default ({system_font_name})" + self.available_fonts[default_name] = "default" + self.font_combo.addItem(default_name) + + # Select startup font + self._select_startup_font() + + self.logger.info(f"Font list refreshed: {len(self.available_fonts)} fonts available") + + except Exception as e: + self.logger.error(f"Error refreshing font list: {e}") + # Fallback: add default option only + self.font_combo.clear() + self.font_combo.addItem("Default (System)") + self.available_fonts = {"Default (System)": "default"} + + def _select_startup_font(self): + """Select the appropriate font on startup.""" + # Use font manager's startup font selection logic + startup_font_name, startup_font_type = self.font_manager.select_startup_font() + + # Find the corresponding display name in our combo box + target_display_name = None + for display_name, actual_name in self.available_fonts.items(): + if actual_name == startup_font_name: + target_display_name = display_name + break + + # If we found the font, select it + if target_display_name: + index = self.font_combo.findText(target_display_name) + if index >= 0: + self.font_combo.setCurrentIndex(index) + self.logger.info(f"Startup font selected: {target_display_name}") + return + + # Fallback to default if we couldn't find the startup font + for i in range(self.font_combo.count()): + item_text = self.font_combo.itemText(i) + if item_text.startswith("Default ("): + self.font_combo.setCurrentIndex(i) + self.logger.info(f"Startup font fallback: {item_text}") + return + + def on_font_changed(self, display_name: str): + """Handle font selection change.""" + if not display_name or display_name not in self.available_fonts: + return + + actual_font_name = self.available_fonts[display_name] + + # Determine font type + if "(Custom)" in display_name: + font_type = "custom" + elif "(System)" in display_name: + font_type = "system" + else: + font_type = "default" + + self.logger.info(f"Font changed: {display_name} -> {actual_font_name} ({font_type})") + + # Apply the font change + self._apply_font_change(actual_font_name, font_type) + + # Emit signal for external listeners (including auto-regeneration) + self.fontChanged.emit(actual_font_name, font_type) + + def on_font_size_changed(self, size: int): + """Handle font size change.""" + self.size_label.setText(f"Qt Font Size: {size}pt") + self.logger.info(f"Qt font size changed: {size}pt") + + # Apply Qt font size change + self._apply_font_size_change(size) + + # Emit signal for external listeners + self.fontSizeChanged.emit(size) + + def on_refresh_plot_clicked(self): + """Handle manual plot refresh button click.""" + self.logger.info("Manual plot refresh requested") + self.plotRefreshRequested.emit() + + def _apply_font_change(self, font_name: str, font_type: str): + """Apply the font change to both Qt and matplotlib.""" + try: + # Use font manager's centralized font application + success = self.font_manager.apply_font_selection(font_name, font_type) + if not success: + self.logger.warning(f"Font application may have failed: {font_name}") + + except Exception as e: + self.logger.error(f"Error applying font change: {e}") + + def _apply_font_size_change(self, size: int): + """Apply Qt font size change.""" + try: + from PyQt5.QtCore import QCoreApplication + from PyQt5.QtGui import QFont + + app = QCoreApplication.instance() + if app: + current_font = app.font() + current_font.setPointSize(size) + app.setFont(current_font) + self.logger.debug(f"Qt font size set to: {size}pt") + + except Exception as e: + self.logger.error(f"Error applying font size change: {e}") + + def get_current_font(self) -> tuple[str, str]: + """ + Get currently selected font. + + Returns: + tuple: (font_name, font_type) + """ + display_name = self.font_combo.currentText() + if display_name in self.available_fonts: + actual_font_name = self.available_fonts[display_name] + + if "(Custom)" in display_name: + font_type = "custom" + elif "(System)" in display_name: + font_type = "system" + else: + font_type = "default" + + return actual_font_name, font_type + + return "default", "default" + + def get_current_font_size(self) -> int: + """ + Get current Qt font size. + + Returns: + int: Current font size in points + """ + return self.size_slider.value() + + def set_font(self, font_name: str): + """ + Programmatically set the font selection. + + Args: + font_name: Name of font to select + """ + # Find matching display name + for display_name, actual_name in self.available_fonts.items(): + if actual_name == font_name: + index = self.font_combo.findText(display_name) + if index >= 0: + self.font_combo.setCurrentIndex(index) + return + + self.logger.warning(f"Font not found in selector: {font_name}") + + def set_font_size(self, size: int): + """ + Programmatically set the font size. + + Args: + size: Font size in points (will be clamped to valid range) + """ + clamped_size = max(self.min_font_size, min(self.max_font_size, size)) + self.size_slider.setValue(clamped_size) + if clamped_size != size: + self.logger.warning(f"Font size clamped: {size} -> {clamped_size}") \ No newline at end of file diff --git a/font_manager.py b/font_manager.py index 8b221ab..dd43a56 100644 --- a/font_manager.py +++ b/font_manager.py @@ -127,7 +127,15 @@ class FontManager: if fonts_loaded > 0: # Clear matplotlib's font cache to recognize new fonts - fm.fontManager._load_fontmanager(try_read_cache=False) + try: + # Try the common method for refreshing font cache + if hasattr(fm.fontManager, '_load_fontmanager'): + fm.fontManager._load_fontmanager(try_read_cache=False) + else: + # For newer matplotlib versions, just reinitialize + fm.fontManager.__init__() + except Exception as font_cache_error: + self.logger.debug(f"Font cache refresh failed (non-critical): {font_cache_error}") except Exception as e: self.logger.error(f"Error loading custom fonts: {e}") @@ -296,6 +304,138 @@ Recommended fonts: {', '.join(self.system_font_fallbacks.get(platform.system(), """ return instructions + def get_available_system_fonts(self) -> List[str]: + """ + Get list of available system fonts that are good candidates for selection. + + Returns: + List[str]: List of available system font names + """ + current_system = platform.system() + fallback_fonts = self.system_font_fallbacks.get(current_system, + self.system_font_fallbacks['Linux']) + + # Combine all font categories + preferred_fonts = [] + for category in ['sans-serif', 'serif', 'monospace']: + preferred_fonts.extend(fallback_fonts.get(category, [])) + + # Get actually available fonts on the system + available_fonts = set(fm.get_font_names()) + + # Filter to only fonts that are actually available + system_candidates = [] + for font_name in preferred_fonts: + if font_name in available_fonts: + system_candidates.append(font_name) + + # Remove duplicates while preserving order + seen = set() + unique_candidates = [] + for font in system_candidates: + if font not in seen: + seen.add(font) + unique_candidates.append(font) + + return unique_candidates + + def get_default_system_font_name(self) -> str: + """ + Get the name of the default system font for display purposes. + + Returns: + str: Human-readable name of the default system font + """ + from PyQt5.QtGui import QFont + from PyQt5.QtCore import QCoreApplication + + # Try to get the actual default font name from Qt + app = QCoreApplication.instance() + if app: + default_font = QFont() + return default_font.family() + + # Fallback to platform-specific defaults + current_system = platform.system() + if current_system == 'Windows': + return 'Segoe UI' + elif current_system == 'Darwin': + return 'SF Pro Display' + else: + return 'DejaVu Sans' + + def select_startup_font(self) -> tuple[str, str]: + """ + Select the appropriate font for application startup. + Priority: First custom font alphabetically, then first system font, then default. + + Returns: + tuple: (font_name, font_type) where font_type is 'custom', 'system', or 'default' + """ + # Check for custom fonts first + if self.loaded_fonts: + custom_font_names = sorted(self.loaded_fonts.keys()) + selected_font = custom_font_names[0] + self.logger.info(f"Startup font selected (custom): {selected_font}") + return selected_font, 'custom' + + # Check for system fonts + system_fonts = self.get_available_system_fonts() + if system_fonts: + selected_font = system_fonts[0] + self.logger.info(f"Startup font selected (system): {selected_font}") + return selected_font, 'system' + + # Default fallback + default_font = self.get_default_system_font_name() + self.logger.info(f"Startup font selected (default): {default_font}") + return default_font, 'default' + + def apply_font_selection(self, font_name: str, font_type: str) -> bool: + """ + Apply a font selection to both matplotlib and Qt. + + Args: + font_name: Name of the font to apply + font_type: Type of font ('custom', 'system', or 'default') + + Returns: + bool: True if successful + """ + try: + if font_type == 'default': + # Reset to default configuration + self._configure_matplotlib() + self._configure_qt() + else: + # Apply specific font + self._set_specific_font(font_name) + + self.logger.info(f"Font applied successfully: {font_name} ({font_type})") + return True + + except Exception as e: + self.logger.error(f"Error applying font selection: {e}") + return False + + def _set_specific_font(self, font_name: str): + """Set a specific font for both matplotlib and Qt.""" + from PyQt5.QtCore import QCoreApplication + from PyQt5.QtGui import QFont + + # Update Qt font + app = QCoreApplication.instance() + if app: + font = QFont(font_name) + app.setFont(font) + + # Update matplotlib font (insert at front of font list) + current_fonts = plt.rcParams['font.sans-serif'].copy() + if font_name in current_fonts: + current_fonts.remove(font_name) + current_fonts.insert(0, font_name) + plt.rcParams['font.sans-serif'] = current_fonts + def get_status_report(self) -> Dict[str, Any]: """ Generate a status report of the font system. @@ -303,11 +443,17 @@ Recommended fonts: {', '.join(self.system_font_fallbacks.get(platform.system(), Returns: dict: Status information """ + startup_font, startup_type = self.select_startup_font() + return { 'matplotlib_configured': self._matplotlib_configured, 'qt_configured': self._qt_configured, 'custom_fonts_loaded': len(self.loaded_fonts), 'custom_font_families': list(self.loaded_fonts.keys()), + 'available_system_fonts': self.get_available_system_fonts(), + 'default_system_font': self.get_default_system_font_name(), + 'startup_font': startup_font, + 'startup_font_type': startup_type, 'fonts_directory_exists': self.fonts_dir.exists(), 'fonts_directory_path': str(self.fonts_dir.absolute()), 'current_matplotlib_fonts': plt.rcParams.get('font.sans-serif', [])[:5], diff --git a/font_selector_widget.py b/font_selector_widget.py new file mode 100644 index 0000000..6201c69 --- /dev/null +++ b/font_selector_widget.py @@ -0,0 +1,208 @@ +""" +Font selector widget providing GUI interface to font management system. +Self-contained widget that can be placed anywhere in the layout. +""" + +import logging +from typing import List, Dict, Optional +from PyQt5.QtWidgets import QWidget, QComboBox, QVBoxLayout, QLabel +from PyQt5.QtCore import pyqtSignal + +from font_manager import get_font_manager + + +class FontSelectorWidget(QWidget): + """ + Self-contained font selector widget. + + Provides a dropdown interface to select fonts from available + custom fonts and system fonts. Integrates with the FontManager + backend for font discovery and application. + """ + + # Signal emitted when font selection changes + fontChanged = pyqtSignal(str, str) # (font_name, font_type) + + def __init__(self, parent=None): + """Initialize the font selector widget.""" + super().__init__(parent) + self.logger = logging.getLogger(__name__) + self.font_manager = get_font_manager() + self.available_fonts: Dict[str, str] = {} # display_name -> actual_font_name + + self.initUI() + self.refresh_font_list() + + def initUI(self): + """Initialize the user interface.""" + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) # Minimal margins for embedding + + # Label + self.label = QLabel("Font:") + layout.addWidget(self.label) + + # Font selector dropdown + self.font_combo = QComboBox() + self.font_combo.currentTextChanged.connect(self.on_font_changed) + layout.addWidget(self.font_combo) + + def refresh_font_list(self): + """Refresh the list of available fonts.""" + self.logger.debug("Refreshing font list...") + self.available_fonts.clear() + + try: + # Get custom fonts from font manager + custom_fonts = self.font_manager.loaded_fonts + + # Get system font candidates + system_fonts = self._get_system_font_candidates() + + # Clear combo box + self.font_combo.clear() + + # Add custom fonts first (highest priority) + if custom_fonts: + for font_family, font_path in custom_fonts.items(): + display_name = f"{font_family} (Custom)" + self.available_fonts[display_name] = font_family + self.font_combo.addItem(display_name) + self.logger.debug(f"Added custom font: {display_name}") + + # Add system fonts + for font_name in system_fonts: + display_name = f"{font_name} (System)" + self.available_fonts[display_name] = font_name + self.font_combo.addItem(display_name) + self.logger.debug(f"Added system font: {display_name}") + + # Add default option with actual system font name + system_font_name = self.font_manager.get_default_system_font_name() + default_name = f"Default ({system_font_name})" + self.available_fonts[default_name] = "default" + self.font_combo.addItem(default_name) + + # Select startup font + self._select_startup_font() + + self.logger.info(f"Font list refreshed: {len(self.available_fonts)} fonts available") + + except Exception as e: + self.logger.error(f"Error refreshing font list: {e}") + # Fallback: add default option only + self.font_combo.clear() + self.font_combo.addItem("Default (System)") + self.available_fonts = {"Default (System)": "default"} + + def _get_system_font_candidates(self) -> List[str]: + """ + Get list of available system fonts that are good candidates. + + Returns: + List[str]: List of available system font names + """ + # Use font manager's enhanced system font discovery + return self.font_manager.get_available_system_fonts()[:10] # Limit to reasonable number + + def _select_startup_font(self): + """Select the appropriate font on startup.""" + # Use font manager's startup font selection logic + startup_font_name, startup_font_type = self.font_manager.select_startup_font() + + # Find the corresponding display name in our combo box + target_display_name = None + for display_name, actual_name in self.available_fonts.items(): + if actual_name == startup_font_name: + target_display_name = display_name + break + + # If we found the font, select it + if target_display_name: + index = self.font_combo.findText(target_display_name) + if index >= 0: + self.font_combo.setCurrentIndex(index) + self.logger.info(f"Startup font selected: {target_display_name}") + return + + # Fallback to default if we couldn't find the startup font + # Find any item that starts with "Default (" + for i in range(self.font_combo.count()): + item_text = self.font_combo.itemText(i) + if item_text.startswith("Default ("): + self.font_combo.setCurrentIndex(i) + self.logger.info(f"Startup font fallback: {item_text}") + return + + def on_font_changed(self, display_name: str): + """Handle font selection change.""" + if not display_name or display_name not in self.available_fonts: + return + + actual_font_name = self.available_fonts[display_name] + + # Determine font type + if "(Custom)" in display_name: + font_type = "custom" + elif "(System)" in display_name: + font_type = "system" + else: + font_type = "default" + + self.logger.info(f"Font changed: {display_name} -> {actual_font_name} ({font_type})") + + # Apply the font change + self._apply_font_change(actual_font_name, font_type) + + # Emit signal for any external listeners + self.fontChanged.emit(actual_font_name, font_type) + + def _apply_font_change(self, font_name: str, font_type: str): + """Apply the font change to the application.""" + try: + # Use font manager's centralized font application + success = self.font_manager.apply_font_selection(font_name, font_type) + if not success: + self.logger.warning(f"Font application may have failed: {font_name}") + + except Exception as e: + self.logger.error(f"Error applying font change: {e}") + + def get_current_font(self) -> tuple[str, str]: + """ + Get currently selected font. + + Returns: + tuple: (font_name, font_type) + """ + display_name = self.font_combo.currentText() + if display_name in self.available_fonts: + actual_font_name = self.available_fonts[display_name] + + if "(Custom)" in display_name: + font_type = "custom" + elif "(System)" in display_name: + font_type = "system" + else: + font_type = "default" + + return actual_font_name, font_type + + return "default", "default" + + def set_font(self, font_name: str): + """ + Programmatically set the font selection. + + Args: + font_name: Name of font to select + """ + # Find matching display name + for display_name, actual_name in self.available_fonts.items(): + if actual_name == font_name: + index = self.font_combo.findText(display_name) + if index >= 0: + self.font_combo.setCurrentIndex(index) + return + + self.logger.warning(f"Font not found in selector: {font_name}") \ No newline at end of file diff --git a/main.py b/main.py index 8347da1..1b710ca 100644 --- a/main.py +++ b/main.py @@ -3,13 +3,14 @@ import os import logging from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QLabel, QListWidget, - QTextEdit, QListWidgetItem) + QTextEdit, QListWidgetItem, QPushButton, QFileDialog) from PyQt5.QtCore import Qt from audio_visualization_widget import AudioVisualizationWidget from analysis_results_manager import AnalysisResultsManager from logger_setup import setup_logging, parse_log_args from font_manager import initialize_fonts, get_font_manager +from font_control_widget import FontControlWidget class MainWindow(QMainWindow): @@ -53,6 +54,18 @@ class MainWindow(QMainWindow): panel = QWidget() layout = QVBoxLayout(panel) + # Open File button + self.open_file_button = QPushButton("Open Audio File...") + self.open_file_button.clicked.connect(self.open_file_dialog) + layout.addWidget(self.open_file_button) + + # Font control cluster + self.font_control = FontControlWidget() + self.font_control.fontChanged.connect(self.on_font_changed) + self.font_control.fontSizeChanged.connect(self.on_font_size_changed) + self.font_control.plotRefreshRequested.connect(self.on_plot_refresh_requested) + layout.addWidget(self.font_control) + # File list self.file_list_label = QLabel("Analyzed Files:") layout.addWidget(self.file_list_label) @@ -116,6 +129,19 @@ class MainWindow(QMainWindow): self.visualization_widget.set_status("No audio files detected in drop") self.logger.warning("No supported audio files found in drop") + def open_file_dialog(self): + """Open file dialog to select audio files for analysis.""" + file_path, _ = QFileDialog.getOpenFileName( + self, + "Select Audio File", + "", # Default directory (empty = current directory) + "Audio Files (*.mp3 *.wav *.flac);;All Files (*)" + ) + + if file_path: # User selected a file (didn't cancel) + self.logger.info(f"File selected via dialog: {os.path.basename(file_path)}") + self.analysis_manager.analyze_file(file_path) + def on_analysis_started(self, file_path): """Called when analysis starts.""" filename = os.path.basename(file_path) @@ -172,6 +198,45 @@ class MainWindow(QMainWindow): # Update metadata display metadata_text = self.analysis_manager.get_metadata_text(file_path) self.metadata_display.setText(metadata_text) + + def on_font_changed(self, font_name: str, font_type: str): + """Called when font selection changes.""" + self.logger.info(f"Font changed via GUI: {font_name} ({font_type})") + # Auto-regenerate current plot with new font + self._regenerate_current_plot() + + def on_font_size_changed(self, font_size: int): + """Called when Qt font size changes.""" + self.logger.info(f"Qt font size changed via GUI: {font_size}pt") + # Qt font size doesn't affect matplotlib plots, so no regeneration needed + + def on_plot_refresh_requested(self): + """Called when manual plot refresh is requested.""" + self.logger.info("Manual plot refresh requested via GUI") + self._regenerate_current_plot() + + def _regenerate_current_plot(self): + """Regenerate the current plot with updated font settings.""" + try: + # Get the currently selected file + current_item = self.file_list.currentItem() + if not current_item: + self.logger.debug("No file selected for plot regeneration") + return + + file_path = current_item.data(Qt.UserRole) + if not file_path: + self.logger.debug("No file path found for current selection") + return + + self.logger.info(f"Regenerating plot for: {os.path.basename(file_path)}") + + # Re-analyze the file to regenerate plots with new font + self.analysis_manager.analyze_file(file_path) + + except Exception as e: + self.logger.error(f"Error regenerating plot: {e}") + self.visualization_widget.set_status(f"Error regenerating plot: {e}") if __name__ == '__main__':