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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||||
Reference in New Issue
Block a user