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>
7.6 KiB
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; nocore/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 anQHBoxLayout(_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, mountself._deck_stackwhereself._control_deckis currently added (replace that oneaddWidget).
_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); flippanel._pinned; if now pinned and<2pinned,_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): readdeck_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_loadingduring refresh. - Pin offered in split mode:
_on_deck_pin_togglereads_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.