From 6037f15e7b3019506b8e601bae1ff31a8b89ebcf Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 13 Jun 2026 12:31:22 +0200 Subject: [PATCH] docs: multi-pane control deck design + plan addendum Pin deck panels (Export/Crop/Scan) side-by-side as resizable columns, mirroring the playlist pin pattern; unpinned panels stay reachable as a tab-column. Spec for the multi-pane feature. Co-Authored-By: Claude Fable 5 --- ...06-13-ui-restructure-multipane-addendum.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/plans/2026-06-13-ui-restructure-multipane-addendum.md diff --git a/docs/plans/2026-06-13-ui-restructure-multipane-addendum.md b/docs/plans/2026-06-13-ui-restructure-multipane-addendum.md new file mode 100644 index 0000000..7ca88f8 --- /dev/null +++ b/docs/plans/2026-06-13-ui-restructure-multipane-addendum.md @@ -0,0 +1,94 @@ +# Multi-pane Control Deck — Design + Plan Addendum + +> Addendum to `2026-06-13-ui-restructure-design.md` / `-implementation.md`. Same branch (`ui-restructure`), same constraints (preserve behavior; reorg/feature only; no `core/` changes). + +**Goal:** Let the control-deck panels (Export / Crop & Track / Scan) optionally show **side-by-side as resizable columns** instead of one-at-a-time tabs — mirroring the existing playlist pin→side-by-side pattern. Chosen model: **pin tabs**; unpinned panels stay reachable as a tab-column so nothing is hidden. + +**Mirror these existing playlist members** (study them — the deck is a simpler, fixed-3-panel version): `_PlaylistTabBar` (main.py:3284), `_refresh_layout` (~4872), `_on_pin_toggle`/`_on_unpin` (~4942), `_detach_all_pws`/`_clear_split_container` (~4861), and the `_list_stack`/`_split_container` setup (~3916–3923). + +--- + +## Design + +### Panel identity +The deck's three pages (`_tab_export`, `_tab_crop`, `_tab_scan`) each get three attributes (set in `_build_control_deck`): +- `_pinned: bool = False` +- `_label: str` — "Export" / "Crop & Track" / "Scan" +- `_deck_key: str` — "export" / "crop" / "scan" (stable key for persistence) + +Keep an ordered list `self._deck_panels = [self._tab_export, self._tab_crop, self._tab_scan]` for deterministic column order. + +### Tab bar +New `class _DeckTabBar(QTabBar)` (minimal version of `_PlaylistTabBar`): on `contextMenuEvent`, show a checkable "Show side-by-side" action reflecting the page's `_pinned`, and emit `pin_toggle_requested(idx)` when chosen. No rename/folder. Install via `self._control_deck.setTabBar(_DeckTabBar())` in `_build_control_deck` and connect `pin_toggle_requested → self._on_deck_pin_toggle`. + +### Stacked container (mirrors `_list_stack`) +Wrap the deck so it can swap between tabbed and split views: +- `self._deck_split_container = QWidget()` with an `QHBoxLayout` (`_deck_split_layout`, margins 0, spacing 2). +- `self._deck_stack = QStackedWidget()`; page 0 = `self._control_deck`, page 1 = `self._deck_split_container`. +- In `right_layout`, mount `self._deck_stack` where `self._control_deck` is currently added (replace that one `addWidget`). + +### `_refresh_deck_layout()` (mirrors `_refresh_layout`) +``` +pinned = [p for p in self._deck_panels if p._pinned] +guard self._deck_loading = True (avoid re-entrant signals) +detach all panels (setParent(None)); self._control_deck.clear(); clear _deck_split_layout +if len(pinned) >= 2: + splitter = QSplitter(Horizontal); splitter.setChildrenCollapsible(False) + leftovers = [] + for panel in self._deck_panels: # preserve deck order + if panel._pinned: + col = QWidget(); v = QVBoxLayout(col) (0 margins) + header = label(panel._label, bold) + "✕" button (unpin, fixed 18x18, + tooltip "Return to tabs", clicked → self._on_deck_unpin(panel)) + header fixed height ~22 + panel.setVisible(True) # reparented pages start hidden + v.addWidget(header); v.addWidget(panel, 1) + splitter.addWidget(col) + else: + leftovers.append(panel) + if leftovers: # keep unpinned reachable as a tab-column + lt = QTabWidget(); lt.setDocumentMode(True) + for panel in leftovers: + panel.setVisible(True); lt.addTab(panel, panel._label) + splitter.addWidget(lt) + splitter.setSizes([1000]*splitter.count()) + _deck_split_layout.addWidget(splitter) + self._deck_stack.setCurrentWidget(self._deck_split_container) +else: + for panel in self._deck_panels: # fixed order + self._control_deck.addTab(panel, panel._label) + self._deck_stack.setCurrentWidget(self._control_deck) +restore self._deck_loading +``` + +### Toggle handlers (mirror `_on_pin_toggle`/`_on_unpin`) +- `_on_deck_pin_toggle(idx)`: `panel = self._control_deck.widget(idx)` (only valid in tabbed mode — pin is only offered there); flip `panel._pinned`; if now pinned and `<2` pinned, `_show_status("Pin another panel to show them side-by-side", 3500)`; `_refresh_deck_layout()`; `_save_deck_layout()`. +- `_on_deck_unpin(panel)`: `panel._pinned = False`; `_refresh_deck_layout()`; `_save_deck_layout()`. + +### Persistence +- `_save_deck_layout()`: `self._settings.setValue("deck_pinned", [p._deck_key for p in self._deck_panels if p._pinned])`. +- Restore at the end of `__init__` (after the deck + menubar exist): read `deck_pinned` (handle str/list like the subprofiles loader at main.py:3867), set each panel's `_pinned`, then `_refresh_deck_layout()` once. + +### Height +The deck pages now also render with a 22px header in split mode. After building, set the stack's minimum height to fit the tallest **split-mode** column (header + Export content) so split mode never clips: compute once via `self._deck_stack.setMinimumHeight(...)` using `sizeHint`, and keep vertical size policy `Fixed` (as the deck has now). Switching INTO split mode may change the deck height slightly (deliberate user action — acceptable); switching tabs within tabbed mode must still not jump. Reuse the existing height-pin logic — apply it to `_deck_stack` instead of `_control_deck`. + +--- + +## Implementation tasks (bite-sized, commit per task) + +**Task M.1 — scaffolding (no behavior change yet).** Add `_DeckTabBar`; in `_build_control_deck` set it on the deck, set `_pinned/_label/_deck_key` on the three pages, build `self._deck_panels`, create `_deck_split_container`/`_deck_split_layout`/`_deck_stack`, and mount `_deck_stack` in `right_layout` instead of `_control_deck`. Connect `pin_toggle_requested` to a stub. App still behaves as plain tabs. Verify: `import main`, structure tests 6/6, and a probe that `_deck_stack.currentWidget() is _control_deck`. + +**Task M.2 — split rendering.** Implement `_refresh_deck_layout`, `_detach_deck_panels`, `_clear_deck_split`, `_on_deck_pin_toggle`, `_on_deck_unpin`. Verify with a probe: set two panels `_pinned=True`, call `_refresh_deck_layout()`, assert stack shows `_deck_split_container`, the splitter has 3 columns (2 pinned + 1 leftover QTabWidget), and all three panels are visible/parented; unpin one → back to `_control_deck` with 3 tabs in order. + +**Task M.3 — persistence.** Add `_save_deck_layout()` + restore block in `__init__`. Verify a probe round-trips a pinned set through QSettings (use an isolated QSettings scope in the test if needed) without error and that restore calls refresh exactly once. + +**Task M.4 — height + tests.** Apply the height-pin to `_deck_stack`; confirm split mode doesn't clip the tallest column. Add structure tests: `test_deck_stack_exists`, and `test_pinning_two_panels_switches_to_split` (programmatically pin 2, refresh, assert `_deck_stack.currentWidget() is _deck_split_container`). + +## Verification note +Env quirk (same as the restructure): bare `python -c` constructing `MainWindow` segfaults on mpv GL; run checks under the pytest fixture and `LD_PRELOAD=/usr/lib/libstdc++.so.6 QT_QPA_PLATFORM=offscreen`. Visual confirmation (drag dividers, pin/unpin gestures, persistence across real launches) is the user's, done at the end. + +## Risks +- **Reparenting hidden pages:** QTabWidget hides non-current pages; reparented panels must be `setVisible(True)` in split columns (same gotcha the playlist documents at main.py:4909-4911). +- **Signal re-entrancy:** guard with `_deck_loading` during refresh. +- **Pin offered in split mode:** `_on_deck_pin_toggle` reads `_control_deck.widget(idx)`, which is only meaningful in tabbed mode. The ✕ header is the unpin path in split mode — don't rely on the context menu there. +- **Height jump on mode toggle:** acceptable (deliberate); tab-switch-within-tabs must remain jump-free.