From bc6e30a2d4550171864be1ee6997682d028c4d3f Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 13 Jun 2026 16:26:35 +0200 Subject: [PATCH] change: deck split shows exactly the pinned panels (no leftover column) Pinning 2 of 3 panels previously showed a 3rd "leftover" tab-column, which read as all-three-pinned and was confusing. Now the split view shows exactly the pinned panels (pin 2 -> 2 columns, pin 3 -> 3). Adds an always-available View > Side-by-side panels submenu of checkable toggles as the way to pin a panel while already in split view (the right-click-tab gesture only works in tabbed mode). Tests assert exactly-N-columns and the menu-pin path; the win fixture now resets deck state so tests don't depend on persisted layout. Co-Authored-By: Claude Fable 5 --- ...06-13-ui-restructure-multipane-addendum.md | 4 +- main.py | 33 +++++++++------- tests/test_ui_structure.py | 39 ++++++++++++++++++- 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/docs/plans/2026-06-13-ui-restructure-multipane-addendum.md b/docs/plans/2026-06-13-ui-restructure-multipane-addendum.md index 7ca88f8..b3fcc59 100644 --- a/docs/plans/2026-06-13-ui-restructure-multipane-addendum.md +++ b/docs/plans/2026-06-13-ui-restructure-multipane-addendum.md @@ -2,7 +2,9 @@ > 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. +**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. + +> **Revision (post-use, 2026-06-13):** The first implementation showed unpinned panels as a "leftover" tab-column so nothing was hidden — but in use, pinning 2 panels then displayed 3 columns, which read as "all three pinned" and was confusing (and inconsistent with what persisted). **Revised behavior:** the split view shows **exactly the pinned panels** as columns (pin 2 → 2 columns, pin 3 → 3). Unpinned panels are not shown as columns. Because the right-click-tab "Show side-by-side" gesture only works in tabbed mode, an always-available **View ▸ Side-by-side panels ▸ Export / Crop / Scan** submenu of checkable toggles is the way to pin/unpin any panel (including adding a 3rd while already in split view). The `if leftovers:` block below is removed; the View submenu + its sync in `_refresh_deck_layout` replace it. **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). diff --git a/main.py b/main.py index f12ce1e..9eb9a16 100755 --- a/main.py +++ b/main.py @@ -4662,6 +4662,19 @@ class MainWindow(QMainWindow): self._act_review.toggled.connect(self._btn_scan_mode.setChecked) m_view.addAction("Subcategory markers…", self._show_subcat_menu) m_view.addSeparator() + # Side-by-side panels: always-available pin toggles (the right-click-tab + # gesture only works in tabbed mode, so this is the way to pin a panel + # while already in the split view). Kept in sync by _refresh_deck_layout. + m_sbs = m_view.addMenu("Side-by-side panels") + self._deck_pin_actions = [] + for _panel in self._deck_panels: + _act = m_sbs.addAction(_panel._label) + _act.setCheckable(True) + _act.setChecked(_panel._pinned) + _act.triggered.connect( + lambda _checked=False, p=_panel: self._toggle_panel_pin(p)) + self._deck_pin_actions.append((_act, _panel)) + m_view.addSeparator() self._act_hide_exported = m_view.addAction("Hide exported") self._act_hide_exported.setCheckable(True) self._act_hide_exported.toggled.connect(self._chk_hide_exported.setChecked) @@ -5049,11 +5062,9 @@ class MainWindow(QMainWindow): 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 + continue # unpinned panels are hidden in split mode col = QWidget() v = QVBoxLayout(col) v.setContentsMargins(0, 0, 0, 0) @@ -5078,16 +5089,6 @@ class MainWindow(QMainWindow): 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) - lt.setTabBar(_DeckTabBar()) - lt.tabBar().pin_toggle_requested.connect( - lambda i, w=lt: self._toggle_panel_pin(w.widget(i))) - 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) @@ -5096,6 +5097,12 @@ class MainWindow(QMainWindow): panel.setVisible(True) self._control_deck.addTab(panel, panel._label) self._deck_stack.setCurrentWidget(self._control_deck) + # Keep the View ▸ Side-by-side menu checkmarks in sync with pin state. + # Guarded: _refresh_deck_layout can run before _build_menubar exists. + # setChecked emits toggled (not triggered), so no re-toggle loop. + if hasattr(self, "_deck_pin_actions"): + for act, panel in self._deck_pin_actions: + act.setChecked(panel._pinned) finally: self._deck_loading = prev diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index 8f29702..08534d7 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -20,6 +20,11 @@ def win(app): w = MainWindow() except Exception as e: # GL/mpv/display unavailable, etc. pytest.skip(f"MainWindow could not be constructed here: {e}") + # Deterministic deck state regardless of any persisted side-by-side layout + # (construction restores deck_pinned from QSettings). + for _p in w._deck_panels: + _p._pinned = False + w._refresh_deck_layout() yield w w.close() w.deleteLater() @@ -63,10 +68,42 @@ def test_deck_stack_exists(win): assert win._deck_stack.currentWidget() is win._control_deck -def test_pinning_two_panels_switches_to_split(win): +def _split_columns(win): + """Widgets of the splitter actually mounted in the layout (not findChild, + which can return a stale deleteLater'd splitter).""" + from PyQt6.QtWidgets import QSplitter + item = win._deck_split_layout.itemAt(0) + spl = item.widget() if item else None + assert isinstance(spl, QSplitter) + return [spl.widget(i) for i in range(spl.count())] + + +def test_pinning_two_panels_shows_exactly_two_columns(win): # Pin two panels directly (avoid the toggle handler so no QSettings write # leaks into other test windows) and refresh. + from PyQt6.QtWidgets import QTabWidget win._tab_export._pinned = True win._tab_crop._pinned = True win._refresh_deck_layout() assert win._deck_stack.currentWidget() is win._deck_split_container + cols = _split_columns(win) + assert len(cols) == 2 # only the pinned ones + assert not any(isinstance(c, QTabWidget) for c in cols) # no leftover tab-column + + +def test_side_by_side_menu_pins_third_panel(win): + # In split mode the View ▸ Side-by-side menu is the way to pin a 3rd panel + # (there's no tab bar to right-click). Suppress the QSettings save via the + # _deck_loading guard so this doesn't leak into other windows. + win._tab_export._pinned = True + win._tab_scan._pinned = True + win._refresh_deck_layout() + assert len(_split_columns(win)) == 2 + act = next(a for a, p in win._deck_pin_actions if p is win._tab_crop) + win._deck_loading = True # suppress _save_deck_layout + try: + act.trigger() # simulate clicking the menu item + finally: + win._deck_loading = False + assert win._tab_crop._pinned is True + assert len(_split_columns(win)) == 3