summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-06-03 16:43:32 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-06-03 16:43:32 -0600
commit768c4c23b420e2f74e50c0bed78e3e26637e8339 (patch)
tree892c6497bd24731e4a58692dd2003f443a202b56 /ui
parent93e7d0ecdc090fd4f0231b13933fc0fa095a1f22 (diff)
parent7651f515728533506391e6cc719fe8ff32c339e7 (diff)
Merge plot-builder-layout-feature: dynamic BSP tiling layout canvas
Replaces layout radio buttons (Stacked/Side-by-Side/Grid) with a drag-and-drop tiling canvas. Three drop zones per tile edge: - squeeze (outer 1/6, amber): insert between two tiles - split (1/6–1/3, blue): bisect tile 50/50 - center (green): swap tile positions BSP tree stores layout; tree_to_grid() converts to rowspan/colspan for pyqtgraph. _tree_equalize_ratios ensures all tiles share space equally after any operation. Backward-compatible with existing .labdaq profiles. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'ui')
-rw-r--r--ui/strip_chart.py33
-rw-r--r--ui/style_dark.qss40
-rw-r--r--ui/style_light.qss8
-rw-r--r--ui/windows/plot_window.py714
4 files changed, 679 insertions, 116 deletions
diff --git a/ui/strip_chart.py b/ui/strip_chart.py
index 523102d..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,25 +95,33 @@ class StripChartWidget(QWidget):
mf = QFont("IBM Plex Mono", 8)
n = len(cfg.panes)
- # Determine row/col for each pane
- if cfg.layout_mode == "sidebyside":
- positions = [(0, c) for c in range(n)]
+ # 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":
+ 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)]
- else: # stacked
- positions = [(r, 0) for r in range(n)]
+ grid_pos = {i: (i // cols, i % cols, 1, 1) for i in range(n)}
+ else: # stacked (legacy default)
+ 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)
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 +142,8 @@ class StripChartWidget(QWidget):
else:
plot.enableAutoRange(axis="y")
- # Set column stretch (relative weight)
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..f929261 100644
--- a/ui/windows/plot_window.py
+++ b/ui/windows/plot_window.py
@@ -4,10 +4,10 @@ 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)
+ • 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
@@ -19,16 +19,17 @@ from __future__ import annotations
import json
from copy import deepcopy
from dataclasses import dataclass, field, asdict
-from typing import List, Optional, Tuple
+from math import gcd
+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
@@ -63,12 +64,13 @@ class PaneSpec:
@dataclass
class LayoutConfig:
- panes: List[PaneSpec] = field(default_factory=list)
- layout_mode: str = "stacked" # stacked | sidebyside | grid
- 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)
@@ -76,11 +78,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,
@@ -108,6 +112,154 @@ def build_default_layout(registry: DeviceRegistry,
# ══════════════════════════════════════════════════════════════════════════════
+# BSP tree functions
+# ══════════════════════════════════════════════════════════════════════════════
+# Tree node schemas (as plain dicts):
+# {"kind": "leaf", "pane": <int>}
+# {"kind": "hsplit", "ratio": <float>, "first": <node>, "second": <node>}
+# {"kind": "vsplit", "ratio": <float>, "first": <node>, "second": <node>}
+# 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 balanced binary vsplit tree with equal ratios for n panes."""
+ if n == 0:
+ return None
+ if n == 1:
+ return {"kind": "leaf", "pane": 0}
+ return _tree_equalize_ratios(_tree_default_range(list(range(n))))
+
+
+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_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
+ 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."""
+ 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:
+ """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
+
+
+# ══════════════════════════════════════════════════════════════════════════════
# Color swatch
# ══════════════════════════════════════════════════════════════════════════════
@@ -169,10 +321,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):
@@ -184,20 +335,19 @@ 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")
- 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)
+ 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)
@@ -207,14 +357,12 @@ 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():
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
@@ -256,7 +404,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 +420,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,6 +451,367 @@ class PaneBlock(QFrame):
# ══════════════════════════════════════════════════════════════════════════════
+# Layout canvas — drag-to-split BSP tiling
+# ══════════════════════════════════════════════════════════════════════════════
+
+_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:
+ # split_* → highlight half of tile
+ if zone == "split_left":
+ return QRect(r.x(), r.y(), r.width() // 2, r.height())
+ if zone == "split_right":
+ return QRect(r.x() + r.width() // 2, r.y(), r.width() - r.width() // 2, r.height())
+ if zone == "split_top":
+ return QRect(r.x(), r.y(), r.width(), r.height() // 2)
+ 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
+
+
+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:
+ 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], 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._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], tree: Optional[dict]) -> None:
+ self._panes = panes
+ self._drag_tile = None
+ 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)
+
+ # ── Geometry ──────────────────────────────────────────────────────────────
+
+ 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()
+ for i, pane in enumerate(self._panes):
+ tile = PaneTile(i, pane.title, self)
+ tile.show()
+ self._tiles.append(tile)
+ self._reposition_tiles()
+
+ 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]]:
+ """
+ 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
+ rx = pos.x() - rect.x()
+ ry = pos.y() - rect.y()
+ w, h = rect.width(), rect.height()
+ 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: 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: 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]:
+ """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:
+ if event.button() != Qt.MouseButton.LeftButton:
+ return
+ 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)
+ 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
+ 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._hover_zone = None
+
+ if zone_info is not None:
+ target_idx, zone = zone_info
+ self._do_drop(dragged_idx, target_idx, zone)
+ else:
+ # No valid drop target — restore tile positions
+ self._reposition_tiles()
+ self.update()
+
+ 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
+
+ if zone == "center":
+ new_tree = _tree_swap(self._tree, dragged_idx, target_idx)
+ 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
+ # 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
+ self._reposition_tiles()
+ self.arrangement_changed.emit()
+
+ # ── Paint ─────────────────────────────────────────────────────────────────
+
+ def paintEvent(self, event) -> None:
+ super().paintEvent(event)
+ 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)
+ if zone == "center":
+ painter.setPen(QPen(QColor("#22c55e"), 2))
+ painter.setBrush(QColor(34, 197, 94, 60))
+ 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)
+
+
+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)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
# PlotWindow
# ══════════════════════════════════════════════════════════════════════════════
@@ -321,7 +828,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()
@@ -329,7 +836,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)
@@ -337,60 +843,46 @@ 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)
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)
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 — 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)
+ self._canvas._cfg_ref = self.cfg
+ 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)
- # Scrollable pane blocks
scroll = QScrollArea(); scroll.setWidgetResizable(True)
scroll.setObjectName("deviceScroll")
self._cont = QWidget()
@@ -398,84 +890,100 @@ 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)
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.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)
- 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()
-
- 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.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 = _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)
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)
+ 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_equalize_ratios(
+ _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)
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)")
+ 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())
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))
+ QMessageBox.critical(self, "Import failed", str(e))
def refresh_channels(self):
self._populate()