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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user