diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-06-03 15:05:36 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-06-03 15:05:36 -0600 |
| commit | 7b926faab3dcd52e45fc1b4b24f955a4dcf66959 (patch) | |
| tree | ede3018b2557d9d06d50f8439f91767c8795776f /ui/windows | |
| parent | 93e7d0ecdc090fd4f0231b13933fc0fa095a1f22 (diff) | |
Add drag-and-snap tiling layout canvas to Plot Builder
Replace layout radio buttons (Stacked/Side-by-Side/Grid) with a visual
LayoutCanvas widget. Pane tiles snap to grid cells on drag-drop; dropping
on an occupied cell swaps positions. Ghost highlight shows snap target
during drag.
- PaneSpec gains row/col fields (None = unassigned; backward compat preserved)
- LayoutConfig gains "free" mode; strip_chart reads explicit positions
- PaneBlock loses ▲▼ move buttons; reordering is canvas-only
- QSS entries added for layoutTile, layoutTileDragging, layoutCanvasBar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'ui/windows')
| -rw-r--r-- | ui/windows/plot_window.py | 317 |
1 files changed, 249 insertions, 68 deletions
diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py index 0207af1..91799f9 100644 --- a/ui/windows/plot_window.py +++ b/ui/windows/plot_window.py @@ -4,8 +4,8 @@ ui/windows/plot_window.py PLOT window — toolbar section 4. Lets operators: - • Choose layout: Stacked (rows) or Side-by-Side (columns) or Grid (N×M) - • Add/remove/reorder subplot panes + • Arrange subplot panes by dragging tiles on the layout canvas (tiling WM style) + • Add/remove subplot panes • Assign any channel (physical or derived) to any pane • Set X-axis: Time (elapsed s) OR any channel (e.g. strain → stress vs strain) • Name axes, set Y auto/fixed range, grid, relative size weight @@ -19,16 +19,16 @@ from __future__ import annotations import json from copy import deepcopy from dataclasses import dataclass, field, asdict -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QFrame, QLineEdit, QDoubleSpinBox, QSpinBox, - QCheckBox, QComboBox, QColorDialog, QToolButton, QButtonGroup, - QFileDialog, QMessageBox, QRadioButton, QGroupBox, QSizePolicy, + QCheckBox, QComboBox, QColorDialog, QToolButton, + QFileDialog, QMessageBox, ) -from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtGui import QColor, QCloseEvent +from PyQt6.QtCore import Qt, pyqtSignal, QPoint, QRect +from PyQt6.QtGui import QColor, QCloseEvent, QPainter, QPen from devices.device_registry import DeviceRegistry from core.signal_processor import SignalProcessor @@ -60,11 +60,13 @@ class PaneSpec: grid: bool = True weight: int = 1 # relative size traces: List[TraceSpec] = field(default_factory=list) + row: Optional[int] = None # canvas grid position; None = unassigned + col: Optional[int] = None @dataclass class LayoutConfig: panes: List[PaneSpec] = field(default_factory=list) - layout_mode: str = "stacked" # stacked | sidebyside | grid + layout_mode: str = "stacked" # stacked | sidebyside | grid | free grid_cols: int = 2 time_window_s: float = 30.0 link_x: bool = True @@ -169,10 +171,9 @@ class TraceRow(QFrame): # ══════════════════════════════════════════════════════════════════════════════ class PaneBlock(QFrame): - removed = pyqtSignal(object) - changed = pyqtSignal() - move_up = pyqtSignal(object) - move_dn = pyqtSignal(object) + removed = pyqtSignal(object) + changed = pyqtSignal() + title_changed = pyqtSignal(object, str) # (self, new_title) def __init__(self, spec: PaneSpec, registry: DeviceRegistry, processor: SignalProcessor): @@ -188,11 +189,11 @@ class PaneBlock(QFrame): hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) hl = QHBoxLayout(hdr); hl.setContentsMargins(8,0,6,0); hl.setSpacing(4) self._title = QLineEdit(self.spec.title); self._title.setObjectName("plotBlockTitle") - self._title.textChanged.connect(lambda t: setattr(self.spec,"title",t)) - hl.addWidget(self._title,1) - for txt, sig in [("▲",self.move_up),("▼",self.move_dn)]: - b = QToolButton(); b.setText(txt); b.setObjectName("plotMoveBtn") - b.setFixedSize(22,22); b.clicked.connect(lambda _,s=sig: s.emit(self)); hl.addWidget(b) + self._title.textChanged.connect(lambda t: ( + setattr(self.spec, "title", t), + self.title_changed.emit(self, t), + )) + hl.addWidget(self._title, 1) rm = QToolButton(); rm.setText("✕"); rm.setObjectName("plotRemoveBtn") rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self)); hl.addWidget(rm) outer.addWidget(hdr) @@ -214,7 +215,6 @@ class PaneBlock(QFrame): for dc in self.processor.get_derived(): self._x_cb.addItem(f"[virtual] {dc.channel_id}", userData=f"derived/{dc.channel_id}") - # Select current for i in range(self._x_cb.count()): if self._x_cb.itemData(i) == self.spec.x_source: self._x_cb.setCurrentIndex(i); break @@ -305,6 +305,200 @@ class PaneBlock(QFrame): # ══════════════════════════════════════════════════════════════════════════════ +# Layout canvas — drag-and-snap tiling pane arranger +# ══════════════════════════════════════════════════════════════════════════════ + +_CANVAS_CELL_W = 120 +_CANVAS_CELL_H = 76 +_CANVAS_GAP = 6 +_CANVAS_COLS = 4 + + +class PaneTile(QFrame): + def __init__(self, pane_index: int, title: str, parent: QWidget) -> None: + super().__init__(parent) + self.pane_index = pane_index + self.setObjectName("layoutTile") + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + lay = QVBoxLayout(self) + lay.setContentsMargins(4, 4, 4, 4) + self._lbl = QLabel(title) + self._lbl.setObjectName("layoutTileLabel") + self._lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._lbl.setWordWrap(True) + lay.addWidget(self._lbl) + + def set_title(self, title: str) -> None: + self._lbl.setText(title) + + def set_dragging(self, v: bool) -> None: + self.setObjectName("layoutTileDragging" if v else "layoutTile") + self.style().unpolish(self) + self.style().polish(self) + self._lbl.setObjectName("layoutTileLabel") + self._lbl.style().unpolish(self._lbl) + self._lbl.style().polish(self._lbl) + + +class LayoutCanvas(QFrame): + arrangement_changed = pyqtSignal() + + def __init__(self, panes: List[PaneSpec], parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.setObjectName("layoutCanvasWidget") + self.setMouseTracking(True) + self._panes: List[PaneSpec] = panes + self._tiles: List[PaneTile] = [] + self._grid: Dict[Tuple[int, int], int] = {} + self._drag_tile: Optional[PaneTile] = None + self._drag_offset: QPoint = QPoint() + self._ghost_cell: Optional[Tuple[int,int]] = None + self._assign_legacy_positions() + self._rebuild_tiles() + + # ── Public ──────────────────────────────────────────────────────────────── + + def sync_panes(self, panes: List[PaneSpec]) -> None: + self._panes = panes + self._drag_tile = None + self._ghost_cell = None + self._assign_legacy_positions() + self._rebuild_tiles() + + def update_tile_title(self, pane_index: int, title: str) -> None: + if 0 <= pane_index < len(self._tiles): + self._tiles[pane_index].set_title(title) + + # ── Position assignment ─────────────────────────────────────────────────── + + def _assign_legacy_positions(self) -> None: + occupied = {(p.row, p.col) for p in self._panes + if p.row is not None and p.col is not None} + for pane in self._panes: + if pane.row is None or pane.col is None: + row = 0 + while (row, 0) in occupied: + row += 1 + pane.row, pane.col = row, 0 + occupied.add((row, 0)) + + # ── Rebuild ─────────────────────────────────────────────────────────────── + + def _rebuild_tiles(self) -> None: + for t in self._tiles: + t.deleteLater() + self._tiles.clear() + self._grid.clear() + for i, pane in enumerate(self._panes): + tile = PaneTile(i, pane.title, self) + tile.setGeometry(self._cell_rect(pane.row, pane.col)) + tile.show() + self._tiles.append(tile) + self._grid[(pane.row, pane.col)] = i + self._resize_canvas() + + def _resize_canvas(self) -> None: + if not self._panes: + self.setFixedSize( + _CANVAS_COLS * (_CANVAS_CELL_W + _CANVAS_GAP) + _CANVAS_GAP, + _CANVAS_CELL_H + 2 * _CANVAS_GAP, + ) + return + max_row = max((p.row for p in self._panes if p.row is not None), default=0) + h = (max_row + 1) * (_CANVAS_CELL_H + _CANVAS_GAP) + _CANVAS_GAP + w = _CANVAS_COLS * (_CANVAS_CELL_W + _CANVAS_GAP) + _CANVAS_GAP + self.setFixedSize(w, h) + + def _cell_rect(self, row: int, col: int) -> QRect: + x = _CANVAS_GAP + col * (_CANVAS_CELL_W + _CANVAS_GAP) + y = _CANVAS_GAP + row * (_CANVAS_CELL_H + _CANVAS_GAP) + return QRect(x, y, _CANVAS_CELL_W, _CANVAS_CELL_H) + + def _tile_at(self, pos: QPoint) -> Optional[PaneTile]: + for tile in reversed(self._tiles): + if tile.geometry().contains(pos): + return tile + return None + + def _nearest_cell(self, pos: QPoint) -> Tuple[int, int]: + max_row = max((p.row for p in self._panes if p.row is not None), default=0) + max_rows = max(max_row + 2, len(self._panes)) + col = max(0, min(_CANVAS_COLS - 1, pos.x() // (_CANVAS_CELL_W + _CANVAS_GAP))) + row = max(0, min(max_rows - 1, pos.y() // (_CANVAS_CELL_H + _CANVAS_GAP))) + return (int(row), int(col)) + + # ── Drag ───────────────────────────────────────────────────────────────── + + def mousePressEvent(self, event) -> None: + if event.button() != Qt.MouseButton.LeftButton: + return + tile = self._tile_at(event.pos()) + if tile is None: + return + self._drag_tile = tile + self._drag_offset = event.pos() - tile.pos() + tile.set_dragging(True) + tile.raise_() + + def mouseMoveEvent(self, event) -> None: + if self._drag_tile is None: + return + self._drag_tile.move(event.pos() - self._drag_offset) + snap = self._nearest_cell(event.pos()) + if snap != self._ghost_cell: + self._ghost_cell = snap + self.update() + + def mouseReleaseEvent(self, event) -> None: + if self._drag_tile is None: + return + snap = self._nearest_cell(event.pos()) + self._do_drop(self._drag_tile.pane_index, snap) + self._drag_tile.set_dragging(False) + self._drag_tile = None + self._ghost_cell = None + self.update() + + def _do_drop(self, pane_index: int, target: Tuple[int, int]) -> None: + dragged = self._panes[pane_index] + old = (dragged.row, dragged.col) + + if old == target: + self._tiles[pane_index].setGeometry(self._cell_rect(*old)) + return + + occupant_idx = self._grid.get(target) + + if occupant_idx is not None: + occupant = self._panes[occupant_idx] + occupant.row, occupant.col = old + self._tiles[occupant_idx].setGeometry(self._cell_rect(*old)) + self._grid[old] = occupant_idx + else: + self._grid.pop(old, None) + + dragged.row, dragged.col = target + self._tiles[pane_index].setGeometry(self._cell_rect(*target)) + self._grid[target] = pane_index + + self._resize_canvas() + self.arrangement_changed.emit() + + # ── Ghost highlight ─────────────────────────────────────────────────────── + + def paintEvent(self, event) -> None: + super().paintEvent(event) + if self._ghost_cell is None: + return + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + r = self._cell_rect(*self._ghost_cell) + painter.setPen(QPen(QColor("#3b82f6"), 2)) + painter.setBrush(QColor(59, 130, 246, 40)) + painter.drawRoundedRect(r, 4, 4) + + +# ══════════════════════════════════════════════════════════════════════════════ # PlotWindow # ══════════════════════════════════════════════════════════════════════════════ @@ -321,7 +515,7 @@ class PlotWindow(QWidget): else build_default_layout(registry, processor) self.setWindowTitle("Plot Builder") self.setMinimumSize(720, 560) - self.resize(820, 700) + self.resize(820, 760) self._blocks: List[PaneBlock] = [] self._build() self._populate() @@ -337,31 +531,10 @@ class PlotWindow(QWidget): div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) div.setObjectName("devWindowDivider"); root.addWidget(div) - # Global settings + # Global settings bar glob = QWidget(); glob.setObjectName("cfgGlobalBar") gl = QHBoxLayout(glob); gl.setContentsMargins(12,7,12,7); gl.setSpacing(12) - # Layout mode - gl.addWidget(QLabel("Layout:")) - self._stacked_rb = QRadioButton("Stacked") - self._side_rb = QRadioButton("Side-by-Side") - self._grid_rb = QRadioButton("Grid") - mode_grp = QButtonGroup(self) - for rb in (self._stacked_rb, self._side_rb, self._grid_rb): - rb.setObjectName("cfgLiveChk"); mode_grp.addButton(rb) - mode_map = {"stacked": self._stacked_rb, - "sidebyside": self._side_rb, "grid": self._grid_rb} - mode_map.get(self.cfg.layout_mode, self._stacked_rb).setChecked(True) - for rb, key in [(self._stacked_rb,"stacked"),(self._side_rb,"sidebyside"),(self._grid_rb,"grid")]: - rb.toggled.connect(lambda v, k=key: self._set_mode(k) if v else None) - gl.addWidget(rb) - - gl.addWidget(QLabel("Cols:")) - self._cols = QSpinBox(); self._cols.setRange(1,6); self._cols.setValue(self.cfg.grid_cols) - self._cols.setFixedWidth(44); self._cols.setObjectName("traceWidthSpin") - self._cols.valueChanged.connect(lambda v: setattr(self.cfg,"grid_cols",v)) - gl.addWidget(self._cols) - gl.addWidget(QLabel("Window:")) self._win = QDoubleSpinBox(); self._win.setRange(1,3600) self._win.setSuffix(" s"); self._win.setValue(self.cfg.time_window_s) @@ -387,6 +560,18 @@ class PlotWindow(QWidget): add_btn.clicked.connect(self._add_pane); gl.addWidget(add_btn) root.addWidget(glob) + # Layout canvas section + canvas_bar = QWidget(); canvas_bar.setObjectName("layoutCanvasBar") + cb_lay = QVBoxLayout(canvas_bar) + cb_lay.setContentsMargins(10, 6, 10, 8); cb_lay.setSpacing(4) + clbl = QLabel("LAYOUT — drag tiles to arrange") + clbl.setObjectName("layoutCanvasLabel") + cb_lay.addWidget(clbl) + self._canvas = LayoutCanvas(self.cfg.panes, self) + self._canvas.arrangement_changed.connect(self._maybe_live) + cb_lay.addWidget(self._canvas) + root.addWidget(canvas_bar) + div2 = QFrame(); div2.setFrameShape(QFrame.Shape.HLine) div2.setObjectName("devWindowDivider"); root.addWidget(div2) @@ -412,45 +597,39 @@ class PlotWindow(QWidget): ap.clicked.connect(self._apply); bl.addWidget(ap) root.addWidget(btm) - def _set_mode(self, mode: str): - self.cfg.layout_mode = mode - self._maybe_live() - def _populate(self): - for b in self._blocks: self._blay.removeWidget(b); b.deleteLater() + for b in self._blocks: + self._blay.removeWidget(b); b.deleteLater() self._blocks.clear() - for spec in self.cfg.panes: self._insert(spec) + for spec in self.cfg.panes: + self._insert(spec) + self._canvas.sync_panes(self.cfg.panes) + self.cfg.layout_mode = "free" def _insert(self, spec: PaneSpec): blk = PaneBlock(spec, self.registry, self.processor) - blk.removed.connect(self._rm); blk.changed.connect(self._maybe_live) - blk.move_up.connect(self._mv_up); blk.move_dn.connect(self._mv_dn) + blk.removed.connect(self._rm) + blk.changed.connect(self._maybe_live) + blk.title_changed.connect(self._on_tile_title) self._blocks.append(blk) self._blay.insertWidget(self._blay.count()-1, blk) + def _on_tile_title(self, blk: PaneBlock, title: str) -> None: + if blk in self._blocks: + self._canvas.update_tile_title(self._blocks.index(blk), title) + def _add_pane(self): spec = PaneSpec(title=f"Plot {len(self.cfg.panes)+1}") - self.cfg.panes.append(spec); self._insert(spec); self._maybe_live() + self.cfg.panes.append(spec) + self._insert(spec) + self._canvas.sync_panes(self.cfg.panes) + self.cfg.layout_mode = "free" + self._maybe_live() def _rm(self, blk): if blk.spec in self.cfg.panes: self.cfg.panes.remove(blk.spec) self._blocks.remove(blk); self._blay.removeWidget(blk); blk.deleteLater() - self._maybe_live() - - def _mv_up(self, blk): - i = self._blocks.index(blk) - if i==0: return - self.cfg.panes.insert(i-1,self.cfg.panes.pop(i)) - self._blocks.insert(i-1,self._blocks.pop(i)) - self._blay.removeWidget(blk); self._blay.insertWidget(i-1,blk) - self._maybe_live() - - def _mv_dn(self, blk): - i = self._blocks.index(blk) - if i>=len(self._blocks)-1: return - self.cfg.panes.insert(i+1,self.cfg.panes.pop(i)) - self._blocks.insert(i+1,self._blocks.pop(i)) - self._blay.removeWidget(blk); self._blay.insertWidget(i+1,blk) + self._canvas.sync_panes(self.cfg.panes) self._maybe_live() def _maybe_live(self): @@ -461,7 +640,8 @@ class PlotWindow(QWidget): def _reset(self): self.cfg = build_default_layout(self.registry, self.processor) self._win.setValue(self.cfg.time_window_s) - self._populate(); self._maybe_live() + self._populate() + self._maybe_live() def _export(self): path, _ = QFileDialog.getSaveFileName(self,"Export","layout.json","JSON (*.json)") @@ -473,7 +653,8 @@ class PlotWindow(QWidget): try: self.cfg = LayoutConfig.from_json(open(path).read()) self._win.setValue(self.cfg.time_window_s) - self._populate(); self._maybe_live() + self._populate() + self._maybe_live() except Exception as e: QMessageBox.critical(self,"Import failed",str(e)) |
