EU-Utility/core/ui_optimizations.py

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!")