From 35907c3be39b003406f32908ecbaa41cfacf7d30 Mon Sep 17 00:00:00 2001 From: axif Date: Tue, 17 Feb 2026 03:16:17 +0600 Subject: [PATCH 01/20] Add thumbnail preview functionality to proof panel and model --- zxlive/proof.py | 125 ++++++++++++++++++++++++++++++++++++++---- zxlive/proof_panel.py | 14 ++++- zxlive/settings.py | 9 +++ 3 files changed, 136 insertions(+), 12 deletions(-) diff --git a/zxlive/proof.py b/zxlive/proof.py index 146c56d2..4ddd7451 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -6,8 +6,8 @@ from PySide6.QtCore import (QAbstractItemModel, QAbstractListModel, QItemSelection, QModelIndex, QPersistentModelIndex, - QPoint, QPointF, QRect, QSize, Qt) -from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen + QPoint, QPointF, QRect, QRectF, QSize, Qt) +from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen, QPixmap from PySide6.QtWidgets import (QAbstractItemView, QLineEdit, QListView, QMenu, QStyle, QStyledItemDelegate, QStyleOptionViewItem, QWidget) @@ -57,6 +57,46 @@ def from_json(json_str: Union[str, Dict[str, Any]]) -> "Rewrite": ) +THUMBNAIL_ROLE = Qt.ItemDataRole.UserRole + 1 + + +def render_graph_thumbnail(graph: GraphT) -> QPixmap: + """Render a graph to a QPixmap thumbnail using an off-screen GraphScene.""" + from .graphscene import GraphScene + + scene = GraphScene() + scene.set_graph(graph) + rect = scene.itemsBoundingRect() + if rect.isEmpty(): + scene.clear() + return QPixmap() + + padding = 20.0 + rect.adjust(-padding, -padding, padding, padding) + + max_dim = 600.0 + scale = min(max_dim / rect.width(), max_dim / rect.height()) + if scale > 2.0: + scale = 2.0 + + w = max(1, int(rect.width() * scale)) + h = max(1, int(rect.height() * scale)) + + pixmap = QPixmap(w, h) + if display_setting.dark_mode: + pixmap.fill(QColor(30, 30, 30)) + else: + pixmap.fill(QColor(255, 255, 255)) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + scene.render(painter, QRectF(0, 0, w, h), rect) + painter.end() + + scene.clear() + return pixmap + + class ProofModel(QAbstractListModel): """List model capturing the individual steps in a proof. @@ -71,6 +111,7 @@ def __init__(self, start_graph: GraphT): super().__init__() self.initial_graph = start_graph self.steps = [] + self._thumbnail_cache: dict[int, QPixmap] = {} def set_graph(self, index: int, graph: GraphT) -> None: if index == 0: @@ -79,6 +120,7 @@ def set_graph(self, index: int, graph: GraphT) -> None: old_step = self.steps[index - 1] new_step = Rewrite(old_step.display_name, old_step.rule, graph) self.steps[index - 1] = new_step + self._thumbnail_cache.pop(index, None) def graphs(self) -> list[GraphT]: return [self.initial_graph] + [step.graph for step in self.steps] @@ -96,6 +138,12 @@ def data(self, index: Union[QModelIndex, QPersistentModelIndex], role: int = Qt. return self.steps[index.row() - 1].display_name elif role == Qt.ItemDataRole.FontRole: return QFont("monospace", 12) + elif role == THUMBNAIL_ROLE: + row = index.row() + if row not in self._thumbnail_cache: + graph = self.get_graph(row) + self._thumbnail_cache[row] = render_graph_thumbnail(graph) + return self._thumbnail_cache.get(row) def flags(self, index: Union[QModelIndex, QPersistentModelIndex]) -> Qt.ItemFlag: if index.row() == 0: @@ -131,6 +179,7 @@ def add_rewrite(self, rewrite: Rewrite, position: Optional[int] = None) -> None: self.beginInsertRows(QModelIndex(), position + 1, position + 1) self.steps.insert(position, rewrite) self.endInsertRows() + self.invalidate_thumbnails(position + 1) def pop_rewrite(self, position: Optional[int] = None) -> tuple[Rewrite, GraphT]: """Removes the latest rewrite from the model. @@ -142,6 +191,7 @@ def pop_rewrite(self, position: Optional[int] = None) -> tuple[Rewrite, GraphT]: self.beginRemoveRows(QModelIndex(), position + 1, position + 1) rewrite = self.steps.pop(position) self.endRemoveRows() + self.invalidate_thumbnails(position + 1) return rewrite, rewrite.graph def get_graph(self, index: int) -> GraphT: @@ -153,6 +203,12 @@ def get_graph(self, index: int) -> GraphT: assert isinstance(copy, GraphT) return copy + def invalidate_thumbnails(self, from_row: int = 0) -> None: + """Remove cached thumbnails from the given row index onward.""" + keys = [k for k in self._thumbnail_cache if k >= from_row] + for k in keys: + del self._thumbnail_cache[k] + def rename_step(self, index: int, name: str) -> None: """Change the display name""" old_step = self.steps[index] @@ -253,7 +309,7 @@ def __init__(self, parent: 'ProofPanel'): self.setSelectionMode(QAbstractItemView.SelectionMode.ContiguousSelection) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.setResizeMode(QListView.ResizeMode.Adjust) - self.setUniformItemSizes(True) + self.setUniformItemSizes(not display_setting.thumbnails_show) self.setAlternatingRowColors(True) self.viewport().setAttribute(Qt.WidgetAttribute.WA_Hover) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) @@ -352,6 +408,19 @@ def ungroup_selected_step(self) -> None: cmd = UngroupRewriteSteps(self.graph_view, self, index - 1) self.undo_stack.push(cmd) + def set_thumbnails_visible(self, visible: bool) -> None: + """Toggle thumbnail previews in the proof step list.""" + display_setting.thumbnails_show = visible + self.setUniformItemSizes(not visible) + self.model()._thumbnail_cache.clear() + self.scheduleDelayedItemsLayout() + self.viewport().update() + + def resizeEvent(self, event: object) -> None: # type: ignore[override] + super().resizeEvent(event) # type: ignore[arg-type] + if display_setting.thumbnails_show: + self.scheduleDelayedItemsLayout() + class ProofStepItemDelegate(QStyledItemDelegate): """This class controls the painting of items in the proof steps list view. @@ -362,6 +431,7 @@ class ProofStepItemDelegate(QStyledItemDelegate): line_width = 3 line_padding = 13 vert_padding = 10 + thumbnail_padding = 8 circle_radius = 4 circle_radius_selected = 6 @@ -369,6 +439,9 @@ class ProofStepItemDelegate(QStyledItemDelegate): def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]) -> None: painter.save() + text_height = QFontMetrics(option.font).height() # type: ignore[attr-defined] + text_row_height = text_height + 2 * self.vert_padding + # Draw background painter.setPen(Qt.GlobalColor.transparent) if display_setting.dark_mode: @@ -387,13 +460,14 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QM painter.setBrush(Qt.GlobalColor.white) painter.drawRect(option.rect) # type: ignore[attr-defined] - # Draw line + # Draw vertical line is_last = index.row() == index.model().rowCount() - 1 + line_height = int(text_row_height / 2) if is_last else int(option.rect.height()) # type: ignore[attr-defined] line_rect = QRect( self.line_padding, int(option.rect.y()), # type: ignore[attr-defined] self.line_width, - int(option.rect.height() if not is_last else option.rect.height() / 2) # type: ignore[attr-defined] + line_height ) if display_setting.dark_mode: painter.setBrush(QColor(180, 180, 180)) @@ -401,7 +475,7 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QM painter.setBrush(Qt.GlobalColor.black) painter.drawRect(line_rect) - # Draw circle + # Draw circle centered in text row if display_setting.dark_mode: painter.setPen(QPen(QColor(180, 180, 180), self.circle_outline_width)) else: @@ -409,17 +483,16 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QM painter.setBrush(display_setting.effective_colors["z_spider"]) circle_radius = self.circle_radius_selected if option.state & QStyle.StateFlag.State_Selected else self.circle_radius # type: ignore[attr-defined] painter.drawEllipse( - QPointF(self.line_padding + self.line_width / 2, option.rect.y() + option.rect.height() / 2), # type: ignore[attr-defined] + QPointF(self.line_padding + self.line_width / 2, option.rect.y() + text_row_height / 2), # type: ignore[attr-defined] circle_radius, circle_radius ) - # Draw text + # Draw text centered in text row text = index.data(Qt.ItemDataRole.DisplayRole) - text_height = QFontMetrics(option.font).height() # type: ignore[attr-defined] text_rect = QRect( int(option.rect.x() + self.line_width + 2 * self.line_padding), # type: ignore[attr-defined] - int(option.rect.y() + option.rect.height() / 2 - text_height / 2), # type: ignore[attr-defined] + int(option.rect.y() + text_row_height / 2 - text_height / 2), # type: ignore[attr-defined] option.rect.width(), # type: ignore[attr-defined] text_height ) @@ -435,11 +508,41 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QM painter.setBrush(Qt.GlobalColor.black) painter.drawText(text_rect, Qt.AlignmentFlag.AlignLeft, text) + # Draw thumbnail preview if enabled + if display_setting.thumbnails_show: + pixmap = index.data(THUMBNAIL_ROLE) + if pixmap is not None and not pixmap.isNull(): + thumb_left = int(option.rect.x()) + self.line_width + 2 * self.line_padding # type: ignore[attr-defined] + thumb_top = int(option.rect.y()) + text_row_height + self.thumbnail_padding # type: ignore[attr-defined] + available_width = int(option.rect.width()) - self.line_width - 2 * self.line_padding - self.thumbnail_padding # type: ignore[attr-defined] + if available_width > 0 and pixmap.width() > 0: + thumb_height = int(pixmap.height() * available_width / pixmap.width()) + target = QRect(thumb_left, thumb_top, available_width, thumb_height) + border_color = QColor(80, 80, 80) if display_setting.dark_mode else QColor(200, 200, 200) + painter.setPen(QPen(border_color, 1)) + painter.setBrush(Qt.GlobalColor.transparent) + painter.drawRect(target.adjusted(-1, -1, 1, 1)) + painter.drawPixmap(target, pixmap) + painter.restore() def sizeHint(self, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]) -> QSize: size = super().sizeHint(option, index) - return QSize(size.width(), size.height() + 2 * self.vert_padding) + text_row_height = size.height() + 2 * self.vert_padding + if not display_setting.thumbnails_show: + return QSize(size.width(), text_row_height) + parent = self.parent() + if not isinstance(parent, QListView): + return QSize(size.width(), text_row_height) + available_width = parent.viewport().width() - self.line_width - 2 * self.line_padding - self.thumbnail_padding + if available_width <= 0: + return QSize(size.width(), text_row_height) + pixmap = index.data(THUMBNAIL_ROLE) + if pixmap is not None and not pixmap.isNull() and pixmap.width() > 0: + thumb_height = int(pixmap.height() * available_width / pixmap.width()) + total = text_row_height + 2 * self.thumbnail_padding + thumb_height + return QSize(size.width(), total) + return QSize(size.width(), text_row_height) def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]) -> QLineEdit: return QLineEdit(parent) diff --git a/zxlive/proof_panel.py b/zxlive/proof_panel.py index 0111cc1f..88081148 100644 --- a/zxlive/proof_panel.py +++ b/zxlive/proof_panel.py @@ -117,6 +117,15 @@ def _toolbar_sections(self) -> Iterator[ToolbarSection]: self.pauli_webs.setText("Pauli Webs") self.pauli_webs.clicked.connect(self._start_pauliwebs) yield ToolbarSection(self.pauli_webs) + + self.thumbnails_toggle = QToolButton(self) + self.thumbnails_toggle.setText("Thumbnails") + self.thumbnails_toggle.setCheckable(True) + self.thumbnails_toggle.setChecked(display_setting.thumbnails_show) + self.thumbnails_toggle.setToolTip("Toggle proof step diagram previews (t)") + self.thumbnails_toggle.setShortcut("t") + self.thumbnails_toggle.toggled.connect(self._toggle_thumbnails) + yield ToolbarSection(self.thumbnails_toggle) def _start_pauliwebs(self) -> None: # note: this code is copied from edit_panel.py - consider refactoring to avoid duplication @@ -134,7 +143,10 @@ def _start_pauliwebs(self) -> None: new_g: GraphT = copy.deepcopy(self.graph_scene.g) self.start_pauliwebs_signal.emit(new_g) - + + def _toggle_thumbnails(self, checked: bool) -> None: + self.step_view.set_thumbnails_visible(checked) + def update_font(self) -> None: self.rewrites_panel.setFont(display_setting.font) self.rewrites_panel.reset_rewrite_panel_style() diff --git a/zxlive/settings.py b/zxlive/settings.py index 21ee1801..c555b5f2 100644 --- a/zxlive/settings.py +++ b/zxlive/settings.py @@ -50,6 +50,7 @@ class ColorScheme(TypedDict): "dark-mode": "system", "auto-save": False, "patterns-folder": "patterns/", + "thumbnails-show": False, } font_defaults: dict[str, str | int | None] = { @@ -241,6 +242,14 @@ def previews_show(self) -> bool: def previews_show(self, value: bool) -> None: settings.setValue("previews-show", value) + @property + def thumbnails_show(self) -> bool: + return get_settings_value("thumbnails-show", bool) + + @thumbnails_show.setter + def thumbnails_show(self, value: bool) -> None: + settings.setValue("thumbnails-show", value) + @property def dark_mode(self) -> bool: dark_mode_setting = str(settings.value("dark-mode", "system")) From b74fbb9ae7c2b8b28aaf1840ace52f015063627e Mon Sep 17 00:00:00 2001 From: axif Date: Tue, 17 Feb 2026 15:20:53 +0600 Subject: [PATCH 02/20] Refactor thumbnail visibility handling in proof panel and model --- zxlive/proof.py | 22 +++++++++++++--------- zxlive/proof_panel.py | 6 +++--- zxlive/settings.py | 9 --------- 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/zxlive/proof.py b/zxlive/proof.py index 4ddd7451..fbad1c63 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -308,8 +308,9 @@ def __init__(self, parent: 'ProofPanel'): self.setSpacing(0) self.setSelectionMode(QAbstractItemView.SelectionMode.ContiguousSelection) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.thumbnails_visible = False self.setResizeMode(QListView.ResizeMode.Adjust) - self.setUniformItemSizes(not display_setting.thumbnails_show) + self.setUniformItemSizes(False) self.setAlternatingRowColors(True) self.viewport().setAttribute(Qt.WidgetAttribute.WA_Hover) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) @@ -410,15 +411,19 @@ def ungroup_selected_step(self) -> None: def set_thumbnails_visible(self, visible: bool) -> None: """Toggle thumbnail previews in the proof step list.""" - display_setting.thumbnails_show = visible - self.setUniformItemSizes(not visible) + self.thumbnails_visible = visible self.model()._thumbnail_cache.clear() self.scheduleDelayedItemsLayout() self.viewport().update() + def showEvent(self, event: object) -> None: # type: ignore[override] + """Force relayout when this tab becomes visible (tab switch).""" + super().showEvent(event) # type: ignore[arg-type] + self.scheduleDelayedItemsLayout() + def resizeEvent(self, event: object) -> None: # type: ignore[override] super().resizeEvent(event) # type: ignore[arg-type] - if display_setting.thumbnails_show: + if self.thumbnails_visible: self.scheduleDelayedItemsLayout() @@ -508,8 +513,9 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QM painter.setBrush(Qt.GlobalColor.black) painter.drawText(text_rect, Qt.AlignmentFlag.AlignLeft, text) - # Draw thumbnail preview if enabled - if display_setting.thumbnails_show: + # Draw thumbnail preview if enabled on this view + parent_view = self.parent() + if isinstance(parent_view, ProofStepView) and parent_view.thumbnails_visible: pixmap = index.data(THUMBNAIL_ROLE) if pixmap is not None and not pixmap.isNull(): thumb_left = int(option.rect.x()) + self.line_width + 2 * self.line_padding # type: ignore[attr-defined] @@ -529,10 +535,8 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QM def sizeHint(self, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]) -> QSize: size = super().sizeHint(option, index) text_row_height = size.height() + 2 * self.vert_padding - if not display_setting.thumbnails_show: - return QSize(size.width(), text_row_height) parent = self.parent() - if not isinstance(parent, QListView): + if not isinstance(parent, ProofStepView) or not parent.thumbnails_visible: return QSize(size.width(), text_row_height) available_width = parent.viewport().width() - self.line_width - 2 * self.line_padding - self.thumbnail_padding if available_width <= 0: diff --git a/zxlive/proof_panel.py b/zxlive/proof_panel.py index 88081148..ace936d9 100644 --- a/zxlive/proof_panel.py +++ b/zxlive/proof_panel.py @@ -58,9 +58,10 @@ def __init__(self, graph: GraphT, *actions: QAction) -> None: self.graph_scene.edge_added.connect(self._add_dummy_edge) self.step_view = ProofStepView(self) - self.splitter.addWidget(self.step_view) + self.thumbnails_toggle.clicked.connect(self._toggle_thumbnails) + @property def proof_model(self) -> ProofModel: return self.step_view.model() @@ -121,10 +122,9 @@ def _toolbar_sections(self) -> Iterator[ToolbarSection]: self.thumbnails_toggle = QToolButton(self) self.thumbnails_toggle.setText("Thumbnails") self.thumbnails_toggle.setCheckable(True) - self.thumbnails_toggle.setChecked(display_setting.thumbnails_show) + self.thumbnails_toggle.setChecked(False) self.thumbnails_toggle.setToolTip("Toggle proof step diagram previews (t)") self.thumbnails_toggle.setShortcut("t") - self.thumbnails_toggle.toggled.connect(self._toggle_thumbnails) yield ToolbarSection(self.thumbnails_toggle) def _start_pauliwebs(self) -> None: diff --git a/zxlive/settings.py b/zxlive/settings.py index c555b5f2..21ee1801 100644 --- a/zxlive/settings.py +++ b/zxlive/settings.py @@ -50,7 +50,6 @@ class ColorScheme(TypedDict): "dark-mode": "system", "auto-save": False, "patterns-folder": "patterns/", - "thumbnails-show": False, } font_defaults: dict[str, str | int | None] = { @@ -242,14 +241,6 @@ def previews_show(self) -> bool: def previews_show(self, value: bool) -> None: settings.setValue("previews-show", value) - @property - def thumbnails_show(self) -> bool: - return get_settings_value("thumbnails-show", bool) - - @thumbnails_show.setter - def thumbnails_show(self, value: bool) -> None: - settings.setValue("thumbnails-show", value) - @property def dark_mode(self) -> bool: dark_mode_setting = str(settings.value("dark-mode", "system")) From e6c6e2704b6d45221495335c9de0722ad3ed3c9e Mon Sep 17 00:00:00 2001 From: axif Date: Wed, 18 Feb 2026 03:27:04 +0600 Subject: [PATCH 03/20] feat: Refactor the proof step view to include a thumbnail toggle and improve thumbnail rendering, and add a signal for keyboard vertex movement. --- zxlive/commands.py | 3 +- zxlive/graphview.py | 3 + zxlive/proof.py | 176 ++++++++++++++++++++++++++++++++++++++---- zxlive/proof_panel.py | 16 ++-- 4 files changed, 170 insertions(+), 28 deletions(-) diff --git a/zxlive/commands.py b/zxlive/commands.py index ec726558..71cb141c 100644 --- a/zxlive/commands.py +++ b/zxlive/commands.py @@ -8,7 +8,6 @@ from PySide6.QtCore import QModelIndex from PySide6.QtGui import QUndoCommand -from PySide6.QtWidgets import QListView from pyzx.graph.diff import GraphDiff from pyzx.symbolic import Poly from pyzx.utils import EdgeType, VertexType, get_w_partner, vertex_is_w, get_w_io, get_z_box_label, set_z_box_label @@ -441,7 +440,7 @@ class AddRewriteStep(UpdateGraph): The rewrite is inserted after the currently selected step. In particular, it replaces all rewrites that were previously after the current selection. """ - step_view: QListView + step_view: ProofStepView name: str diff: Optional[GraphDiff] = None diff --git a/zxlive/graphview.py b/zxlive/graphview.py index fb6269c9..78109e4c 100644 --- a/zxlive/graphview.py +++ b/zxlive/graphview.py @@ -74,6 +74,7 @@ class GraphView(QGraphicsView): wand_trace_finished = Signal(object) merge_triggered = Signal() + keyboard_vertices_moved = Signal() draw_background_lines = True def __init__(self, graph_scene: GraphScene) -> None: @@ -175,6 +176,8 @@ def keyPressEvent(self, e: QKeyEvent) -> None: elif e.key() == Qt.Key.Key_Right: g.set_position(v, y, x + distance) vitem.set_pos_from_graph() + if self.graph_scene.selected_vertices: + self.keyboard_vertices_moved.emit() else: super().keyPressEvent(e) diff --git a/zxlive/proof.py b/zxlive/proof.py index fbad1c63..8743c213 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -8,9 +8,10 @@ QItemSelection, QModelIndex, QPersistentModelIndex, QPoint, QPointF, QRect, QRectF, QSize, Qt) from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen, QPixmap -from PySide6.QtWidgets import (QAbstractItemView, QLineEdit, QListView, QMenu, - QStyle, QStyledItemDelegate, - QStyleOptionViewItem, QWidget) +from PySide6.QtWidgets import (QAbstractItemView, QFrame, QHBoxLayout, QLabel, + QLineEdit, QListView, QMenu, QStyle, + QStyledItemDelegate, QStyleOptionViewItem, + QToolButton, QVBoxLayout, QWidget) from .common import GraphT from .settings import display_setting @@ -58,6 +59,7 @@ def from_json(json_str: Union[str, Dict[str, Any]]) -> "Rewrite": THUMBNAIL_ROLE = Qt.ItemDataRole.UserRole + 1 +MAX_THUMBNAIL_HEIGHT = 200 # Maximum thumbnail height in pixels when displayed def render_graph_thumbnail(graph: GraphT) -> QPixmap: @@ -82,6 +84,12 @@ def render_graph_thumbnail(graph: GraphT) -> QPixmap: w = max(1, int(rect.width() * scale)) h = max(1, int(rect.height() * scale)) + # Limit the aspect ratio so that very tall diagrams don't produce + # absurdly large pixmaps. We cap the height at 3x the width. + max_h = w * 3 + if h > max_h: + h = max_h + pixmap = QPixmap(w, h) if display_setting.dark_mode: pixmap.fill(QColor(30, 30, 30)) @@ -121,6 +129,9 @@ def set_graph(self, index: int, graph: GraphT) -> None: new_step = Rewrite(old_step.display_name, old_step.rule, graph) self.steps[index - 1] = new_step self._thumbnail_cache.pop(index, None) + # Notify views so that the delegate re-computes sizeHint / repaints + model_index = self.createIndex(index, 0) + self.dataChanged.emit(model_index, model_index, []) def graphs(self) -> list[GraphT]: return [self.initial_graph] + [step.graph for step in self.steps] @@ -281,13 +292,133 @@ def from_json(json_str: Union[str, Dict[str, Any]]) -> "ProofModel": return model -class ProofStepView(QListView): - """A view for displaying the steps in a proof.""" +class ProofStepView(QWidget): + """Widget containing the proof step list and a thumbnail toggle button.""" def __init__(self, parent: 'ProofPanel'): super().__init__(parent) self.graph_view = parent.graph_view self.undo_stack = parent.undo_stack + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # --- Thumbnail toggle header row --- + header = QWidget(self) + header_layout = QHBoxLayout(header) + header_layout.setContentsMargins(4, 4, 4, 4) + + title = QLabel("Proof steps", header) + font = title.font() + font.setBold(True) + title.setFont(font) + header_layout.addWidget(title) + + header_layout.addStretch() + + self.thumbnails_toggle = QToolButton(header) + self.thumbnails_toggle.setText("Thumbnails") + self.thumbnails_toggle.setCheckable(True) + self.thumbnails_toggle.setChecked(False) + self.thumbnails_toggle.setAutoRaise(True) + self.thumbnails_toggle.setToolTip("Toggle proof step diagram previews (t)") + self.thumbnails_toggle.setShortcut("t") + self.thumbnails_toggle.clicked.connect(self._toggle_thumbnails) + header_layout.addWidget(self.thumbnails_toggle) + layout.addWidget(header) + + # Add a subtle separator line + line = QFrame() + line.setFrameShape(QFrame.Shape.HLine) + line.setFrameShadow(QFrame.Shadow.Sunken) + layout.addWidget(line) + + # --- List view --- + self._list = _ProofStepListView(self) + layout.addWidget(self._list) + + # ---- proxy properties so the rest of the codebase keeps working ---- + @property + def thumbnails_visible(self) -> bool: + return self._list.thumbnails_visible + + @thumbnails_visible.setter + def thumbnails_visible(self, value: bool) -> None: + self._list.thumbnails_visible = value + + def model(self) -> 'ProofModel': + return self._list.model() + + def setModel(self, model: 'ProofModel') -> None: + self._list.setModel(model) + + def currentIndex(self) -> QModelIndex: + return self._list.currentIndex() + + def setCurrentIndex(self, index: QModelIndex) -> None: + self._list.setCurrentIndex(index) + + def clearSelection(self) -> None: + self._list.clearSelection() + + def selectionModel(self): + return self._list.selectionModel() + + def selectedIndexes(self): + return self._list.selectedIndexes() + + def update(self, *args, **kwargs) -> None: # type: ignore[override] + self._list.update(*args, **kwargs) + + def edit(self, index: QModelIndex) -> None: # type: ignore[override] + self._list.edit(index) + + def mapToGlobal(self, point: QPoint) -> QPoint: + return self._list.mapToGlobal(point) + + def _toggle_thumbnails(self, checked: bool) -> None: + self._list.set_thumbnails_visible(checked) + + # ---- delegated methods ---- + def set_model(self, model: 'ProofModel') -> None: + self._list.set_model(model) + + def move_to_step(self, index: int) -> None: + self._list.move_to_step(index) + + def set_thumbnails_visible(self, visible: bool) -> None: + self.thumbnails_toggle.setChecked(visible) + self._list.set_thumbnails_visible(visible) + + def refresh_current_thumbnail(self) -> None: + """Invalidate and repaint the thumbnail for the currently selected step.""" + self._list.refresh_current_thumbnail() + + def show_context_menu(self, position: QPoint) -> None: + self._list.show_context_menu(position) + + def rename_proof_step(self, new_name: str, index: int) -> None: + self._list.rename_proof_step(new_name, index) + + def proof_step_selected(self, selected: QItemSelection, deselected: QItemSelection) -> None: + self._list.proof_step_selected(selected, deselected) + + def group_selected_steps(self) -> None: + self._list.group_selected_steps() + + def ungroup_selected_step(self) -> None: + self._list.ungroup_selected_step() + + +class _ProofStepListView(QListView): + """Internal list view for proof steps.""" + + def __init__(self, owner: ProofStepView): + super().__init__(owner) + self._owner = owner + self.graph_view = owner.graph_view + self.undo_stack = owner.undo_stack self.setModel(ProofModel(self.graph_view.graph_scene.g)) self.setCurrentIndex(self.model().index(0, 0)) self.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked) @@ -392,7 +523,7 @@ def group_selected_steps(self) -> None: raise ValueError("Cannot group the first step") self.move_to_step(indices[-1] - 1) - cmd = GroupRewriteSteps(self.graph_view, self, indices[0] - 1, indices[-1] - 1) + cmd = GroupRewriteSteps(self.graph_view, self._owner, indices[0] - 1, indices[-1] - 1) self.undo_stack.push(cmd) def ungroup_selected_step(self) -> None: @@ -406,7 +537,7 @@ def ungroup_selected_step(self) -> None: raise ValueError("Step is not grouped") self.move_to_step(index - 1) - cmd = UngroupRewriteSteps(self.graph_view, self, index - 1) + cmd = UngroupRewriteSteps(self.graph_view, self._owner, index - 1) self.undo_stack.push(cmd) def set_thumbnails_visible(self, visible: bool) -> None: @@ -416,6 +547,16 @@ def set_thumbnails_visible(self, visible: bool) -> None: self.scheduleDelayedItemsLayout() self.viewport().update() + def refresh_current_thumbnail(self) -> None: + """Invalidate and repaint the thumbnail for the currently selected step.""" + if not self.thumbnails_visible: + return + row = self.currentIndex().row() + self.model()._thumbnail_cache.pop(row, None) + idx = self.model().createIndex(row, 0) + self.model().dataChanged.emit(idx, idx, []) + self.scheduleDelayedItemsLayout() + def showEvent(self, event: object) -> None: # type: ignore[override] """Force relayout when this tab becomes visible (tab switch).""" super().showEvent(event) # type: ignore[arg-type] @@ -515,20 +656,25 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QM # Draw thumbnail preview if enabled on this view parent_view = self.parent() - if isinstance(parent_view, ProofStepView) and parent_view.thumbnails_visible: + if isinstance(parent_view, _ProofStepListView) and parent_view.thumbnails_visible: pixmap = index.data(THUMBNAIL_ROLE) if pixmap is not None and not pixmap.isNull(): thumb_left = int(option.rect.x()) + self.line_width + 2 * self.line_padding # type: ignore[attr-defined] thumb_top = int(option.rect.y()) + text_row_height + self.thumbnail_padding # type: ignore[attr-defined] available_width = int(option.rect.width()) - self.line_width - 2 * self.line_padding - self.thumbnail_padding # type: ignore[attr-defined] if available_width > 0 and pixmap.width() > 0: - thumb_height = int(pixmap.height() * available_width / pixmap.width()) - target = QRect(thumb_left, thumb_top, available_width, thumb_height) + thumb_height = min(int(pixmap.height() * available_width / pixmap.width()), MAX_THUMBNAIL_HEIGHT) + target_rect = QRect(thumb_left, thumb_top, available_width, thumb_height) border_color = QColor(80, 80, 80) if display_setting.dark_mode else QColor(200, 200, 200) painter.setPen(QPen(border_color, 1)) painter.setBrush(Qt.GlobalColor.transparent) - painter.drawRect(target.adjusted(-1, -1, 1, 1)) - painter.drawPixmap(target, pixmap) + painter.drawRect(target_rect.adjusted(-1, -1, 1, 1)) + + # Draw pixmap scaled + scaled = pixmap.scaled(target_rect.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + x_off = (target_rect.width() - scaled.width()) / 2 + y_off = (target_rect.height() - scaled.height()) / 2 + painter.drawPixmap(int(target_rect.x() + x_off), int(target_rect.y() + y_off), scaled) painter.restore() @@ -536,14 +682,14 @@ def sizeHint(self, option: QStyleOptionViewItem, index: Union[QModelIndex, QPers size = super().sizeHint(option, index) text_row_height = size.height() + 2 * self.vert_padding parent = self.parent() - if not isinstance(parent, ProofStepView) or not parent.thumbnails_visible: + if not isinstance(parent, _ProofStepListView) or not parent.thumbnails_visible: return QSize(size.width(), text_row_height) available_width = parent.viewport().width() - self.line_width - 2 * self.line_padding - self.thumbnail_padding if available_width <= 0: return QSize(size.width(), text_row_height) pixmap = index.data(THUMBNAIL_ROLE) if pixmap is not None and not pixmap.isNull() and pixmap.width() > 0: - thumb_height = int(pixmap.height() * available_width / pixmap.width()) + thumb_height = min(int(pixmap.height() * available_width / pixmap.width()), MAX_THUMBNAIL_HEIGHT) total = text_row_height + 2 * self.thumbnail_padding + thumb_height return QSize(size.width(), total) return QSize(size.width(), text_row_height) @@ -558,6 +704,6 @@ def setEditorData(self, editor: QWidget, index: Union[QModelIndex, QPersistentMo def setModelData(self, editor: QWidget, model: QAbstractItemModel, index: Union[QModelIndex, QPersistentModelIndex]) -> None: step_view = self.parent() - assert isinstance(step_view, ProofStepView) + assert isinstance(step_view, _ProofStepListView) assert isinstance(editor, QLineEdit) step_view.rename_proof_step(editor.text(), index.row() - 1) diff --git a/zxlive/proof_panel.py b/zxlive/proof_panel.py index ace936d9..2f5a5be0 100644 --- a/zxlive/proof_panel.py +++ b/zxlive/proof_panel.py @@ -60,7 +60,7 @@ def __init__(self, graph: GraphT, *actions: QAction) -> None: self.step_view = ProofStepView(self) self.splitter.addWidget(self.step_view) - self.thumbnails_toggle.clicked.connect(self._toggle_thumbnails) + self.graph_view.keyboard_vertices_moved.connect(self._on_keyboard_vertices_moved) @property def proof_model(self) -> ProofModel: @@ -118,14 +118,6 @@ def _toolbar_sections(self) -> Iterator[ToolbarSection]: self.pauli_webs.setText("Pauli Webs") self.pauli_webs.clicked.connect(self._start_pauliwebs) yield ToolbarSection(self.pauli_webs) - - self.thumbnails_toggle = QToolButton(self) - self.thumbnails_toggle.setText("Thumbnails") - self.thumbnails_toggle.setCheckable(True) - self.thumbnails_toggle.setChecked(False) - self.thumbnails_toggle.setToolTip("Toggle proof step diagram previews (t)") - self.thumbnails_toggle.setShortcut("t") - yield ToolbarSection(self.thumbnails_toggle) def _start_pauliwebs(self) -> None: # note: this code is copied from edit_panel.py - consider refactoring to avoid duplication @@ -144,8 +136,10 @@ def _start_pauliwebs(self) -> None: new_g: GraphT = copy.deepcopy(self.graph_scene.g) self.start_pauliwebs_signal.emit(new_g) - def _toggle_thumbnails(self, checked: bool) -> None: - self.step_view.set_thumbnails_visible(checked) + + def _on_keyboard_vertices_moved(self) -> None: + """Refresh the thumbnail for the current proof step after keyboard-based vertex moves.""" + self.step_view.refresh_current_thumbnail() def update_font(self) -> None: self.rewrites_panel.setFont(display_setting.font) From 00be501c9b3dac260777289d3442524f2d94d9c2 Mon Sep 17 00:00:00 2001 From: axif Date: Wed, 18 Feb 2026 16:11:35 +0600 Subject: [PATCH 04/20] Enhance type hinting in proof.py --- zxlive/proof.py | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/zxlive/proof.py b/zxlive/proof.py index 8743c213..86711090 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -1,13 +1,15 @@ import json -from typing import TYPE_CHECKING, Any, NamedTuple, Optional, Union, Dict +from typing import TYPE_CHECKING, Any, Dict, NamedTuple, Optional, Union, overload if TYPE_CHECKING: from .proof_panel import ProofPanel from PySide6.QtCore import (QAbstractItemModel, QAbstractListModel, - QItemSelection, QModelIndex, QPersistentModelIndex, - QPoint, QPointF, QRect, QRectF, QSize, Qt) -from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen, QPixmap + QItemSelection, QItemSelectionModel, QModelIndex, + QPersistentModelIndex, QPoint, QPointF, QRect, + QRectF, QSize, Qt) +from PySide6.QtGui import (QBitmap, QColor, QFont, QFontMetrics, QPainter, + QPen, QPixmap, QPolygon, QRegion) from PySide6.QtWidgets import (QAbstractItemView, QFrame, QHBoxLayout, QLabel, QLineEdit, QListView, QMenu, QStyle, QStyledItemDelegate, QStyleOptionViewItem, @@ -362,21 +364,28 @@ def setCurrentIndex(self, index: QModelIndex) -> None: def clearSelection(self) -> None: self._list.clearSelection() - def selectionModel(self): + def selectionModel(self) -> QItemSelectionModel: return self._list.selectionModel() - def selectedIndexes(self): + def selectedIndexes(self) -> list[QModelIndex]: return self._list.selectedIndexes() - def update(self, *args, **kwargs) -> None: # type: ignore[override] - self._list.update(*args, **kwargs) + @overload + def update(self) -> None: ... + + @overload + def update(self, region: QRegion | QBitmap | QPolygon | QRect, /) -> None: ... + + @overload + def update(self, x: int, y: int, w: int, h: int, /) -> None: ... + + def update(self, *args: Any) -> None: + super().update(*args) + self._list.viewport().update() def edit(self, index: QModelIndex) -> None: # type: ignore[override] self._list.edit(index) - def mapToGlobal(self, point: QPoint) -> QPoint: - return self._list.mapToGlobal(point) - def _toggle_thumbnails(self, checked: bool) -> None: self._list.set_thumbnails_visible(checked) From 60a036abc52a62c0e407ec8e1265771fc6ff4c71 Mon Sep 17 00:00:00 2001 From: axif Date: Tue, 24 Feb 2026 02:57:36 +0600 Subject: [PATCH 05/20] refactor: Update type hint for `update` method's `region` parameter to use `typing.Union`. --- zxlive/proof.py | 6 +++--- zxlive/proof_panel.py | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/zxlive/proof.py b/zxlive/proof.py index 86711090..c75bdde4 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -316,7 +316,7 @@ def __init__(self, parent: 'ProofPanel'): font.setBold(True) title.setFont(font) header_layout.addWidget(title) - + header_layout.addStretch() self.thumbnails_toggle = QToolButton(header) @@ -374,7 +374,7 @@ def selectedIndexes(self) -> list[QModelIndex]: def update(self) -> None: ... @overload - def update(self, region: QRegion | QBitmap | QPolygon | QRect, /) -> None: ... + def update(self, region: Union[QRegion, QBitmap, QPolygon, QRect], /) -> None: ... @overload def update(self, x: int, y: int, w: int, h: int, /) -> None: ... @@ -678,7 +678,7 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QM painter.setPen(QPen(border_color, 1)) painter.setBrush(Qt.GlobalColor.transparent) painter.drawRect(target_rect.adjusted(-1, -1, 1, 1)) - + # Draw pixmap scaled scaled = pixmap.scaled(target_rect.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) x_off = (target_rect.width() - scaled.width()) / 2 diff --git a/zxlive/proof_panel.py b/zxlive/proof_panel.py index 6d5079fb..99983419 100644 --- a/zxlive/proof_panel.py +++ b/zxlive/proof_panel.py @@ -136,7 +136,6 @@ def _start_pauliwebs(self) -> None: new_g: GraphT = copy.deepcopy(self.graph_scene.g) self.start_pauliwebs_signal.emit(new_g) - def _on_keyboard_vertices_moved(self) -> None: """Refresh the thumbnail for the current proof step after keyboard-based vertex moves.""" self.step_view.refresh_current_thumbnail() From f0fbc22539beb4366a0ed4a61982a1976ba7abbe Mon Sep 17 00:00:00 2001 From: axif Date: Thu, 26 Feb 2026 01:37:30 +0600 Subject: [PATCH 06/20] feat: improve thumbnail rendering and sizing in the proof panel and ensure keyboard vertex moves update the proof model. --- zxlive/proof.py | 72 ++++++++++++++++++++++++++++--------------- zxlive/proof_panel.py | 3 +- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/zxlive/proof.py b/zxlive/proof.py index c75bdde4..77620259 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -1,5 +1,5 @@ import json -from typing import TYPE_CHECKING, Any, Dict, NamedTuple, Optional, Union, overload +from typing import TYPE_CHECKING, Any, Dict, NamedTuple, Optional, Union if TYPE_CHECKING: from .proof_panel import ProofPanel @@ -8,8 +8,8 @@ QItemSelection, QItemSelectionModel, QModelIndex, QPersistentModelIndex, QPoint, QPointF, QRect, QRectF, QSize, Qt) -from PySide6.QtGui import (QBitmap, QColor, QFont, QFontMetrics, QPainter, - QPen, QPixmap, QPolygon, QRegion) +from PySide6.QtGui import (QColor, QFont, QFontMetrics, QPainter, + QPen, QPixmap) from PySide6.QtWidgets import (QAbstractItemView, QFrame, QHBoxLayout, QLabel, QLineEdit, QListView, QMenu, QStyle, QStyledItemDelegate, QStyleOptionViewItem, @@ -370,20 +370,11 @@ def selectionModel(self) -> QItemSelectionModel: def selectedIndexes(self) -> list[QModelIndex]: return self._list.selectedIndexes() - @overload - def update(self) -> None: ... - - @overload - def update(self, region: Union[QRegion, QBitmap, QPolygon, QRect], /) -> None: ... - - @overload - def update(self, x: int, y: int, w: int, h: int, /) -> None: ... - def update(self, *args: Any) -> None: super().update(*args) self._list.viewport().update() - def edit(self, index: QModelIndex) -> None: # type: ignore[override] + def edit(self, index: QModelIndex) -> None: self._list.edit(index) def _toggle_thumbnails(self, checked: bool) -> None: @@ -452,6 +443,10 @@ def __init__(self, owner: ProofStepView): self.setResizeMode(QListView.ResizeMode.Adjust) self.setUniformItemSizes(False) self.setAlternatingRowColors(True) + # Never allow horizontal scrolling: items must always fit within the + # viewport width. Without this, Qt may assign items a width wider than + # the viewport, which causes thumbnail overflow and broken sizeHints. + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.viewport().setAttribute(Qt.WidgetAttribute.WA_Hover) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.show_context_menu) @@ -575,6 +570,7 @@ def resizeEvent(self, event: object) -> None: # type: ignore[override] super().resizeEvent(event) # type: ignore[arg-type] if self.thumbnails_visible: self.scheduleDelayedItemsLayout() + self.viewport().update() class ProofStepItemDelegate(QStyledItemDelegate): @@ -668,18 +664,26 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QM if isinstance(parent_view, _ProofStepListView) and parent_view.thumbnails_visible: pixmap = index.data(THUMBNAIL_ROLE) if pixmap is not None and not pixmap.isNull(): - thumb_left = int(option.rect.x()) + self.line_width + 2 * self.line_padding # type: ignore[attr-defined] + # Clamp available_width to the actual usable space inside the + # viewport, accounting for where this item starts horizontally. + # This prevents overflow when the panel is very narrow. + viewport_right = parent_view.viewport().width() + item_x = int(option.rect.x()) # type: ignore[attr-defined] + content_left = item_x + self.line_width + 2 * self.line_padding + max_right = viewport_right - self.thumbnail_padding + available_width = max(0, max_right - content_left) thumb_top = int(option.rect.y()) + text_row_height + self.thumbnail_padding # type: ignore[attr-defined] - available_width = int(option.rect.width()) - self.line_width - 2 * self.line_padding - self.thumbnail_padding # type: ignore[attr-defined] if available_width > 0 and pixmap.width() > 0: thumb_height = min(int(pixmap.height() * available_width / pixmap.width()), MAX_THUMBNAIL_HEIGHT) - target_rect = QRect(thumb_left, thumb_top, available_width, thumb_height) + target_rect = QRect(content_left, thumb_top, available_width, thumb_height) + # Clip all drawing to the item rect to guarantee no overflow. + painter.setClipRect(option.rect) # type: ignore[attr-defined] border_color = QColor(80, 80, 80) if display_setting.dark_mode else QColor(200, 200, 200) painter.setPen(QPen(border_color, 1)) painter.setBrush(Qt.GlobalColor.transparent) painter.drawRect(target_rect.adjusted(-1, -1, 1, 1)) - # Draw pixmap scaled + # Draw pixmap scaled to fit, keeping aspect ratio. scaled = pixmap.scaled(target_rect.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) x_off = (target_rect.width() - scaled.width()) / 2 y_off = (target_rect.height() - scaled.height()) / 2 @@ -687,21 +691,39 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QM painter.restore() + def _compute_thumb_height(self, parent: '_ProofStepListView', pixmap: QPixmap) -> int: + """Compute the thumbnail display height for the current viewport width. + + Uses the same formula as paint() so that sizeHint() and the actual + rendered content always agree, preventing misalignment on resize. + """ + # Mirror the clamping logic from paint(): usable width runs from + # content_left to (viewport_right - thumbnail_padding). Here we assume + # item_x == 0 (typical for QListView with no indent), which is safe + # because paint() will also clamp to the same boundary. + viewport_right = parent.viewport().width() + content_left = self.line_width + 2 * self.line_padding + available_width = max(0, viewport_right - self.thumbnail_padding - content_left) + if available_width > 0 and not pixmap.isNull() and pixmap.width() > 0: + return min(int(pixmap.height() * available_width / pixmap.width()), MAX_THUMBNAIL_HEIGHT) + return MAX_THUMBNAIL_HEIGHT + def sizeHint(self, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]) -> QSize: size = super().sizeHint(option, index) text_row_height = size.height() + 2 * self.vert_padding parent = self.parent() if not isinstance(parent, _ProofStepListView) or not parent.thumbnails_visible: return QSize(size.width(), text_row_height) - available_width = parent.viewport().width() - self.line_width - 2 * self.line_padding - self.thumbnail_padding - if available_width <= 0: - return QSize(size.width(), text_row_height) + # Compute the thumbnail height that paint() will actually use for the + # current viewport width so that the allocated row height always matches + # what is painted, preventing gaps or clipping when the panel is narrow. pixmap = index.data(THUMBNAIL_ROLE) - if pixmap is not None and not pixmap.isNull() and pixmap.width() > 0: - thumb_height = min(int(pixmap.height() * available_width / pixmap.width()), MAX_THUMBNAIL_HEIGHT) - total = text_row_height + 2 * self.thumbnail_padding + thumb_height - return QSize(size.width(), total) - return QSize(size.width(), text_row_height) + if pixmap is not None and not pixmap.isNull(): + thumb_height = self._compute_thumb_height(parent, pixmap) + else: + thumb_height = MAX_THUMBNAIL_HEIGHT + total = text_row_height + 2 * self.thumbnail_padding + thumb_height + return QSize(size.width(), total) def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]) -> QLineEdit: return QLineEdit(parent) diff --git a/zxlive/proof_panel.py b/zxlive/proof_panel.py index 99983419..e463bfaa 100644 --- a/zxlive/proof_panel.py +++ b/zxlive/proof_panel.py @@ -138,7 +138,8 @@ def _start_pauliwebs(self) -> None: def _on_keyboard_vertices_moved(self) -> None: """Refresh the thumbnail for the current proof step after keyboard-based vertex moves.""" - self.step_view.refresh_current_thumbnail() + idx = self.step_view.currentIndex().row() + self.proof_model.set_graph(idx, copy.deepcopy(self.graph_scene.g)) def update_font(self) -> None: self.rewrites_panel.setFont(display_setting.font) From be9e1d6f0777e2af97938b17a2af8eba536d12de Mon Sep 17 00:00:00 2001 From: axif Date: Fri, 27 Feb 2026 00:19:21 +0600 Subject: [PATCH 07/20] Refactor `ProofPanel` to use `__getattr__` for dynamic method delegation to `_ProofStepListView` and extract thumbnail drawing into a dedicated method. --- zxlive/proof.py | 59 +++++++------------------------------------ zxlive/proof_panel.py | 2 +- 2 files changed, 10 insertions(+), 51 deletions(-) diff --git a/zxlive/proof.py b/zxlive/proof.py index 77620259..14922949 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -5,7 +5,7 @@ from .proof_panel import ProofPanel from PySide6.QtCore import (QAbstractItemModel, QAbstractListModel, - QItemSelection, QItemSelectionModel, QModelIndex, + QItemSelection, QModelIndex, QPersistentModelIndex, QPoint, QPointF, QRect, QRectF, QSize, Qt) from PySide6.QtGui import (QColor, QFont, QFontMetrics, QPainter, @@ -349,27 +349,6 @@ def thumbnails_visible(self) -> bool: def thumbnails_visible(self, value: bool) -> None: self._list.thumbnails_visible = value - def model(self) -> 'ProofModel': - return self._list.model() - - def setModel(self, model: 'ProofModel') -> None: - self._list.setModel(model) - - def currentIndex(self) -> QModelIndex: - return self._list.currentIndex() - - def setCurrentIndex(self, index: QModelIndex) -> None: - self._list.setCurrentIndex(index) - - def clearSelection(self) -> None: - self._list.clearSelection() - - def selectionModel(self) -> QItemSelectionModel: - return self._list.selectionModel() - - def selectedIndexes(self) -> list[QModelIndex]: - return self._list.selectedIndexes() - def update(self, *args: Any) -> None: super().update(*args) self._list.viewport().update() @@ -380,35 +359,12 @@ def edit(self, index: QModelIndex) -> None: def _toggle_thumbnails(self, checked: bool) -> None: self._list.set_thumbnails_visible(checked) - # ---- delegated methods ---- - def set_model(self, model: 'ProofModel') -> None: - self._list.set_model(model) - - def move_to_step(self, index: int) -> None: - self._list.move_to_step(index) - def set_thumbnails_visible(self, visible: bool) -> None: self.thumbnails_toggle.setChecked(visible) self._list.set_thumbnails_visible(visible) - def refresh_current_thumbnail(self) -> None: - """Invalidate and repaint the thumbnail for the currently selected step.""" - self._list.refresh_current_thumbnail() - - def show_context_menu(self, position: QPoint) -> None: - self._list.show_context_menu(position) - - def rename_proof_step(self, new_name: str, index: int) -> None: - self._list.rename_proof_step(new_name, index) - - def proof_step_selected(self, selected: QItemSelection, deselected: QItemSelection) -> None: - self._list.proof_step_selected(selected, deselected) - - def group_selected_steps(self) -> None: - self._list.group_selected_steps() - - def ungroup_selected_step(self) -> None: - self._list.ungroup_selected_step() + def __getattr__(self, name: str) -> Any: + return getattr(self._list, name) class _ProofStepListView(QListView): @@ -659,7 +615,12 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QM painter.setBrush(Qt.GlobalColor.black) painter.drawText(text_rect, Qt.AlignmentFlag.AlignLeft, text) - # Draw thumbnail preview if enabled on this view + self._draw_thumbnail(painter, option, index, text_row_height) + + painter.restore() + + def _draw_thumbnail(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex], text_row_height: int) -> None: + """Draw thumbnail preview if enabled on this view.""" parent_view = self.parent() if isinstance(parent_view, _ProofStepListView) and parent_view.thumbnails_visible: pixmap = index.data(THUMBNAIL_ROLE) @@ -689,8 +650,6 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QM y_off = (target_rect.height() - scaled.height()) / 2 painter.drawPixmap(int(target_rect.x() + x_off), int(target_rect.y() + y_off), scaled) - painter.restore() - def _compute_thumb_height(self, parent: '_ProofStepListView', pixmap: QPixmap) -> int: """Compute the thumbnail display height for the current viewport width. diff --git a/zxlive/proof_panel.py b/zxlive/proof_panel.py index e463bfaa..ca42b0df 100644 --- a/zxlive/proof_panel.py +++ b/zxlive/proof_panel.py @@ -64,7 +64,7 @@ def __init__(self, graph: GraphT, *actions: QAction) -> None: @property def proof_model(self) -> ProofModel: - return self.step_view.model() + return cast(ProofModel, self.step_view.model()) def _toolbar_sections(self) -> Iterator[ToolbarSection]: icon_size = QSize(32, 32) From 8120d36f3dfa831d086d625238930a4909b5d38f Mon Sep 17 00:00:00 2001 From: Boldi Date: Fri, 27 Feb 2026 19:01:10 +0000 Subject: [PATCH 08/20] Remove unnecessary functions --- zxlive/proof.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/zxlive/proof.py b/zxlive/proof.py index 14922949..573c05da 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -326,7 +326,7 @@ def __init__(self, parent: 'ProofPanel'): self.thumbnails_toggle.setAutoRaise(True) self.thumbnails_toggle.setToolTip("Toggle proof step diagram previews (t)") self.thumbnails_toggle.setShortcut("t") - self.thumbnails_toggle.clicked.connect(self._toggle_thumbnails) + self.thumbnails_toggle.clicked.connect(self.set_thumbnails_visible) header_layout.addWidget(self.thumbnails_toggle) layout.addWidget(header) @@ -353,12 +353,6 @@ def update(self, *args: Any) -> None: super().update(*args) self._list.viewport().update() - def edit(self, index: QModelIndex) -> None: - self._list.edit(index) - - def _toggle_thumbnails(self, checked: bool) -> None: - self._list.set_thumbnails_visible(checked) - def set_thumbnails_visible(self, visible: bool) -> None: self.thumbnails_toggle.setChecked(visible) self._list.set_thumbnails_visible(visible) From ef3f62cedf551d7893b5d53c70346d6d37b7d499 Mon Sep 17 00:00:00 2001 From: axif Date: Sat, 28 Feb 2026 04:00:06 +0600 Subject: [PATCH 09/20] Modernize type hints and improve graph thumbnail rendering --- zxlive/proof.py | 248 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 171 insertions(+), 77 deletions(-) diff --git a/zxlive/proof.py b/zxlive/proof.py index 573c05da..55f2a2b2 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -1,5 +1,6 @@ +from __future__ import annotations import json -from typing import TYPE_CHECKING, Any, Dict, NamedTuple, Optional, Union +from typing import TYPE_CHECKING, Any, NamedTuple if TYPE_CHECKING: from .proof_panel import ProofPanel @@ -25,9 +26,9 @@ class Rewrite(NamedTuple): display_name: str # Name of proof displayed to user rule: str # Name of the rule that was applied to get to this step graph: GraphT # New graph after applying the rewrite - grouped_rewrites: Optional[list['Rewrite']] = None # Optional field to store the grouped rewrites + grouped_rewrites: list['Rewrite'] | None = None # Optional field to store the grouped rewrites - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Serializes the rewrite to Python dictionary.""" return { "display_name": self.display_name, @@ -41,7 +42,7 @@ def to_json(self) -> str: return json.dumps(self.to_dict()) @staticmethod - def from_json(json_str: Union[str, Dict[str, Any]]) -> "Rewrite": + def from_json(json_str: str | dict[str, Any]) -> "Rewrite": """Deserializes the rewrite from JSON or Python dict.""" if isinstance(json_str, str): d = json.loads(json_str) @@ -64,8 +65,30 @@ def from_json(json_str: Union[str, Dict[str, Any]]) -> "Rewrite": MAX_THUMBNAIL_HEIGHT = 200 # Maximum thumbnail height in pixels when displayed -def render_graph_thumbnail(graph: GraphT) -> QPixmap: - """Render a graph to a QPixmap thumbnail using an off-screen GraphScene.""" +def render_graph_thumbnail( + graph: GraphT, + target_width: int = 0, + target_height: int = 0, + device_pixel_ratio: float = 1.0, +) -> QPixmap: + """Render a graph to a QPixmap thumbnail at exactly the requested display size. + + Instead of rendering at an arbitrary large resolution and then letting Qt + scale the result down (which can introduce blurriness), we compute the + scene's natural aspect ratio and render directly into a pixmap whose + *physical* pixel dimensions match what will actually be displayed on screen + (i.e. ``target_* * device_pixel_ratio``). The returned pixmap has its + ``devicePixelRatio`` set accordingly so Qt renders it without any further + scaling. + + Args: + graph: the graph to render. + target_width: desired display width in logical pixels. 0 means + unconstrained (uses ``target_height`` or falls back to 400 px). + target_height: desired display height in logical pixels. 0 means + unconstrained (uses ``target_width`` or falls back to 400 px). + device_pixel_ratio: the screen's DPR (e.g. 2.0 on HiDPI displays). + """ from .graphscene import GraphScene scene = GraphScene() @@ -78,21 +101,38 @@ def render_graph_thumbnail(graph: GraphT) -> QPixmap: padding = 20.0 rect.adjust(-padding, -padding, padding, padding) - max_dim = 600.0 - scale = min(max_dim / rect.width(), max_dim / rect.height()) - if scale > 2.0: - scale = 2.0 + aspect = rect.width() / rect.height() if rect.height() > 0 else 1.0 - w = max(1, int(rect.width() * scale)) - h = max(1, int(rect.height() * scale)) + # Determine the logical display size we are aiming for. + if target_width > 0 and target_height > 0: + h_fit = target_width / aspect + if h_fit <= target_height: + disp_w = float(target_width) + disp_h = h_fit + else: + disp_h = float(target_height) + disp_w = target_height * aspect + elif target_width > 0: + disp_w = float(target_width) + disp_h = target_width / aspect + elif target_height > 0: + disp_h = float(target_height) + disp_w = target_height * aspect + else: + # No hint: pick a reasonable default. + disp_w = 400.0 + disp_h = 400.0 / aspect - # Limit the aspect ratio so that very tall diagrams don't produce - # absurdly large pixmaps. We cap the height at 3x the width. - max_h = w * 3 - if h > max_h: - h = max_h + disp_w = max(1, int(disp_w)) + disp_h = max(1, int(disp_h)) - pixmap = QPixmap(w, h) + # Physical pixel dimensions for the backing store. + dpr = max(1.0, device_pixel_ratio) + phys_w = max(1, int(disp_w * dpr)) + phys_h = max(1, int(disp_h * dpr)) + + pixmap = QPixmap(phys_w, phys_h) + pixmap.setDevicePixelRatio(dpr) if display_setting.dark_mode: pixmap.fill(QColor(30, 30, 30)) else: @@ -100,7 +140,8 @@ def render_graph_thumbnail(graph: GraphT) -> QPixmap: painter = QPainter(pixmap) painter.setRenderHint(QPainter.RenderHint.Antialiasing) - scene.render(painter, QRectF(0, 0, w, h), rect) + # Render the scene into the full physical pixmap area. + scene.render(painter, QRectF(0, 0, phys_w, phys_h), rect) painter.end() scene.clear() @@ -138,7 +179,7 @@ def set_graph(self, index: int, graph: GraphT) -> None: def graphs(self) -> list[GraphT]: return [self.initial_graph] + [step.graph for step in self.steps] - def data(self, index: Union[QModelIndex, QPersistentModelIndex], role: int = Qt.ItemDataRole.DisplayRole) -> Any: + def data(self, index: QModelIndex | QPersistentModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: """Overrides `QAbstractItemModel.data` to populate a view with rewrite steps""" if index.row() >= len(self.steps) + 1 or index.column() >= 1: @@ -154,11 +195,14 @@ def data(self, index: Union[QModelIndex, QPersistentModelIndex], role: int = Qt. elif role == THUMBNAIL_ROLE: row = index.row() if row not in self._thumbnail_cache: + # Thumbnails are rendered on demand without target-size hints + # here because the model has no view knowledge. The view-aware + # rendering path goes through ProofModel.render_thumbnail_for_view. graph = self.get_graph(row) self._thumbnail_cache[row] = render_graph_thumbnail(graph) return self._thumbnail_cache.get(row) - def flags(self, index: Union[QModelIndex, QPersistentModelIndex]) -> Qt.ItemFlag: + def flags(self, index: QModelIndex | QPersistentModelIndex) -> Qt.ItemFlag: if index.row() == 0: return super().flags(index) return super().flags(index) | Qt.ItemFlag.ItemIsEditable @@ -171,11 +215,11 @@ def headerData(self, section: int, orientation: Qt.Orientation, """ return None - def columnCount(self, index: Union[QModelIndex, QPersistentModelIndex] = QModelIndex()) -> int: + def columnCount(self, index: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int: """The number of columns""" return 1 - def rowCount(self, index: Union[QModelIndex, QPersistentModelIndex] = QModelIndex()) -> int: + def rowCount(self, index: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int: """The number of rows""" # This is a quirk of Qt list models: Since they are based on tree models, the # user has to specify the index of the parent. In a list, we always expect the @@ -185,7 +229,7 @@ def rowCount(self, index: Union[QModelIndex, QPersistentModelIndex] = QModelInde else: return 0 - def add_rewrite(self, rewrite: Rewrite, position: Optional[int] = None) -> None: + def add_rewrite(self, rewrite: Rewrite, position: int | None = None) -> None: """Adds a rewrite step to the model.""" if position is None: position = len(self.steps) @@ -194,7 +238,7 @@ def add_rewrite(self, rewrite: Rewrite, position: Optional[int] = None) -> None: self.endInsertRows() self.invalidate_thumbnails(position + 1) - def pop_rewrite(self, position: Optional[int] = None) -> tuple[Rewrite, GraphT]: + def pop_rewrite(self, position: int | None = None) -> tuple[Rewrite, GraphT]: """Removes the latest rewrite from the model. Returns the rewrite and the graph that previously resulted from this rewrite. @@ -261,7 +305,7 @@ def ungroup_steps(self, index: int) -> None: self.createIndex(index + len(individual_steps), 0), []) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Serializes the model to Python dict.""" initial_graph = self.initial_graph.to_dict() proof_steps = [step.to_dict() for step in self.steps] @@ -276,7 +320,7 @@ def to_json(self) -> str: return json.dumps(self.to_dict()) @staticmethod - def from_json(json_str: Union[str, Dict[str, Any]]) -> "ProofModel": + def from_json(json_str: str | dict[str, Any]) -> "ProofModel": """Deserializes the model from JSON or Python dict.""" if isinstance(json_str, str): d = json.loads(json_str) @@ -538,7 +582,7 @@ class ProofStepItemDelegate(QStyledItemDelegate): circle_radius_selected = 6 circle_outline_width = 3 - def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]) -> None: + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex) -> None: painter.save() text_height = QFontMetrics(option.font).height() # type: ignore[attr-defined] text_row_height = text_height + 2 * self.vert_padding @@ -613,36 +657,92 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QM painter.restore() - def _draw_thumbnail(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex], text_row_height: int) -> None: + def _draw_thumbnail(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex, text_row_height: int) -> None: """Draw thumbnail preview if enabled on this view.""" parent_view = self.parent() - if isinstance(parent_view, _ProofStepListView) and parent_view.thumbnails_visible: - pixmap = index.data(THUMBNAIL_ROLE) - if pixmap is not None and not pixmap.isNull(): - # Clamp available_width to the actual usable space inside the - # viewport, accounting for where this item starts horizontally. - # This prevents overflow when the panel is very narrow. - viewport_right = parent_view.viewport().width() - item_x = int(option.rect.x()) # type: ignore[attr-defined] - content_left = item_x + self.line_width + 2 * self.line_padding - max_right = viewport_right - self.thumbnail_padding - available_width = max(0, max_right - content_left) - thumb_top = int(option.rect.y()) + text_row_height + self.thumbnail_padding # type: ignore[attr-defined] - if available_width > 0 and pixmap.width() > 0: - thumb_height = min(int(pixmap.height() * available_width / pixmap.width()), MAX_THUMBNAIL_HEIGHT) - target_rect = QRect(content_left, thumb_top, available_width, thumb_height) - # Clip all drawing to the item rect to guarantee no overflow. - painter.setClipRect(option.rect) # type: ignore[attr-defined] - border_color = QColor(80, 80, 80) if display_setting.dark_mode else QColor(200, 200, 200) - painter.setPen(QPen(border_color, 1)) - painter.setBrush(Qt.GlobalColor.transparent) - painter.drawRect(target_rect.adjusted(-1, -1, 1, 1)) - - # Draw pixmap scaled to fit, keeping aspect ratio. - scaled = pixmap.scaled(target_rect.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - x_off = (target_rect.width() - scaled.width()) / 2 - y_off = (target_rect.height() - scaled.height()) / 2 - painter.drawPixmap(int(target_rect.x() + x_off), int(target_rect.y() + y_off), scaled) + if not isinstance(parent_view, _ProofStepListView) or not parent_view.thumbnails_visible: + return + + # Compute the display rectangle we want to fill. + viewport_right = parent_view.viewport().width() + item_x = int(option.rect.x()) # type: ignore[attr-defined] + content_left = item_x + self.line_width + 2 * self.line_padding + max_right = viewport_right - self.thumbnail_padding + available_width = max(0, max_right - content_left) + if available_width <= 0: + return + + thumb_top = int(option.rect.y()) + text_row_height + self.thumbnail_padding # type: ignore[attr-defined] + + # Retrieve (or regenerate at the correct size) the thumbnail. + pixmap = self._get_thumbnail_for_width(parent_view, index, available_width) + if pixmap is None or pixmap.isNull(): + return + + # pixmap is already rendered at the right logical size; compute the + # actual display height and center it inside the available width slot. + logical_w = int(pixmap.width() / pixmap.devicePixelRatio()) + logical_h = int(pixmap.height() / pixmap.devicePixelRatio()) + thumb_height = min(logical_h, MAX_THUMBNAIL_HEIGHT) + + # The bounding box stretches across available width + target_rect = QRect(content_left, thumb_top, available_width, thumb_height) + + # Clip all drawing to the item rect to guarantee no overflow. + painter.setClipRect(option.rect) # type: ignore[attr-defined] + border_color = QColor(80, 80, 80) if display_setting.dark_mode else QColor(200, 200, 200) + painter.setPen(QPen(border_color, 1)) + + # Draw transparent outline matching available slot (like the old view) + painter.setBrush(Qt.GlobalColor.transparent) + painter.drawRect(target_rect.adjusted(-1, -1, 1, 1)) + + # Fill the actual background where the thumbnail will go, in case + # of transparent rendering edges or anti-aliasing artifacts + x_off = max(0, (available_width - logical_w) // 2) + + # Draw the pixmap horizontally centered + painter.drawPixmap(content_left + x_off, thumb_top, pixmap) + + def _available_thumb_width(self, parent: '_ProofStepListView') -> int: + """Return the usable logical display width for a thumbnail.""" + viewport_right = parent.viewport().width() + content_left = self.line_width + 2 * self.line_padding + return max(0, viewport_right - self.thumbnail_padding - content_left) + + def _get_thumbnail_for_width( + self, + parent: '_ProofStepListView', + index: QModelIndex | QPersistentModelIndex, + target_width: int, + ) -> QPixmap | None: + """Return a thumbnail rendered at *target_width* logical pixels. + + If the cached thumbnail already has the right logical width it is + returned as-is; otherwise we re-render at the new size and update + the cache. + """ + model = parent.model() + row = index.row() + dpr = parent.devicePixelRatio() + cached = model._thumbnail_cache.get(row) + if cached is not None and not cached.isNull(): + cached_logical_w = int(cached.width() / cached.devicePixelRatio()) + cached_logical_h = int(cached.height() / cached.devicePixelRatio()) + # We can reuse the cache if the width perfectly matches OR if it's + # already capped by the height constraint and fits comfortably in the new width + if cached_logical_w == target_width or (cached_logical_h >= MAX_THUMBNAIL_HEIGHT - 1 and cached_logical_w <= target_width): + return cached + # (Re-)render at the correct size. + graph = model.get_graph(row) + pixmap = render_graph_thumbnail( + graph, + target_width=target_width, + target_height=MAX_THUMBNAIL_HEIGHT, + device_pixel_ratio=dpr, + ) + model._thumbnail_cache[row] = pixmap + return pixmap def _compute_thumb_height(self, parent: '_ProofStepListView', pixmap: QPixmap) -> int: """Compute the thumbnail display height for the current viewport width. @@ -650,43 +750,37 @@ def _compute_thumb_height(self, parent: '_ProofStepListView', pixmap: QPixmap) - Uses the same formula as paint() so that sizeHint() and the actual rendered content always agree, preventing misalignment on resize. """ - # Mirror the clamping logic from paint(): usable width runs from - # content_left to (viewport_right - thumbnail_padding). Here we assume - # item_x == 0 (typical for QListView with no indent), which is safe - # because paint() will also clamp to the same boundary. - viewport_right = parent.viewport().width() - content_left = self.line_width + 2 * self.line_padding - available_width = max(0, viewport_right - self.thumbnail_padding - content_left) - if available_width > 0 and not pixmap.isNull() and pixmap.width() > 0: - return min(int(pixmap.height() * available_width / pixmap.width()), MAX_THUMBNAIL_HEIGHT) - return MAX_THUMBNAIL_HEIGHT + if pixmap.isNull(): + return MAX_THUMBNAIL_HEIGHT + logical_h = int(pixmap.height() / pixmap.devicePixelRatio()) + return min(logical_h, MAX_THUMBNAIL_HEIGHT) - def sizeHint(self, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]) -> QSize: + def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex) -> QSize: size = super().sizeHint(option, index) text_row_height = size.height() + 2 * self.vert_padding parent = self.parent() if not isinstance(parent, _ProofStepListView) or not parent.thumbnails_visible: return QSize(size.width(), text_row_height) - # Compute the thumbnail height that paint() will actually use for the - # current viewport width so that the allocated row height always matches - # what is painted, preventing gaps or clipping when the panel is narrow. - pixmap = index.data(THUMBNAIL_ROLE) - if pixmap is not None and not pixmap.isNull(): - thumb_height = self._compute_thumb_height(parent, pixmap) + # Ask for (or create) the thumbnail at the correct width so that + # sizeHint and paint() always agree on the row height. + target_width = self._available_thumb_width(parent) + if target_width > 0: + pixmap = self._get_thumbnail_for_width(parent, index, target_width) + thumb_height = self._compute_thumb_height(parent, pixmap) if pixmap else MAX_THUMBNAIL_HEIGHT else: - thumb_height = MAX_THUMBNAIL_HEIGHT + thumb_height = 0 total = text_row_height + 2 * self.thumbnail_padding + thumb_height return QSize(size.width(), total) - def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]) -> QLineEdit: + def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex) -> QLineEdit: return QLineEdit(parent) - def setEditorData(self, editor: QWidget, index: Union[QModelIndex, QPersistentModelIndex]) -> None: + def setEditorData(self, editor: QWidget, index: QModelIndex | QPersistentModelIndex) -> None: assert isinstance(editor, QLineEdit) value = index.model().data(index, Qt.ItemDataRole.DisplayRole) editor.setText(str(value)) - def setModelData(self, editor: QWidget, model: QAbstractItemModel, index: Union[QModelIndex, QPersistentModelIndex]) -> None: + def setModelData(self, editor: QWidget, model: QAbstractItemModel, index: QModelIndex | QPersistentModelIndex) -> None: step_view = self.parent() assert isinstance(step_view, _ProofStepListView) assert isinstance(editor, QLineEdit) From bcc5f7ec11089c267cb53886c187afc700c793f8 Mon Sep 17 00:00:00 2001 From: axif Date: Sat, 28 Feb 2026 04:02:20 +0600 Subject: [PATCH 10/20] fix lint error --- zxlive/proof.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zxlive/proof.py b/zxlive/proof.py index 55f2a2b2..f968c58a 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -700,7 +700,7 @@ def _draw_thumbnail(self, painter: QPainter, option: QStyleOptionViewItem, index # Fill the actual background where the thumbnail will go, in case # of transparent rendering edges or anti-aliasing artifacts x_off = max(0, (available_width - logical_w) // 2) - + # Draw the pixmap horizontally centered painter.drawPixmap(content_left + x_off, thumb_top, pixmap) @@ -729,7 +729,7 @@ def _get_thumbnail_for_width( if cached is not None and not cached.isNull(): cached_logical_w = int(cached.width() / cached.devicePixelRatio()) cached_logical_h = int(cached.height() / cached.devicePixelRatio()) - # We can reuse the cache if the width perfectly matches OR if it's + # We can reuse the cache if the width perfectly matches OR if it's # already capped by the height constraint and fits comfortably in the new width if cached_logical_w == target_width or (cached_logical_h >= MAX_THUMBNAIL_HEIGHT - 1 and cached_logical_w <= target_width): return cached From 484879f559b2eb33bf408494b39a4d236316e047 Mon Sep 17 00:00:00 2001 From: axif Date: Sat, 28 Feb 2026 04:28:54 +0600 Subject: [PATCH 11/20] Debounce thumbnail re-rendering on resize events using QTimer to prevent excessive updates. --- zxlive/proof.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/zxlive/proof.py b/zxlive/proof.py index f968c58a..2794a583 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -8,7 +8,7 @@ from PySide6.QtCore import (QAbstractItemModel, QAbstractListModel, QItemSelection, QModelIndex, QPersistentModelIndex, QPoint, QPointF, QRect, - QRectF, QSize, Qt) + QRectF, QSize, Qt, QTimer) from PySide6.QtGui import (QColor, QFont, QFontMetrics, QPainter, QPen, QPixmap) from PySide6.QtWidgets import (QAbstractItemView, QFrame, QHBoxLayout, QLabel, @@ -446,6 +446,12 @@ def __init__(self, owner: ProofStepView): self.customContextMenuRequested.connect(self.show_context_menu) self.selectionModel().selectionChanged.connect(self.proof_step_selected) self.setItemDelegate(ProofStepItemDelegate(self)) + # Debounce thumbnail re-renders on resize: fire layout only after + # 150 ms of no further resize events. + self._resize_debounce = QTimer(self) + self._resize_debounce.setSingleShot(True) + self._resize_debounce.setInterval(150) + self._resize_debounce.timeout.connect(self._on_resize_settled) # overriding this method to change the return type and stop mypy from complaining def model(self) -> ProofModel: @@ -563,8 +569,15 @@ def showEvent(self, event: object) -> None: # type: ignore[override] def resizeEvent(self, event: object) -> None: # type: ignore[override] super().resizeEvent(event) # type: ignore[arg-type] if self.thumbnails_visible: - self.scheduleDelayedItemsLayout() - self.viewport().update() + # Debounce: restart the timer so we only re-render when the + # user stops dragging (avoids churn on every pixel of movement). + self._resize_debounce.start() + + def _on_resize_settled(self) -> None: + """Called 150 ms after the last resize event; triggers re-render.""" + self.model()._thumbnail_cache.clear() + self.scheduleDelayedItemsLayout() + self.viewport().update() class ProofStepItemDelegate(QStyledItemDelegate): From 4c637cbda09b78cb6ba3f92adc051bece96385bd Mon Sep 17 00:00:00 2001 From: axif Date: Sun, 1 Mar 2026 00:48:45 +0600 Subject: [PATCH 12/20] Fix HiDPI thumbnail blurriness natively without excessive scaling complexity --- zxlive/proof.py | 66 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/zxlive/proof.py b/zxlive/proof.py index 573c05da..71d1576d 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -64,8 +64,15 @@ def from_json(json_str: Union[str, Dict[str, Any]]) -> "Rewrite": MAX_THUMBNAIL_HEIGHT = 200 # Maximum thumbnail height in pixels when displayed -def render_graph_thumbnail(graph: GraphT) -> QPixmap: - """Render a graph to a QPixmap thumbnail using an off-screen GraphScene.""" +def render_graph_thumbnail(graph: GraphT, device_pixel_ratio: float = 1.0) -> QPixmap: + """Render a graph to a QPixmap thumbnail using an off-screen GraphScene. + + Args: + graph: the graph to render. + device_pixel_ratio: the screen's DPR (e.g. 2.0 on HiDPI displays). + The pixmap is rendered at DPR times the logical size so that + it appears crisp on high-resolution screens. + """ from .graphscene import GraphScene scene = GraphScene() @@ -78,7 +85,9 @@ def render_graph_thumbnail(graph: GraphT) -> QPixmap: padding = 20.0 rect.adjust(-padding, -padding, padding, padding) - max_dim = 600.0 + # Render at a higher maximum resolution so that thumbnails remain crisp on + # modern HiDPI (Retina) displays when dynamically scaled down by QPainter. + max_dim = 1500.0 scale = min(max_dim / rect.width(), max_dim / rect.height()) if scale > 2.0: scale = 2.0 @@ -92,7 +101,13 @@ def render_graph_thumbnail(graph: GraphT) -> QPixmap: if h > max_h: h = max_h - pixmap = QPixmap(w, h) + # Render at physical resolution for HiDPI crispness. + dpr = max(1.0, device_pixel_ratio) + phys_w = max(1, int(w * dpr)) + phys_h = max(1, int(h * dpr)) + + pixmap = QPixmap(phys_w, phys_h) + pixmap.setDevicePixelRatio(dpr) if display_setting.dark_mode: pixmap.fill(QColor(30, 30, 30)) else: @@ -100,7 +115,7 @@ def render_graph_thumbnail(graph: GraphT) -> QPixmap: painter = QPainter(pixmap) painter.setRenderHint(QPainter.RenderHint.Antialiasing) - scene.render(painter, QRectF(0, 0, w, h), rect) + scene.render(painter, QRectF(0, 0, phys_w, phys_h), rect) painter.end() scene.clear() @@ -617,7 +632,15 @@ def _draw_thumbnail(self, painter: QPainter, option: QStyleOptionViewItem, index """Draw thumbnail preview if enabled on this view.""" parent_view = self.parent() if isinstance(parent_view, _ProofStepListView) and parent_view.thumbnails_visible: + screen_dpr = parent_view.devicePixelRatio() + model = parent_view.model() + row = index.row() pixmap = index.data(THUMBNAIL_ROLE) + # Re-render if cached pixmap was made at a different DPR. + if pixmap is not None and not pixmap.isNull() and pixmap.devicePixelRatio() != screen_dpr: + graph = model.get_graph(row) + pixmap = render_graph_thumbnail(graph, screen_dpr) + model._thumbnail_cache[row] = pixmap if pixmap is not None and not pixmap.isNull(): # Clamp available_width to the actual usable space inside the # viewport, accounting for where this item starts horizontally. @@ -628,8 +651,12 @@ def _draw_thumbnail(self, painter: QPainter, option: QStyleOptionViewItem, index max_right = viewport_right - self.thumbnail_padding available_width = max(0, max_right - content_left) thumb_top = int(option.rect.y()) + text_row_height + self.thumbnail_padding # type: ignore[attr-defined] - if available_width > 0 and pixmap.width() > 0: - thumb_height = min(int(pixmap.height() * available_width / pixmap.width()), MAX_THUMBNAIL_HEIGHT) + # Use logical dimensions (physical / DPR) for layout. + dpr = pixmap.devicePixelRatio() + logical_w = pixmap.width() / dpr + logical_h = pixmap.height() / dpr + if available_width > 0 and logical_w > 0: + thumb_height = min(int(logical_h * available_width / logical_w), MAX_THUMBNAIL_HEIGHT) target_rect = QRect(content_left, thumb_top, available_width, thumb_height) # Clip all drawing to the item rect to guarantee no overflow. painter.setClipRect(option.rect) # type: ignore[attr-defined] @@ -638,11 +665,18 @@ def _draw_thumbnail(self, painter: QPainter, option: QStyleOptionViewItem, index painter.setBrush(Qt.GlobalColor.transparent) painter.drawRect(target_rect.adjusted(-1, -1, 1, 1)) - # Draw pixmap scaled to fit, keeping aspect ratio. - scaled = pixmap.scaled(target_rect.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) - x_off = (target_rect.width() - scaled.width()) / 2 - y_off = (target_rect.height() - scaled.height()) / 2 - painter.drawPixmap(int(target_rect.x() + x_off), int(target_rect.y() + y_off), scaled) + # Let QPainter natively scale the pixmap dynamically during paint. + # This avoids QPixmap.scaled() which drops resolution on High-DPI screens. + scale = min(target_rect.width() / logical_w, target_rect.height() / logical_h) + draw_w = logical_w * scale + draw_h = logical_h * scale + + x_off = (target_rect.width() - draw_w) / 2 + y_off = (target_rect.height() - draw_h) / 2 + + draw_rect = QRectF(target_rect.x() + x_off, target_rect.y() + y_off, draw_w, draw_h) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + painter.drawPixmap(draw_rect, pixmap, QRectF(pixmap.rect())) def _compute_thumb_height(self, parent: '_ProofStepListView', pixmap: QPixmap) -> int: """Compute the thumbnail display height for the current viewport width. @@ -657,8 +691,12 @@ def _compute_thumb_height(self, parent: '_ProofStepListView', pixmap: QPixmap) - viewport_right = parent.viewport().width() content_left = self.line_width + 2 * self.line_padding available_width = max(0, viewport_right - self.thumbnail_padding - content_left) - if available_width > 0 and not pixmap.isNull() and pixmap.width() > 0: - return min(int(pixmap.height() * available_width / pixmap.width()), MAX_THUMBNAIL_HEIGHT) + # Use logical dimensions for consistent layout. + dpr = pixmap.devicePixelRatio() + logical_w = pixmap.width() / dpr + logical_h = pixmap.height() / dpr + if available_width > 0 and not pixmap.isNull() and logical_w > 0: + return min(int(logical_h * available_width / logical_w), MAX_THUMBNAIL_HEIGHT) return MAX_THUMBNAIL_HEIGHT def sizeHint(self, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]) -> QSize: From ca47c4334204075d274dd15ed9c8b2295174b302 Mon Sep 17 00:00:00 2001 From: axif Date: Sun, 1 Mar 2026 01:01:06 +0600 Subject: [PATCH 13/20] Fix mypy warning --- zxlive/proof.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zxlive/proof.py b/zxlive/proof.py index 2a9e24fb..8e25a233 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -26,7 +26,7 @@ class Rewrite(NamedTuple): display_name: str # Name of proof displayed to user rule: str # Name of the rule that was applied to get to this step graph: GraphT # New graph after applying the rewrite - grouped_rewrites: Optional[list['Rewrite']] = None # Optional field to store the grouped rewrites + grouped_rewrites: list['Rewrite'] | None = None # Optional field to store the grouped rewrites def to_dict(self) -> dict[str, Any]: """Serializes the rewrite to Python dictionary.""" From 5879f6e2cb523fbf1ac238371364f8e833ea785d Mon Sep 17 00:00:00 2001 From: axif Date: Mon, 2 Mar 2026 03:39:02 +0600 Subject: [PATCH 14/20] undo type hint changes of mine --- zxlive/proof.py | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/zxlive/proof.py b/zxlive/proof.py index 8e25a233..284fdb51 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -1,6 +1,5 @@ -from __future__ import annotations import json -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple, Optional, Union, Dict if TYPE_CHECKING: from .proof_panel import ProofPanel @@ -26,7 +25,7 @@ class Rewrite(NamedTuple): display_name: str # Name of proof displayed to user rule: str # Name of the rule that was applied to get to this step graph: GraphT # New graph after applying the rewrite - grouped_rewrites: list['Rewrite'] | None = None # Optional field to store the grouped rewrites + grouped_rewrites: Optional[list['Rewrite']] = None # Optional field to store the grouped rewrites def to_dict(self) -> dict[str, Any]: """Serializes the rewrite to Python dictionary.""" @@ -42,7 +41,7 @@ def to_json(self) -> str: return json.dumps(self.to_dict()) @staticmethod - def from_json(json_str: str | dict[str, Any]) -> "Rewrite": + def from_json(json_str: Union[str, Dict[str, Any]]) -> "Rewrite": """Deserializes the rewrite from JSON or Python dict.""" if isinstance(json_str, str): d = json.loads(json_str) @@ -131,13 +130,13 @@ class ProofModel(QAbstractListModel): """ initial_graph: GraphT - steps: list[Rewrite] + steps: list['Rewrite'] def __init__(self, start_graph: GraphT): super().__init__() self.initial_graph = start_graph self.steps = [] - self._thumbnail_cache: dict[int, QPixmap] = {} + self._thumbnail_cache: Dict[int, QPixmap] = {} def set_graph(self, index: int, graph: GraphT) -> None: if index == 0: @@ -154,7 +153,7 @@ def set_graph(self, index: int, graph: GraphT) -> None: def graphs(self) -> list[GraphT]: return [self.initial_graph] + [step.graph for step in self.steps] - def data(self, index: QModelIndex | QPersistentModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + def data(self, index: Union[QModelIndex, QPersistentModelIndex], role: int = Qt.ItemDataRole.DisplayRole) -> Any: """Overrides `QAbstractItemModel.data` to populate a view with rewrite steps""" if index.row() >= len(self.steps) + 1 or index.column() >= 1: @@ -174,7 +173,7 @@ def data(self, index: QModelIndex | QPersistentModelIndex, role: int = Qt.ItemDa self._thumbnail_cache[row] = render_graph_thumbnail(graph) return self._thumbnail_cache.get(row) - def flags(self, index: QModelIndex | QPersistentModelIndex) -> Qt.ItemFlag: + def flags(self, index: Union[QModelIndex, QPersistentModelIndex]) -> Qt.ItemFlag: if index.row() == 0: return super().flags(index) return super().flags(index) | Qt.ItemFlag.ItemIsEditable @@ -187,11 +186,11 @@ def headerData(self, section: int, orientation: Qt.Orientation, """ return None - def columnCount(self, index: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int: + def columnCount(self, index: Union[QModelIndex, QPersistentModelIndex] = QModelIndex()) -> int: """The number of columns""" return 1 - def rowCount(self, index: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int: + def rowCount(self, index: Union[QModelIndex, QPersistentModelIndex] = QModelIndex()) -> int: """The number of rows""" # This is a quirk of Qt list models: Since they are based on tree models, the # user has to specify the index of the parent. In a list, we always expect the @@ -201,7 +200,7 @@ def rowCount(self, index: QModelIndex | QPersistentModelIndex = QModelIndex()) - else: return 0 - def add_rewrite(self, rewrite: Rewrite, position: int | None = None) -> None: + def add_rewrite(self, rewrite: Rewrite, position: Optional[int] = None) -> None: """Adds a rewrite step to the model.""" if position is None: position = len(self.steps) @@ -210,7 +209,7 @@ def add_rewrite(self, rewrite: Rewrite, position: int | None = None) -> None: self.endInsertRows() self.invalidate_thumbnails(position + 1) - def pop_rewrite(self, position: int | None = None) -> tuple[Rewrite, GraphT]: + def pop_rewrite(self, position: Optional[int] = None) -> tuple[Rewrite, GraphT]: """Removes the latest rewrite from the model. Returns the rewrite and the graph that previously resulted from this rewrite. @@ -277,7 +276,7 @@ def ungroup_steps(self, index: int) -> None: self.createIndex(index + len(individual_steps), 0), []) - def to_dict(self) -> dict[str, Any]: + def to_dict(self) -> Dict[str, Any]: """Serializes the model to Python dict.""" initial_graph = self.initial_graph.to_dict() proof_steps = [step.to_dict() for step in self.steps] @@ -292,7 +291,7 @@ def to_json(self) -> str: return json.dumps(self.to_dict()) @staticmethod - def from_json(json_str: str | dict[str, Any]) -> "ProofModel": + def from_json(json_str: Union[str, Dict[str, Any]]) -> "ProofModel": """Deserializes the model from JSON or Python dict.""" if isinstance(json_str, str): d = json.loads(json_str) @@ -554,7 +553,7 @@ class ProofStepItemDelegate(QStyledItemDelegate): circle_radius_selected = 6 circle_outline_width = 3 - def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex) -> None: + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]) -> None: painter.save() text_height = QFontMetrics(option.font).height() # type: ignore[attr-defined] text_row_height = text_height + 2 * self.vert_padding @@ -629,7 +628,7 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIn painter.restore() - def _draw_thumbnail(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex, text_row_height: int) -> None: + def _draw_thumbnail(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex], text_row_height: int) -> None: """Draw thumbnail preview if enabled on this view.""" parent_view = self.parent() if isinstance(parent_view, _ProofStepListView) and parent_view.thumbnails_visible: @@ -700,7 +699,7 @@ def _compute_thumb_height(self, parent: '_ProofStepListView', pixmap: QPixmap) - return min(int(logical_h * available_width / logical_w), MAX_THUMBNAIL_HEIGHT) return MAX_THUMBNAIL_HEIGHT - def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex) -> QSize: + def sizeHint(self, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]) -> QSize: size = super().sizeHint(option, index) text_row_height = size.height() + 2 * self.vert_padding parent = self.parent() @@ -717,15 +716,15 @@ def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex | QPersisten total = text_row_height + 2 * self.thumbnail_padding + thumb_height return QSize(size.width(), total) - def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex) -> QLineEdit: + def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]) -> QLineEdit: return QLineEdit(parent) - def setEditorData(self, editor: QWidget, index: QModelIndex | QPersistentModelIndex) -> None: + def setEditorData(self, editor: QWidget, index: Union[QModelIndex, QPersistentModelIndex]) -> None: assert isinstance(editor, QLineEdit) value = index.model().data(index, Qt.ItemDataRole.DisplayRole) editor.setText(str(value)) - def setModelData(self, editor: QWidget, model: QAbstractItemModel, index: QModelIndex | QPersistentModelIndex) -> None: + def setModelData(self, editor: QWidget, model: QAbstractItemModel, index: Union[QModelIndex, QPersistentModelIndex]) -> None: step_view = self.parent() assert isinstance(step_view, _ProofStepListView) assert isinstance(editor, QLineEdit) From 28c11eed81ccce3762d2f5a450a789a1f6d013a5 Mon Sep 17 00:00:00 2001 From: axif Date: Mon, 2 Mar 2026 04:18:41 +0600 Subject: [PATCH 15/20] Add a setting for default proof step thumbnail visibility and improve thumbnail rendering with asynchronous loading and LRU caching. --- zxlive/graphview.py | 5 +-- zxlive/proof.py | 75 +++++++++++++++++++++++++++++---------- zxlive/settings.py | 1 + zxlive/settings_dialog.py | 1 + 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/zxlive/graphview.py b/zxlive/graphview.py index 78109e4c..a14d337d 100644 --- a/zxlive/graphview.py +++ b/zxlive/graphview.py @@ -163,7 +163,8 @@ def keyPressEvent(self, e: QKeyEvent) -> None: # Merge vertices at the same position self.merge_triggered.emit() return - for v in self.graph_scene.selected_vertices: + selected = list(self.graph_scene.selected_vertices) + for v in selected: vitem = self.graph_scene.vertex_map[v] x = g.row(v) y = g.qubit(v) @@ -176,7 +177,7 @@ def keyPressEvent(self, e: QKeyEvent) -> None: elif e.key() == Qt.Key.Key_Right: g.set_position(v, y, x + distance) vitem.set_pos_from_graph() - if self.graph_scene.selected_vertices: + if selected: self.keyboard_vertices_moved.emit() else: super().keyPressEvent(e) diff --git a/zxlive/proof.py b/zxlive/proof.py index 284fdb51..7a1488fa 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -15,7 +15,7 @@ QStyledItemDelegate, QStyleOptionViewItem, QToolButton, QVBoxLayout, QWidget) -from .common import GraphT +from .common import GraphT, get_settings_value from .settings import display_setting @@ -27,7 +27,7 @@ class Rewrite(NamedTuple): graph: GraphT # New graph after applying the rewrite grouped_rewrites: Optional[list['Rewrite']] = None # Optional field to store the grouped rewrites - def to_dict(self) -> dict[str, Any]: + def to_dict(self) -> Dict[str, Any]: """Serializes the rewrite to Python dictionary.""" return { "display_name": self.display_name, @@ -87,7 +87,7 @@ def render_graph_thumbnail(graph: GraphT, device_pixel_ratio: float = 1.0) -> QP # Render at a higher maximum resolution so that thumbnails remain crisp on # modern HiDPI (Retina) displays when dynamically scaled down by QPainter. - max_dim = 1500.0 + max_dim = 800.0 scale = min(max_dim / rect.width(), max_dim / rect.height()) if scale > 2.0: scale = 2.0 @@ -130,13 +130,14 @@ class ProofModel(QAbstractListModel): """ initial_graph: GraphT - steps: list['Rewrite'] + steps: list[Rewrite] def __init__(self, start_graph: GraphT): super().__init__() + import collections self.initial_graph = start_graph self.steps = [] - self._thumbnail_cache: Dict[int, QPixmap] = {} + self._thumbnail_cache: collections.OrderedDict[int, QPixmap] = collections.OrderedDict() def set_graph(self, index: int, graph: GraphT) -> None: if index == 0: @@ -145,7 +146,7 @@ def set_graph(self, index: int, graph: GraphT) -> None: old_step = self.steps[index - 1] new_step = Rewrite(old_step.display_name, old_step.rule, graph) self.steps[index - 1] = new_step - self._thumbnail_cache.pop(index, None) + self.invalidate_thumbnail(index) # Notify views so that the delegate re-computes sizeHint / repaints model_index = self.createIndex(index, 0) self.dataChanged.emit(model_index, model_index, []) @@ -169,9 +170,11 @@ def data(self, index: Union[QModelIndex, QPersistentModelIndex], role: int = Qt. elif role == THUMBNAIL_ROLE: row = index.row() if row not in self._thumbnail_cache: - graph = self.get_graph(row) - self._thumbnail_cache[row] = render_graph_thumbnail(graph) - return self._thumbnail_cache.get(row) + return None + # Move accessed item to end for LRU + pixmap = self._thumbnail_cache.pop(row) + self._thumbnail_cache[row] = pixmap + return pixmap def flags(self, index: Union[QModelIndex, QPersistentModelIndex]) -> Qt.ItemFlag: if index.row() == 0: @@ -237,6 +240,19 @@ def invalidate_thumbnails(self, from_row: int = 0) -> None: for k in keys: del self._thumbnail_cache[k] + def invalidate_thumbnail(self, row: int) -> None: + """Remove a specific cached thumbnail.""" + self._thumbnail_cache.pop(row, None) + + def set_thumbnail(self, row: int, pixmap: QPixmap) -> None: + """Set the cached thumbnail for a specific row, preserving an LRU bound.""" + if row in self._thumbnail_cache: + self._thumbnail_cache.pop(row) + self._thumbnail_cache[row] = pixmap + # Limit cache size to 50 thumbnails max + while len(self._thumbnail_cache) > 50: + self._thumbnail_cache.popitem(last=False) + def rename_step(self, index: int, name: str) -> None: """Change the display name""" old_step = self.steps[index] @@ -337,7 +353,8 @@ def __init__(self, parent: 'ProofPanel'): self.thumbnails_toggle = QToolButton(header) self.thumbnails_toggle.setText("Thumbnails") self.thumbnails_toggle.setCheckable(True) - self.thumbnails_toggle.setChecked(False) + show_thumbnails = get_settings_value("proof/show-thumbnails", bool) + self.thumbnails_toggle.setChecked(show_thumbnails) self.thumbnails_toggle.setAutoRaise(True) self.thumbnails_toggle.setToolTip("Toggle proof step diagram previews (t)") self.thumbnails_toggle.setShortcut("t") @@ -404,7 +421,7 @@ def __init__(self, owner: ProofStepView): self.setSpacing(0) self.setSelectionMode(QAbstractItemView.SelectionMode.ContiguousSelection) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.thumbnails_visible = False + self.thumbnails_visible = owner.thumbnails_toggle.isChecked() self.setResizeMode(QListView.ResizeMode.Adjust) self.setUniformItemSizes(False) self.setAlternatingRowColors(True) @@ -512,7 +529,7 @@ def ungroup_selected_step(self) -> None: def set_thumbnails_visible(self, visible: bool) -> None: """Toggle thumbnail previews in the proof step list.""" self.thumbnails_visible = visible - self.model()._thumbnail_cache.clear() + self.model().invalidate_thumbnails() self.scheduleDelayedItemsLayout() self.viewport().update() @@ -521,7 +538,7 @@ def refresh_current_thumbnail(self) -> None: if not self.thumbnails_visible: return row = self.currentIndex().row() - self.model()._thumbnail_cache.pop(row, None) + self.model().invalidate_thumbnail(row) idx = self.model().createIndex(row, 0) self.model().dataChanged.emit(idx, idx, []) self.scheduleDelayedItemsLayout() @@ -636,11 +653,33 @@ def _draw_thumbnail(self, painter: QPainter, option: QStyleOptionViewItem, index model = parent_view.model() row = index.row() pixmap = index.data(THUMBNAIL_ROLE) - # Re-render if cached pixmap was made at a different DPR. - if pixmap is not None and not pixmap.isNull() and pixmap.devicePixelRatio() != screen_dpr: - graph = model.get_graph(row) - pixmap = render_graph_thumbnail(graph, screen_dpr) - model._thumbnail_cache[row] = pixmap + if pixmap is None or pixmap.isNull() or pixmap.devicePixelRatio() != screen_dpr: + # To prevent freezing the UI, we do not render the graph asynchronously here if + # there's lots of rows. We instead ask the model to do it, and in the meantime + # the delegate continues without drawing one. + # Use QTimer to delay rendering. + from PySide6.QtCore import QTimer + + # Check if we already scheduled this row + if not hasattr(parent_view, '_pending_thumbnails'): + parent_view._pending_thumbnails = set() + + if row not in parent_view._pending_thumbnails: + parent_view._pending_thumbnails.add(row) + + def render_task(r=row, dpr=screen_dpr): + parent_view._pending_thumbnails.discard(r) + try: + graph = model.get_graph(r) + new_pixmap = render_graph_thumbnail(graph, dpr) + model.set_thumbnail(r, new_pixmap) + idx = model.index(r, 0) + model.dataChanged.emit(idx, idx, []) + except Exception: + pass # Graph might be deleted in between + + QTimer.singleShot(0, render_task) + if pixmap is not None and not pixmap.isNull(): # Clamp available_width to the actual usable space inside the # viewport, accounting for where this item starts horizontally. diff --git a/zxlive/settings.py b/zxlive/settings.py index 21ee1801..6340da19 100644 --- a/zxlive/settings.py +++ b/zxlive/settings.py @@ -44,6 +44,7 @@ class ColorScheme(TypedDict): "snap-granularity": '4', "input-circuit-format": 'openqasm', "previews-show": True, + "proof/show-thumbnails": False, "sparkle-mode": True, 'sound-effects': False, "matrix/precision": 4, diff --git a/zxlive/settings_dialog.py b/zxlive/settings_dialog.py index 6180b051..3ab891ec 100644 --- a/zxlive/settings_dialog.py +++ b/zxlive/settings_dialog.py @@ -83,6 +83,7 @@ class SettingsData(TypedDict): {"id": "dark-mode", "label": "Theme", "type": FormInputType.Combo, "data": dark_mode_options}, {"id": "sparkle-mode", "label": "Sparkle Mode", "type": FormInputType.Bool}, {"id": "previews-show", "label": "Show rewrite previews", "type": FormInputType.Bool}, + {"id": "proof/show-thumbnails", "label": "Show proof-step thumbnails by default", "type": FormInputType.Bool}, {"id": "sound-effects", "label": "Sound Effects", "type": FormInputType.Bool}, {"id": "color-scheme", "label": "Color scheme", "type": FormInputType.Combo, "data": color_scheme_data}, {"id": "swap-pauli-web-colors", "label": "Swap Pauli web colors", "type": FormInputType.Bool}, From c5879213961b95b8b9104c1733585a1fdb4785fc Mon Sep 17 00:00:00 2001 From: axif Date: Mon, 2 Mar 2026 04:24:37 +0600 Subject: [PATCH 16/20] fixed lint error --- zxlive/proof.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/zxlive/proof.py b/zxlive/proof.py index 7a1488fa..7ef02b4e 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -404,6 +404,7 @@ def __init__(self, owner: ProofStepView): self.setModel(ProofModel(self.graph_view.graph_scene.g)) self.setCurrentIndex(self.model().index(0, 0)) self.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked) + self._pending_thumbnails: set[int] = set() # Set background color for dark mode (panel background) if display_setting.dark_mode: self.setStyleSheet("background-color: #23272e;") @@ -660,14 +661,10 @@ def _draw_thumbnail(self, painter: QPainter, option: QStyleOptionViewItem, index # Use QTimer to delay rendering. from PySide6.QtCore import QTimer - # Check if we already scheduled this row - if not hasattr(parent_view, '_pending_thumbnails'): - parent_view._pending_thumbnails = set() - if row not in parent_view._pending_thumbnails: parent_view._pending_thumbnails.add(row) - def render_task(r=row, dpr=screen_dpr): + def render_task(r: int = row, dpr: float = screen_dpr) -> None: parent_view._pending_thumbnails.discard(r) try: graph = model.get_graph(r) From 6a07fcea9f5b2830b6d1d8d033214f2a0afc0b65 Mon Sep 17 00:00:00 2001 From: axif Date: Mon, 2 Mar 2026 04:28:17 +0600 Subject: [PATCH 17/20] adjust whitespace and comment spacing --- zxlive/proof.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/zxlive/proof.py b/zxlive/proof.py index 7ef02b4e..4ed06539 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -660,10 +660,10 @@ def _draw_thumbnail(self, painter: QPainter, option: QStyleOptionViewItem, index # the delegate continues without drawing one. # Use QTimer to delay rendering. from PySide6.QtCore import QTimer - + if row not in parent_view._pending_thumbnails: parent_view._pending_thumbnails.add(row) - + def render_task(r: int = row, dpr: float = screen_dpr) -> None: parent_view._pending_thumbnails.discard(r) try: @@ -673,8 +673,8 @@ def render_task(r: int = row, dpr: float = screen_dpr) -> None: idx = model.index(r, 0) model.dataChanged.emit(idx, idx, []) except Exception: - pass # Graph might be deleted in between - + pass # Graph might be deleted in between + QTimer.singleShot(0, render_task) if pixmap is not None and not pixmap.isNull(): From 6db6c816a081f7a04b4be0d97c002a747f0e8fb4 Mon Sep 17 00:00:00 2001 From: axif Date: Thu, 5 Mar 2026 15:47:57 +0600 Subject: [PATCH 18/20] Correct scene rendering dimensions when rendering to pixmap. --- zxlive/proof.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zxlive/proof.py b/zxlive/proof.py index 4ed06539..683564ca 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -115,7 +115,7 @@ def render_graph_thumbnail(graph: GraphT, device_pixel_ratio: float = 1.0) -> QP painter = QPainter(pixmap) painter.setRenderHint(QPainter.RenderHint.Antialiasing) - scene.render(painter, QRectF(0, 0, phys_w, phys_h), rect) + scene.render(painter, QRectF(0, 0, w, h), rect) painter.end() scene.clear() From 0215a5739d9a3f2b4bfed3eb160eb0b85ee9d2f6 Mon Sep 17 00:00:00 2001 From: axif Date: Thu, 5 Mar 2026 17:09:00 +0600 Subject: [PATCH 19/20] reduce max complexity --- zxlive/graphview.py | 59 +++++++++++--------- zxlive/proof.py | 133 +++++++++++++++++++++++--------------------- 2 files changed, 101 insertions(+), 91 deletions(-) diff --git a/zxlive/graphview.py b/zxlive/graphview.py index 171ddb80..7c76c264 100644 --- a/zxlive/graphview.py +++ b/zxlive/graphview.py @@ -64,6 +64,14 @@ def __init__(self, start: QPointF, shift: bool = False) -> None: GRID_SCALE = SCALE / 2 +# Maps arrow keys to (qubit_delta, row_delta) offsets for vertex movement. +_ARROW_OFFSETS: dict[int, tuple[int, int]] = { + Qt.Key.Key_Up: (-1, 0), + Qt.Key.Key_Down: ( 1, 0), + Qt.Key.Key_Left: ( 0, -1), + Qt.Key.Key_Right: ( 0, 1), +} + class GraphView(QGraphicsView): """QtWidget containing a graph @@ -153,34 +161,31 @@ def mousePressEvent(self, e: QMouseEvent) -> None: def keyPressEvent(self, e: QKeyEvent) -> None: """Logic for moving selected vertices with arrow keys and merging selected vertices with Ctrl+M""" - if Qt.KeyboardModifier.ControlModifier & e.modifiers(): - g = self.graph_scene.g - if Qt.KeyboardModifier.ShiftModifier & e.modifiers(): - distance = 1 / get_settings_value("snap-granularity", int) - else: - distance = 0.5 - if e.key() == Qt.Key.Key_M: - # Merge vertices at the same position - self.merge_triggered.emit() - return - selected = list(self.graph_scene.selected_vertices) - for v in selected: - vitem = self.graph_scene.vertex_map[v] - x = g.row(v) - y = g.qubit(v) - if e.key() == Qt.Key.Key_Up: - g.set_position(v, y - distance, x) - elif e.key() == Qt.Key.Key_Down: - g.set_position(v, y + distance, x) - elif e.key() == Qt.Key.Key_Left: - g.set_position(v, y, x - distance) - elif e.key() == Qt.Key.Key_Right: - g.set_position(v, y, x + distance) - vitem.set_pos_from_graph() - if selected: - self.keyboard_vertices_moved.emit() - else: + if not (Qt.KeyboardModifier.ControlModifier & e.modifiers()): super().keyPressEvent(e) + return + + if e.key() == Qt.Key.Key_M: + self.merge_triggered.emit() + return + + offsets = _ARROW_OFFSETS.get(e.key()) + if offsets is None: + return + + g = self.graph_scene.g + if Qt.KeyboardModifier.ShiftModifier & e.modifiers(): + distance = 1 / get_settings_value("snap-granularity", int) + else: + distance = 0.5 + + dy, dx = offsets + selected = list(self.graph_scene.selected_vertices) + for v in selected: + g.set_position(v, g.qubit(v) + dy * distance, g.row(v) + dx * distance) + self.graph_scene.vertex_map[v].set_pos_from_graph() + if selected: + self.keyboard_vertices_moved.emit() # TODO: Fix code complexity # noqa: complexipy diff --git a/zxlive/proof.py b/zxlive/proof.py index c1b3087f..de6656fa 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -651,70 +651,75 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QM def _draw_thumbnail(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex], text_row_height: int) -> None: """Draw thumbnail preview if enabled on this view.""" parent_view = self.parent() - if isinstance(parent_view, _ProofStepListView) and parent_view.thumbnails_visible: - screen_dpr = parent_view.devicePixelRatio() - model = parent_view.model() - row = index.row() - pixmap = index.data(THUMBNAIL_ROLE) - if pixmap is None or pixmap.isNull() or pixmap.devicePixelRatio() != screen_dpr: - # To prevent freezing the UI, we do not render the graph asynchronously here if - # there's lots of rows. We instead ask the model to do it, and in the meantime - # the delegate continues without drawing one. - # Use QTimer to delay rendering. - from PySide6.QtCore import QTimer - - if row not in parent_view._pending_thumbnails: - parent_view._pending_thumbnails.add(row) - - def render_task(r: int = row, dpr: float = screen_dpr) -> None: - parent_view._pending_thumbnails.discard(r) - try: - graph = model.get_graph(r) - new_pixmap = render_graph_thumbnail(graph, dpr) - model.set_thumbnail(r, new_pixmap) - idx = model.index(r, 0) - model.dataChanged.emit(idx, idx, []) - except Exception: - pass # Graph might be deleted in between - - QTimer.singleShot(0, render_task) - - if pixmap is not None and not pixmap.isNull(): - # Clamp available_width to the actual usable space inside the - # viewport, accounting for where this item starts horizontally. - # This prevents overflow when the panel is very narrow. - viewport_right = parent_view.viewport().width() - item_x = int(option.rect.x()) # type: ignore[attr-defined] - content_left = item_x + self.line_width + 2 * self.line_padding - max_right = viewport_right - self.thumbnail_padding - available_width = max(0, max_right - content_left) - thumb_top = int(option.rect.y()) + text_row_height + self.thumbnail_padding # type: ignore[attr-defined] - # Use logical dimensions (physical / DPR) for layout. - dpr = pixmap.devicePixelRatio() - logical_w = pixmap.width() / dpr - logical_h = pixmap.height() / dpr - if available_width > 0 and logical_w > 0: - thumb_height = min(int(logical_h * available_width / logical_w), MAX_THUMBNAIL_HEIGHT) - target_rect = QRect(content_left, thumb_top, available_width, thumb_height) - # Clip all drawing to the item rect to guarantee no overflow. - painter.setClipRect(option.rect) # type: ignore[attr-defined] - border_color = QColor(80, 80, 80) if display_setting.dark_mode else QColor(200, 200, 200) - painter.setPen(QPen(border_color, 1)) - painter.setBrush(Qt.GlobalColor.transparent) - painter.drawRect(target_rect.adjusted(-1, -1, 1, 1)) - - # Let QPainter natively scale the pixmap dynamically during paint. - # This avoids QPixmap.scaled() which drops resolution on High-DPI screens. - scale = min(target_rect.width() / logical_w, target_rect.height() / logical_h) - draw_w = logical_w * scale - draw_h = logical_h * scale - - x_off = (target_rect.width() - draw_w) / 2 - y_off = (target_rect.height() - draw_h) / 2 - - draw_rect = QRectF(target_rect.x() + x_off, target_rect.y() + y_off, draw_w, draw_h) - painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) - painter.drawPixmap(draw_rect, pixmap, QRectF(pixmap.rect())) + if not isinstance(parent_view, _ProofStepListView) or not parent_view.thumbnails_visible: + return + + screen_dpr = parent_view.devicePixelRatio() + model = parent_view.model() + row = index.row() + pixmap = index.data(THUMBNAIL_ROLE) + if pixmap is None or pixmap.isNull() or pixmap.devicePixelRatio() != screen_dpr: + self._schedule_thumbnail_render(parent_view, model, row, screen_dpr) + + if pixmap is not None and not pixmap.isNull(): + self._paint_pixmap(painter, option, parent_view, pixmap, text_row_height) + + def _schedule_thumbnail_render(self, parent_view: '_ProofStepListView', model: 'ProofModel', row: int, screen_dpr: float) -> None: + """Schedule asynchronous rendering of a thumbnail for the given row.""" + from PySide6.QtCore import QTimer + + if row in parent_view._pending_thumbnails: + return + parent_view._pending_thumbnails.add(row) + + def render_task(r: int = row, dpr: float = screen_dpr) -> None: + parent_view._pending_thumbnails.discard(r) + try: + graph = model.get_graph(r) + new_pixmap = render_graph_thumbnail(graph, dpr) + model.set_thumbnail(r, new_pixmap) + idx = model.index(r, 0) + model.dataChanged.emit(idx, idx, []) + except Exception: + pass # Graph might be deleted in between + + QTimer.singleShot(0, render_task) + + def _paint_pixmap(self, painter: QPainter, option: QStyleOptionViewItem, parent_view: '_ProofStepListView', pixmap: QPixmap, text_row_height: int) -> None: + """Paint a thumbnail pixmap into the delegate area.""" + viewport_right = parent_view.viewport().width() + item_x = int(option.rect.x()) # type: ignore[attr-defined] + content_left = item_x + self.line_width + 2 * self.line_padding + available_width = max(0, viewport_right - self.thumbnail_padding - content_left) + thumb_top = int(option.rect.y()) + text_row_height + self.thumbnail_padding # type: ignore[attr-defined] + + dpr = pixmap.devicePixelRatio() + logical_w = pixmap.width() / dpr + logical_h = pixmap.height() / dpr + if available_width <= 0 or logical_w <= 0: + return + + thumb_height = min(int(logical_h * available_width / logical_w), MAX_THUMBNAIL_HEIGHT) + target_rect = QRect(content_left, thumb_top, available_width, thumb_height) + # Clip all drawing to the item rect to guarantee no overflow. + painter.setClipRect(option.rect) # type: ignore[attr-defined] + border_color = QColor(80, 80, 80) if display_setting.dark_mode else QColor(200, 200, 200) + painter.setPen(QPen(border_color, 1)) + painter.setBrush(Qt.GlobalColor.transparent) + painter.drawRect(target_rect.adjusted(-1, -1, 1, 1)) + + # Let QPainter natively scale the pixmap dynamically during paint. + # This avoids QPixmap.scaled() which drops resolution on High-DPI screens. + scale = min(target_rect.width() / logical_w, target_rect.height() / logical_h) + draw_w = logical_w * scale + draw_h = logical_h * scale + + x_off = (target_rect.width() - draw_w) / 2 + y_off = (target_rect.height() - draw_h) / 2 + + draw_rect = QRectF(target_rect.x() + x_off, target_rect.y() + y_off, draw_w, draw_h) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + painter.drawPixmap(draw_rect, pixmap, QRectF(pixmap.rect())) def _compute_thumb_height(self, parent: '_ProofStepListView', pixmap: QPixmap) -> int: """Compute the thumbnail display height for the current viewport width. From 293d0192cf211f133d773620e57a53efe27b7f5c Mon Sep 17 00:00:00 2001 From: Razin Shaikh Date: Mon, 23 Mar 2026 13:47:41 +0000 Subject: [PATCH 20/20] add "Show thumbnails" View menu action --- zxlive/mainwindow.py | 32 +++++++++++++++++++++++++++++++- zxlive/proof.py | 20 +++++++++++--------- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/zxlive/mainwindow.py b/zxlive/mainwindow.py index 3abbfda9..6914b232 100644 --- a/zxlive/mainwindow.py +++ b/zxlive/mainwindow.py @@ -218,11 +218,17 @@ def __init__(self) -> None: self.fit_view_action = self._new_action( "Fit view", self.fit_view, QKeySequence("C"), "Fits the view to the diagram") + self.show_thumbnails_action = self._new_action( + "Show thumbnails", self.toggle_thumbnails, QKeySequence("t"), + "Toggle proof step diagram previews", checkable=True) + self.show_thumbnails_action.setChecked(get_settings_value("proof/show-thumbnails", bool)) view_menu = menu.addMenu("&View") view_menu.addAction(self.zoom_in_action) view_menu.addAction(self.zoom_out_action) view_menu.addAction(self.fit_view_action) + view_menu.addSeparator() + view_menu.addAction(self.show_thumbnails_action) new_rewrite_from_file = self._new_action( "New rewrite from file", lambda: create_new_rewrite(self), @@ -283,6 +289,9 @@ def _reset_menus(self, has_active_tab: bool) -> None: # Export to tikz and gif are enabled only if there is a proof in the active tab. self.export_tikz_proof.setEnabled(has_active_tab and isinstance(self.active_panel, ProofPanel)) self.export_gif_proof.setEnabled(has_active_tab and isinstance(self.active_panel, ProofPanel)) + self.show_thumbnails_action.setEnabled(has_active_tab and isinstance(self.active_panel, ProofPanel)) + if has_active_tab and isinstance(self.active_panel, ProofPanel): + self.show_thumbnails_action.setChecked(self.active_panel.step_view.thumbnails_visible) # Paste is enabled only if there is something in the clipboard. self.paste_action.setEnabled(has_active_tab and self._has_pasteable_clipboard_data()) @@ -299,10 +308,13 @@ def _new_action( self, name: str, trigger: Callable, shortcut: QKeySequence | QKeySequence.StandardKey | None, tooltip: str, icon_file: Optional[str] = None, - alt_shortcut: Optional[QKeySequence | QKeySequence.StandardKey] = None + alt_shortcut: Optional[QKeySequence | QKeySequence.StandardKey] = None, + checkable: bool = False ) -> QAction: assert not alt_shortcut or shortcut action = QAction(name, self) + if checkable: + action.setCheckable(True) if icon_file: action.setIcon(QIcon(get_data(f"icons/{icon_file}"))) action.setStatusTip(tooltip) @@ -505,8 +517,11 @@ def update_tab_name(self, clean: bool) -> None: def tab_changed(self, i: int) -> None: if isinstance(self.active_panel, ProofPanel): self.proof_as_rewrite_action.setEnabled(True) + self.show_thumbnails_action.setEnabled(True) + self.show_thumbnails_action.setChecked(self.active_panel.step_view.thumbnails_visible) else: self.proof_as_rewrite_action.setEnabled(False) + self.show_thumbnails_action.setEnabled(False) self._undo_changed() self._redo_changed() if self.active_panel: @@ -749,6 +764,14 @@ def _new_panel(self, panel: BasePanel, name: str) -> None: panel.undo_stack.canRedoChanged.connect(self._redo_changed) panel.play_sound_signal.connect(self.play_sound) panel.undo_stack.indexChanged.connect(self._auto_save_if_needed) + if isinstance(panel, ProofPanel): + panel.step_view.thumbnails_toggled.connect(self._on_proof_thumbnails_toggled) + + def _on_proof_thumbnails_toggled(self, checked: bool) -> None: + # Check if the signal came from the currently active panel + if hasattr(self, 'active_panel') and isinstance(self.active_panel, ProofPanel): + if self.sender() is self.active_panel.step_view: + self.show_thumbnails_action.setChecked(checked) def _auto_save_if_needed(self) -> None: panel = self.active_panel @@ -946,6 +969,13 @@ def update_font(self) -> None: w = cast(BasePanel, self.tab_widget.widget(i)) w.update_font() + def toggle_thumbnails(self) -> None: + """Toggle the show thumbnails setting from the View menu.""" + checked = self.show_thumbnails_action.isChecked() + # update current proof panel + if isinstance(self.active_panel, ProofPanel): + self.active_panel.step_view.thumbnails_visible = checked + def toggle_auto_save(self) -> None: """Toggle the auto-save setting from the File menu.""" from .common import set_settings_value diff --git a/zxlive/proof.py b/zxlive/proof.py index de6656fa..b4fedc9a 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -7,7 +7,7 @@ from PySide6.QtCore import (QAbstractItemModel, QAbstractListModel, QItemSelection, QModelIndex, QPersistentModelIndex, QPoint, QPointF, QRect, - QRectF, QSize, Qt) + QRectF, QSize, Qt, Signal) from PySide6.QtGui import (QColor, QFont, QFontMetrics, QPainter, QPen, QPixmap) from PySide6.QtWidgets import (QAbstractItemView, QFrame, QHBoxLayout, QLabel, @@ -328,6 +328,8 @@ def from_json(json_str: Union[str, Dict[str, Any]]) -> "ProofModel": class ProofStepView(QWidget): """Widget containing the proof step list and a thumbnail toggle button.""" + thumbnails_toggled = Signal(bool) + def __init__(self, parent: 'ProofPanel'): super().__init__(parent) self.graph_view = parent.graph_view @@ -357,9 +359,9 @@ def __init__(self, parent: 'ProofPanel'): self.thumbnails_toggle.setChecked(show_thumbnails) self.thumbnails_toggle.setAutoRaise(True) self.thumbnails_toggle.setToolTip("Toggle proof step diagram previews (t)") - self.thumbnails_toggle.setShortcut("t") - self.thumbnails_toggle.clicked.connect(self.set_thumbnails_visible) + self.thumbnails_toggle.toggled.connect(self._on_toggled) header_layout.addWidget(self.thumbnails_toggle) + layout.addWidget(header) # Add a subtle separator line @@ -372,23 +374,23 @@ def __init__(self, parent: 'ProofPanel'): self._list = _ProofStepListView(self) layout.addWidget(self._list) + def _on_toggled(self, checked: bool) -> None: + self._list.set_thumbnails_visible(checked) + self.thumbnails_toggled.emit(checked) + # ---- proxy properties so the rest of the codebase keeps working ---- @property def thumbnails_visible(self) -> bool: - return self._list.thumbnails_visible + return self.thumbnails_toggle.isChecked() @thumbnails_visible.setter def thumbnails_visible(self, value: bool) -> None: - self._list.thumbnails_visible = value + self.thumbnails_toggle.setChecked(value) def update(self, *args: Any) -> None: super().update(*args) self._list.viewport().update() - def set_thumbnails_visible(self, visible: bool) -> None: - self.thumbnails_toggle.setChecked(visible) - self._list.set_thumbnails_visible(visible) - def __getattr__(self, name: str) -> Any: return getattr(self._list, name)