Add unified font control system with auto-regeneration

- Add open file button for direct audio file selection
- Implement clustered FontControlWidget with font selector, size slider, and refresh controls
- Add auto-regeneration of matplotlib plots when fonts change
- Enhance FontManager with system font discovery and startup selection logic
- Replace individual font selector with comprehensive font control interface
- Support both custom fonts and system font candidates with proper prioritization

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2025-08-22 00:18:31 +09:00
parent 4337a31b80
commit 265e8254cd
4 changed files with 757 additions and 2 deletions
+336
View File
@@ -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}")
+147 -1
View File
@@ -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],
+208
View File
@@ -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}")
+66 -1
View File
@@ -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__':