summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ui/strip_chart.py26
-rw-r--r--ui/windows/plot_window.py460
2 files changed, 326 insertions, 160 deletions
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,
@@ -110,6 +111,106 @@ 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(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()