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 From 216a278102e3f63f155d76d62790a3ca60fcec02 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Wed, 3 Jun 2026 15:20:45 -0600 Subject: Replace grid-snap canvas with BSP-tree drag-to-split tiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging a tile to the edge (left/right/top/bottom) of another tile splits that tile's space and inserts the dragged tile on that side. Drop zones occupy the outer third of each tile edge; center has no zone. Ghost overlay shows the split target during drag. Data model: LayoutConfig.tree (BSP tree as nested dict). PaneSpec.row/col removed. strip_chart uses tree_to_grid() → rowspan/colspan for pyqtgraph. Backward compat: tree=None falls back to layout_mode stacking path. Example: 3 stacked → drag Tile 3 to left of Tile 1 → Tile 3|Tile 1 on top (side-by-side), Tile 2 spans full bottom. Co-Authored-By: Claude Sonnet 4.6 --- ui/strip_chart.py | 26 ++- ui/windows/plot_window.py | 460 +++++++++++++++++++++++++++++++--------------- 2 files changed, 326 insertions(+), 160 deletions(-) (limited to 'ui') diff --git a/ui/strip_chart.py b/ui/strip_chart.py index d855038..5a1d1e9 100644 --- a/ui/strip_chart.py +++ b/ui/strip_chart.py @@ -42,7 +42,8 @@ if _HAS_PG: from devices.device_registry import DeviceRegistry from core.acquisition import AcquisitionEngine from core.signal_processor import SignalProcessor -from ui.windows.plot_window import LayoutConfig, PaneSpec, TraceSpec, build_default_layout +from ui.windows.plot_window import (LayoutConfig, PaneSpec, TraceSpec, + build_default_layout, tree_to_grid, _tree_grid_size) _STYLES = { "solid": Qt.PenStyle.SolidLine, @@ -94,20 +95,26 @@ class StripChartWidget(QWidget): mf = QFont("IBM Plex Mono", 8) n = len(cfg.panes) - # Determine row/col for each pane - if cfg.layout_mode == "free": - positions = [(spec.row or 0, spec.col or 0) for spec in cfg.panes] + # Determine row/col/rowspan/colspan for each pane + if cfg.layout_mode == "free" and cfg.tree is not None: + gs = _tree_grid_size(cfg.tree) + grid_pos: dict = {} + tree_to_grid(cfg.tree, 0, 0, gs, gs, grid_pos) + # grid_pos[pane_idx] = (row, col, rowspan, colspan) elif cfg.layout_mode == "sidebyside": - positions = [(0, c) for c in range(n)] + grid_pos = {i: (0, i, 1, 1) for i in range(n)} elif cfg.layout_mode == "grid": cols = max(1, cfg.grid_cols) - positions = [(i // cols, i % cols) for i in range(n)] + grid_pos = {i: (i // cols, i % cols, 1, 1) for i in range(n)} else: # stacked (legacy default) - positions = [(r, 0) for r in range(n)] + grid_pos = {i: (i, 0, 1, 1) for i in range(n)} ref_plot = None - for idx, (spec, (row, col)) in enumerate(zip(cfg.panes, positions)): - plot = self._gw.addPlot(row=row, col=col) + for idx, spec in enumerate(cfg.panes): + if idx not in grid_pos: + continue + row, col, rowspan, colspan = grid_pos[idx] + plot = self._gw.addPlot(row=row, col=col, rowspan=rowspan, colspan=colspan) plot.setLabel("left", spec.y_label or spec.title) plot.setLabel("bottom", spec.x_label or "Elapsed (s)") plot.showGrid(x=spec.grid, y=spec.grid, alpha=0.12) @@ -135,7 +142,6 @@ class StripChartWidget(QWidget): else: plot.enableAutoRange(axis="y") - # Apply weight to row and column stretch self._gw.ci.layout.setColumnStretchFactor(col, spec.weight) self._gw.ci.layout.setRowStretchFactor(row, spec.weight) diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py index 91799f9..b933df1 100644 --- a/ui/windows/plot_window.py +++ b/ui/windows/plot_window.py @@ -7,7 +7,7 @@ Lets operators: • 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) + • Set X-axis: Time (elapsed s) OR any channel • Name axes, set Y auto/fixed range, grid, relative size weight • Per-trace: color, label, line style, width, visibility • Export/Import JSON layout @@ -60,17 +60,16 @@ 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 | free - grid_cols: int = 2 - time_window_s: float = 30.0 - link_x: bool = True - show_legend: bool = True + panes: List[PaneSpec] = field(default_factory=list) + tree: Optional[dict] = None # BSP tree; None = legacy layout_mode + layout_mode: str = "stacked" # stacked | sidebyside | grid | free + grid_cols: int = 2 + time_window_s: float = 30.0 + link_x: bool = True + show_legend: bool = True def to_json(self) -> str: return json.dumps(asdict(self), indent=2) @@ -78,11 +77,13 @@ class LayoutConfig: @staticmethod def from_json(s: str) -> "LayoutConfig": d = json.loads(s) + tree = d.pop("tree", None) panes = [] for p in d.pop("panes", []): traces = [TraceSpec(**t) for t in p.pop("traces", [])] + p.pop("row", None); p.pop("col", None) # compat: old branch fields panes.append(PaneSpec(**p, traces=traces)) - return LayoutConfig(**d, panes=panes) + return LayoutConfig(**d, panes=panes, tree=tree) def build_default_layout(registry: DeviceRegistry, @@ -109,6 +110,106 @@ def build_default_layout(registry: DeviceRegistry, return cfg +# ══════════════════════════════════════════════════════════════════════════════ +# BSP tree functions +# ══════════════════════════════════════════════════════════════════════════════ +# Tree node schemas (as plain dicts): +# {"kind": "leaf", "pane": } +# {"kind": "hsplit", "ratio": , "first": , "second": } +# {"kind": "vsplit", "ratio": , "first": , "second": } +# hsplit = side-by-side (first=left, second=right) +# vsplit = top-bottom (first=top, second=bottom) + +def _tree_default(n: int) -> Optional[dict]: + """Build default equal vsplit chain for n panes.""" + if n == 0: + return None + leaves = [{"kind": "leaf", "pane": i} for i in range(n)] + if n == 1: + return leaves[0] + node = leaves[-1] + for i in range(n - 2, -1, -1): + remaining = n - i + ratio = 1.0 / remaining + node = {"kind": "vsplit", "ratio": ratio, + "first": leaves[i], "second": node} + return node + + +def _tree_depth(node: dict) -> int: + if node["kind"] == "leaf": + return 0 + return 1 + max(_tree_depth(node["first"]), _tree_depth(node["second"])) + + +def _tree_remove(node: dict, pane_idx: int) -> Optional[dict]: + """Remove leaf with pane_idx; promote sibling when parent loses a child.""" + if node["kind"] == "leaf": + return None if node["pane"] == pane_idx else node + first = _tree_remove(node["first"], pane_idx) + second = _tree_remove(node["second"], pane_idx) + if first is None and second is None: + return None + if first is None: + return second + if second is None: + return first + return {**node, "first": first, "second": second} + + +def _tree_insert(node: dict, target_idx: int, new_idx: int, zone: str) -> dict: + """Wrap the leaf matching target_idx in a new split containing new_idx.""" + if node["kind"] == "leaf": + if node["pane"] != target_idx: + return node + new_leaf = {"kind": "leaf", "pane": new_idx} + if zone == "left": + return {"kind": "hsplit", "ratio": 0.5, "first": new_leaf, "second": node} + if zone == "right": + return {"kind": "hsplit", "ratio": 0.5, "first": node, "second": new_leaf} + if zone == "top": + return {"kind": "vsplit", "ratio": 0.5, "first": new_leaf, "second": node} + # bottom (default) + return {"kind": "vsplit", "ratio": 0.5, "first": node, "second": new_leaf} + return {**node, + "first": _tree_insert(node["first"], target_idx, new_idx, zone), + "second": _tree_insert(node["second"], target_idx, new_idx, zone)} + + +def _tree_reindex(node: dict, removed_idx: int) -> dict: + """Decrement all leaf pane values > removed_idx by 1.""" + if node["kind"] == "leaf": + pane = node["pane"] + return {**node, "pane": pane - 1} if pane > removed_idx else node + return {**node, + "first": _tree_reindex(node["first"], removed_idx), + "second": _tree_reindex(node["second"], removed_idx)} + + +def tree_to_grid(node: dict, row: int, col: int, rowspan: int, colspan: int, + result: Dict[int, Tuple[int, int, int, int]]) -> None: + """Recursively compute (row, col, rowspan, colspan) for each leaf.""" + if node["kind"] == "leaf": + result[node["pane"]] = (row, col, rowspan, colspan) + elif node["kind"] == "hsplit": + first_cs = max(1, round(colspan * node["ratio"])) + second_cs = max(1, colspan - first_cs) + first_cs = colspan - second_cs + tree_to_grid(node["first"], row, col, rowspan, first_cs, result) + tree_to_grid(node["second"], row, col + first_cs, rowspan, second_cs, result) + elif node["kind"] == "vsplit": + first_rs = max(1, round(rowspan * node["ratio"])) + second_rs = max(1, rowspan - first_rs) + first_rs = rowspan - second_rs + tree_to_grid(node["first"], row, col, first_rs, colspan, result) + tree_to_grid(node["second"], row + first_rs, col, second_rs, colspan, result) + + +def _tree_grid_size(node: dict) -> int: + """Grid dimension = 2^depth, capped at 8.""" + return 2 ** min(_tree_depth(node), 3) + + # ══════════════════════════════════════════════════════════════════════════════ # Color swatch # ══════════════════════════════════════════════════════════════════════════════ @@ -185,7 +286,6 @@ class PaneBlock(QFrame): def _build(self): outer = QVBoxLayout(self); outer.setContentsMargins(0,0,0,0); outer.setSpacing(0) - # Header 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") @@ -195,10 +295,10 @@ class PaneBlock(QFrame): )) 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) + rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self)) + hl.addWidget(rm) outer.addWidget(hdr) - # Settings row sett = QWidget(); sett.setObjectName("plotBlockSettings") sl = QHBoxLayout(sett); sl.setContentsMargins(8,6,8,6); sl.setSpacing(8) @@ -208,8 +308,7 @@ class PaneBlock(QFrame): self._x_cb.addItem("⏱ Time (elapsed s)", userData="time") for dev in self.registry.all_instances(): for ch in dev.info.channels: - if not ch.enabled: - continue + if not ch.enabled: continue self._x_cb.addItem(f"{dev.info.device_id}/{ch.channel_id} ({ch.name})", userData=f"{dev.info.device_id}/{ch.channel_id}") for dc in self.processor.get_derived(): @@ -256,7 +355,6 @@ class PaneBlock(QFrame): sl.addWidget(self._wt); sl.addStretch() outer.addWidget(sett) - # Traces tr_w = QWidget(); tr_w.setObjectName("plotBlockTraces") self._tlay = QVBoxLayout(tr_w) self._tlay.setContentsMargins(4,2,4,4); self._tlay.setSpacing(2) @@ -273,8 +371,7 @@ class PaneBlock(QFrame): cb = QComboBox(); cb.setObjectName("channelPickerCb") for dev in self.registry.all_instances(): for ch in dev.info.channels: - if not ch.enabled: - continue + if not ch.enabled: continue cb.addItem(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})", userData=(dev.info.device_id, ch.channel_id, ch.name, ch.color)) for dc in self.processor.get_derived(): @@ -305,13 +402,23 @@ class PaneBlock(QFrame): # ══════════════════════════════════════════════════════════════════════════════ -# Layout canvas — drag-and-snap tiling pane arranger +# Layout canvas — drag-to-split BSP tiling # ══════════════════════════════════════════════════════════════════════════════ -_CANVAS_CELL_W = 120 -_CANVAS_CELL_H = 76 -_CANVAS_GAP = 6 -_CANVAS_COLS = 4 +_LC_GAP = 5 # gap between tiles in pixels +_LC_MIN_TILE = 40 # minimum tile dimension in pixels +_LC_HEIGHT = 160 # fixed canvas height + + +def _zone_overlay_rect(r: QRect, zone: str) -> QRect: + if zone == "left": + return QRect(r.x(), r.y(), r.width() // 2, r.height()) + if zone == "right": + return QRect(r.x() + r.width() // 2, r.y(), r.width() - r.width() // 2, r.height()) + if zone == "top": + return QRect(r.x(), r.y(), r.width(), r.height() // 2) + # bottom + return QRect(r.x(), r.y() + r.height() // 2, r.width(), r.height() - r.height() // 2) class PaneTile(QFrame): @@ -332,170 +439,213 @@ class PaneTile(QFrame): 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) + name = "layoutTileDragging" if v else "layoutTile" + self.setObjectName(name) + self.style().unpolish(self); self.style().polish(self) + 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: + def __init__(self, panes: List[PaneSpec], tree: Optional[dict], + parent: Optional[QWidget] = None) -> None: super().__init__(parent) self.setObjectName("layoutCanvasWidget") self.setMouseTracking(True) + self.setFixedHeight(_LC_HEIGHT) + self._cfg_ref: Optional[LayoutConfig] = None # set by PlotWindow self._panes: List[PaneSpec] = panes + self._tree: Optional[dict] = None 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() + self._tile_rects: Dict[int, QRect] = {} + self._drag_tile: Optional[PaneTile] = None + self._drag_offset: QPoint = QPoint() + self._hover_zone: Optional[Tuple[int, str]] = None + self.sync_panes(panes, tree) # ── Public ──────────────────────────────────────────────────────────────── - def sync_panes(self, panes: List[PaneSpec]) -> None: + def sync_panes(self, panes: List[PaneSpec], tree: Optional[dict]) -> None: self._panes = panes self._drag_tile = None - self._ghost_cell = None - self._assign_legacy_positions() + self._hover_zone = None + n = len(panes) + if tree is None or not _tree_valid(tree, n): + tree = _tree_default(n) + self._tree = tree 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)) + # ── Geometry ────────────────────────────────────────────────────────────── - # ── Rebuild ─────────────────────────────────────────────────────────────── + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self._reposition_tiles() 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) + self._reposition_tiles() - 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 + def _reposition_tiles(self) -> None: + if not self._tree or not self._tiles: + self._tile_rects = {} + return + canvas = QRect(_LC_GAP, _LC_GAP, + self.width() - 2 * _LC_GAP, + self.height() - 2 * _LC_GAP) + self._tile_rects = {} + self._walk_rects(self._tree, canvas) + for tile in self._tiles: + r = self._tile_rects.get(tile.pane_index) + if r: + tile.setGeometry(r) + + def _walk_rects(self, node: dict, rect: QRect) -> None: + if node["kind"] == "leaf": + self._tile_rects[node["pane"]] = rect + elif node["kind"] == "hsplit": + left_w = max(_LC_MIN_TILE, int(rect.width() * node["ratio"])) + right_w = rect.width() - left_w - _LC_GAP + right_w = max(_LC_MIN_TILE, right_w) + left_w = rect.width() - right_w - _LC_GAP + self._walk_rects(node["first"], + QRect(rect.x(), rect.y(), left_w, rect.height())) + self._walk_rects(node["second"], + QRect(rect.x() + left_w + _LC_GAP, rect.y(), right_w, rect.height())) + elif node["kind"] == "vsplit": + top_h = max(_LC_MIN_TILE, int(rect.height() * node["ratio"])) + bot_h = rect.height() - top_h - _LC_GAP + bot_h = max(_LC_MIN_TILE, bot_h) + top_h = rect.height() - bot_h - _LC_GAP + self._walk_rects(node["first"], + QRect(rect.x(), rect.y(), rect.width(), top_h)) + self._walk_rects(node["second"], + QRect(rect.x(), rect.y() + top_h + _LC_GAP, rect.width(), bot_h)) + + # ── Drop zone detection ─────────────────────────────────────────────────── + + def _zone_at(self, pos: QPoint) -> Optional[Tuple[int, str]]: + dragging_idx = self._drag_tile.pane_index if self._drag_tile else -1 + for pane_idx, rect in self._tile_rects.items(): + if pane_idx == dragging_idx: + continue + if not rect.contains(pos): + continue + rel_x = pos.x() - rect.x() + rel_y = pos.y() - rect.y() + w, h = rect.width(), rect.height() + zone_w = max(1, w // 3) + zone_h = max(1, h // 3) + if rel_x < zone_w: + return (pane_idx, "left") + if rel_x > w - zone_w: + return (pane_idx, "right") + if rel_y < zone_h: + return (pane_idx, "top") + if rel_y > h - zone_h: + return (pane_idx, "bottom") + return None # center — no drop zone 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_() + for tile in reversed(self._tiles): + if tile.geometry().contains(event.pos()): + self._drag_tile = tile + self._drag_offset = event.pos() - tile.pos() + tile.set_dragging(True) + tile.raise_() + return 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 + zone = self._zone_at(event.pos()) + if zone != self._hover_zone: + self._hover_zone = zone 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) + dragged_idx = self._drag_tile.pane_index + zone_info = self._zone_at(event.pos()) self._drag_tile.set_dragging(False) self._drag_tile = None - self._ghost_cell = None - self.update() + self._hover_zone = None - 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 + if zone_info is not None: + target_idx, zone = zone_info + self._do_drop(dragged_idx, target_idx, zone) 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 + # No valid drop target — restore tile positions + self._reposition_tiles() + self.update() - self._resize_canvas() + def _do_drop(self, dragged_idx: int, target_idx: int, zone: str) -> None: + if dragged_idx == target_idx or self._tree is None: + self._reposition_tiles() + return + new_tree = _tree_remove(self._tree, dragged_idx) + if new_tree is None: + # Only one pane + self._reposition_tiles() + return + new_tree = _tree_insert(new_tree, target_idx, dragged_idx, zone) + self._tree = new_tree + if self._cfg_ref is not None: + self._cfg_ref.tree = new_tree + self._reposition_tiles() self.arrangement_changed.emit() - # ── Ghost highlight ─────────────────────────────────────────────────────── + # ── Paint ───────────────────────────────────────────────────────────────── def paintEvent(self, event) -> None: super().paintEvent(event) - if self._ghost_cell is None: + if self._hover_zone is None or self._drag_tile is None: return + pane_idx, zone = self._hover_zone + r = self._tile_rects.get(pane_idx) + if r is None: + return + overlay = _zone_overlay_rect(r, zone) 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) + painter.setBrush(QColor(59, 130, 246, 70)) + painter.drawRoundedRect(overlay, 4, 4) + + +def _tree_valid(tree: dict, n_panes: int) -> bool: + """Check tree references only valid pane indices.""" + try: + leaves = [] + _collect_leaves(tree, leaves) + return sorted(leaves) == list(range(n_panes)) + except Exception: + return False + + +def _collect_leaves(node: dict, out: list) -> None: + if node["kind"] == "leaf": + out.append(node["pane"]) + else: + _collect_leaves(node["first"], out) + _collect_leaves(node["second"], out) # ══════════════════════════════════════════════════════════════════════════════ @@ -523,7 +673,6 @@ class PlotWindow(QWidget): def _build(self): root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) - # Header hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44) hl = QHBoxLayout(hdr); hl.setContentsMargins(14,0,10,0) hl.addWidget(QLabel("PLOT BUILDER").also(lambda w: w.setObjectName("devWindowTitle")),1) @@ -534,27 +683,22 @@ class PlotWindow(QWidget): # Global settings bar glob = QWidget(); glob.setObjectName("cfgGlobalBar") gl = QHBoxLayout(glob); gl.setContentsMargins(12,7,12,7); gl.setSpacing(12) - gl.addWidget(QLabel("Window:")) self._win = QDoubleSpinBox(); self._win.setRange(1,3600) self._win.setSuffix(" s"); self._win.setValue(self.cfg.time_window_s) self._win.setObjectName("cfgGlobalSpin") self._win.valueChanged.connect(lambda v: setattr(self.cfg,"time_window_s",v)) gl.addWidget(self._win) - self._linkx = QCheckBox("Link X"); self._linkx.setChecked(self.cfg.link_x) self._linkx.setObjectName("cfgLiveChk") self._linkx.toggled.connect(lambda v: setattr(self.cfg,"link_x",v)) gl.addWidget(self._linkx) - self._legend = QCheckBox("Legend"); self._legend.setChecked(self.cfg.show_legend) self._legend.setObjectName("cfgLiveChk") self._legend.toggled.connect(lambda v: setattr(self.cfg,"show_legend",v)) gl.addWidget(self._legend) - self._live = QCheckBox("Live preview"); self._live.setObjectName("cfgLiveChk") gl.addWidget(self._live) - gl.addStretch() add_btn = QPushButton("+ Add Pane"); add_btn.setObjectName("addDeviceButton") add_btn.clicked.connect(self._add_pane); gl.addWidget(add_btn) @@ -564,10 +708,11 @@ class PlotWindow(QWidget): 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 = QLabel("LAYOUT — drag tiles to edges to split, center to move") clbl.setObjectName("layoutCanvasLabel") cb_lay.addWidget(clbl) - self._canvas = LayoutCanvas(self.cfg.panes, self) + self._canvas = LayoutCanvas(self.cfg.panes, self.cfg.tree, self) + self._canvas._cfg_ref = self.cfg self._canvas.arrangement_changed.connect(self._maybe_live) cb_lay.addWidget(self._canvas) root.addWidget(canvas_bar) @@ -575,7 +720,6 @@ class PlotWindow(QWidget): div2 = QFrame(); div2.setFrameShape(QFrame.Shape.HLine) div2.setObjectName("devWindowDivider"); root.addWidget(div2) - # Scrollable pane blocks scroll = QScrollArea(); scroll.setWidgetResizable(True) scroll.setObjectName("deviceScroll") self._cont = QWidget() @@ -583,15 +727,15 @@ class PlotWindow(QWidget): self._blay.setContentsMargins(10,10,10,10); self._blay.setSpacing(12) self._blay.addStretch() scroll.setWidget(self._cont) - root.addWidget(scroll,1) + root.addWidget(scroll, 1) - # Bottom btm = QWidget(); btm.setObjectName("cfgBottomBar") bl = QHBoxLayout(btm); bl.setContentsMargins(12,8,12,8) - for lbl, fn in [("⟳ Rebuild from Channels",self._reset), - ("↓ Export JSON",self._export), - ("↑ Import JSON",self._import_json)]: - b = QPushButton(lbl); b.setObjectName("configButton"); b.clicked.connect(fn); bl.addWidget(b) + for lbl, fn in [("⟳ Rebuild from Channels", self._reset), + ("↓ Export JSON", self._export), + ("↑ Import JSON", self._import_json)]: + b = QPushButton(lbl); b.setObjectName("configButton") + b.clicked.connect(fn); bl.addWidget(b) bl.addStretch() ap = QPushButton("✓ Apply"); ap.setObjectName("applyButton") ap.clicked.connect(self._apply); bl.addWidget(ap) @@ -603,8 +747,9 @@ class PlotWindow(QWidget): self._blocks.clear() for spec in self.cfg.panes: self._insert(spec) - self._canvas.sync_panes(self.cfg.panes) self.cfg.layout_mode = "free" + self._canvas._cfg_ref = self.cfg + self._canvas.sync_panes(self.cfg.panes, self.cfg.tree) def _insert(self, spec: PaneSpec): blk = PaneBlock(spec, self.registry, self.processor) @@ -621,21 +766,36 @@ class PlotWindow(QWidget): def _add_pane(self): spec = PaneSpec(title=f"Plot {len(self.cfg.panes)+1}") self.cfg.panes.append(spec) + new_idx = len(self.cfg.panes) - 1 + if self.cfg.tree is None: + self.cfg.tree = {"kind": "leaf", "pane": 0} + else: + self.cfg.tree = {"kind": "vsplit", "ratio": 0.5, + "first": self.cfg.tree, + "second": {"kind": "leaf", "pane": new_idx}} self._insert(spec) - self._canvas.sync_panes(self.cfg.panes) self.cfg.layout_mode = "free" + self._canvas.sync_panes(self.cfg.panes, self.cfg.tree) 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._canvas.sync_panes(self.cfg.panes) + def _rm(self, blk: PaneBlock): + removed_idx = self._blocks.index(blk) + self.cfg.panes.remove(blk.spec) + self._blocks.remove(blk) + self._blay.removeWidget(blk); blk.deleteLater() + if self.cfg.tree: + self.cfg.tree = _tree_remove(self.cfg.tree, removed_idx) + if self.cfg.tree: + self.cfg.tree = _tree_reindex(self.cfg.tree, removed_idx) + self._canvas.sync_panes(self.cfg.panes, self.cfg.tree) self._maybe_live() def _maybe_live(self): - if self._live.isChecked(): self.layout_applied.emit(deepcopy(self.cfg)) + if self._live.isChecked(): + self.layout_applied.emit(deepcopy(self.cfg)) - def _apply(self): self.layout_applied.emit(deepcopy(self.cfg)) + def _apply(self): + self.layout_applied.emit(deepcopy(self.cfg)) def _reset(self): self.cfg = build_default_layout(self.registry, self.processor) @@ -644,11 +804,11 @@ class PlotWindow(QWidget): self._maybe_live() def _export(self): - path, _ = QFileDialog.getSaveFileName(self,"Export","layout.json","JSON (*.json)") + path, _ = QFileDialog.getSaveFileName(self, "Export", "layout.json", "JSON (*.json)") if path: open(path,"w").write(self.cfg.to_json()) def _import_json(self): - path, _ = QFileDialog.getOpenFileName(self,"Import","","JSON (*.json)") + path, _ = QFileDialog.getOpenFileName(self, "Import", "", "JSON (*.json)") if path: try: self.cfg = LayoutConfig.from_json(open(path).read()) @@ -656,7 +816,7 @@ class PlotWindow(QWidget): self._populate() self._maybe_live() except Exception as e: - QMessageBox.critical(self,"Import failed",str(e)) + QMessageBox.critical(self, "Import failed", str(e)) def refresh_channels(self): self._populate() -- cgit v1.2.3 From 903df67b153ca4af877ba0b534d4802d74cf34df Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Wed, 3 Jun 2026 16:17:58 -0600 Subject: Equalize tile sizes after every BSP tree operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After each drag-drop, add, or remove, walk the tree and reset each split's ratio to left_leaves/(left_leaves+right_leaves). This distributes space equally per pane regardless of the prior tree structure. - _tree_equalize_ratios: recomputes all ratios bottom-up by leaf count - _tree_leaf_count: counts leaves in a subtree - _tree_grid_size: uses LCM of split denominators (capped at 12) instead of 2^depth, giving exact integer rowspans for equal distributions - _tree_default: balanced binary tree + equalize (not uneven chain) - Applied in _do_drop, _add_pane, _rm Result: 3 side-by-side panes → equal widths; any mixed layout → equal area. Co-Authored-By: Claude Sonnet 4.6 --- ui/windows/plot_window.py | 76 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 59 insertions(+), 17 deletions(-) (limited to 'ui') diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py index b933df1..80a1624 100644 --- a/ui/windows/plot_window.py +++ b/ui/windows/plot_window.py @@ -19,6 +19,7 @@ from __future__ import annotations import json from copy import deepcopy from dataclasses import dataclass, field, asdict +from math import gcd from typing import Dict, List, Optional, Tuple from PyQt6.QtWidgets import ( @@ -120,20 +121,22 @@ def build_default_layout(registry: DeviceRegistry, # hsplit = side-by-side (first=left, second=right) # vsplit = top-bottom (first=top, second=bottom) +def _tree_default_range(indices: List[int]) -> dict: + if len(indices) == 1: + return {"kind": "leaf", "pane": indices[0]} + mid = len(indices) // 2 + return {"kind": "vsplit", "ratio": 0.5, + "first": _tree_default_range(indices[:mid]), + "second": _tree_default_range(indices[mid:])} + + def _tree_default(n: int) -> Optional[dict]: - """Build default equal vsplit chain for n panes.""" + """Build balanced binary vsplit tree with equal ratios for n panes.""" if n == 0: return None - leaves = [{"kind": "leaf", "pane": i} for i in range(n)] if n == 1: - return leaves[0] - node = leaves[-1] - for i in range(n - 2, -1, -1): - remaining = n - i - ratio = 1.0 / remaining - node = {"kind": "vsplit", "ratio": ratio, - "first": leaves[i], "second": node} - return node + return {"kind": "leaf", "pane": 0} + return _tree_equalize_ratios(_tree_default_range(list(range(n)))) def _tree_depth(node: dict) -> int: @@ -186,6 +189,32 @@ def _tree_reindex(node: dict, removed_idx: int) -> dict: "second": _tree_reindex(node["second"], removed_idx)} +def _tree_leaf_count(node: dict) -> int: + if node["kind"] == "leaf": + return 1 + return _tree_leaf_count(node["first"]) + _tree_leaf_count(node["second"]) + + +def _tree_equalize_ratios(node: dict) -> dict: + """Reset each split's ratio so both subtrees get equal space per leaf.""" + if node["kind"] == "leaf": + return node + first = _tree_equalize_ratios(node["first"]) + second = _tree_equalize_ratios(node["second"]) + n1 = _tree_leaf_count(first) + n2 = _tree_leaf_count(second) + return {**node, "ratio": n1 / (n1 + n2), "first": first, "second": second} + + +def _collect_denoms(node: dict, out: List[int]) -> None: + if node["kind"] != "leaf": + n1 = _tree_leaf_count(node["first"]) + n2 = _tree_leaf_count(node["second"]) + out.append(n1 + n2) + _collect_denoms(node["first"], out) + _collect_denoms(node["second"], out) + + def tree_to_grid(node: dict, row: int, col: int, rowspan: int, colspan: int, result: Dict[int, Tuple[int, int, int, int]]) -> None: """Recursively compute (row, col, rowspan, colspan) for each leaf.""" @@ -206,8 +235,17 @@ def tree_to_grid(node: dict, row: int, col: int, rowspan: int, colspan: int, def _tree_grid_size(node: dict) -> int: - """Grid dimension = 2^depth, capped at 8.""" - return 2 ** min(_tree_depth(node), 3) + """Minimal grid = LCM of all split denominators (leaf counts), capped at 12.""" + denoms: List[int] = [] + _collect_denoms(node, denoms) + if not denoms: + return 1 + result = 1 + for d in denoms: + result = result * d // gcd(result, d) + if result >= 12: + return 12 + return result # ══════════════════════════════════════════════════════════════════════════════ @@ -605,7 +643,7 @@ class LayoutCanvas(QFrame): # Only one pane self._reposition_tiles() return - new_tree = _tree_insert(new_tree, target_idx, dragged_idx, zone) + new_tree = _tree_equalize_ratios(_tree_insert(new_tree, target_idx, dragged_idx, zone)) self._tree = new_tree if self._cfg_ref is not None: self._cfg_ref.tree = new_tree @@ -770,9 +808,11 @@ class PlotWindow(QWidget): if self.cfg.tree is None: self.cfg.tree = {"kind": "leaf", "pane": 0} else: - self.cfg.tree = {"kind": "vsplit", "ratio": 0.5, - "first": self.cfg.tree, - "second": {"kind": "leaf", "pane": new_idx}} + self.cfg.tree = _tree_equalize_ratios({ + "kind": "vsplit", "ratio": 0.5, + "first": self.cfg.tree, + "second": {"kind": "leaf", "pane": new_idx}, + }) self._insert(spec) self.cfg.layout_mode = "free" self._canvas.sync_panes(self.cfg.panes, self.cfg.tree) @@ -786,7 +826,9 @@ class PlotWindow(QWidget): if self.cfg.tree: self.cfg.tree = _tree_remove(self.cfg.tree, removed_idx) if self.cfg.tree: - self.cfg.tree = _tree_reindex(self.cfg.tree, removed_idx) + self.cfg.tree = _tree_equalize_ratios( + _tree_reindex(self.cfg.tree, removed_idx) + ) self._canvas.sync_panes(self.cfg.panes, self.cfg.tree) self._maybe_live() -- cgit v1.2.3 From f007593abc9a666df85120e08c7f1d327d9215da Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Wed, 3 Jun 2026 16:23:35 -0600 Subject: Add center-zone move (swap); restore center drop zone - Edge zones (left/right/top/bottom): split target tile equally - Center zone: swap dragged and target tile positions in BSP tree (tree structure preserved, only leaf pane values exchanged) - Center overlay: green highlight (vs blue for split zones) - _tree_swap: immutable swap of two leaf pane values Co-Authored-By: Claude Sonnet 4.6 --- ui/windows/plot_window.py | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) (limited to 'ui') diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py index 80a1624..615b7ca 100644 --- a/ui/windows/plot_window.py +++ b/ui/windows/plot_window.py @@ -189,6 +189,17 @@ def _tree_reindex(node: dict, removed_idx: int) -> dict: "second": _tree_reindex(node["second"], removed_idx)} +def _tree_swap(node: dict, idx_a: int, idx_b: int) -> dict: + """Swap two leaf pane values in-place (preserves tree structure).""" + if node["kind"] == "leaf": + if node["pane"] == idx_a: return {**node, "pane": idx_b} + if node["pane"] == idx_b: return {**node, "pane": idx_a} + return node + return {**node, + "first": _tree_swap(node["first"], idx_a, idx_b), + "second": _tree_swap(node["second"], idx_a, idx_b)} + + def _tree_leaf_count(node: dict) -> int: if node["kind"] == "leaf": return 1 @@ -455,8 +466,10 @@ def _zone_overlay_rect(r: QRect, zone: str) -> QRect: return QRect(r.x() + r.width() // 2, r.y(), r.width() - r.width() // 2, r.height()) if zone == "top": return QRect(r.x(), r.y(), r.width(), r.height() // 2) - # bottom - return QRect(r.x(), r.y() + r.height() // 2, r.width(), r.height() - r.height() // 2) + if zone == "bottom": + return QRect(r.x(), r.y() + r.height() // 2, r.width(), r.height() - r.height() // 2) + # center (move) + return r class PaneTile(QFrame): @@ -592,7 +605,7 @@ class LayoutCanvas(QFrame): return (pane_idx, "top") if rel_y > h - zone_h: return (pane_idx, "bottom") - return None # center — no drop zone + return (pane_idx, "center") # center — move/swap return None # ── Drag ───────────────────────────────────────────────────────────────── @@ -638,12 +651,14 @@ class LayoutCanvas(QFrame): if dragged_idx == target_idx or self._tree is None: self._reposition_tiles() return - new_tree = _tree_remove(self._tree, dragged_idx) - if new_tree is None: - # Only one pane - self._reposition_tiles() - return - new_tree = _tree_equalize_ratios(_tree_insert(new_tree, target_idx, dragged_idx, zone)) + if zone == "center": + new_tree = _tree_swap(self._tree, dragged_idx, target_idx) + else: + new_tree = _tree_remove(self._tree, dragged_idx) + if new_tree is None: + self._reposition_tiles() + return + new_tree = _tree_equalize_ratios(_tree_insert(new_tree, target_idx, dragged_idx, zone)) self._tree = new_tree if self._cfg_ref is not None: self._cfg_ref.tree = new_tree @@ -663,8 +678,12 @@ class LayoutCanvas(QFrame): overlay = _zone_overlay_rect(r, zone) painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) - painter.setPen(QPen(QColor("#3b82f6"), 2)) - painter.setBrush(QColor(59, 130, 246, 70)) + if zone == "center": + painter.setPen(QPen(QColor("#22c55e"), 2)) + painter.setBrush(QColor(34, 197, 94, 60)) + else: + painter.setPen(QPen(QColor("#3b82f6"), 2)) + painter.setBrush(QColor(59, 130, 246, 70)) painter.drawRoundedRect(overlay, 4, 4) -- cgit v1.2.3 From f60de21eeaa50f847108a9c103934cc8da7352fb Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Wed, 3 Jun 2026 16:35:15 -0600 Subject: Three-zone drop system: squeeze, split, center MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zone layout (outer to inner per edge): 0–1/6 squeeze (amber): insert dropped tile between two existing tiles 1/6–1/3 split (blue): bisect the target tile 50/50 1/3–2/3 center (green): swap/move Squeeze behaviour: find the tile adjacent to the target in the drop direction; insert dragged tile to the opposite side of that neighbor so it lands visually between them. Falls back to split when no neighbor exists (edge tile). - _zone_at: returns "squeeze_*"/"split_*"/"center" with dominant-axis selection (nearest edge wins) - _adjacent_tile: finds geometrically adjacent tile by pixel proximity - _do_drop: routes squeeze→insert-at-neighbor, split→bisect, center→swap - paintEvent: amber/blue/green per zone type Co-Authored-By: Claude Sonnet 4.6 --- ui/windows/plot_window.py | 151 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 128 insertions(+), 23 deletions(-) (limited to 'ui') diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py index 615b7ca..15d509b 100644 --- a/ui/windows/plot_window.py +++ b/ui/windows/plot_window.py @@ -460,14 +460,25 @@ _LC_HEIGHT = 160 # fixed canvas height def _zone_overlay_rect(r: QRect, zone: str) -> QRect: - if zone == "left": + # split_* → highlight half of tile + if zone == "split_left": return QRect(r.x(), r.y(), r.width() // 2, r.height()) - if zone == "right": + if zone == "split_right": return QRect(r.x() + r.width() // 2, r.y(), r.width() - r.width() // 2, r.height()) - if zone == "top": + if zone == "split_top": return QRect(r.x(), r.y(), r.width(), r.height() // 2) - if zone == "bottom": + if zone == "split_bottom": return QRect(r.x(), r.y() + r.height() // 2, r.width(), r.height() - r.height() // 2) + # squeeze_* → thin bar at the edge (insert-between indicator) + BAR = 10 + if zone == "squeeze_left": + return QRect(r.x(), r.y(), BAR, r.height()) + if zone == "squeeze_right": + return QRect(r.right() - BAR, r.y(), BAR, r.height()) + if zone == "squeeze_top": + return QRect(r.x(), r.y(), r.width(), BAR) + if zone == "squeeze_bottom": + return QRect(r.x(), r.bottom() - BAR, r.width(), BAR) # center (move) return r @@ -586,28 +597,81 @@ class LayoutCanvas(QFrame): # ── Drop zone detection ─────────────────────────────────────────────────── def _zone_at(self, pos: QPoint) -> Optional[Tuple[int, str]]: + """ + Returns (pane_idx, zone) where zone is one of: + squeeze_left/right/top/bottom — outer 1/6, insert between tiles + split_left/right/top/bottom — 1/6 to 1/3, bisect tile + center — inner 1/3, swap/move + """ dragging_idx = self._drag_tile.pane_index if self._drag_tile else -1 for pane_idx, rect in self._tile_rects.items(): if pane_idx == dragging_idx: continue if not rect.contains(pos): continue - rel_x = pos.x() - rect.x() - rel_y = pos.y() - rect.y() + rx = pos.x() - rect.x() + ry = pos.y() - rect.y() w, h = rect.width(), rect.height() - zone_w = max(1, w // 3) - zone_h = max(1, h // 3) - if rel_x < zone_w: - return (pane_idx, "left") - if rel_x > w - zone_w: - return (pane_idx, "right") - if rel_y < zone_h: - return (pane_idx, "top") - if rel_y > h - zone_h: - return (pane_idx, "bottom") - return (pane_idx, "center") # center — move/swap + sq_w = max(1, w // 6) + sp_w = max(1, w // 3) + sq_h = max(1, h // 6) + sp_h = max(1, h // 3) + + # X-axis zone + if rx < sq_w: x_zone = "squeeze_left" + elif rx < sp_w: x_zone = "split_left" + elif rx > w - sq_w: x_zone = "squeeze_right" + elif rx > w - sp_w: x_zone = "split_right" + else: x_zone = "center" + + # Y-axis zone + if ry < sq_h: y_zone = "squeeze_top" + elif ry < sp_h: y_zone = "split_top" + elif ry > h - sq_h: y_zone = "squeeze_bottom" + elif ry > h - sp_h: y_zone = "split_bottom" + else: y_zone = "center" + + # Pick dominant axis (nearest edge wins; center loses to any edge) + if x_zone == "center" and y_zone == "center": + return (pane_idx, "center") + if x_zone == "center": + return (pane_idx, y_zone) + if y_zone == "center": + return (pane_idx, x_zone) + # Both non-center: whichever is closer to the edge + dx = min(rx, w - rx) + dy = min(ry, h - ry) + return (pane_idx, x_zone if dx <= dy else y_zone) return None + def _adjacent_tile(self, pane_idx: int, direction: str) -> Optional[int]: + """Find the tile immediately adjacent to pane_idx in the given direction.""" + r = self._tile_rects.get(pane_idx) + if r is None: + return None + dragging_idx = self._drag_tile.pane_index if self._drag_tile else -1 + tol = _LC_GAP + 3 + best: Optional[int] = None + best_dist = float("inf") + for other_idx, other_r in self._tile_rects.items(): + if other_idx in (pane_idx, dragging_idx): + continue + if direction == "left": + dist = r.left() - other_r.right() + overlap = min(r.bottom(), other_r.bottom()) - max(r.top(), other_r.top()) + elif direction == "right": + dist = other_r.left() - r.right() + overlap = min(r.bottom(), other_r.bottom()) - max(r.top(), other_r.top()) + elif direction == "top": + dist = r.top() - other_r.bottom() + overlap = min(r.right(), other_r.right()) - max(r.left(), other_r.left()) + else: # bottom + dist = other_r.top() - r.bottom() + overlap = min(r.right(), other_r.right()) - max(r.left(), other_r.left()) + if 0 <= dist <= tol and overlap > 4 and dist < best_dist: + best, best_dist = other_idx, dist + return best + # ── Drag ───────────────────────────────────────────────────────────────── def mousePressEvent(self, event) -> None: @@ -651,14 +715,52 @@ class LayoutCanvas(QFrame): if dragged_idx == target_idx or self._tree is None: self._reposition_tiles() return + if zone == "center": new_tree = _tree_swap(self._tree, dragged_idx, target_idx) - else: - new_tree = _tree_remove(self._tree, dragged_idx) - if new_tree is None: + self._tree = new_tree + if self._cfg_ref is not None: + self._cfg_ref.tree = new_tree + self._reposition_tiles() + self.arrangement_changed.emit() + return + + # Determine insert direction from zone name + # split_* → bisect target tile + # squeeze_* → insert next to adjacent tile (fall back to split if no neighbor) + _OPPOSITE = {"left": "right", "right": "left", "top": "bottom", "bottom": "top"} + _SPLIT_DIR = {"split_left": "left", "split_right": "right", + "split_top": "top", "split_bottom": "bottom", + "squeeze_left": "left","squeeze_right": "right", + "squeeze_top": "top", "squeeze_bottom": "bottom"} + + direction = _SPLIT_DIR.get(zone, "left") + + if zone.startswith("squeeze_"): + neighbor = self._adjacent_tile(target_idx, direction) + if neighbor is not None: + # Insert dragged tile to the opposite side of the neighbor + # so it lands between neighbor and target + new_tree = _tree_remove(self._tree, dragged_idx) + if new_tree is None: + self._reposition_tiles(); return + new_tree = _tree_equalize_ratios( + _tree_insert(new_tree, neighbor, dragged_idx, _OPPOSITE[direction]) + ) + self._tree = new_tree + if self._cfg_ref is not None: + self._cfg_ref.tree = new_tree self._reposition_tiles() + self.arrangement_changed.emit() return - new_tree = _tree_equalize_ratios(_tree_insert(new_tree, target_idx, dragged_idx, zone)) + # No neighbor — fall through to split behaviour + + # split (or squeeze with no neighbor) + new_tree = _tree_remove(self._tree, dragged_idx) + if new_tree is None: + self._reposition_tiles() + return + new_tree = _tree_equalize_ratios(_tree_insert(new_tree, target_idx, dragged_idx, direction)) self._tree = new_tree if self._cfg_ref is not None: self._cfg_ref.tree = new_tree @@ -681,7 +783,10 @@ class LayoutCanvas(QFrame): if zone == "center": painter.setPen(QPen(QColor("#22c55e"), 2)) painter.setBrush(QColor(34, 197, 94, 60)) - else: + elif zone.startswith("squeeze_"): + painter.setPen(QPen(QColor("#f59e0b"), 2)) + painter.setBrush(QColor(245, 158, 11, 80)) + else: # split_* painter.setPen(QPen(QColor("#3b82f6"), 2)) painter.setBrush(QColor(59, 130, 246, 70)) painter.drawRoundedRect(overlay, 4, 4) @@ -765,7 +870,7 @@ class PlotWindow(QWidget): 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 edges to split, center to move") + clbl = QLabel("LAYOUT — edge: split (blue) | edge gap: insert between (amber) | center: move (green)") clbl.setObjectName("layoutCanvasLabel") cb_lay.addWidget(clbl) self._canvas = LayoutCanvas(self.cfg.panes, self.cfg.tree, self) -- cgit v1.2.3 From 7651f515728533506391e6cc719fe8ff32c339e7 Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Wed, 3 Jun 2026 16:40:11 -0600 Subject: Fix horizontal zone detection for wide/short tiles _zone_at used absolute pixel distance to pick dominant axis, which always favoured the short (vertical) axis for landscape tiles. Replaced with normalized fraction (dist/dimension), so left/right zones are accessible on wide tiles. Co-Authored-By: Claude Sonnet 4.6 --- ui/windows/plot_window.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) (limited to 'ui') diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py index 15d509b..f929261 100644 --- a/ui/windows/plot_window.py +++ b/ui/windows/plot_window.py @@ -631,17 +631,18 @@ class LayoutCanvas(QFrame): elif ry > h - sp_h: y_zone = "split_bottom" else: y_zone = "center" - # Pick dominant axis (nearest edge wins; center loses to any edge) + # Pick dominant axis: normalize by dimension so wide/short tiles + # don't always favour the short axis if x_zone == "center" and y_zone == "center": return (pane_idx, "center") if x_zone == "center": return (pane_idx, y_zone) if y_zone == "center": return (pane_idx, x_zone) - # Both non-center: whichever is closer to the edge - dx = min(rx, w - rx) - dy = min(ry, h - ry) - return (pane_idx, x_zone if dx <= dy else y_zone) + # Both non-center: fractional distance from nearest edge decides + x_frac = min(rx, w - rx) / max(w, 1) + y_frac = min(ry, h - ry) / max(h, 1) + return (pane_idx, x_zone if x_frac <= y_frac else y_zone) return None def _adjacent_tile(self, pane_idx: int, direction: str) -> Optional[int]: -- cgit v1.2.3