feat: side-by-side mode for the control deck (pin panels into columns)

Mirror the playlist pin→side-by-side pattern for the Export / Crop &
Track / Scan control-deck panels. Right-click a deck tab → "Show
side-by-side"; pinning 2+ panels lays them out as resizable QSplitter
columns, with any unpinned panel kept reachable in a leftover tab-column.
The ✕ header returns a panel to tabs. State persists across launches via
the deck_pinned QSettings key.

- _DeckTabBar: minimal QTabBar emitting pin_toggle_requested(idx).
- _build_control_deck wraps _control_deck + a split container in a
  QStackedWidget (_deck_stack), mounted in right_layout in its place;
  sets _pinned/_label/_deck_key on each page; builds _deck_panels.
- _refresh_deck_layout / _detach_deck_panels / _clear_deck_split /
  _on_deck_pin_toggle / _on_deck_unpin / _save_deck_layout, guarded by
  _deck_loading. Reparented pages are setVisible(True) so they don't
  render blank (same gotcha the playlist documents).
- Restore block at the end of __init__ reads deck_pinned (str/list).
- Height-pin now targets _deck_stack and fits the tallest split-mode
  column (22px header + content) so split mode never clips.

Default (nothing pinned) behaves exactly like the prior tabbed deck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 12:36:03 +02:00
parent 6037f15e7b
commit 780832d4aa
+171 -8
View File
@@ -3344,6 +3344,29 @@ class _PlaylistTabBar(QTabBar):
editor.editingFinished.connect(finish)
class _DeckTabBar(QTabBar):
"""Control-deck tab bar: right-click a tab to pin it for the side-by-side
view. Minimal version of _PlaylistTabBar (no rename / folder)."""
pin_toggle_requested = pyqtSignal(int)
def contextMenuEvent(self, event):
idx = self.tabAt(event.pos())
if idx < 0:
return
from PyQt6.QtWidgets import QMenu
menu = QMenu(self)
act_pin = menu.addAction("Show side-by-side")
act_pin.setCheckable(True)
pw = None
tw = self.parent()
if hasattr(tw, "widget"):
pw = tw.widget(idx)
act_pin.setChecked(bool(getattr(pw, "_pinned", False)))
chosen = menu.exec(event.globalPos())
if chosen == act_pin:
self.pin_toggle_requested.emit(idx)
class PlaylistWidget(QListWidget):
file_selected = pyqtSignal(str) # emits full path of selected file
_SEP_END = "\x00END" # anchor for a separator after the last visible file
@@ -4414,10 +4437,15 @@ class MainWindow(QMainWindow):
for _b in (self._btn_train, self._btn_scan_all, self._btn_hide_subcats):
_b.setParent(self); _b.hide()
# Pin the deck height (after all tabs are populated) so switching tabs
# doesn't resize the video.
# doesn't resize the video. Fit the tallest SPLIT-mode column too — a
# split column is a 22px header + panel content, which is taller than
# the tabbed deck — so pinning never clips. Pin the stack (the mounted
# widget) rather than _control_deck, which becomes a stack page.
from PyQt6.QtWidgets import QSizePolicy
self._control_deck.setMinimumHeight(self._control_deck.sizeHint().height())
self._control_deck.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
_tabbed_h = self._control_deck.sizeHint().height()
_split_h = 22 + max(p.sizeHint().height() for p in self._deck_panels)
self._deck_stack.setMinimumHeight(max(_tabbed_h, _split_h))
self._deck_stack.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
# Root: horizontal splitter
splitter = QSplitter(Qt.Orientation.Horizontal)
@@ -4484,6 +4512,17 @@ class MainWindow(QMainWindow):
# Apply persisted subcategory visibility to timeline + buttons.
self._apply_subcat_visibility()
# Restore the control deck's side-by-side layout (after the deck and
# menubar exist). QSettings may hand back a bare str for a single value.
_deck_pinned = self._settings.value("deck_pinned", [])
if isinstance(_deck_pinned, str):
_deck_pinned = [_deck_pinned] if _deck_pinned else []
_deck_pinned = set(_deck_pinned or [])
if _deck_pinned:
for panel in self._deck_panels:
panel._pinned = panel._deck_key in _deck_pinned
self._refresh_deck_layout()
# Defer the changelog modal so the window paints/interacts first.
QTimer.singleShot(120, self._show_changelog)
@@ -4495,18 +4534,46 @@ class MainWindow(QMainWindow):
line.setFixedHeight(1)
return line
def _build_control_deck(self) -> "QTabWidget":
def _build_control_deck(self) -> "QWidget":
deck = QTabWidget()
deck.setObjectName("control_deck")
deck.setDocumentMode(True)
deck.setTabBar(_DeckTabBar())
self._tab_export = QWidget(); self._tab_export.setObjectName("export_tab")
self._tab_crop = QWidget(); self._tab_crop.setObjectName("crop_tab")
self._tab_scan = QWidget(); self._tab_scan.setObjectName("scan_tab")
deck.addTab(self._tab_export, "Export")
deck.addTab(self._tab_crop, "Crop && Track")
deck.addTab(self._tab_scan, "Scan")
# Panel identity for the side-by-side view (mirrors PlaylistWidget).
# _label drives the tab text and split-column header; _deck_key is a
# stable persistence key; _pinned tracks side-by-side membership.
self._tab_export._pinned = False
self._tab_export._label = "Export"
self._tab_export._deck_key = "export"
self._tab_crop._pinned = False
self._tab_crop._label = "Crop && Track"
self._tab_crop._deck_key = "crop"
self._tab_scan._pinned = False
self._tab_scan._label = "Scan"
self._tab_scan._deck_key = "scan"
# Ordered list for deterministic column / tab order.
self._deck_panels = [self._tab_export, self._tab_crop, self._tab_scan]
deck.addTab(self._tab_export, self._tab_export._label)
deck.addTab(self._tab_crop, self._tab_crop._label)
deck.addTab(self._tab_scan, self._tab_scan._label)
self._control_deck = deck
return deck
deck.tabBar().pin_toggle_requested.connect(self._on_deck_pin_toggle)
# Side-by-side container (shown when 2+ panels are pinned), wrapped in a
# stack so the deck can swap between tabbed and split views.
self._deck_loading = False
self._deck_split_container = QWidget()
self._deck_split_layout = QHBoxLayout(self._deck_split_container)
self._deck_split_layout.setContentsMargins(0, 0, 0, 0)
self._deck_split_layout.setSpacing(2)
from PyQt6.QtWidgets import QStackedWidget
self._deck_stack = QStackedWidget()
self._deck_stack.addWidget(self._control_deck) # page 0: tabs
self._deck_stack.addWidget(self._deck_split_container) # page 1: split
return self._deck_stack
def _build_export_tab(self) -> None:
g = QGridLayout(self._tab_export)
@@ -4954,6 +5021,102 @@ class MainWindow(QMainWindow):
self._refresh_layout()
self._save_playlist_tabs()
# ── Control deck: tabs vs. side-by-side ──────────────────────
def _detach_deck_panels(self) -> None:
for panel in self._deck_panels:
panel.setParent(None)
def _clear_deck_split(self) -> None:
while self._deck_split_layout.count():
item = self._deck_split_layout.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
def _refresh_deck_layout(self) -> None:
"""Render the deck panels either as tabs or, when 2+ are pinned,
side-by-side as resizable columns (mirrors _refresh_layout)."""
pinned = [p for p in self._deck_panels if p._pinned]
prev = self._deck_loading
self._deck_loading = True
try:
self._detach_deck_panels()
self._control_deck.clear()
self._clear_deck_split()
if len(pinned) >= 2:
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.setChildrenCollapsible(False)
leftovers = []
for panel in self._deck_panels: # preserve deck order
if not panel._pinned:
leftovers.append(panel)
continue
col = QWidget()
v = QVBoxLayout(col)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(0)
header = QWidget()
hdr = QHBoxLayout(header)
hdr.setContentsMargins(2, 1, 2, 1)
lbl = QLabel(panel._label)
lbl.setStyleSheet("font-weight: bold;")
btn = QPushButton("")
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
btn.setFixedSize(18, 18)
btn.setToolTip("Return to tabs")
btn.clicked.connect(
lambda _=False, p=panel: self._on_deck_unpin(p))
hdr.addWidget(lbl, 1)
hdr.addWidget(btn)
header.setFixedHeight(22)
# QTabWidget hides non-current pages; reparented panels stay
# hidden and render blank unless re-shown.
panel.setVisible(True)
v.addWidget(header)
v.addWidget(panel, 1)
splitter.addWidget(col)
if leftovers: # keep unpinned panels 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())
self._deck_split_layout.addWidget(splitter)
self._deck_stack.setCurrentWidget(self._deck_split_container)
else:
for panel in self._deck_panels: # fixed order
panel.setVisible(True)
self._control_deck.addTab(panel, panel._label)
self._deck_stack.setCurrentWidget(self._control_deck)
finally:
self._deck_loading = prev
def _on_deck_pin_toggle(self, idx: int) -> None:
# Pin is only offered in tabbed mode, so the index maps to a deck tab.
panel = self._control_deck.widget(idx)
if panel is None:
return
panel._pinned = not panel._pinned
if panel._pinned and sum(1 for p in self._deck_panels if p._pinned) < 2:
self._show_status("Pin another panel to show them side-by-side", 3500)
self._refresh_deck_layout()
self._save_deck_layout()
def _on_deck_unpin(self, panel: "QWidget") -> None:
panel._pinned = False
self._refresh_deck_layout()
self._save_deck_layout()
def _save_deck_layout(self) -> None:
if self._deck_loading:
return
self._settings.setValue(
"deck_pinned",
[p._deck_key for p in self._deck_panels if p._pinned],
)
def _setup_keyboard_focus(self) -> None:
"""Keep keyboard focus off transient controls so the timeline hotkeys
keep working after you click a button or set a spinbox.