diff options
Diffstat (limited to 'ui/windows')
| -rw-r--r-- | ui/windows/plot_window.py | 714 |
1 files changed, 611 insertions, 103 deletions
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() |
