Compare commits

18 Commits

Author SHA1 Message Date
Ethanfel cbbdfeadb1 feat: logo-based icon set + accent aligned to brand palette
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:56:54 +02:00
Ethanfel 8a7d761815 chore: drop stale scaffolding comments flagged in final review
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:47:41 +02:00
Ethanfel 140a424469 docs: changelog + README for the UI overhaul (v1.1)
Bump APP_VERSION to 1.1 with a "What's new" entry covering the menu bar,
tabbed control deck, side-by-side panels, status bar, and visual polish.
Add an Interface section to the README. Shortcuts unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 16:39:53 +02:00
Ethanfel bc6e30a2d4 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 <noreply@anthropic.com>
2026-06-13 16:26:35 +02:00
Ethanfel 2ea3a9149a fix: allow pinning the 3rd deck panel from split mode; dedupe header height
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:50:04 +02:00
Ethanfel e820c106af test: structure tests for control-deck side-by-side mode
- test_deck_stack_exists: _deck_stack present; default shows _control_deck.
- test_pinning_two_panels_switches_to_split: pin 2 panels + refresh →
  stack shows _deck_split_container.

Pin via _pinned flags directly (not the toggle handler) so no QSettings
write leaks into other function-scoped windows; existing 6 tests run in
default/tabbed state and still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:36:08 +02:00
Ethanfel 780832d4aa 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>
2026-06-13 12:36:03 +02:00
Ethanfel 6037f15e7b 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>
2026-06-13 12:31:22 +02:00
Ethanfel 035eaf3894 style: unified theme, primary Export, group separators, clearer labels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:19:25 +02:00
Ethanfel 35ea1baec8 fix: keep Subprofiles▸Remove menu in sync with subprofile changes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:15:07 +02:00
Ethanfel 6a71386ed8 fix: robust deck height, state-aware Scan/Train menu items, import cleanup
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:04:15 +02:00
Ethanfel d1fb35af8e refactor: populate Crop & Scan tabs; menu-only buttons hidden; drop settings row
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:54:10 +02:00
Ethanfel c55693094d refactor: add control deck; move export/encode controls into Export tab
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:48:26 +02:00
Ethanfel 5832d08b26 feat: real status bar replaces inline status label
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:37:52 +02:00
Ethanfel b4cfa7561a fix: resolve menu-bar shortcut collision, checkmark desync, brittle test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:33:05 +02:00
Ethanfel 0ccc29709e feat: add menu bar wired to existing handlers; move profile selector and help into menu-bar corner
Adds MainWindow._build_menubar building File/Edit/Scan/View/Help menus
whose actions reuse the existing handler methods. Profile combo and the
? shortcuts button move from top_bar into a TopRightCorner widget. Adds
_show_about and _rebuild_remove_subprofile_menu helpers. Bidirectional
sync for Hide exported / Show hidden; forward-only sync for Review mode
(reverse added in a later stage).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:24:24 +02:00
Ethanfel 7e917d00a6 test: add MainWindow structure smoke test (skips headless)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:22:49 +02:00
Ethanfel 2ffb81eaa3 docs: UI restructure design + implementation plan
Tabbed control deck reorg of MainWindow: menu bar for rare actions,
always-visible transport bar, 3-tab control deck (Export / Crop & Track /
Scan), real status bar, plus a visual-polish pass. No behavior, shortcut,
or core/ changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:18:47 +02:00
16 changed files with 1392 additions and 84 deletions
+5
View File
@@ -30,6 +30,11 @@ mpv_dir = Path(os.environ.get("MPV_DIR", base))
datas = []
# Bundled assets (icons, logo) — must exist at runtime under sys._MEIPASS/assets
assets_dir = base / "assets"
if assets_dir.exists():
datas.append((str(assets_dir), "assets"))
# YOLOv8 model (optional — large, skip if missing)
yolo = base / "yolov8n.pt"
if yolo.exists():
+7
View File
@@ -61,6 +61,13 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
- **Subprofiles** — lightweight export folder variants for multiple output targets
- **Review mode** — clean timeline view for navigating scan results without export clutter
### Interface
- **Menu bar** — File / Edit / Scan / View / Help hold the occasional actions (open files, train, scan all, profiles); the profile selector and shortcuts (`?`) sit in the top-right corner
- **Control deck** — a compact tabbed panel under the video groups the settings into **Export** (label, name, folder, format, resize, duration/clips/spread, workers), **Crop & Track**, and **Scan** (model, threshold, fuse, scan/auto/speech/review)
- **Side-by-side panels** — pin deck panels to view them as resizable columns: right-click a deck tab → *Show side-by-side*, or toggle them under *View ▸ Side-by-side panels*; drag the dividers to reallocate space, and the layout persists between sessions
- **Status bar** — export/scan progress and messages, with the current file · profile · worker count always shown
## Keyboard shortcuts
| Key | Action |
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g8" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffd230"/>
<stop offset="100%" stop-color="#e6a800"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="13" fill="#161616"/>
<rect x="8" y="42" width="48" height="11" rx="2" fill="#2a2a2a" stroke="#333" stroke-width="1"/>
<rect x="26" y="42" width="16" height="11" fill="#3c82dc" fill-opacity="0.45"/>
<line x1="26" y1="38" x2="26" y2="55" stroke="#ffd230" stroke-width="2"/>
<polygon points="22,38 30,38 26,44" fill="#ffd230"/>
<text x="32" y="33" font-family="'Helvetica Neue',Helvetica,Arial,sans-serif" font-size="34" font-weight="bold" fill="url(#g8)" text-anchor="middle">8</text>
</svg>

After

Width:  |  Height:  |  Size: 790 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M7.5 10 V7.5 a4.5 4.5 0 0 1 9 0 V10" stroke="#ffd230" stroke-width="2"/>
<rect x="5" y="10" width="14" height="10" rx="2" fill="#ffd230"/>
<circle cx="12" cy="14.3" r="1.4" fill="#161616"/>
<rect x="11.2" y="14.3" width="1.6" height="3.4" rx="0.8" fill="#161616"/>
</svg>

After

Width:  |  Height:  |  Size: 362 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M7.5 10 V7.5 a4.5 4.5 0 0 1 8.6 -1.8" stroke="#8a8a8a" stroke-width="2"/>
<rect x="5" y="10" width="14" height="10" rx="2" fill="#8a8a8a"/>
<circle cx="12" cy="14.3" r="1.4" fill="#1e1e1e"/>
<rect x="11.2" y="14.3" width="1.6" height="3.4" rx="0.8" fill="#1e1e1e"/>
</svg>

After

Width:  |  Height:  |  Size: 363 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="6.5" y="5" width="4" height="14" rx="1.2" fill="#ffd230"/>
<rect x="13.5" y="5" width="4" height="14" rx="1.2" fill="#ffd230"/>
</svg>

After

Width:  |  Height:  |  Size: 209 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M7 5 L19 12 L7 19 Z" fill="#ffd230" stroke="#ffd230" stroke-width="1.5" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 177 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#aad4ff" stroke-width="2" stroke-linecap="round">
<circle cx="10.5" cy="10.5" r="6"/>
<line x1="15" y1="15" x2="20" y2="20"/>
</svg>

After

Width:  |  Height:  |  Size: 217 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffd230" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="6.5" cy="6.5" r="2.6"/>
<circle cx="6.5" cy="17.5" r="2.6"/>
<line x1="8.8" y1="8" x2="20" y2="17"/>
<line x1="8.8" y1="16" x2="20" y2="7"/>
</svg>

After

Width:  |  Height:  |  Size: 322 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffd230" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4,17 10,11 14,14 20,6"/>
<polyline points="15,6 20,6 20,11"/>
</svg>

After

Width:  |  Height:  |  Size: 245 B

+4
View File
@@ -1,2 +1,6 @@
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
def pytest_configure(config):
config.addinivalue_line("markers", "gui: constructs Qt widgets; needs a display")
@@ -0,0 +1,130 @@
# Main Window UI Restructure — Design
**Goal:** Reorganize the `MainWindow` UI in `main.py` from a flat wall of ~50 always-visible controls into a legible, grouped layout — a menu bar for rare actions, a tabbed control deck for settings, an always-visible transport bar, and a real status bar — plus a visual polish pass. Keep every existing behavior, shortcut, and mouse interaction working.
**Scope:** Reorganization **and** visual polish. **Not** an interaction-model change — single-key shortcuts, timeline mouse overloading, and the export/scan logic are untouched.
**Audience:** Single power user. Optimize for density and speed. The goal is *order, not hiding*: keep everything fast to reach; push only genuinely rare actions into menus.
**Runs in:** Python/Qt client (`main.py`), `MainWindow` class only. No `core/` changes.
---
## Problem (from audit)
- **No information architecture.** No menu bar, no toolbar; status bar explicitly disabled (`setStatusBar(None)`, main.py:4440). Every function is a permanently-visible widget at equal weight.
- **`settings_row` overloaded** (main.py:43344370): 24 widgets in one non-wrapping `QHBoxLayout` spanning three unrelated domains (encode/clip params, export variants, audio-scan ML). Needs >1500px; window opens at 1100px.
- **Stranded controls** — e.g. the workers spinbox sits between Cancel and Delete in the transport row (main.py:4316).
- **Weak feedback** — only an 11px `#888` status label at the far-right end of the overflowing settings row (main.py:4364).
- **Flat visual hierarchy** — single Fusion stylesheet, scattered inline `setStyleSheet` state swaps, no primary/secondary distinction, no grouping.
---
## Chosen approach: Tabbed control deck
The 3-pane horizontal splitter (Queue · Center · Scan results) is unchanged. The center column is restructured:
```
╔═ File Edit Scan View Help ═══════════════════ Profile:[default▾] [?] ╗ menu bar (+ corner widgets)
║ ┌Queue──┐ │ current_file.mp4 │ ┌ Scan results ─────┐ ║
║ │+Open │ │ ┌──────────────────────────────────────┐ │ │ [model tabs] │ ║
║ │filter │ │ │ VIDEO (mpv) │ │ │ version▾ │ ║
║ │┌List┬+┐│ │ │ │ │ │ start end score │ ║
║ ││f1 ││ │ │ └──────────────────────────────────────┘ │ │ ... │ ║
║ ││f2 ││ │ │ [════════════ timeline ════════════════] │ │ │ ║
║ │└────┘ ││ │ [════════════ crop bar ════════════════] │ │ [Neg] [Export] │ ║
║ └───────┘ │ ┌─ transport (always visible) ──────────┐ │ └───────────────────┘ ║
║ │ │▶ ⏸ x2 x4 🔒 --/-- ··· [Export] +₁+₂ Cancel Delete│ ║
║ │ ├─[ Export ]─[ Crop & Track ]─[ Scan ]──┤ ← control deck (tabs) ║
║ │ │ (controls for the active tab here) │ ║
║ │ └───────────────────────────────────────┘ ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║ Ready. current file · profile: default · 8 wk ║ status bar
╚═══════════════════════════════════════════════════════════════════════════════╝
```
**Why tabbed deck:** Replaces the three stacked rows with a compact tab strip. The transport bar (most-used controls) stays always visible above the tabs; settings group by concern behind tabs. Trade-off accepted: viewing Scan + Export controls simultaneously costs a tab switch.
---
## Control mapping
Every current control has an explicit home; nothing is removed.
### Menu bar (rare / batch / management)
| Menu | Items |
|------|-------|
| **File** | Open Files… · Set export folder… · Quit |
| **Edit** | Undo *(Ctrl+Z → `_scan_panel.undo`)* · Subprofiles ▸ (Add… / Remove…) |
| **Scan** | Scan current · Auto-export · Scan All… · Train classifier… |
| **View** | Review mode ✓ · Subcategory markers ▸ · Hide exported ✓ · Show hidden ✓ |
| **Help** | Keyboard shortcuts *(? / F1)* · What's new · About |
| *corner (right)* | Profile ▾ · `?` |
*Hard Negatives and Dataset Stats remain inside the Train dialog (main.py:682, 762) — not surfaced separately. Profile new/delete remains driven by the profile combo's `activated` handler.*
### Transport bar (always visible — playback + one-press export actions)
`▶ Play · ⏸ Pause · x2 · x4 · 🔒 Lock · --/-- time · ⟨stretch⟩ · next-preview · **Export** · subprofile buttons ₁₂… · Cancel · Delete`
### Control deck — Export tab
`Label · Category · Name · Folder + browse · Format · HW encode · Resize · Duration · Clips · Spread · Workers · Re-export`
### Control deck — Crop & Track tab
`Portrait ratio · 1 random portrait · 1 random square · Track subject`
### Control deck — Scan tab
`Scan model ▾ · ⏲ history · Scan · Auto · Speech · Review · Fuse · Threshold`
### Left pane (Queue) — unchanged
`+ Open · filter · Hide exported · Show hidden · list tabs (tabbed / side-by-side)`
### Right pane (Scan results) — unchanged structurally
### Decisions
- **Train** → Scan menu only (no deck button).
- **Subcategory markers ("Sub")** → View menu submenu (off the deck).
- Items appearing in both a menu and a visible control (Hide exported, Review, Scan, Auto) share one handler and stay synced.
---
## Status bar
Restores `QStatusBar` (removes `setStatusBar(None)`):
- **Left**: transient feedback — `Exporting 2/3…`, `Scan complete · 14 regions`, `Ready.` — with an optional inline `QProgressBar` for export/scan runs. Replaces `_lbl_status` and the `_status_timer` clear logic.
- **Right (permanent widget)**: `current file · profile: <name> · <n> workers`.
---
## Visual polish
Extends the existing dark Fusion theme — no theme change.
1. **Aligned tab layouts** — each deck tab uses `QFormLayout`/grid so `label : control` pairs align in columns (biggest legibility win vs. today's ragged horizontal runs).
2. **Primary/secondary button weight****Export** gets an accent style (blue, reusing `#3a6ea8`); Cancel/Delete read as secondary/destructive. The existing **red Export = "armed to overwrite"** state (main.py:5403) is preserved as a distinct state layered on top.
3. **Consistent toggle states** — x2 / x4 / 🔒 Lock / Review are checkable; one global `:checked` style replaces Lock's ad-hoc inline `#4a3000` swap (main.py:5705).
4. **Spacing rhythm** — uniform margins/spacing; **fixed deck height** (= tallest tab) so the video never resizes on tab switch.
5. **Label cleanup** — de-abbreviate where cheap (`Thr→Threshold`, `Dur→Duration`); replace cryptic `⏲` with a clearer history affordance.
6. **One stylesheet block** — fold scattered inline `setStyleSheet` calls into the central sheet (tabs, separators, status bar, toggles, primary button); keep per-widget overrides only for genuine state changes (overwrite-armed Export).
---
## Implementation notes & risks
- **Preserve all signal wiring.** Controls are re-parented into new layouts, but every existing `connect()` and the controls' object identities are kept — this is a layout move, not a rewrite of handlers.
- **Preserve all shortcuts.** The `QShortcut` block (main.py:44504483) and `_KeyFilter` focus suppression are untouched. Menu items reuse the same handler methods and may display the matching shortcut text.
- **Fixed deck height** prevents video-area jump when switching tabs.
- **Synced menu/button state** — checkable menu items (Review, Hide exported) and their visible toggles must reflect each other; route both through the existing handler and update both widgets.
- **Profile combo** moves to a menu-bar corner widget but keeps its existing `activated` → new/delete/switch logic intact.
- Risk: re-parenting a large `__init__` is error-prone. Mitigate by moving controls in small, independently-runnable stages (menu bar → status bar → deck tabs → transport bar → polish), launching the app after each.
---
## What this does NOT do
- No change to export, scan, tracking, or DB logic — `core/` untouched.
- No change to keyboard shortcuts or timeline mouse interactions.
- No theme change — stays dark Fusion.
- No new features — every control already exists; this is rehousing + polish.
- No change to the Queue or Scan-results panes' internal structure.
@@ -0,0 +1,547 @@
# Main Window UI Restructure — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Re-house `MainWindow`'s ~50 flat controls into a menu bar (rare actions), an always-visible transport bar, a 3-tab control deck (Export / Crop & Track / Scan), and a real status bar — then a visual-polish pass — without changing any behavior, shortcut, or `core/` logic.
**Architecture:** Pure layout reorganization inside `main.py`'s `MainWindow`. Existing widget objects and every `connect()` are **preserved and re-parented**, not recreated. The monster `__init__` is incrementally broken into `_build_*` helper methods (stays single-file — matches the project's architecture). Companion design doc: `docs/plans/2026-06-13-ui-restructure-design.md`.
**Tech Stack:** Python 3.11+, PyQt6, pytest. App entry: `main.py`; launch via `./8cut.sh`.
---
## Conventions for every task
- **Line references drift** as edits land. Always locate by the named symbol (method/variable), not the line number alone. Numbers are the *starting* anchors as of this plan.
- **Authoritative verification is a manual launch.** After each task, run `./8cut.sh`, load a video, and confirm the task's controls work AND prior behavior is intact (play, scrub, export, scan). Use the `verify` skill for structured manual checks.
- **Structure test is the safety net.** `tests/test_ui_structure.py` (built in Task 0.2) constructs `MainWindow` and asserts containment invariants. It **skips gracefully** if construction fails (e.g. no GL for `MpvWidget` in headless CI), so it never blocks `core/` tests. Run with a display: `pytest tests/test_ui_structure.py -v`.
- **Commit after every task.** Small, reversible commits. Commit message convention matches the repo (`feat:`/`fix:`/`refactor:`/`change:`).
- **Do not touch** `core/`, export/scan/tracking logic, the `QShortcut` block (around main.py:44504483), `_KeyFilter`, or `TimelineWidget` mouse handling.
---
## Stage 0 — Branch & safety net
### Task 0.1: Create a working branch
**Step 1:** Confirm clean intent and branch off `master`:
```bash
git switch -c ui-restructure
```
**Step 2:** Verify: `git branch --show-current``ui-restructure`.
(The repo has pre-existing untracked/modified files; leave them alone — they are not part of this work.)
### Task 0.2: Add the structure-test safety net
**Files:**
- Create: `tests/test_ui_structure.py`
**Step 1: Write the test harness + baseline invariant**
```python
import os
import pytest
# A real platform is needed because MpvWidget creates a GL context.
# If construction fails for any environment reason, skip — this test is a
# best-effort structural net, not a gate on core/ tests.
pytestmark = pytest.mark.gui
@pytest.fixture(scope="module")
def app():
from PyQt6.QtWidgets import QApplication
inst = QApplication.instance() or QApplication([])
yield inst
@pytest.fixture
def win(app):
try:
from main import MainWindow
w = MainWindow()
except Exception as e: # GL/mpv/display unavailable, etc.
pytest.skip(f"MainWindow could not be constructed here: {e}")
yield w
w.close()
w.deleteLater()
def _descendant_object_names(widget):
"""All objectNames in a widget's child tree (for containment asserts)."""
return {c.objectName() for c in widget.findChildren(object) if c.objectName()}
def test_window_constructs(win):
assert win.windowTitle() == "8-cut"
```
**Step 2: Run it**
Run: `pytest tests/test_ui_structure.py -v`
Expected: `test_window_constructs` PASSES (with a display) or SKIPS (headless). Either is acceptable — it must not ERROR.
**Step 3:** Register the `gui` marker to silence warnings.
Modify `conftest.py` — append:
```python
def pytest_configure(config):
config.addinivalue_line("markers", "gui: constructs Qt widgets; needs a display")
```
**Step 4: Confirm core tests still pass**
Run: `pytest tests/test_utils.py tests/test_db.py -q`
Expected: PASS (unchanged).
**Step 5: Commit**
```bash
git add tests/test_ui_structure.py conftest.py
git commit -m "test: add MainWindow structure smoke test (skips headless)"
```
---
## Stage 1 — Menu bar
Add a `QMenuBar` whose actions reuse existing handler methods. Move the profile combo and `?` button into menu-bar corner widgets. Keep the original buttons that also live elsewhere (Scan, Auto) — menus and buttons share handlers.
### Task 1.1: Extract a `_build_menubar()` and add the five menus
**Files:**
- Modify: `main.py` `MainWindow.__init__` (call site) and add method `_build_menubar`
**Step 1:** Add the method (place near other `_build`/setup helpers, e.g. after `__init__`). Wire each action to the **existing** handler method:
```python
def _build_menubar(self) -> None:
from PyQt6.QtGui import QAction
mb = self.menuBar()
# File
m_file = mb.addMenu("&File")
m_file.addAction("Open Files…", self._on_open_files)
m_file.addAction("Set export folder…", self._pick_folder)
m_file.addSeparator()
m_file.addAction("Quit", self.close)
# Edit
m_edit = mb.addMenu("&Edit")
self._act_undo = m_edit.addAction("Undo scan edit", self._scan_panel.undo)
self._act_undo.setShortcut("Ctrl+Z")
m_edit.addSeparator()
m_subs = m_edit.addMenu("Subprofiles")
m_subs.addAction("Add…", self._new_subprofile)
self._menu_subprofiles_remove = m_subs.addMenu("Remove")
self._rebuild_remove_subprofile_menu() # built in Task 4.x
# Scan
m_scan = mb.addMenu("&Scan")
m_scan.addAction("Scan current", self._start_scan)
m_scan.addAction("Auto-export", self._auto_export)
m_scan.addSeparator()
m_scan.addAction("Scan All…", self._start_scan_all)
m_scan.addAction("Train classifier…", self._open_train_dialog)
# View
m_view = mb.addMenu("&View")
self._act_review = m_view.addAction("Review mode")
self._act_review.setCheckable(True)
self._act_review.toggled.connect(self._btn_scan_mode.setChecked)
m_view.addAction("Subcategory markers…", self._show_subcat_menu)
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)
self._chk_hide_exported.toggled.connect(self._act_hide_exported.setChecked)
self._act_show_hidden = m_view.addAction("Show hidden")
self._act_show_hidden.setCheckable(True)
self._act_show_hidden.toggled.connect(self._btn_show_hidden.setChecked)
self._btn_show_hidden.toggled.connect(self._act_show_hidden.setChecked)
# Help
m_help = mb.addMenu("&Help")
m_help.addAction("Keyboard shortcuts", self._show_shortcuts).setShortcut("F1")
m_help.addAction("What's new", self._show_changelog)
m_help.addAction("About", self._show_about) # tiny method, Task 1.3
```
> **Sync note:** `QAction.toggled`/`QAbstractButton.toggled` do not re-emit when the value is unchanged, so the bidirectional `setChecked` connections (Review, Hide exported, Show hidden) cannot loop. `_btn_scan_mode` → `_act_review` reverse sync is added in Task 3.4 once the button is in the Scan tab.
**Step 2:** Stub the two small new methods referenced above:
```python
def _show_about(self) -> None:
QMessageBox.about(self, "About 8-cut",
f"<b>8-cut</b> v{self.APP_VERSION}<br>"
"8-second clips for foley datasets.")
def _rebuild_remove_subprofile_menu(self) -> None:
self._menu_subprofiles_remove.clear()
for name in self._subprofiles:
self._menu_subprofiles_remove.addAction(
name, lambda _=False, n=name: self._remove_subprofile(n))
self._menu_subprofiles_remove.setEnabled(bool(self._subprofiles))
```
**Step 3:** Call `self._build_menubar()` in `__init__`, **after** `self._scan_panel` and all referenced buttons exist (i.e. just before/after the splitter assembly around main.py:4429). The scan panel is created at main.py:4414, so place the call after that.
**Step 4 (manual verify):** `./8cut.sh` → menu bar shows File/Edit/Scan/View/Help; each item triggers its action; Ctrl+Z still undoes scan edits; F1 shows shortcuts.
**Step 5:** Commit: `feat: add menu bar wired to existing handlers`.
### Task 1.2: Move profile combo + `?` into menu-bar corner
**Files:** Modify `main.py``top_bar` assembly (around main.py:42904294) and `_build_menubar`.
**Step 1:** Remove `self._cmb_profile` and `self._btn_shortcuts` (and the `"Profile:"` `QLabel`) from `top_bar`. Keep `self._lbl_file` in `top_bar` (it stays as the slim filename header above the video).
**Step 2:** In `_build_menubar`, set a corner widget:
```python
from PyQt6.QtWidgets import QWidget, QHBoxLayout, QLabel
corner = QWidget()
ch = QHBoxLayout(corner)
ch.setContentsMargins(0, 0, 6, 0)
ch.addWidget(QLabel("Profile:"))
ch.addWidget(self._cmb_profile)
ch.addWidget(self._btn_shortcuts)
mb.setCornerWidget(corner, Qt.Corner.TopRightCorner)
```
(Build the corner widget at the end of `_build_menubar`, after `self._cmb_profile` exists — it is created at main.py:4272.)
**Step 3 (manual verify):** Profile dropdown works (switch/new/delete); `?` opens shortcuts; filename still shows above the video.
**Step 4:** Commit: `change: move profile selector and help into menu-bar corner`.
---
## Stage 2 — Status bar
### Task 2.1: Restore `QStatusBar` and route `_show_status` to it
**Files:** Modify `main.py``__init__` (`setStatusBar(None)` at main.py:4440, `_lbl_status`/`_status_timer` at main.py:43644370) and `_show_status` (main.py:5065).
**Step 1:** Replace `self.setStatusBar(None)` with a real status bar built in a helper:
```python
def _build_status_bar(self) -> None:
sb = self.statusBar()
self._status_perm = QLabel("")
self._status_perm.setStyleSheet("color: #888;")
sb.addPermanentWidget(self._status_perm)
self._update_status_perm()
def _update_status_perm(self) -> None:
name = os.path.basename(self._file_path) if self._file_path else ""
self._status_perm.setText(
f"{name} · profile: {self._profile()} · {self._spn_workers.value()} workers")
```
Call `self._build_status_bar()` in `__init__` near the menubar call.
**Step 2:** Rewrite `_show_status` to use the status bar (this subsumes `_status_timer`):
```python
def _show_status(self, msg: str, timeout: int = 0) -> None:
"""Show a transient message in the status bar. timeout in ms (0 = sticky)."""
self.statusBar().showMessage(msg, timeout)
```
**Step 3:** Delete `self._lbl_status`, `self._status_timer`, and `settings_row.addWidget(self._lbl_status)` (main.py:43644370). Remove the `_status_timer.timeout` connection.
**Step 4:** Keep `_update_status_perm()` fresh — call it where file/profile/workers change: end of `_after_load`, in `_on_profile_activated`, and in the `_spn_workers.valueChanged` lambda.
**Step 5 (manual verify):** Start an export → status text appears bottom-left and auto-clears; bottom-right shows file · profile · workers and updates on file/profile/worker change.
**Step 6:** Commit: `feat: real status bar replaces inline status label`.
---
## Stage 3 — Control deck (the core move)
Build a fixed-height `QTabWidget` with three tab pages, then **re-parent** the existing controls from `path_row` and `settings_row` into them. Give each page an `objectName` for the structure test. Do tabs one at a time so the app stays runnable.
### Task 3.1: Build the empty deck and mount it
**Files:** Modify `main.py``right_layout` assembly (main.py:43724382).
**Step 1:** Add a helper that creates the deck and three empty pages:
```python
def _build_control_deck(self) -> "QTabWidget":
from PyQt6.QtWidgets import QTabWidget, QWidget
deck = QTabWidget()
deck.setObjectName("control_deck")
deck.setDocumentMode(True)
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")
self._control_deck = deck
return deck
```
**Step 2:** In `right_layout`, **keep** `transport_row` for now, but replace the `path_row` and `settings_row` additions with the deck:
- Remove `right_layout.addLayout(path_row)` and `right_layout.addLayout(settings_row)`.
- Add `right_layout.addWidget(self._build_control_deck())`.
- Leave the `path_row`/`settings_row` *construction* in place for this task (the widgets are still parented to nothing visible) — they get moved into tabs in 3.23.4. **App is briefly missing those controls between 3.1 and 3.4; that's expected mid-stage.**
**Step 3 (manual verify):** App launches; three empty tabs appear under the transport bar; switching tabs doesn't resize the video (height fixed in Task 3.5).
**Step 4:** Commit: `refactor: add empty 3-tab control deck under transport`.
### Task 3.2: Populate the Export tab
**Files:** Modify `main.py` — move widgets from `path_row` (main.py:43224331) and the encode/clip parts of `settings_row` (main.py:43344348) plus `_spn_workers` (main.py:4213).
**Step 1:** Build the Export tab with an aligned grid:
```python
def _build_export_tab(self) -> None:
from PyQt6.QtWidgets import QGridLayout, QLabel, QHBoxLayout
g = QGridLayout(self._tab_export)
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
# Row 0: annotation
g.addWidget(QLabel("Label:"), 0, 0); g.addWidget(self._txt_label, 0, 1)
g.addWidget(QLabel("Cat:"), 0, 2); g.addWidget(self._cmb_category, 0, 3)
g.addWidget(QLabel("Name:"), 0, 4); g.addWidget(self._txt_name, 0, 5)
# Row 1: output path
folder_row = QHBoxLayout()
folder_row.addWidget(self._txt_folder, 1); folder_row.addWidget(self._btn_folder)
g.addWidget(QLabel("Folder:"), 1, 0); g.addLayout(folder_row, 1, 1, 1, 5)
# Row 2: encode / clip params
g.addWidget(QLabel("Format:"), 2, 0); g.addWidget(self._cmb_format, 2, 1)
g.addWidget(self._chk_hw, 2, 2)
g.addWidget(QLabel("Resize:"), 2, 3); g.addWidget(self._spn_resize, 2, 4)
# Row 3: batch params + actions
g.addWidget(QLabel("Duration:"), 3, 0); g.addWidget(self._spn_clip_dur, 3, 1)
g.addWidget(QLabel("Clips:"), 3, 2); g.addWidget(self._spn_clips, 3, 3)
g.addWidget(QLabel("Spread:"), 3, 4); g.addWidget(self._spn_spread, 3, 5)
g.addWidget(QLabel("Workers:"), 4, 0); g.addWidget(self._spn_workers, 4, 1)
g.addWidget(self._btn_reexport, 4, 5)
```
Call it from `_build_control_deck` (or right after, in `__init__`).
**Step 2:** Delete the now-duplicate `addWidget` calls for these widgets from `path_row` and `settings_row` construction. (Re-parenting via `addWidget` into the grid auto-removes them from the old layout, but remove the dead lines to keep `__init__` honest.)
**Step 3 (manual verify):** Export tab shows aligned Label/Cat/Name, Folder+browse, Format/HW/Resize, Duration/Clips/Spread/Workers/Re-export. Change each → still persists to `QSettings` and updates the timeline span / next-label as before. Export still works (E).
**Step 4:** Commit: `refactor: move export & encode controls into Export tab`.
### Task 3.3: Populate the Crop & Track tab
**Files:** Modify `main.py` — move `_cmb_portrait`, `_chk_rand_portrait`, `_chk_rand_square`, `_chk_track` from `settings_row` (main.py:4337, 43494351).
**Step 1:**
```python
def _build_crop_tab(self) -> None:
from PyQt6.QtWidgets import QGridLayout, QLabel
g = QGridLayout(self._tab_crop)
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
g.addWidget(QLabel("Portrait:"), 0, 0); g.addWidget(self._cmb_portrait, 0, 1)
g.addWidget(self._chk_rand_portrait, 1, 0, 1, 2)
g.addWidget(self._chk_rand_square, 2, 0, 1, 2)
g.addWidget(self._chk_track, 3, 0, 1, 2)
g.setRowStretch(4, 1); g.setColumnStretch(2, 1)
```
**Step 2:** Remove those four widgets' old `settings_row.addWidget` lines.
**Step 3 (manual verify):** Crop & Track tab shows the four controls; portrait ratio still toggles the crop overlay/crop-bar; random/track checkboxes persist.
**Step 4:** Commit: `refactor: move crop & track controls into their tab`.
### Task 3.4: Populate the Scan tab (and drop menu-only buttons)
**Files:** Modify `main.py` — move scan widgets from `settings_row` (main.py:43524362). Buttons that became **menu-only** (Train, Scan All, Sub) are NOT added to the tab and are deleted.
**Step 1:**
```python
def _build_scan_tab(self) -> None:
from PyQt6.QtWidgets import QGridLayout, QLabel, QHBoxLayout
g = QGridLayout(self._tab_scan)
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
model_row = QHBoxLayout()
model_row.addWidget(self._cmb_scan_model, 1); model_row.addWidget(self._btn_model_history)
g.addWidget(QLabel("Model:"), 0, 0); g.addLayout(model_row, 0, 1, 1, 3)
g.addWidget(self._btn_scan, 1, 0); g.addWidget(self._btn_auto_export, 1, 1)
g.addWidget(self._btn_speech, 1, 2); g.addWidget(self._btn_scan_mode, 1, 3)
g.addWidget(self._spn_auto_fuse, 2, 0); g.addWidget(self._sld_threshold, 2, 1)
g.setColumnStretch(3, 1)
```
**Step 2:** Reverse-sync Review with the View menu (the forward sync was added in Task 1.1):
```python
self._btn_scan_mode.toggled.connect(self._act_review.setChecked)
```
Add this right after `_build_scan_tab` runs (both `_btn_scan_mode` and `_act_review` exist by then).
**Step 3:** Delete the menu-only buttons and their `settings_row` lines: `self._btn_train` (main.py:41674170), `self._btn_scan_all` (main.py:41724174), `self._btn_hide_subcats` (main.py:41544157). Their handlers (`_open_train_dialog`, `_start_scan_all`, `_show_subcat_menu`) stay — now reached via menus.
**Step 4:** Re-anchor `_show_subcat_menu` (main.py:5989) so it no longer depends on the deleted `_btn_hide_subcats`:
```python
# was: self._btn_hide_subcats.mapToGlobal(self._btn_hide_subcats.rect().bottomLeft())
from PyQt6.QtGui import QCursor
menu.exec(QCursor.pos())
```
Apply to **both** `exec` call sites in that method.
**Step 5 (manual verify):** Scan tab shows Model+history, Scan/Auto/Speech/Review, Fuse/Threshold. `Scan` runs; `Review` toggles and stays in sync with View ▸ Review mode (both directions); View ▸ Subcategory markers… opens the full popup near the cursor; Scan ▸ Scan All / Train still work.
**Step 6:** Commit: `refactor: move scan controls into Scan tab; Train/ScanAll/Sub to menus`.
### Task 3.5: Fix deck height; remove dead `path_row`/`settings_row`
**Files:** Modify `main.py``__init__`.
**Step 1:** The `path_row`/`settings_row` `QHBoxLayout`s should now be empty. Delete their construction blocks entirely (main.py:43214370 minus what was already removed), including the `self._transport_row = transport_row` line only if unused elsewhere (it IS used by `_rebuild_subprofile_buttons` — keep `transport_row`).
**Step 2:** Pin the deck height so tab switches don't move the video:
```python
self._control_deck.setFixedHeight(self._control_deck.sizeHint().height())
```
Call after all three tabs are built. If the tallest tab (Export, 5 rows) clips, set an explicit value instead (e.g. `setFixedHeight(150)`); confirm visually.
**Step 3 (manual verify):** Switching Export↔Crop↔Scan keeps the video size constant; no clipped controls; all three tabs fully usable.
**Step 4:** Commit: `refactor: fix control-deck height; drop dead settings rows`.
### Task 3.6: Extend the structure test for the deck
**Files:** Modify `tests/test_ui_structure.py`.
**Step 1:** Add invariants:
```python
def test_menubar_has_expected_menus(win):
titles = [m.title().replace("&", "") for m in win.menuBar().findChildren(type(win.menuBar().addMenu("")))]
for expected in ("File", "Edit", "Scan", "View", "Help"):
assert any(expected == t for t in titles)
def test_status_bar_exists(win):
assert win.statusBar() is not None
def test_workers_spinbox_in_export_tab(win):
from PyQt6.QtWidgets import QSpinBox
assert win._spn_workers in win._tab_export.findChildren(QSpinBox)
def test_scan_button_in_scan_tab(win):
from PyQt6.QtWidgets import QPushButton
assert win._btn_scan in win._tab_scan.findChildren(QPushButton)
def test_portrait_combo_in_crop_tab(win):
from PyQt6.QtWidgets import QComboBox
assert win._cmb_portrait in win._tab_crop.findChildren(QComboBox)
```
(Adjust the menu-title introspection if the helper is awkward; the key invariants are the tab-containment ones.)
**Step 2:** Run: `pytest tests/test_ui_structure.py -v` → PASS with a display (or SKIP headless).
**Step 3:** Commit: `test: assert control-deck containment invariants`.
---
## Stage 4 — Transport bar tidy & subprofile menu sync
### Task 4.1: Confirm transport bar contents; keep subprofile export buttons inline
**Files:** Modify `main.py``transport_row` (main.py:42964319).
**Step 1:** The workers spinbox was moved in Task 3.2 — confirm `transport_row.addWidget(self._spn_workers)` is gone. Remaining transport order: Play, Pause, x2, x4, Lock, time, stretch, next-label, **Export**, subprofile buttons, `+` (add subprofile), Cancel, Delete. Leave subprofile **export** buttons inline (they carry the 19 shortcuts and belong with Export).
**Step 2:** Keep the inline `+` add-subprofile button, but also ensure the Edit ▸ Subprofiles ▸ Remove submenu is rebuilt whenever subprofiles change. In `_rebuild_subprofile_buttons` (main.py:5530-ish) and after add/remove, call `self._rebuild_remove_subprofile_menu()`.
**Step 3 (manual verify):** Transport row reads cleanly; adding/removing a subprofile updates both the inline buttons and Edit ▸ Subprofiles ▸ Remove; number keys 19 still export to subprofiles.
**Step 4:** Commit: `change: tidy transport row; sync subprofile remove menu`.
---
## Stage 5 — Visual polish
All Stage 5 verification is **manual** (visual). Take a screenshot before 5.1 for comparison (use the `run`/`verify` skill).
### Task 5.1: Consolidate the stylesheet (tabs, status bar, toggles, primary button)
**Files:** Modify `main.py` — global stylesheet in `main()` (main.py:38113827).
**Step 1:** Extend the central sheet (append rules; keep existing ones):
```css
QTabWidget::pane { border: 1px solid #444; border-radius: 3px; top: -1px; }
QTabBar::tab { background: #2a2a2a; color: #bbb; padding: 5px 12px;
border: 1px solid #444; border-bottom: none;
border-top-left-radius: 3px; border-top-right-radius: 3px; }
QTabBar::tab:selected { background: #333; color: #fff; }
QPushButton:checked { background: #4a3000; border-color: #ffd230; color: #fff; }
QStatusBar { background: #1a1a1a; color: #bbb; }
QStatusBar::item { border: none; }
QPushButton#primary { background: #3a6ea8; border-color: #4f86c6; color: #fff; }
QPushButton#primary:hover { background: #4f86c6; }
QMenuBar { background: #1e1e1e; } QMenuBar::item:selected { background: #3a6ea8; }
QMenu { background: #2a2a2a; border: 1px solid #555; }
QMenu::item:selected { background: #3a6ea8; }
```
**Step 2:** Mark Export primary: `self._btn_export.setObjectName("primary")`.
**Step 3:** Replace Lock's inline stylesheet swap (main.py:5705) — since `QPushButton:checked` now styles all toggles, delete the two `self._btn_lock.setStyleSheet(...)` lines in `_on_lock_toggled` (keep the rest of the handler).
**Step 4 (manual verify):** Tabs, menus, status bar, and checked toggles (x2/x4/Lock/Review) all read consistently; Export stands out as primary; Lock still highlights when active.
**Step 5:** Commit: `style: unify tab/menu/statusbar/toggle styling; mark Export primary`.
### Task 5.2: Preserve the "armed to overwrite" Export state
**Files:** Inspect `main.py` — the red-Export swaps (main.py:5403, and the resets at 4960/5211/5447/7170/7199/7218).
**Step 1:** These set/clear `self._btn_export.setStyleSheet("QPushButton { background: #6a3030; ... }")` to mean "this export will overwrite". With Export now `objectName("primary")`, an empty `setStyleSheet("")` reset reverts to the **primary** look (good). Confirm the armed (red) state still visually overrides primary — inline stylesheet beats the objectName rule, so it does.
**Step 2 (manual verify):** Select a marker for re-export → Export turns red (armed); deselect → returns to blue primary; export → resets correctly.
**Step 3:** Commit (only if changes were needed): `fix: keep armed-overwrite Export state over primary style`.
### Task 5.3: Label cleanup
**Files:** Modify `main.py` — prefixes/labels.
**Step 1:** De-abbreviate where free: `_sld_threshold.setPrefix("Threshold: ")` (main.py:4207) → keep short if it overflows the tab; `_spn_auto_fuse` prefix stays `"Fuse: "`. Replace the `⏲` history button text with a tooltip-backed `"History"` or a clearer glyph; keep `setFixedWidth` generous enough.
**Step 2 (manual verify):** Labels legible; nothing clipped in the Scan tab.
**Step 3:** Commit: `style: de-abbreviate scan labels`.
---
## Stage 6 — Finalize
### Task 6.1: Full regression pass
**Step 1 (manual, use `verify` skill):** With a real video loaded, confirm end-to-end: scrub/play/pause/speed/lock; export (E) single + batch + subprofile (19); re-export; delete; portrait crop + random + track; scan + auto + speech + review + threshold/fuse; scan-all; train dialog opens; profile switch; queue filter/hide/show-hidden; Ctrl+Z undo; F1/`?` shortcuts.
**Step 2:** Run `pytest -q` (all suites). Expected: `core/` PASS; `test_ui_structure` PASS (display) or SKIP.
### Task 6.2: Docs & changelog
**Files:** Modify `README.md` (UI/shortcuts sections if any references moved) and the in-app `CHANGELOG` list (main.py:4500) — bump `APP_VERSION` and add a "UI restructure" entry so the What's-new dialog announces it.
**Step 1:** Add changelog entry summarizing: menu bar, tabbed control deck, status bar, visual polish; note all shortcuts unchanged.
**Step 2:** Commit: `docs: changelog + README for UI restructure`.
### Task 6.3: Hand off the branch
**Step 1:** `git log --oneline master..ui-restructure` — review the commit series.
**Step 2:** Offer the user: merge to `master`, open a PR, or keep iterating (use `finishing-a-development-branch` skill).
---
## Risk register
| Risk | Mitigation |
|------|-----------|
| Re-parenting breaks a `connect()` | Widgets keep identity; only layout membership changes. Manual launch after every task catches breakage immediately. |
| Headless test can't build `MpvWidget` | Structure test skips on construction failure; manual launch is authoritative. |
| Menu/button state desync (Review, Hide exported) | Bidirectional `setChecked` (no re-emit on equal value → no loop); verified manually in 3.4. |
| Subcat popup anchored to deleted button | Re-anchored to `QCursor.pos()` in Task 3.4. |
| Deck height jump on tab switch | `setFixedHeight` in Task 3.5. |
| Armed-overwrite red Export lost under primary style | Inline stylesheet overrides objectName rule; verified in 5.2. |
| Mid-Stage-3 app missing controls | Expected between 3.13.4; each sub-task is still committable and launchable. |
## What this plan does NOT change
`core/` logic · export/scan/tracking/DB behavior · keyboard shortcuts · timeline mouse interactions · the Queue and Scan-results panes' internals · the dark Fusion theme.
@@ -0,0 +1,96 @@
# 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.
> **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 (~39163923).
---
## 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.
+447 -84
View File
@@ -18,9 +18,10 @@ from PyQt6.QtWidgets import (
QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox,
QMessageBox, QInputDialog, QDialog, QDialogButtonBox, QFormLayout,
QTableWidget, QTableWidgetItem, QTabWidget, QTabBar, QHeaderView,
QGridLayout,
)
from PyQt6.QtCore import Qt, QObject, QThread, QTimer, QRect, QSize, pyqtSignal, QSettings
from PyQt6.QtGui import QPainter, QColor, QPen, QPixmap, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeySequence, QShortcut
from PyQt6.QtGui import QPainter, QColor, QPen, QPixmap, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeySequence, QShortcut, QIcon
if sys.platform == "win32":
# Help ctypes find libmpv-2.dll next to main.py or in frozen bundle
_dll_dir = Path(sys._MEIPASS) if getattr(sys, "frozen", False) else Path(__file__).parent
@@ -39,6 +40,13 @@ from core.db import ProcessedDB
from core.annotations import remove_clip_annotation, upsert_clip_annotation
from core.tracking import track_centers_for_jobs
_ASSET_DIR = (Path(sys._MEIPASS) if getattr(sys, "frozen", False) else Path(__file__).parent) / "assets"
def _icon(name: str) -> "QIcon":
return QIcon(str(_ASSET_DIR / "icons" / name))
_SELVA_CATEGORIES = ["", "Human", "Animal", "Vehicle", "Tool", "Music", "Nature", "Sport", "Other"]
@@ -3343,6 +3351,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
@@ -3804,6 +3835,7 @@ def main():
QSurfaceFormat.setDefaultFormat(_fmt)
app = QApplication(sys.argv)
app.setWindowIcon(_icon("app.svg"))
locale.setlocale(locale.LC_NUMERIC, "C") # QApplication resets locale; re-apply for libmpv
_kf = _KeyFilter(app)
app.installEventFilter(_kf)
@@ -3817,13 +3849,27 @@ def main():
QComboBox { background: #2a2a2a; border: 1px solid #555; padding: 3px 6px; border-radius: 3px; }
QComboBox::drop-down { subcontrol-position: right center; width: 18px; border-left: 1px solid #444; }
QComboBox::down-arrow { image: none; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid #888; margin-right: 4px; }
QComboBox QAbstractItemView { background: #2a2a2a; border: 1px solid #555; selection-background-color: #3a6ea8; }
QComboBox QAbstractItemView { background: #2a2a2a; border: 1px solid #555; selection-background-color: #3c82dc; }
QSpinBox, QDoubleSpinBox { background: #2a2a2a; border: 1px solid #555; padding: 3px; border-radius: 3px; }
QCheckBox::indicator { width: 14px; height: 14px; }
QListWidget { background: #252525; alternate-background-color: #2a2a2a; }
QListWidget::item { padding: 4px; color: #ccc; }
QListWidget::item:alternate { color: #ddd; }
QListWidget::item:selected { background: #3a6ea8; color: #fff; }
QListWidget::item:selected { background: #3c82dc; color: #fff; }
QTabWidget::pane { border: 1px solid #444; border-radius: 3px; top: -1px; }
QTabBar::tab { background: #2a2a2a; color: #bbb; padding: 5px 12px;
border: 1px solid #444; border-bottom: none;
border-top-left-radius: 3px; border-top-right-radius: 3px; }
QTabBar::tab:selected { background: #333; color: #fff; }
QPushButton:checked { background: #4a3000; border-color: #ffd230; color: #fff; }
QStatusBar { background: #1a1a1a; color: #bbb; }
QStatusBar::item { border: none; }
QPushButton#primary { background: #3c82dc; border-color: #5a9bf0; color: #fff; }
QPushButton#primary:hover { background: #5a9bf0; }
QMenuBar { background: #1e1e1e; } QMenuBar::item:selected { background: #3c82dc; }
QMenu { background: #2a2a2a; border: 1px solid #555; }
QMenu::item:selected { background: #3c82dc; }
QWidget#group_sep { background: #3a3a3a; }
""")
win = MainWindow()
win.show()
@@ -3839,6 +3885,7 @@ class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("8-cut")
self.setWindowIcon(_icon("app.svg"))
self.resize(1100, 680)
self.setAcceptDrops(True)
@@ -3953,12 +4000,14 @@ class MainWindow(QMainWindow):
from PyQt6.QtWidgets import QSizePolicy
self._lbl_file.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
self._btn_play = QPushButton("▶ Play")
self._btn_play = QPushButton("Play")
self._btn_play.setIcon(_icon("play.svg"))
self._btn_play.setEnabled(False)
self._btn_play.setToolTip("Play selection loop (Space / P)")
self._btn_play.clicked.connect(self._on_play)
self._btn_pause = QPushButton("⏸ Pause")
self._btn_pause = QPushButton("Pause")
self._btn_pause.setIcon(_icon("pause.svg"))
self._btn_pause.setEnabled(False)
self._btn_pause.setToolTip("Pause playback (Space / K)")
self._btn_pause.clicked.connect(self._on_pause)
@@ -3975,7 +4024,8 @@ class MainWindow(QMainWindow):
self._btn_speed4.setToolTip("Playback at 4× speed")
self._btn_speed4.clicked.connect(lambda: self._set_playback_speed(4.0))
self._btn_lock = QPushButton("🔒 Lock")
self._btn_lock = QPushButton("Lock")
self._btn_lock.setIcon(_icon("lock_open.svg"))
self._btn_lock.setCheckable(True)
self._btn_lock.setToolTip("Lock cursor — click/drag scrubs playback without moving the export point")
self._btn_lock.toggled.connect(self._on_lock_toggled)
@@ -4157,6 +4207,7 @@ class MainWindow(QMainWindow):
self._hidden_subcats: set[str] = set()
self._btn_scan = QPushButton("Scan")
self._btn_scan.setIcon(_icon("scan.svg"))
self._btn_scan.setToolTip("Scan current video for audio segments matching reference clips")
self._btn_scan.clicked.connect(self._start_scan)
@@ -4179,8 +4230,7 @@ class MainWindow(QMainWindow):
self._cmb_scan_model.setMinimumWidth(120)
self._cmb_scan_model.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self._cmb_scan_model.customContextMenuRequested.connect(self._show_model_versions_menu)
self._btn_model_history = QPushButton("\u23f2")
self._btn_model_history.setFixedWidth(28)
self._btn_model_history = QPushButton("History")
self._btn_model_history.setToolTip("Rollback to a previous model version")
self._btn_model_history.clicked.connect(
lambda: self._show_model_versions_menu(None)
@@ -4191,7 +4241,7 @@ class MainWindow(QMainWindow):
self._spn_auto_fuse.setRange(0.0, 60.0)
self._spn_auto_fuse.setSingleStep(1.0)
self._spn_auto_fuse.setValue(float(self._settings.value("auto_fuse", "4.0")))
self._spn_auto_fuse.setPrefix("Fuse: ")
self._spn_auto_fuse.setPrefix("Fuse gap: ")
self._spn_auto_fuse.setSuffix("s")
self._spn_auto_fuse.setToolTip("Max gap between scan regions to merge into one cluster")
self._spn_auto_fuse.valueChanged.connect(
@@ -4204,7 +4254,7 @@ class MainWindow(QMainWindow):
self._sld_threshold.setRange(0.0, 1.0)
self._sld_threshold.setSingleStep(0.01)
self._sld_threshold.setValue(0.50)
self._sld_threshold.setPrefix("Thr: ")
self._sld_threshold.setPrefix("Threshold: ")
self._sld_threshold.setToolTip("Similarity threshold (0=match everything, 1=exact match)")
self._scan_worker: ScanWorker | None = None
@@ -4218,6 +4268,7 @@ class MainWindow(QMainWindow):
self._spn_workers.valueChanged.connect(
lambda v: self._settings.setValue("workers", str(v))
)
self._spn_workers.valueChanged.connect(lambda: self._update_status_perm())
self._txt_label = QComboBox()
self._txt_label.setEditable(True)
@@ -4254,6 +4305,8 @@ class MainWindow(QMainWindow):
self._update_next_label()
self._btn_export = QPushButton("Export")
self._btn_export.setIcon(_icon("scissors.svg"))
self._btn_export.setObjectName("primary")
self._btn_export.setEnabled(False)
self._btn_export.setToolTip("Export clips at cursor position (E)")
self._btn_export.clicked.connect(self._on_export)
@@ -4287,11 +4340,11 @@ class MainWindow(QMainWindow):
self._btn_shortcuts.clicked.connect(self._show_shortcuts)
# Right-side layout (video + controls)
# Profile selector and the ? shortcuts button live in the menu-bar
# corner widget (built in _build_menubar); top_bar keeps only the
# slim filename header above the video.
top_bar = QHBoxLayout()
top_bar.addWidget(self._lbl_file, stretch=1)
top_bar.addWidget(QLabel("Profile:"))
top_bar.addWidget(self._cmb_profile)
top_bar.addWidget(self._btn_shortcuts)
# Row 1 — transport + export actions
transport_row = QHBoxLayout()
@@ -4313,61 +4366,13 @@ class MainWindow(QMainWindow):
self._btn_add_sub.clicked.connect(self._add_subprofile)
transport_row.addWidget(self._btn_add_sub)
transport_row.addWidget(self._btn_cancel)
transport_row.addWidget(self._spn_workers)
transport_row.addWidget(self._btn_delete)
self._transport_row = transport_row
self._rebuild_subprofile_buttons()
# Row 2 — annotation + output path
path_row = QHBoxLayout()
path_row.addWidget(QLabel("Label:"))
path_row.addWidget(self._txt_label)
path_row.addWidget(QLabel("Cat:"))
path_row.addWidget(self._cmb_category)
path_row.addWidget(QLabel("Name:"))
path_row.addWidget(self._txt_name)
path_row.addWidget(QLabel("Folder:"))
path_row.addWidget(self._txt_folder, stretch=1)
path_row.addWidget(self._btn_folder)
# Row 3 — video + encoding settings
settings_row = QHBoxLayout()
settings_row.addWidget(QLabel("Resize:"))
settings_row.addWidget(self._spn_resize)
settings_row.addWidget(QLabel("Portrait:"))
settings_row.addWidget(self._cmb_portrait)
settings_row.addWidget(QLabel("Format:"))
settings_row.addWidget(self._cmb_format)
settings_row.addWidget(self._chk_hw)
settings_row.addWidget(QLabel("Dur:"))
settings_row.addWidget(self._spn_clip_dur)
settings_row.addWidget(QLabel("Clips:"))
settings_row.addWidget(self._spn_clips)
settings_row.addWidget(QLabel("Spread:"))
settings_row.addWidget(self._spn_spread)
settings_row.addWidget(self._btn_reexport)
settings_row.addWidget(self._chk_rand_portrait)
settings_row.addWidget(self._chk_rand_square)
settings_row.addWidget(self._chk_track)
settings_row.addWidget(self._cmb_scan_model)
settings_row.addWidget(self._btn_model_history)
settings_row.addWidget(self._btn_scan)
settings_row.addWidget(self._btn_speech)
settings_row.addWidget(self._btn_scan_mode)
settings_row.addWidget(self._btn_hide_subcats)
settings_row.addWidget(self._btn_auto_export)
settings_row.addWidget(self._spn_auto_fuse)
settings_row.addWidget(self._sld_threshold)
settings_row.addWidget(self._btn_train)
settings_row.addWidget(self._btn_scan_all)
settings_row.addStretch()
self._lbl_status = QLabel()
self._lbl_status.setStyleSheet("color: #888; font-size: 11px;")
self._lbl_status.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._status_timer = QTimer(self)
self._status_timer.setSingleShot(True)
self._status_timer.timeout.connect(lambda: self._lbl_status.clear())
settings_row.addWidget(self._lbl_status)
# Row 2/3 — annotation, output path, crop and scan controls all live in
# the control deck's tabs now (_build_export_tab / _build_crop_tab /
# _build_scan_tab); path_row and settings_row are no longer mounted.
right = QWidget()
right_layout = QVBoxLayout(right)
@@ -4378,8 +4383,10 @@ class MainWindow(QMainWindow):
right_layout.addWidget(self._timeline)
right_layout.addWidget(self._crop_bar)
right_layout.addLayout(transport_row)
right_layout.addLayout(path_row)
right_layout.addLayout(settings_row)
right_layout.addWidget(self._build_control_deck())
self._build_export_tab()
self._build_crop_tab()
self._build_scan_tab()
# Left: queue header + playlist
self._btn_open = QPushButton("+ Open Files")
@@ -4425,6 +4432,35 @@ class MainWindow(QMainWindow):
self._scan_panel.loaded.connect(self._on_scan_panel_loaded)
self._sld_threshold.valueChanged.connect(self._on_threshold_changed)
# Menu bar — wires to the existing handler methods above. Built here,
# after _scan_panel and every referenced widget/button exist.
# Must run after the scan-toggle button and profile combo exist — the menu
# forward-syncs _btn_scan_mode and embeds _cmb_profile in the corner widget.
self._build_menubar()
self._build_status_bar()
# Reverse-sync the Scan tab's Review toggle back to the View ▸ Review
# mode action (the forward sync was wired in _build_menubar). Done here
# because _act_review only exists after _build_menubar(). setChecked
# does not re-emit on an unchanged value, so this cannot loop.
self._btn_scan_mode.toggled.connect(self._act_review.setChecked)
# Menu-only buttons (Train, Scan All, Sub) are reached via the menu bar
# now, but other code still references them (enable/disable, text). Keep
# the objects, re-parent to the window, and hide so they are not stray
# top-level windows.
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. 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
_tabbed_h = self._control_deck.sizeHint().height()
_split_h = self._SPLIT_HEADER_H + 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)
splitter.addWidget(left)
@@ -4437,7 +4473,6 @@ class MainWindow(QMainWindow):
self._main_splitter = splitter
self.setCentralWidget(splitter)
self.setStatusBar(None)
self._setup_keyboard_focus()
if saved_ratio != "Off":
self._crop_bar.setVisible(True)
@@ -4491,13 +4526,247 @@ 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)
# ── Control deck ─────────────────────────────────────────
def _group_sep(self) -> QWidget:
line = QWidget()
line.setObjectName("group_sep")
line.setFixedHeight(1)
return line
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")
# 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
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)
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
# Row 0: annotation
g.addWidget(QLabel("Label:"), 0, 0); g.addWidget(self._txt_label, 0, 1)
g.addWidget(QLabel("Cat:"), 0, 2); g.addWidget(self._cmb_category, 0, 3)
g.addWidget(QLabel("Name:"), 0, 4); g.addWidget(self._txt_name, 0, 5)
# Row 1: output path
folder_row = QHBoxLayout()
folder_row.addWidget(self._txt_folder, 1); folder_row.addWidget(self._btn_folder)
g.addWidget(QLabel("Folder:"), 1, 0); g.addLayout(folder_row, 1, 1, 1, 5)
# Row 2: separator — annotation+folder │ encode
g.addWidget(self._group_sep(), 2, 0, 1, 7)
# Row 3: encode / clip params
g.addWidget(QLabel("Format:"), 3, 0); g.addWidget(self._cmb_format, 3, 1)
g.addWidget(self._chk_hw, 3, 2)
g.addWidget(QLabel("Resize:"), 3, 3); g.addWidget(self._spn_resize, 3, 4)
# Row 4: separator — encode │ batch
g.addWidget(self._group_sep(), 4, 0, 1, 7)
# Row 5/6: batch params + actions
g.addWidget(QLabel("Duration:"), 5, 0); g.addWidget(self._spn_clip_dur, 5, 1)
g.addWidget(QLabel("Clips:"), 5, 2); g.addWidget(self._spn_clips, 5, 3)
g.addWidget(QLabel("Spread:"), 5, 4); g.addWidget(self._spn_spread, 5, 5)
g.addWidget(QLabel("Workers:"), 6, 0); g.addWidget(self._spn_workers, 6, 1)
g.addWidget(self._btn_reexport, 6, 5)
g.setColumnStretch(6, 1)
def _build_crop_tab(self) -> None:
g = QGridLayout(self._tab_crop)
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
g.addWidget(QLabel("Portrait:"), 0, 0); g.addWidget(self._cmb_portrait, 0, 1)
g.addWidget(self._chk_rand_portrait, 1, 0, 1, 2)
g.addWidget(self._chk_rand_square, 2, 0, 1, 2)
g.addWidget(self._chk_track, 3, 0, 1, 2)
g.setRowStretch(4, 1); g.setColumnStretch(2, 1)
def _build_scan_tab(self) -> None:
g = QGridLayout(self._tab_scan)
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
model_row = QHBoxLayout()
model_row.addWidget(self._cmb_scan_model, 1); model_row.addWidget(self._btn_model_history)
g.addWidget(QLabel("Model:"), 0, 0); g.addLayout(model_row, 0, 1, 1, 3)
# Row 1: separator — model │ actions
g.addWidget(self._group_sep(), 1, 0, 1, 4)
g.addWidget(self._btn_scan, 2, 0); g.addWidget(self._btn_auto_export, 2, 1)
g.addWidget(self._btn_speech, 2, 2); g.addWidget(self._btn_scan_mode, 2, 3)
# Row 3: separator — actions │ fuse/threshold
g.addWidget(self._group_sep(), 3, 0, 1, 4)
g.addWidget(self._spn_auto_fuse, 4, 0); g.addWidget(self._sld_threshold, 4, 1)
g.setColumnStretch(3, 1)
# ── Menu bar ─────────────────────────────────────────────
def _build_menubar(self) -> None:
mb = self.menuBar()
# File
m_file = mb.addMenu("&File")
m_file.addAction("Open Files…", self._on_open_files)
m_file.addAction("Set export folder…", self._pick_folder)
m_file.addSeparator()
m_file.addAction("Quit", self.close)
# Edit
m_edit = mb.addMenu("&Edit")
self._act_undo = m_edit.addAction("Undo scan edit", self._scan_panel.undo)
m_edit.addSeparator()
m_subs = m_edit.addMenu("Subprofiles")
m_subs.addAction("Add…", self._new_subprofile)
self._menu_subprofiles_remove = m_subs.addMenu("Remove")
self._rebuild_remove_subprofile_menu()
# Scan
m_scan = mb.addMenu("&Scan")
_act_scan_cur = m_scan.addAction("Scan current", self._start_scan)
_act_scan_cur.setIcon(_icon("scan.svg"))
m_scan.addAction("Auto-export", self._auto_export)
m_scan.addSeparator()
m_scan.addAction("Scan All…", self._btn_scan_all.click)
_act_train = m_scan.addAction("Train classifier…", self._btn_train.click)
_act_train.setIcon(_icon("train.svg"))
# View
m_view = mb.addMenu("&View")
self._act_review = m_view.addAction("Review mode")
self._act_review.setCheckable(True)
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)
self._chk_hide_exported.toggled.connect(self._act_hide_exported.setChecked)
self._act_show_hidden = m_view.addAction("Show hidden")
self._act_show_hidden.setCheckable(True)
self._act_show_hidden.toggled.connect(self._btn_show_hidden.setChecked)
self._btn_show_hidden.toggled.connect(self._act_show_hidden.setChecked)
self._act_review.setChecked(self._btn_scan_mode.isChecked())
self._act_hide_exported.setChecked(self._chk_hide_exported.isChecked())
self._act_show_hidden.setChecked(self._btn_show_hidden.isChecked())
# Help
m_help = mb.addMenu("&Help")
m_help.addAction("Keyboard shortcuts", self._show_shortcuts)
m_help.addAction("What's new", self._show_changelog)
m_help.addAction("About", self._show_about)
# Profile selector + ? help button live in the top-right corner.
corner = QWidget()
ch = QHBoxLayout(corner)
ch.setContentsMargins(0, 0, 6, 0)
ch.addWidget(QLabel("Profile:"))
ch.addWidget(self._cmb_profile)
ch.addWidget(self._btn_shortcuts)
mb.setCornerWidget(corner, Qt.Corner.TopRightCorner)
def _show_about(self) -> None:
QMessageBox.about(self, "About 8-cut",
f"<b>8-cut</b> v{self.APP_VERSION}<br>"
"8-second clips for foley datasets.")
def _rebuild_remove_subprofile_menu(self) -> None:
self._menu_subprofiles_remove.clear()
for name in self._subprofiles:
self._menu_subprofiles_remove.addAction(
name, lambda _=False, n=name: self._remove_subprofile(n))
self._menu_subprofiles_remove.setEnabled(bool(self._subprofiles))
def _build_status_bar(self) -> None:
sb = self.statusBar()
self._status_perm = QLabel("")
self._status_perm.setStyleSheet("color: #888;")
sb.addPermanentWidget(self._status_perm)
self._update_status_perm()
def _update_status_perm(self) -> None:
name = os.path.basename(self._file_path) if self._file_path else ""
self._status_perm.setText(
f"{name} · profile: {self._profile} · {self._spn_workers.value()} workers")
# ── Changelog ────────────────────────────────────────────
APP_VERSION = "1.0"
APP_VERSION = "1.1"
_SPLIT_HEADER_H = 22 # deck split-column header height (keep both deck spots in sync)
CHANGELOG: list[tuple[str, list[str]]] = [
("1.1", [
"<b>Reorganized interface</b> — the dense control rows are now a "
"<b>menu bar</b> (File / Edit / Scan / View / Help) for occasional "
"actions plus a compact <b>tabbed control deck</b> "
"(Export / Crop &amp; Track / Scan) under the video. Every control "
"and keyboard shortcut works exactly as before; the profile selector "
"and shortcuts (?) moved to the top-right corner.",
"<b>Side-by-side panels</b> — pin deck panels to view them as "
"resizable columns: right-click a deck tab → <i>Show side-by-side</i>, "
"or toggle them under <i>View ▸ Side-by-side panels</i>. Drag the "
"dividers to reallocate space; the layout is remembered between "
"sessions.",
"<b>Status bar</b> — export/scan progress and messages now appear in "
"a real status bar, with the current file, profile, and worker count "
"always shown on the right.",
"<b>Visual polish</b> — a primary Export button, a consistent "
"highlight for toggle buttons (×2 / ×4 / Lock / Review), grouped "
"controls with separators, and clearer labels.",
]),
("1.0", [
"<b>New export layout</b> — clips are now stored in per-video "
"<code>vid_NNN/</code> folders instead of per-clip "
@@ -4801,6 +5070,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
# Defensive: suppress _save_deck_layout during a rebuild (mirrors the
# playlist's _loading_tabs). No re-entrant save path exists today.
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)
for panel in self._deck_panels: # preserve deck order
if not panel._pinned:
continue # unpinned panels are hidden in split mode
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(self._SPLIT_HEADER_H)
# 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)
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)
# 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
def _toggle_panel_pin(self, panel) -> None:
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_pin_toggle(self, idx: int) -> None:
self._toggle_panel_pin(self._control_deck.widget(idx))
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.
@@ -4969,6 +5334,7 @@ class MainWindow(QMainWindow):
if self._playlist.count() > 0:
self._playlist._select(0)
self._refresh_markers()
self._update_status_perm()
_log(f"Profile switched: {text}")
self._show_status(f"Profile: {text}", 3000)
@@ -5026,6 +5392,10 @@ class MainWindow(QMainWindow):
self._transport_row.insertWidget(anchor + i, btn)
self._subprofile_btns.append(btn)
self._rebuild_format_buttons()
# Keep the Edit ▸ Subprofiles ▸ Remove submenu in sync. Guarded because
# this method runs in __init__ before _build_menubar creates the menu.
if hasattr(self, "_menu_subprofiles_remove"):
self._rebuild_remove_subprofile_menu()
def _add_subprofile(self):
from PyQt6.QtWidgets import QMenu
@@ -5063,12 +5433,8 @@ class MainWindow(QMainWindow):
btn.setEnabled(enabled)
def _show_status(self, msg: str, timeout: int = 0) -> None:
"""Show a message in the inline status label. Timeout in ms (0 = sticky)."""
self._lbl_status.setText(msg)
if timeout > 0:
self._status_timer.start(timeout)
else:
self._status_timer.stop()
"""Show a transient message in the status bar. timeout in ms (0 = sticky)."""
self.statusBar().showMessage(msg, timeout)
def _on_hide_exported_toggled(self, hide: bool) -> None:
self._settings.setValue("hide_exported", "true" if hide else "false")
@@ -5233,6 +5599,8 @@ class MainWindow(QMainWindow):
self._db_worker.result.connect(self._on_db_result)
self._db_worker.start()
self._update_status_perm()
def _on_db_result(self, queried: str, match: object, markers: list) -> None:
# Discard stale results if the user loaded a different file already.
if os.path.basename(self._file_path) != queried:
@@ -5240,7 +5608,7 @@ class MainWindow(QMainWindow):
if match:
self._show_status(f"⚠ Similar to already processed: {match}")
else:
self._lbl_status.clear()
self._show_status("")
self._timeline.set_markers(markers)
self._refresh_other_markers()
@@ -5700,11 +6068,8 @@ class MainWindow(QMainWindow):
def _on_lock_toggled(self, locked: bool):
self._timeline._locked = locked
self._btn_lock.setText("🔒 Lock" if locked else "🔓 Lock")
if locked:
self._btn_lock.setStyleSheet("background: #4a3000; border-color: #ffd230;")
else:
self._btn_lock.setStyleSheet("")
self._btn_lock.setIcon(_icon("lock.svg" if locked else "lock_open.svg"))
if not locked:
# Clear keyframes when unlocking.
if self._crop_keyframes:
n = len(self._crop_keyframes)
@@ -5986,8 +6351,7 @@ class MainWindow(QMainWindow):
folders = sorted(folder_set)
if not folders:
menu.addAction("(no subcategories)").setEnabled(False)
menu.exec(self._btn_hide_subcats.mapToGlobal(
self._btn_hide_subcats.rect().bottomLeft()))
menu.exec(QCursor.pos())
return
container = QWidget()
@@ -6040,8 +6404,7 @@ class MainWindow(QMainWindow):
wa = QWidgetAction(menu)
wa.setDefaultWidget(container)
menu.addAction(wa)
menu.exec(self._btn_hide_subcats.mapToGlobal(
self._btn_hide_subcats.rect().bottomLeft()))
menu.exec(QCursor.pos())
def _disable_all_subcats(self) -> None:
"""Disable every enabled subcategory at once (across all videos)."""
+109
View File
@@ -0,0 +1,109 @@
import pytest
# A real platform is needed because MpvWidget creates a GL context.
# If construction fails for any environment reason, skip — this test is a
# best-effort structural net, not a gate on core/ tests.
pytestmark = pytest.mark.gui
@pytest.fixture(scope="module")
def app():
from PyQt6.QtWidgets import QApplication
inst = QApplication.instance() or QApplication([])
yield inst
@pytest.fixture
def win(app):
try:
from main import MainWindow
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()
def test_window_constructs(win):
assert win.windowTitle().startswith("8-cut")
def test_status_bar_exists(win):
assert win.statusBar() is not None
def test_workers_spinbox_in_export_tab(win):
from PyQt6.QtWidgets import QSpinBox
assert win._spn_workers in win._tab_export.findChildren(QSpinBox)
def test_scan_button_in_scan_tab(win):
from PyQt6.QtWidgets import QPushButton
assert win._btn_scan in win._tab_scan.findChildren(QPushButton)
def test_portrait_combo_in_crop_tab(win):
from PyQt6.QtWidgets import QComboBox
assert win._cmb_portrait in win._tab_crop.findChildren(QComboBox)
def test_menu_only_buttons_not_in_deck(win):
from PyQt6.QtWidgets import QPushButton
deck_btns = win._control_deck.findChildren(QPushButton)
assert win._btn_train not in deck_btns
assert win._btn_scan_all not in deck_btns
assert win._btn_hide_subcats not in deck_btns
def test_deck_stack_exists(win):
# The deck is wrapped in a stack so it can swap tabbed <-> side-by-side.
# Default (nothing pinned) shows the tabbed control deck.
assert win._deck_stack is not None
assert win._deck_stack.currentWidget() is win._control_deck
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