Source code for pyqt_reactive.widgets.shared.list_item_delegate

"""
Shared QListWidget item delegate for rendering multiline items with grey preview text.

Single source of truth for list item rendering across PipelineEditor, PlateManager,
and other widgets that display items with preview labels.
"""

from PyQt6.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem, QStyle
from PyQt6.QtGui import QPainter, QColor, QFont, QFontMetrics, QPen
from PyQt6.QtCore import Qt, QRect

from pyqt_reactive.widgets.shared.scope_color_utils import tint_color_perceptual
from pyqt_reactive.widgets.shared.scope_visual_config import ScopeColorScheme
from pyqt_reactive.widgets.shared.list_item_text_rendering import (
    StyledTextRenderer,
    StyledTextSizeCalculator,
    TextPaintContext,
)
from pyqt_reactive.widgets.shared.styled_text_layout import (
    Segment,
    StyledText,
    StyledTextLayout,
    join_segments,
)

# Custom data role for scope color scheme (must match manager)
SCOPE_SCHEME_ROLE = Qt.ItemDataRole.UserRole + 10
# Flash key role - stores scope_id for flash color lookup
FLASH_KEY_ROLE = Qt.ItemDataRole.UserRole + 11
# Per-field styling roles
LAYOUT_ROLE = Qt.ItemDataRole.UserRole + 12  # StyledTextLayout for structured rendering
DIRTY_FIELDS_ROLE = Qt.ItemDataRole.UserRole + 13  # Set[str] - dotted paths of dirty fields
SIG_DIFF_FIELDS_ROLE = Qt.ItemDataRole.UserRole + 14  # Set[str] - dotted paths of sig-diff fields

# Backwards compat alias
SEGMENTS_ROLE = LAYOUT_ROLE

# Border patterns matching ScopedBorderMixin
BORDER_PATTERNS = {
    "solid": (Qt.PenStyle.SolidLine, None),
    "dashed": (Qt.PenStyle.DashLine, [8, 6]),
    "dotted": (Qt.PenStyle.DotLine, [2, 6]),
    "dashdot": (Qt.PenStyle.DashDotLine, [8, 4, 2, 4]),
}


[docs] class MultilinePreviewItemDelegate(QStyledItemDelegate): """Custom delegate to render multiline items with grey preview text. TRUE O(1) ARCHITECTURE: Flash effects are rendered by WindowFlashOverlay. This delegate does NOT paint flash backgrounds - window overlay handles all flash rendering in a single paintEvent for O(1) per window. Supports: - Multiline text rendering (automatic height calculation) - Grey preview text for lines containing specific markers - Proper hover/selection/border rendering - Configurable colors for normal/preview/selected text """
[docs] def __init__(self, name_color: QColor, preview_color: QColor, selected_text_color: QColor, parent=None, manager=None): """Initialize delegate with color scheme. Args: name_color: Color for normal text lines preview_color: Color for preview text lines (grey) selected_text_color: Color for text when item is selected parent: Parent widget (QListWidget) manager: Manager widget (unused - kept for API compat) """ super().__init__(parent) self.name_color = name_color self.preview_color = preview_color self.selected_text_color = selected_text_color self._manager = manager self._text_renderer = StyledTextRenderer() self._size_calculator = StyledTextSizeCalculator()
# NOTE: Flash rendering moved to WindowFlashOverlay for O(1) performance
[docs] def paint(self, painter: QPainter, option: QStyleOptionViewItem, index) -> None: """Paint the item with multiline support and flash behind text.""" from PyQt6.QtGui import QFont, QFontMetrics # Prepare a copy to let style draw backgrounds, hover, selection, borders, etc. opt = QStyleOptionViewItem(option) self.initStyleOption(opt, index) # Capture text and prevent default text draw text = opt.text or "" opt.text = "" # Calculate border inset (used for background and flash) scheme = index.data(SCOPE_SCHEME_ROLE) border_inset = 0 layers = None if isinstance(scheme, ScopeColorScheme): layers = scheme.step_border_layers if layers: border_inset = sum(layer[0] for layer in layers) content_rect = option.rect.adjusted(border_inset, border_inset, -border_inset, -border_inset) # Scope-based background: match border colors (only when not selected) is_selected = bool(option.state & QStyle.StateFlag.State_Selected) if not is_selected: self._paint_scope_background(painter, content_rect, scheme, layers) # Flash effect - drawn BEHIND text but inside borders flash_key = index.data(FLASH_KEY_ROLE) if flash_key and self._manager is not None: flash_color = self._manager.get_flash_color_for_key(flash_key) if flash_color and flash_color.alpha() > 0: if isinstance(scheme, ScopeColorScheme): base_rgb = scheme.base_color_rgb item_layers = scheme.step_border_layers if base_rgb and item_layers: _, tint_idx, _ = (item_layers[0] + ("solid",))[:3] computed_color = tint_color_perceptual(base_rgb, tint_idx).darker(120) computed_color.setAlpha(flash_color.alpha()) flash_color = computed_color if layers and len(layers) > 1: self._paint_checkerboard_flash(painter, content_rect, flash_color) else: painter.fillRect(content_rect, flash_color) # Let the style draw selection, hover, borders self.parent().style().drawControl(QStyle.ControlElement.CE_ItemViewItem, opt, painter, self.parent()) # Now draw text manually with custom colors painter.save() is_disabled = index.data(Qt.ItemDataRole.UserRole + 1) or False # Get structured layout - no string parsing needed! layout = index.data(LAYOUT_ROLE) dirty_fields = index.data(DIRTY_FIELDS_ROLE) or set() sig_diff_fields = index.data(SIG_DIFF_FIELDS_ROLE) or set() base_font = QFont(option.font) base_font.setStrikeOut(is_disabled) base_font.setUnderline(False) fm = QFontMetrics(base_font) line_height = fm.height() text_rect = option.rect x_start = text_rect.left() + 5 y_offset = text_rect.top() + fm.ascent() + 3 if isinstance(layout, StyledTextLayout): name_color = self.selected_text_color if is_selected else self.name_color preview_color = self.selected_text_color if is_selected else self.preview_color self._text_renderer.paint_layout( painter, layout, TextPaintContext( dirty_fields=dirty_fields, sig_diff_fields=sig_diff_fields, base_font=base_font, name_color=name_color, preview_color=preview_color, ), x_start, y_offset, line_height, ) else: # No fallback - this should never happen import logging logging.error(f"Expected StyledTextLayout but got: {type(layout)}, text: {text[:100]}") return painter.restore() if scheme is not None: self._paint_border_layers(painter, option.rect, scheme)
def _paint_scope_background(self, painter: QPainter, content_rect: QRect, scheme, layers) -> None: """Paint background matching border colors. If single layer: solid color matching border. If multiple layers: grid pattern of layer colors. """ from pyqt_reactive.widgets.shared.scope_visual_config import ScopeVisualConfig if not isinstance(scheme, ScopeColorScheme): return base_rgb = scheme.base_color_rgb if not base_rgb: return opacity = ScopeVisualConfig.STEP_ITEM_BG_OPACITY if not layers or len(layers) == 1: # Single layer: solid background matching first layer color if layers: _, tint_idx, _ = (layers[0] + ("solid",))[:3] else: tint_idx = 1 # default to middle tint color = tint_color_perceptual(base_rgb, tint_idx) color.setAlphaF(opacity) painter.fillRect(content_rect, color) else: # Multiple layers: draw checkerboard with 2 perceptually distinct lightness levels cell_size = 8 # pixels per grid cell painter.save() painter.setClipRect(content_rect) # Use dark (tint 0) and light (tint 2) variants - no hue shift color1 = tint_color_perceptual(base_rgb, 0) # dark color2 = tint_color_perceptual(base_rgb, 2) # light color1.setAlphaF(opacity) color2.setAlphaF(opacity) self._paint_checkerboard_cells(painter, content_rect, color1, color2, cell_size) painter.restore() def _paint_checkerboard_flash(self, painter: QPainter, content_rect: QRect, flash_color: QColor) -> None: """Paint flash effect as checkerboard for multi-layer items.""" cell_size = 8 painter.save() painter.setClipRect(content_rect) # Create light/dark variants of flash color base_alpha = flash_color.alphaF() color1 = QColor(flash_color) color2 = QColor(flash_color) color1.setAlphaF(base_alpha * 0.6) # darker cells color2.setAlphaF(base_alpha * 1.4) # lighter cells (capped by Qt) self._paint_checkerboard_cells(painter, content_rect, color1, color2, cell_size) painter.restore() def _paint_checkerboard_cells( self, painter: QPainter, content_rect: QRect, color1: QColor, color2: QColor, cell_size: int, ) -> None: """Paint alternating clipped cells with caller-provided colors.""" for x in range(content_rect.left(), content_rect.right(), cell_size): for y in range(content_rect.top(), content_rect.bottom(), cell_size): is_even = ((x // cell_size) + (y // cell_size)) % 2 == 0 cell_rect = QRect(x, y, cell_size, cell_size) painter.fillRect(cell_rect.intersected(content_rect), color1 if is_even else color2) def _paint_border_layers(self, painter: QPainter, rect: QRect, scheme) -> None: """Paint layered borders matching window border style. Uses same algorithm as ScopedBorderMixin._paint_border_layers() to ensure list items have identical borders to their corresponding windows. """ if not isinstance(scheme, ScopeColorScheme): return layers = scheme.step_border_layers base_rgb = scheme.base_color_rgb if not layers or not base_rgb: # Fallback: simple border using orchestrator border color border_color = scheme.to_qcolor_orchestrator_border() painter.save() pen = QPen(border_color, 2) pen.setStyle(Qt.PenStyle.SolidLine) painter.setPen(pen) painter.drawRect(rect.adjusted(1, 1, -2, -2)) painter.restore() return # Paint layered borders (same logic as ScopedBorderMixin) painter.save() painter.setRenderHint(QPainter.RenderHint.Antialiasing) inset = 0 for layer in layers: width, tint_idx, pattern = (layer + ("solid",))[:3] color = tint_color_perceptual(base_rgb, tint_idx).darker(120) pen = QPen(color, width) style, dash_pattern = BORDER_PATTERNS.get(pattern, BORDER_PATTERNS["solid"]) pen.setStyle(style) if dash_pattern: pen.setDashPattern(dash_pattern) offset = int(inset + width / 2) painter.setPen(pen) painter.drawRect(rect.adjusted(offset, offset, -offset - 1, -offset - 1)) inset += width painter.restore()
[docs] def sizeHint(self, option: QStyleOptionViewItem, index) -> 'QSize': """Calculate size hint based on layout structure.""" import logging logger = logging.getLogger(__name__) # Get structured layout data layout = index.data(LAYOUT_ROLE) if layout is not None: # Use structured layout for accurate sizing size = self._size_calculator.from_layout(layout, option.font) logger.debug(f"📏 sizeHint from layout: {size}") return size else: # Fallback to text-based sizing text = index.data(Qt.ItemDataRole.DisplayRole) or "" size = self._size_calculator.from_text(text, option.font) logger.warning(f"⚠️ sizeHint from text fallback (no layout): {size}, text: {text[:50] if text else 'empty'}") return size