UI/UX polish: Tools menu, clickable timeline, live drag-seek, drag-and-drop load

- Clap detection moved from inline buttons to a Tools menu, plain labels
  (no more '(whole file)' / '(from cursor)' parentheticals) -- status
  messages moved to the window's status bar since the dedicated label
  widget went away with the button row.
- ClickableSlider subclass: clicking anywhere along the seek track jumps
  straight there instead of QSlider's default page-by-a-step behavior;
  reposition-then-delegate-to-super keeps native drag behavior intact.
- Dragging now seeks live via sliderMoved instead of waiting for
  sliderReleased.
- PlayerWidget accepts drag-and-drop of a local file to load it.

Verified with real Qt events (QMouseEvent clicks, a QDropEvent, a real
mouse click via xdotool opening the Tools menu on an actual running
window) rather than just calling the underlying methods directly.
This commit is contained in:
ThePendulum
2026-07-26 01:55:32 +02:00
parent f7c15e924c
commit 5a6c4e6e84
4 changed files with 166 additions and 40 deletions
+50 -35
View File
@@ -22,6 +22,24 @@ def format_timestamp(seconds: float) -> str:
return f"{int(m):02d}:{s:06.3f}" return f"{int(m):02d}:{s:06.3f}"
class ClickableSlider(QSlider):
"""QSlider normally only jumps to the clicked position if you click
exactly on the handle; clicking elsewhere in the groove pages by a
fixed step. Override so any click along the track jumps straight
there, then let the base class take over so dragging still works
(the handle is now under the cursor, so its own drag tracking picks
up seamlessly).
"""
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton and self.maximum() > self.minimum():
ratio = min(max(event.position().x() / max(self.width(), 1), 0.0), 1.0)
value = round(self.minimum() + ratio * (self.maximum() - self.minimum()))
self.setValue(value)
self.sliderMoved.emit(value)
super().mousePressEvent(event)
class ExportWorker(QThread): class ExportWorker(QThread):
finished_ok = pyqtSignal(object) # ExportResult finished_ok = pyqtSignal(object) # ExportResult
finished_err = pyqtSignal(str, str) # message, stderr finished_err = pyqtSignal(str, str) # message, stderr
@@ -78,6 +96,7 @@ class MainWindow(QMainWindow):
self.player = PlayerWidget() self.player = PlayerWidget()
self.player.setMinimumHeight(400) self.player.setMinimumHeight(400)
self.player.fileDropped.connect(self._load)
self._build_ui() self._build_ui()
self._build_menu() self._build_menu()
@@ -130,9 +149,10 @@ class MainWindow(QMainWindow):
self.marker_strip.markerClicked.connect(self._on_marker_clicked) self.marker_strip.markerClicked.connect(self._on_marker_clicked)
slider_stack.addWidget(self.marker_strip) slider_stack.addWidget(self.marker_strip)
self.seek_slider = QSlider(Qt.Orientation.Horizontal) self.seek_slider = ClickableSlider(Qt.Orientation.Horizontal)
self.seek_slider.setRange(0, 0) self.seek_slider.setRange(0, 0)
self.seek_slider.sliderPressed.connect(lambda: setattr(self, "_slider_being_dragged", True)) self.seek_slider.sliderPressed.connect(lambda: setattr(self, "_slider_being_dragged", True))
self.seek_slider.sliderMoved.connect(self._on_slider_moved)
self.seek_slider.sliderReleased.connect(self._on_slider_released) self.seek_slider.sliderReleased.connect(self._on_slider_released)
slider_stack.addWidget(self.seek_slider) slider_stack.addWidget(self.seek_slider)
seek_row.addLayout(slider_stack, stretch=1) seek_row.addLayout(slider_stack, stretch=1)
@@ -141,25 +161,6 @@ class MainWindow(QMainWindow):
seek_row.addWidget(self.duration_label) seek_row.addWidget(self.duration_label)
layout.addLayout(seek_row) layout.addLayout(seek_row)
# Clap/onset auto-detect row
detect_group = QGroupBox("Clap detection")
detect_layout = QHBoxLayout(detect_group)
self.detect_all_button = QPushButton("Detect All Claps (whole file)")
self.detect_all_button.setEnabled(False)
self.detect_all_button.clicked.connect(self._on_detect_all_clicked)
detect_layout.addWidget(self.detect_all_button)
self.detect_next_button = QPushButton("Detect Next Clap (from cursor)")
self.detect_next_button.setEnabled(False)
self.detect_next_button.clicked.connect(self._on_detect_next_clicked)
detect_layout.addWidget(self.detect_next_button)
self.detect_status_label = QLabel("")
detect_layout.addWidget(self.detect_status_label, stretch=1)
layout.addWidget(detect_group)
# Marker row # Marker row
marker_group = QGroupBox("Sync markers") marker_group = QGroupBox("Sync markers")
marker_layout = QHBoxLayout(marker_group) marker_layout = QHBoxLayout(marker_group)
@@ -214,6 +215,18 @@ class MainWindow(QMainWindow):
self.toggle_log_action.toggled.connect(self.log_view.setVisible) self.toggle_log_action.toggled.connect(self.log_view.setVisible)
view_menu.addAction(self.toggle_log_action) view_menu.addAction(self.toggle_log_action)
tools_menu = self.menuBar().addMenu("&Tools")
self.detect_all_action = QAction("Detect All Claps", self)
self.detect_all_action.setEnabled(False)
self.detect_all_action.triggered.connect(self._on_detect_all_clicked)
tools_menu.addAction(self.detect_all_action)
self.detect_next_action = QAction("Detect Next Clap", self)
self.detect_next_action.setEnabled(False)
self.detect_next_action.triggered.connect(self._on_detect_next_clicked)
tools_menu.addAction(self.detect_next_action)
def _wire_shortcuts(self): def _wire_shortcuts(self):
QShortcut(QKeySequence(","), self, activated=lambda: self._step_frame(-1)) QShortcut(QKeySequence(","), self, activated=lambda: self._step_frame(-1))
QShortcut(QKeySequence("."), self, activated=lambda: self._step_frame(1)) QShortcut(QKeySequence("."), self, activated=lambda: self._step_frame(1))
@@ -246,9 +259,9 @@ class MainWindow(QMainWindow):
self.marker_strip.set_duration(duration) self.marker_strip.set_duration(duration)
self.marker_strip.clear() self.marker_strip.clear()
self.detect_status_label.setText("") self.statusBar().clearMessage()
self.detect_all_button.setEnabled(True) self.detect_all_action.setEnabled(True)
self.detect_next_button.setEnabled(True) self.detect_next_action.setEnabled(True)
def _toggle_play(self): def _toggle_play(self):
if self.player.mpv.pause: if self.player.mpv.pause:
@@ -267,9 +280,11 @@ class MainWindow(QMainWindow):
def _on_mute_toggled(self, checked: bool): def _on_mute_toggled(self, checked: bool):
self.player.mpv.mute = checked self.player.mpv.mute = checked
def _on_slider_moved(self, value: int):
self.player.seek(value / 1000.0)
self._update_position_ui()
def _on_slider_released(self): def _on_slider_released(self):
target_sec = self.seek_slider.value() / 1000.0
self.player.seek(target_sec)
self._slider_being_dragged = False self._slider_being_dragged = False
self._update_position_ui() self._update_position_ui()
@@ -320,9 +335,9 @@ class MainWindow(QMainWindow):
def _start_detection(self, start_offset: float, max_results, label: str): def _start_detection(self, start_offset: float, max_results, label: str):
if self.input_path is None: if self.input_path is None:
return return
self.detect_all_button.setEnabled(False) self.detect_all_action.setEnabled(False)
self.detect_next_button.setEnabled(False) self.detect_next_action.setEnabled(False)
self.detect_status_label.setText(label) self.statusBar().showMessage(label)
self._detect_worker = DetectWorker(self.input_path, start_offset=start_offset, max_results=max_results) self._detect_worker = DetectWorker(self.input_path, start_offset=start_offset, max_results=max_results)
self._detect_worker.finished_ok.connect(self._on_detect_done) self._detect_worker.finished_ok.connect(self._on_detect_done)
@@ -330,26 +345,26 @@ class MainWindow(QMainWindow):
self._detect_worker.start() self._detect_worker.start()
def _on_detect_done(self, events: list): def _on_detect_done(self, events: list):
self.detect_all_button.setEnabled(True) self.detect_all_action.setEnabled(True)
self.detect_next_button.setEnabled(True) self.detect_next_action.setEnabled(True)
existing = set(self.marker_strip.markers()) existing = set(self.marker_strip.markers())
existing.update(e.time_sec for e in events) existing.update(e.time_sec for e in events)
self.marker_strip.set_markers(list(existing)) self.marker_strip.set_markers(list(existing))
if not events: if not events:
self.detect_status_label.setText("No claps found.") self.statusBar().showMessage("No claps found.", 5000)
self.log_view.appendPlainText("Clap detection: no onsets found.") self.log_view.appendPlainText("Clap detection: no onsets found.")
return return
self.detect_status_label.setText(f"Found {len(events)} clap(s) — click a marker above the timeline to jump to it.") self.statusBar().showMessage(f"Found {len(events)} clap(s) — click a marker above the timeline to jump to it.", 8000)
for e in events: for e in events:
self.log_view.appendPlainText(f"Clap detected at {format_timestamp(e.time_sec)} (strength {e.strength:.2f})") self.log_view.appendPlainText(f"Clap detected at {format_timestamp(e.time_sec)} (strength {e.strength:.2f})")
def _on_detect_failed(self, message: str): def _on_detect_failed(self, message: str):
self.detect_all_button.setEnabled(True) self.detect_all_action.setEnabled(True)
self.detect_next_button.setEnabled(True) self.detect_next_action.setEnabled(True)
self.detect_status_label.setText("Detection failed (see log).") self.statusBar().showMessage("Detection failed (see log).", 8000)
self.log_view.appendPlainText(f"Clap detection FAILED: {message}") self.log_view.appendPlainText(f"Clap detection FAILED: {message}")
# -- export ----------------------------------------------------- # -- export -----------------------------------------------------
+15 -1
View File
@@ -9,7 +9,7 @@ import time
import mpv import mpv
from PyQt6.QtWidgets import QWidget from PyQt6.QtWidgets import QWidget
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt, pyqtSignal
class PlayerWidget(QWidget): class PlayerWidget(QWidget):
@@ -20,10 +20,13 @@ class PlayerWidget(QWidget):
this exists instead of a QMediaPlayer/HTML5-video-style backend. this exists instead of a QMediaPlayer/HTML5-video-style backend.
""" """
fileDropped = pyqtSignal(str)
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_DontCreateNativeAncestors) self.setAttribute(Qt.WidgetAttribute.WA_DontCreateNativeAncestors)
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow) self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow)
self.setAcceptDrops(True)
# QApplication resets LC_NUMERIC to the system locale on construction, # QApplication resets LC_NUMERIC to the system locale on construction,
# which undoes python-mpv's own import-time fix and makes libmpv abort # which undoes python-mpv's own import-time fix and makes libmpv abort
@@ -108,3 +111,14 @@ class PlayerWidget(QWidget):
def shutdown(self): def shutdown(self):
self.mpv.terminate() self.mpv.terminate()
def dragEnterEvent(self, event):
if event.mimeData().hasUrls() and any(u.isLocalFile() for u in event.mimeData().urls()):
event.acceptProposedAction()
def dropEvent(self, event):
for url in event.mimeData().urls():
if url.isLocalFile():
self.fileDropped.emit(url.toLocalFile())
event.acceptProposedAction()
return
+4 -4
View File
@@ -39,12 +39,12 @@ def main():
time.sleep(0.3) time.sleep(0.3)
qapp.processEvents() qapp.processEvents()
assert win.detect_all_button.isEnabled() assert win.detect_all_action.isEnabled()
win.detect_all_button.click() win.detect_all_action.trigger()
qapp.processEvents() qapp.processEvents()
assert not win.detect_all_button.isEnabled(), "button should disable while detection runs" assert not win.detect_all_action.isEnabled(), "action should disable while detection runs"
ok = wait_for(lambda: win.detect_all_button.isEnabled(), qapp) ok = wait_for(lambda: win.detect_all_action.isEnabled(), qapp)
assert ok, "detection did not finish in time" assert ok, "detection did not finish in time"
markers = win.marker_strip.markers() markers = win.marker_strip.markers()
+97
View File
@@ -0,0 +1,97 @@
"""Verifies the four interaction changes requested after the first review:
- clap detection moved to a Tools menu (no more buttons in the layout)
- clicking anywhere on the slider track jumps straight there
- dragging the slider seeks live (via sliderMoved), not just on release
- dropping a file onto the player widget loads it
Drives real Qt events (QMouseEvent, QDropEvent) against the actual
MainWindow/PlayerWidget classes rather than calling internal methods
directly, so this proves the actual event wiring, not just the logic
behind it.
"""
import sys
import time
from pathlib import Path
from PyQt6.QtCore import Qt, QPointF, QUrl, QMimeData
from PyQt6.QtGui import QMouseEvent, QDropEvent
from PyQt6.QtWidgets import QApplication
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import main as app_main
ROOT = Path(__file__).resolve().parent.parent
CLIP = str(ROOT / "test_media" / "sync_test_INSYNC.mp4")
def main():
qapp = QApplication(sys.argv)
win = app_main.MainWindow()
win.show()
qapp.processEvents()
# -- Tools menu has the clap detection actions, no buttons in the layout
menu_titles = [a.text() for a in win.menuBar().actions()]
assert "&Tools" in menu_titles, f"expected a Tools menu, got {menu_titles}"
assert not hasattr(win, "detect_all_button"), "old button attribute should be gone"
assert win.detect_all_action.text() == "Detect All Claps"
assert win.detect_next_action.text() == "Detect Next Clap"
print("PASS: Tools menu has plain-labeled clap detection actions")
# -- Load via drag-and-drop simulation on the player widget
mime = QMimeData()
mime.setUrls([QUrl.fromLocalFile(CLIP)])
drop_event = QDropEvent(
QPointF(10, 10), Qt.DropAction.CopyAction, mime,
Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
QDropEvent.Type.Drop,
)
win.player.dropEvent(drop_event)
qapp.processEvents()
time.sleep(0.3)
qapp.processEvents()
assert win.input_path == CLIP, f"drag-and-drop should have loaded the file, got {win.input_path}"
print(f"PASS: drag-and-drop loaded {Path(win.input_path).name}")
# -- Clicking directly on the slider track (not the handle) jumps there
duration_ms = win.seek_slider.maximum()
assert duration_ms > 0
target_x = win.seek_slider.width() * 0.5
click_pos = QPointF(target_x, win.seek_slider.height() / 2)
press_event = QMouseEvent(
QMouseEvent.Type.MouseButtonPress, click_pos, click_pos,
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
)
before = win.player.get_position()
win.seek_slider.mousePressEvent(press_event)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
after = win.player.get_position()
expected = 0.5 * (duration_ms / 1000.0)
print(f"clicked slider track at 50%: before={before:.3f}s after={after:.3f}s expected~{expected:.3f}s")
assert abs(after - expected) < 0.5, "clicking the track should jump the player near that position"
print("PASS: click-anywhere-on-track seeking works")
# -- Dragging (sliderMoved) seeks live, without waiting for release
win.seek_slider.sliderPressed.emit()
qapp.processEvents()
drag_value = int(0.2 * duration_ms)
win.seek_slider.setValue(drag_value)
win.seek_slider.sliderMoved.emit(drag_value)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
live_pos = win.player.get_position()
expected_drag = drag_value / 1000.0
print(f"dragged to 20% (still 'pressed', not released): live player position={live_pos:.3f}s expected~{expected_drag:.3f}s")
assert abs(live_pos - expected_drag) < 0.5, "dragging should seek immediately, before release"
win.seek_slider.sliderReleased.emit()
print("PASS: live-seek during drag works")
win.player.shutdown()
print("\nALL INTERACTION SMOKE TESTS PASSED")
if __name__ == "__main__":
main()