From 7b926faab3dcd52e45fc1b4b24f955a4dcf66959 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Wed, 3 Jun 2026 15:05:36 -0600 Subject: Add drag-and-snap tiling layout canvas to Plot Builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/strip_chart.py | 13 +- ui/style_dark.qss | 40 ++++++ ui/style_light.qss | 8 ++ ui/windows/plot_window.py | 317 ++++++++++++++++++++++++++++++++++++---------- 4 files changed, 304 insertions(+), 74 deletions(-) (limited to 'ui') diff --git a/ui/strip_chart.py b/ui/strip_chart.py index 523102d..d855038 100644 --- a/ui/strip_chart.py +++ b/ui/strip_chart.py @@ -95,12 +95,14 @@ class StripChartWidget(QWidget): n = len(cfg.panes) # Determine row/col for each pane - if cfg.layout_mode == "sidebyside": + if cfg.layout_mode == "free": + positions = [(spec.row or 0, spec.col or 0) for spec in cfg.panes] + elif cfg.layout_mode == "sidebyside": positions = [(0, c) for c in range(n)] elif cfg.layout_mode == "grid": cols = max(1, cfg.grid_cols) positions = [(i // cols, i % cols) for i in range(n)] - else: # stacked + else: # stacked (legacy default) positions = [(r, 0) for r in range(n)] ref_plot = None @@ -112,7 +114,7 @@ class StripChartWidget(QWidget): plot.getAxis("left").setStyle(tickFont=mf) plot.getAxis("bottom").setStyle(tickFont=mf) - # Hide bottom axis labels for all but bottom row in stacked + # Hide bottom axis labels for non-bottom panes in stacked legacy mode if cfg.layout_mode == "stacked" and idx < n - 1: plot.getAxis("bottom").setStyle(showValues=False) plot.getAxis("bottom").setHeight(0) @@ -133,10 +135,9 @@ class StripChartWidget(QWidget): else: plot.enableAutoRange(axis="y") - # Set column stretch (relative weight) + # Apply weight to row and column stretch self._gw.ci.layout.setColumnStretchFactor(col, spec.weight) - if cfg.layout_mode == "stacked": - self._gw.ci.layout.setRowStretchFactor(row, spec.weight) + self._gw.ci.layout.setRowStretchFactor(row, spec.weight) for tr in spec.traces: if not tr.visible: continue diff --git a/ui/style_dark.qss b/ui/style_dark.qss index 2c24d1c..e562365 100644 --- a/ui/style_dark.qss +++ b/ui/style_dark.qss @@ -563,3 +563,43 @@ QFrame#controlWidgetWrapper { background: transparent; border: none; } + +/* ── Layout canvas ───────────────────────────────────────────────── */ +QWidget#layoutCanvasBar { + background-color: #0f1521; + border-bottom: 1px solid #1c2540; +} +QLabel#layoutCanvasLabel { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 2px; + color: #4338ca; + padding: 0 2px; +} +QFrame#layoutCanvasWidget { + background-color: #0b0e13; + border: 1px solid #2a3558; + border-radius: 4px; +} +QFrame#layoutTile { + background-color: #1c2540; + border: 1px solid #2a3558; + border-radius: 5px; +} +QFrame#layoutTile:hover { + border-color: #4338ca; + background-color: #212c52; +} +QFrame#layoutTileDragging { + background-color: #1e3a5f; + border: 2px solid #3b82f6; + border-radius: 5px; +} +QLabel#layoutTileLabel { + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + font-weight: 700; + color: #8b9dc3; + background: transparent; +} diff --git a/ui/style_light.qss b/ui/style_light.qss index 38b91f8..e0a1987 100644 --- a/ui/style_light.qss +++ b/ui/style_light.qss @@ -124,3 +124,11 @@ QMenu#profileMenu { background:#ffffff; border:1px solid #e2e8f0; border-radius: QMenu#profileMenu::item { padding:7px 20px; color:#334155; } QMenu#profileMenu::item:selected { background:#dbeafe; color:#1d4ed8; } QMenu#profileMenu::separator { height:1px; background:#e2e8f0; margin:3px 8px; } +/* Layout canvas */ +QWidget#layoutCanvasBar { background-color:#f8fafc; border-bottom:1px solid #e2e8f0; } +QLabel#layoutCanvasLabel { font-family:"IBM Plex Mono",monospace; font-size:10px; font-weight:700; letter-spacing:2px; color:#4338ca; } +QFrame#layoutCanvasWidget { background-color:#f1f5f9; border:1px solid #cbd5e1; border-radius:4px; } +QFrame#layoutTile { background-color:#e2e8f0; border:1px solid #94a3b8; border-radius:5px; } +QFrame#layoutTile:hover { border-color:#4338ca; background-color:#c7d2fe; } +QFrame#layoutTileDragging { background-color:#bfdbfe; border:2px solid #3b82f6; border-radius:5px; } +QLabel#layoutTileLabel { font-family:"IBM Plex Mono",monospace; font-size:11px; font-weight:700; color:#334155; background:transparent; } 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 @@ -304,6 +304,200 @@ class PaneBlock(QFrame): self.spec.y_auto = v; self._ymin.setEnabled(not v); self._ymax.setEnabled(not v) +# ══════════════════════════════════════════════════════════════════════════════ +# 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)) -- cgit v1.2.3