From 5a6c4e6e84fa0f07ab6b27441af9257e3e362039 Mon Sep 17 00:00:00 2001 From: ThePendulum Date: Sun, 26 Jul 2026 01:55:32 +0200 Subject: [PATCH] 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. --- main.py | 85 ++++++++++++++---------- player_widget.py | 16 ++++- scripts/smoke_test_clap_detection.py | 8 +-- scripts/smoke_test_interactions.py | 97 ++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 40 deletions(-) create mode 100644 scripts/smoke_test_interactions.py diff --git a/main.py b/main.py index 172e700..70a06b2 100644 --- a/main.py +++ b/main.py @@ -22,6 +22,24 @@ def format_timestamp(seconds: float) -> str: 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): finished_ok = pyqtSignal(object) # ExportResult finished_err = pyqtSignal(str, str) # message, stderr @@ -78,6 +96,7 @@ class MainWindow(QMainWindow): self.player = PlayerWidget() self.player.setMinimumHeight(400) + self.player.fileDropped.connect(self._load) self._build_ui() self._build_menu() @@ -130,9 +149,10 @@ class MainWindow(QMainWindow): self.marker_strip.markerClicked.connect(self._on_marker_clicked) 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.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) slider_stack.addWidget(self.seek_slider) seek_row.addLayout(slider_stack, stretch=1) @@ -141,25 +161,6 @@ class MainWindow(QMainWindow): seek_row.addWidget(self.duration_label) 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_group = QGroupBox("Sync markers") marker_layout = QHBoxLayout(marker_group) @@ -214,6 +215,18 @@ class MainWindow(QMainWindow): self.toggle_log_action.toggled.connect(self.log_view.setVisible) 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): 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.clear() - self.detect_status_label.setText("") - self.detect_all_button.setEnabled(True) - self.detect_next_button.setEnabled(True) + self.statusBar().clearMessage() + self.detect_all_action.setEnabled(True) + self.detect_next_action.setEnabled(True) def _toggle_play(self): if self.player.mpv.pause: @@ -267,9 +280,11 @@ class MainWindow(QMainWindow): def _on_mute_toggled(self, checked: bool): 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): - target_sec = self.seek_slider.value() / 1000.0 - self.player.seek(target_sec) self._slider_being_dragged = False self._update_position_ui() @@ -320,9 +335,9 @@ class MainWindow(QMainWindow): def _start_detection(self, start_offset: float, max_results, label: str): if self.input_path is None: return - self.detect_all_button.setEnabled(False) - self.detect_next_button.setEnabled(False) - self.detect_status_label.setText(label) + self.detect_all_action.setEnabled(False) + self.detect_next_action.setEnabled(False) + self.statusBar().showMessage(label) 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) @@ -330,26 +345,26 @@ class MainWindow(QMainWindow): self._detect_worker.start() def _on_detect_done(self, events: list): - self.detect_all_button.setEnabled(True) - self.detect_next_button.setEnabled(True) + self.detect_all_action.setEnabled(True) + self.detect_next_action.setEnabled(True) existing = set(self.marker_strip.markers()) existing.update(e.time_sec for e in events) self.marker_strip.set_markers(list(existing)) 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.") 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: self.log_view.appendPlainText(f"Clap detected at {format_timestamp(e.time_sec)} (strength {e.strength:.2f})") def _on_detect_failed(self, message: str): - self.detect_all_button.setEnabled(True) - self.detect_next_button.setEnabled(True) - self.detect_status_label.setText("Detection failed (see log).") + self.detect_all_action.setEnabled(True) + self.detect_next_action.setEnabled(True) + self.statusBar().showMessage("Detection failed (see log).", 8000) self.log_view.appendPlainText(f"Clap detection FAILED: {message}") # -- export ----------------------------------------------------- diff --git a/player_widget.py b/player_widget.py index fe8b5f0..f2fb61c 100644 --- a/player_widget.py +++ b/player_widget.py @@ -9,7 +9,7 @@ import time import mpv from PyQt6.QtWidgets import QWidget -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, pyqtSignal class PlayerWidget(QWidget): @@ -20,10 +20,13 @@ class PlayerWidget(QWidget): this exists instead of a QMediaPlayer/HTML5-video-style backend. """ + fileDropped = pyqtSignal(str) + def __init__(self, parent=None): super().__init__(parent) self.setAttribute(Qt.WidgetAttribute.WA_DontCreateNativeAncestors) self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow) + self.setAcceptDrops(True) # QApplication resets LC_NUMERIC to the system locale on construction, # which undoes python-mpv's own import-time fix and makes libmpv abort @@ -108,3 +111,14 @@ class PlayerWidget(QWidget): def shutdown(self): 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 diff --git a/scripts/smoke_test_clap_detection.py b/scripts/smoke_test_clap_detection.py index baa508a..c35cf09 100644 --- a/scripts/smoke_test_clap_detection.py +++ b/scripts/smoke_test_clap_detection.py @@ -39,12 +39,12 @@ def main(): time.sleep(0.3) qapp.processEvents() - assert win.detect_all_button.isEnabled() - win.detect_all_button.click() + assert win.detect_all_action.isEnabled() + win.detect_all_action.trigger() 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" markers = win.marker_strip.markers() diff --git a/scripts/smoke_test_interactions.py b/scripts/smoke_test_interactions.py new file mode 100644 index 0000000..3ceb655 --- /dev/null +++ b/scripts/smoke_test_interactions.py @@ -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()