Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbbdfeadb1 | |||
| 8a7d761815 | |||
| 140a424469 | |||
| bc6e30a2d4 | |||
| 2ea3a9149a | |||
| e820c106af | |||
| 780832d4aa | |||
| 6037f15e7b | |||
| 035eaf3894 | |||
| 35ea1baec8 | |||
| 6a71386ed8 | |||
| d1fb35af8e | |||
| c55693094d | |||
| 5832d08b26 | |||
| b4cfa7561a | |||
| 0ccc29709e | |||
| 7e917d00a6 | |||
| 2ffb81eaa3 |
@@ -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():
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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:4334–4370): 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:4450–4483) 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:4450–4483), `_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:4290–4294) 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:4364–4370) 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:4364–4370). 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:4372–4382).
|
||||
|
||||
**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.2–3.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:4322–4331) and the encode/clip parts of `settings_row` (main.py:4334–4348) 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, 4349–4351).
|
||||
|
||||
**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:4352–4362). 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:4167–4170), `self._btn_scan_all` (main.py:4172–4174), `self._btn_hide_subcats` (main.py:4154–4157). 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:4321–4370 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:4296–4319).
|
||||
|
||||
**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 1–9 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 1–9 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:3811–3827).
|
||||
|
||||
**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 (1–9); 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.1–3.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 (~3916–3923).
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### Panel identity
|
||||
The deck's three pages (`_tab_export`, `_tab_crop`, `_tab_scan`) each get three attributes (set in `_build_control_deck`):
|
||||
- `_pinned: bool = False`
|
||||
- `_label: str` — "Export" / "Crop & Track" / "Scan"
|
||||
- `_deck_key: str` — "export" / "crop" / "scan" (stable key for persistence)
|
||||
|
||||
Keep an ordered list `self._deck_panels = [self._tab_export, self._tab_crop, self._tab_scan]` for deterministic column order.
|
||||
|
||||
### Tab bar
|
||||
New `class _DeckTabBar(QTabBar)` (minimal version of `_PlaylistTabBar`): on `contextMenuEvent`, show a checkable "Show side-by-side" action reflecting the page's `_pinned`, and emit `pin_toggle_requested(idx)` when chosen. No rename/folder. Install via `self._control_deck.setTabBar(_DeckTabBar())` in `_build_control_deck` and connect `pin_toggle_requested → self._on_deck_pin_toggle`.
|
||||
|
||||
### Stacked container (mirrors `_list_stack`)
|
||||
Wrap the deck so it can swap between tabbed and split views:
|
||||
- `self._deck_split_container = QWidget()` with 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.
|
||||
@@ -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 & 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)."""
|
||||
|
||||
@@ -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
|
||||