diff --git a/zxlive/commands.py b/zxlive/commands.py index 630939c7..1cef823f 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 @@ -445,7 +444,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 a51d52de..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 @@ -74,6 +82,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: @@ -152,31 +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 - for v in self.graph_scene.selected_vertices: - 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() - 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/mainwindow.py b/zxlive/mainwindow.py index 42c7c6f3..9104e6d0 100644 --- a/zxlive/mainwindow.py +++ b/zxlive/mainwindow.py @@ -224,11 +224,17 @@ def native_shortcut(key: QKeySequence.StandardKey) -> str: 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), @@ -290,6 +296,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()) @@ -306,10 +315,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.setToolTip(tooltip) @@ -514,8 +526,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: @@ -763,6 +778,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 @@ -984,6 +1007,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 91a1e5e0..b4fedc9a 100644 --- a/zxlive/proof.py +++ b/zxlive/proof.py @@ -5,14 +5,17 @@ from .proof_panel import ProofPanel from PySide6.QtCore import (QAbstractItemModel, QAbstractListModel, - QItemSelection, QModelIndex, QPersistentModelIndex, - QPoint, QPointF, QRect, QSize, Qt) -from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen -from PySide6.QtWidgets import (QAbstractItemView, QLineEdit, QListView, QMenu, - QStyle, QStyledItemDelegate, - QStyleOptionViewItem, QWidget) - -from .common import GraphT + QItemSelection, QModelIndex, + QPersistentModelIndex, QPoint, QPointF, QRect, + QRectF, QSize, Qt, Signal) +from PySide6.QtGui import (QColor, QFont, QFontMetrics, QPainter, + QPen, QPixmap) +from PySide6.QtWidgets import (QAbstractItemView, QFrame, QHBoxLayout, QLabel, + QLineEdit, QListView, QMenu, QStyle, + QStyledItemDelegate, QStyleOptionViewItem, + QToolButton, QVBoxLayout, QWidget) + +from .common import GraphT, get_settings_value from .settings import display_setting @@ -57,6 +60,68 @@ 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, 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() + scene.set_graph(graph) + rect = scene.itemsBoundingRect() + if rect.isEmpty(): + scene.clear() + return QPixmap() + + padding = 20.0 + rect.adjust(-padding, -padding, padding, padding) + + # Render at a higher maximum resolution so that thumbnails remain crisp on + # modern HiDPI (Retina) displays when dynamically scaled down by QPainter. + max_dim = 800.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)) + + # 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 + + # 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: + 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. @@ -69,8 +134,10 @@ class ProofModel(QAbstractListModel): def __init__(self, start_graph: GraphT): super().__init__() + import collections self.initial_graph = start_graph self.steps = [] + self._thumbnail_cache: collections.OrderedDict[int, QPixmap] = collections.OrderedDict() def set_graph(self, index: int, graph: GraphT) -> None: if index == 0: @@ -79,6 +146,10 @@ 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.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, []) def graphs(self) -> list[GraphT]: return [self.initial_graph] + [step.graph for step in self.steps] @@ -96,6 +167,14 @@ 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: + 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: @@ -131,6 +210,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 +222,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 +234,25 @@ 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 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] @@ -225,16 +325,88 @@ 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.""" + + thumbnails_toggled = Signal(bool) 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) + 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.toggled.connect(self._on_toggled) + 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) + + 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.thumbnails_toggle.isChecked() + + @thumbnails_visible.setter + def thumbnails_visible(self, value: bool) -> None: + self.thumbnails_toggle.setChecked(value) + + def update(self, *args: Any) -> None: + super().update(*args) + self._list.viewport().update() + + def __getattr__(self, name: str) -> Any: + return getattr(self._list, name) + + +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) + self._pending_thumbnails: set[int] = set() # Set background color for dark mode (panel background) if display_setting.dark_mode: self.setStyleSheet("background-color: #23272e;") @@ -252,9 +424,14 @@ def __init__(self, parent: 'ProofPanel'): self.setSpacing(0) self.setSelectionMode(QAbstractItemView.SelectionMode.ContiguousSelection) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.thumbnails_visible = owner.thumbnails_toggle.isChecked() self.setResizeMode(QListView.ResizeMode.Adjust) - self.setUniformItemSizes(True) + 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) @@ -335,7 +512,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: @@ -349,9 +526,37 @@ 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: + """Toggle thumbnail previews in the proof step list.""" + self.thumbnails_visible = visible + self.model().invalidate_thumbnails() + 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().invalidate_thumbnail(row) + 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] + self.scheduleDelayedItemsLayout() + + 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): """This class controls the painting of items in the proof steps list view. @@ -362,6 +567,7 @@ class ProofStepItemDelegate(QStyledItemDelegate): line_width = 3 line_padding = 13 vert_padding = 10 + thumbnail_padding = 8 circle_radius = 4 circle_radius_selected = 6 @@ -371,6 +577,9 @@ class ProofStepItemDelegate(QStyledItemDelegate): # noqa: complexipy def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QModelIndex, QPersistentModelIndex]) -> None: # noqa: PLR0912 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: @@ -389,13 +598,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)) @@ -403,7 +613,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: @@ -411,17 +621,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 ) @@ -437,11 +646,120 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: Union[QM painter.setBrush(Qt.GlobalColor.black) painter.drawText(text_rect, Qt.AlignmentFlag.AlignLeft, text) + 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 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. + + 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) + # 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: size = super().sizeHint(option, index) - return QSize(size.width(), size.height() + 2 * self.vert_padding) + 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) + 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) @@ -453,6 +771,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 c6042c56..a4b4946c 100644 --- a/zxlive/proof_panel.py +++ b/zxlive/proof_panel.py @@ -59,12 +59,13 @@ 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.graph_view.keyboard_vertices_moved.connect(self._on_keyboard_vertices_moved) + @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) @@ -140,6 +141,11 @@ 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.""" + 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) self.rewrites_panel.reset_rewrite_panel_style() diff --git a/zxlive/settings.py b/zxlive/settings.py index a238572e..009fdbde 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, "rewrite-animations": True, "sparkle-mode": True, 'sound-effects': False, diff --git a/zxlive/settings_dialog.py b/zxlive/settings_dialog.py index 96c92e62..34b1855b 100644 --- a/zxlive/settings_dialog.py +++ b/zxlive/settings_dialog.py @@ -119,6 +119,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": "rewrite-animations", "label": "Rewrite animations", "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},