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