415 lines
13 KiB
Python
415 lines
13 KiB
Python
"""
|
|
EU-Utility - UI Performance Optimizations
|
|
|
|
Performance improvements for UI rendering:
|
|
1. Double buffering for smooth rendering
|
|
2. Lazy widget loading
|
|
3. Virtual scrolling for large lists
|
|
4. Style caching
|
|
5. Reduced repaints
|
|
"""
|
|
|
|
from typing import Optional, List, Callable, Any
|
|
from functools import lru_cache
|
|
from PyQt6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
|
QPushButton, QStackedWidget, QListWidget,
|
|
QListWidgetItem, QScrollArea, QFrame
|
|
)
|
|
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QSize, QRect
|
|
from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QFontMetrics
|
|
|
|
from core.eu_styles import EU_COLORS
|
|
|
|
|
|
class LazyWidgetLoader:
|
|
"""
|
|
Lazy loader for plugin UI widgets.
|
|
Only creates widgets when they are first shown.
|
|
"""
|
|
|
|
def __init__(self, factory: Callable[[], QWidget]):
|
|
self._factory = factory
|
|
self._widget: Optional[QWidget] = None
|
|
self._loaded = False
|
|
|
|
def get_widget(self) -> QWidget:
|
|
"""Get or create the widget."""
|
|
if not self._loaded:
|
|
self._widget = self._factory()
|
|
self._loaded = True
|
|
return self._widget
|
|
|
|
def is_loaded(self) -> bool:
|
|
"""Check if widget has been loaded."""
|
|
return self._loaded
|
|
|
|
def unload(self):
|
|
"""Unload the widget to free memory."""
|
|
if self._widget:
|
|
self._widget.deleteLater()
|
|
self._widget = None
|
|
self._loaded = False
|
|
|
|
|
|
class VirtualListWidget(QListWidget):
|
|
"""
|
|
Virtual list widget for large datasets.
|
|
Only renders visible items.
|
|
"""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self._all_items: List[Any] = []
|
|
self._item_factory: Optional[Callable[[Any], QListWidgetItem]] = None
|
|
self._page_size = 50
|
|
self._current_page = 0
|
|
self._is_virtual = False
|
|
|
|
# Setup scroll handling
|
|
self.verticalScrollBar().valueChanged.connect(self._on_scroll)
|
|
|
|
def set_virtual_mode(self, enabled: bool, item_factory: Callable[[Any], QListWidgetItem] = None):
|
|
"""Enable/disable virtual mode."""
|
|
self._is_virtual = enabled
|
|
self._item_factory = item_factory
|
|
|
|
def set_data(self, items: List[Any]):
|
|
"""Set the full dataset."""
|
|
self._all_items = items
|
|
|
|
if self._is_virtual:
|
|
self._render_visible_items()
|
|
else:
|
|
# Non-virtual: render all
|
|
self.clear()
|
|
for item_data in items:
|
|
if self._item_factory:
|
|
item = self._item_factory(item_data)
|
|
self.addItem(item)
|
|
|
|
def _render_visible_items(self):
|
|
"""Render only visible items."""
|
|
self.clear()
|
|
|
|
start_idx = self._current_page * self._page_size
|
|
end_idx = min(start_idx + self._page_size, len(self._all_items))
|
|
|
|
for i in range(start_idx, end_idx):
|
|
if self._item_factory:
|
|
item = self._item_factory(self._all_items[i])
|
|
self.addItem(item)
|
|
|
|
def _on_scroll(self, value: int):
|
|
"""Handle scroll events."""
|
|
if not self._is_virtual:
|
|
return
|
|
|
|
# Calculate which page should be visible
|
|
scrollbar = self.verticalScrollBar()
|
|
if scrollbar.maximum() > 0:
|
|
ratio = value / scrollbar.maximum()
|
|
new_page = int(ratio * (len(self._all_items) / self._page_size))
|
|
|
|
if new_page != self._current_page:
|
|
self._current_page = new_page
|
|
self._render_visible_items()
|
|
|
|
|
|
class CachedStyleSheet:
|
|
"""
|
|
Cache for computed stylesheets.
|
|
Avoids recomputing styles on every update.
|
|
"""
|
|
|
|
_cache: dict = {}
|
|
_max_size = 100
|
|
|
|
@classmethod
|
|
def get(cls, style_type: str, **params) -> str:
|
|
"""Get cached stylesheet."""
|
|
cache_key = f"{style_type}:{hash(frozenset(params.items()))}"
|
|
|
|
if cache_key not in cls._cache:
|
|
stylesheet = cls._compute_style(style_type, **params)
|
|
|
|
# Simple LRU: clear if too big
|
|
if len(cls._cache) >= cls._max_size:
|
|
cls._cache.clear()
|
|
|
|
cls._cache[cache_key] = stylesheet
|
|
|
|
return cls._cache[cache_key]
|
|
|
|
@classmethod
|
|
def _compute_style(cls, style_type: str, **params) -> str:
|
|
"""Compute stylesheet for given type."""
|
|
if style_type == "button":
|
|
return f"""
|
|
QPushButton {{
|
|
background-color: {params.get('bg', EU_COLORS['bg_panel'])};
|
|
color: {params.get('color', EU_COLORS['text_secondary'])};
|
|
border: 1px solid {params.get('border', EU_COLORS['border_subtle'])};
|
|
border-radius: {params.get('radius', 4)}px;
|
|
padding: {params.get('padding_v', 4)}px {params.get('padding_h', 12)}px;
|
|
font-size: {params.get('font_size', 11)}px;
|
|
}}
|
|
QPushButton:hover {{
|
|
background-color: {params.get('bg_hover', EU_COLORS['bg_hover'])};
|
|
border-color: {params.get('border_hover', EU_COLORS['accent_orange'])};
|
|
}}
|
|
"""
|
|
elif style_type == "label":
|
|
return f"""
|
|
QLabel {{
|
|
color: {params.get('color', EU_COLORS['text_primary'])};
|
|
font-size: {params.get('font_size', 12)}px;
|
|
font-weight: {params.get('weight', 'normal')};
|
|
}}
|
|
"""
|
|
elif style_type == "frame":
|
|
return f"""
|
|
QFrame {{
|
|
background-color: {params.get('bg', EU_COLORS['bg_dark'])};
|
|
border: {params.get('border_width', 1)}px solid {params.get('border', EU_COLORS['border_medium'])};
|
|
border-radius: {params.get('radius', 6)}px;
|
|
}}
|
|
"""
|
|
return ""
|
|
|
|
@classmethod
|
|
def clear_cache(cls):
|
|
"""Clear the stylesheet cache."""
|
|
cls._cache.clear()
|
|
|
|
|
|
class DoubleBufferedWidget(QWidget):
|
|
"""
|
|
Widget with double buffering for smooth rendering.
|
|
Reduces flickering during updates.
|
|
"""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self._buffer: Optional[QPixmap] = None
|
|
self._buffer_dirty = True
|
|
self._buffer_size: Optional[QSize] = None
|
|
|
|
def resizeEvent(self, event):
|
|
"""Handle resize - invalidate buffer."""
|
|
super().resizeEvent(event)
|
|
self._buffer_dirty = True
|
|
self._buffer = None
|
|
|
|
def paintEvent(self, event):
|
|
"""Paint with double buffering."""
|
|
if self._buffer_dirty or self._buffer is None:
|
|
self._repaint_buffer()
|
|
|
|
# Draw from buffer
|
|
painter = QPainter(self)
|
|
if self._buffer:
|
|
painter.drawPixmap(0, 0, self._buffer)
|
|
painter.end()
|
|
|
|
def _repaint_buffer(self):
|
|
"""Repaint the off-screen buffer."""
|
|
size = self.size()
|
|
if size.width() <= 0 or size.height() <= 0:
|
|
return
|
|
|
|
self._buffer = QPixmap(size)
|
|
self._buffer.fill(QColor(EU_COLORS['bg_dark']))
|
|
|
|
painter = QPainter(self._buffer)
|
|
self._draw_content(painter)
|
|
painter.end()
|
|
|
|
self._buffer_dirty = False
|
|
|
|
def _draw_content(self, painter: QPainter):
|
|
"""Override this to draw content."""
|
|
pass
|
|
|
|
def invalidate_buffer(self):
|
|
"""Mark buffer as needing repaint."""
|
|
self._buffer_dirty = True
|
|
self.update()
|
|
|
|
|
|
class ThrottledUpdater:
|
|
"""
|
|
Throttles UI updates to a maximum frequency.
|
|
Useful for high-frequency data updates.
|
|
"""
|
|
|
|
def __init__(self, update_func: Callable, min_interval_ms: int = 100):
|
|
self.update_func = update_func
|
|
self.min_interval_ms = min_interval_ms
|
|
self._pending = False
|
|
self._last_update = 0
|
|
self._timer = QTimer()
|
|
self._timer.setSingleShot(True)
|
|
self._timer.timeout.connect(self._do_update)
|
|
|
|
def trigger(self):
|
|
"""Trigger an update (may be throttled)."""
|
|
import time
|
|
now = int(time.time() * 1000)
|
|
|
|
if now - self._last_update >= self.min_interval_ms:
|
|
# Update immediately
|
|
self._do_update()
|
|
elif not self._pending:
|
|
# Schedule update
|
|
self._pending = True
|
|
delay = self.min_interval_ms - (now - self._last_update)
|
|
self._timer.start(delay)
|
|
|
|
def _do_update(self):
|
|
"""Perform the actual update."""
|
|
import time
|
|
self._last_update = int(time.time() * 1000)
|
|
self._pending = False
|
|
self.update_func()
|
|
|
|
|
|
class FontCache:
|
|
"""
|
|
Cache for QFont objects to avoid recreation.
|
|
"""
|
|
|
|
_cache: dict = {}
|
|
|
|
@classmethod
|
|
def get(cls, family: str = "Segoe UI", size: int = 12,
|
|
weight: int = QFont.Weight.Normal, bold: bool = False) -> QFont:
|
|
"""Get cached font."""
|
|
cache_key = (family, size, weight, bold)
|
|
|
|
if cache_key not in cls._cache:
|
|
font = QFont(family, size, weight)
|
|
font.setBold(bold)
|
|
cls._cache[cache_key] = font
|
|
|
|
return cls._cache[cache_key]
|
|
|
|
@classmethod
|
|
def get_metrics(cls, font: QFont) -> QFontMetrics:
|
|
"""Get font metrics (cached per font)."""
|
|
cache_key = id(font)
|
|
if cache_key not in cls._cache:
|
|
cls._cache[cache_key] = QFontMetrics(font)
|
|
return cls._cache[cache_key]
|
|
|
|
|
|
class OptimizedStackedWidget(QStackedWidget):
|
|
"""
|
|
Stacked widget with lazy loading and memory management.
|
|
"""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self._lazy_loaders: dict = {}
|
|
self._max_loaded = 5 # Maximum widgets to keep loaded
|
|
|
|
def add_lazy_widget(self, index: int, loader: LazyWidgetLoader):
|
|
"""Add a lazily-loaded widget."""
|
|
self._lazy_loaders[index] = loader
|
|
|
|
# Add placeholder
|
|
placeholder = QWidget()
|
|
self.addWidget(placeholder)
|
|
|
|
def setCurrentIndex(self, index: int):
|
|
"""Switch to widget, loading if necessary."""
|
|
# Load if needed
|
|
if index in self._lazy_loaders:
|
|
loader = self._lazy_loaders[index]
|
|
if not loader.is_loaded():
|
|
# Load the widget
|
|
widget = loader.get_widget()
|
|
old_widget = self.widget(index)
|
|
self.insertWidget(index, widget)
|
|
self.removeWidget(old_widget)
|
|
old_widget.deleteLater()
|
|
|
|
# Unload old widgets if too many loaded
|
|
self._unload_old_widgets()
|
|
|
|
super().setCurrentIndex(index)
|
|
|
|
def _unload_old_widgets(self):
|
|
"""Unload least recently used widgets."""
|
|
loaded = [
|
|
(idx, loader) for idx, loader in self._lazy_loaders.items()
|
|
if loader.is_loaded()
|
|
]
|
|
|
|
if len(loaded) > self._max_loaded:
|
|
# Unload oldest (simple FIFO)
|
|
for idx, loader in loaded[:-self._max_loaded]:
|
|
if idx != self.currentIndex():
|
|
loader.unload()
|
|
# Restore placeholder
|
|
placeholder = QWidget()
|
|
old_widget = self.widget(idx)
|
|
self.insertWidget(idx, placeholder)
|
|
self.removeWidget(old_widget)
|
|
|
|
|
|
class DebouncedLineEdit:
|
|
"""
|
|
Line edit with debounced text change signals.
|
|
Reduces processing for rapid text input.
|
|
"""
|
|
|
|
def __init__(self, callback: Callable[[str], None], delay_ms: int = 300):
|
|
from PyQt6.QtWidgets import QLineEdit
|
|
|
|
self.widget = QLineEdit()
|
|
self.callback = callback
|
|
self.delay_ms = delay_ms
|
|
|
|
self._timer = QTimer()
|
|
self._timer.setSingleShot(True)
|
|
self._timer.timeout.connect(self._emit_text)
|
|
|
|
self.widget.textChanged.connect(self._on_text_changed)
|
|
self._pending_text = ""
|
|
|
|
def _on_text_changed(self, text: str):
|
|
"""Handle text change with debounce."""
|
|
self._pending_text = text
|
|
self._timer.start(self.delay_ms)
|
|
|
|
def _emit_text(self):
|
|
"""Emit the debounced text."""
|
|
self.callback(self._pending_text)
|
|
|
|
def set_text(self, text: str):
|
|
"""Set text without triggering debounce."""
|
|
self._timer.stop()
|
|
self.widget.setText(text)
|
|
|
|
|
|
# ========== Usage Example ==========
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
from PyQt6.QtWidgets import QApplication
|
|
|
|
app = QApplication(sys.argv)
|
|
|
|
# Test cached stylesheet
|
|
style1 = CachedStyleSheet.get("button", bg="#333", color="#fff")
|
|
style2 = CachedStyleSheet.get("button", bg="#333", color="#fff")
|
|
print(f"Stylesheet cached: {style1 is style2}")
|
|
|
|
# Test font cache
|
|
font1 = FontCache.get("Arial", 12)
|
|
font2 = FontCache.get("Arial", 12)
|
|
print(f"Font cached: {font1 is font2}")
|
|
|
|
print("UI optimizations loaded successfully!")
|