From 25fbf8142f286fee6fa513b7f648a42ed7dd2f97 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 2 Jul 2026 17:02:35 +0200 Subject: [PATCH] feat: waveform drag-select + wheel zoom (selection_changed/view_changed) --- main.py | 84 ++++++++++++++++++++++++++++++++++++++ tests/test_ui_structure.py | 26 ++++++++++++ 2 files changed, 110 insertions(+) diff --git a/main.py b/main.py index 6c1ecb7..24ff2d2 100755 --- a/main.py +++ b/main.py @@ -3943,6 +3943,13 @@ class AudioWaveform(QWidget): audio region. Populated on demand via manual refresh (no implicit UI-thread decode).""" + selection_changed = pyqtSignal(float, float) + view_changed = pyqtSignal(float, float) + + HANDLE_PX = 6 + MIN_SPAN = 0.05 + MIN_SEL = 0.01 + def __init__(self, parent=None): super().__init__(parent) self._peaks: list[float] = [] @@ -3950,6 +3957,7 @@ class AudioWaveform(QWidget): self._view_dur = 0.0 self._sel: tuple[float, float] | None = None self._playhead: float | None = None + self._drag_anchor: float | None = None self.setFixedHeight(96) self.setToolTip("Waveform of the current audio area (↻ to refresh)") @@ -4011,6 +4019,82 @@ class AudioWaveform(QWidget): p.setPen(QPen(QColor(255, 255, 255, 160), 1)) p.drawLine(px, 0, px, h) + # --- pixel/time helpers ------------------------------------------------- + def _px_to_t(self, x: float) -> float: + from core.waveform import x_to_t + return x_to_t(x, self.width(), self._view_start, self._view_dur) + + def _t_to_px(self, t: float) -> int: + from core.waveform import t_to_x + return t_to_x(t, self.width(), self._view_start, self._view_dur) + + # --- drag-select / zoom ------------------------------------------------- + def _begin_drag_at_x(self, x: float) -> None: + t = self._px_to_t(x) + anchor = t + if self._sel is not None and self._view_dur > 0: + xs = self._t_to_px(self._sel[0]) + xe = self._t_to_px(self._sel[1]) + if abs(x - xs) <= self.HANDLE_PX: + anchor = self._sel[1] # drag start; pivot on end + elif abs(x - xe) <= self.HANDLE_PX: + anchor = self._sel[0] # drag end; pivot on start + self._drag_anchor = anchor + self._drag_to_x(x) + + def _drag_to_x(self, x: float) -> None: + if self._drag_anchor is None: + return + t = self._px_to_t(x) + lo, hi = min(self._drag_anchor, t), max(self._drag_anchor, t) + vs, ve = self._view_start, self._view_start + self._view_dur + if self._view_dur > 0: + lo = max(vs, min(lo, ve)) + hi = max(vs, min(hi, ve)) + if hi - lo < self.MIN_SEL: + if hi + self.MIN_SEL <= ve or self._view_dur <= 0: + hi = lo + self.MIN_SEL # room above + else: + lo = hi - self.MIN_SEL # at the ceiling -> grow downward + self.set_selection(lo, hi) + + def _end_drag(self) -> None: + if self._drag_anchor is None: + return + self._drag_anchor = None + if self._sel is not None: + self.selection_changed.emit(self._sel[0], self._sel[1]) + + def _zoom_at_x(self, x: float, factor: float) -> None: + if self._view_dur <= 0 or self.width() <= 0: + return + t = self._px_to_t(x) + new_dur = max(self.MIN_SPAN, self._view_dur * factor) + frac = x / self.width() + self._view_start = t - frac * new_dur + self._view_dur = new_dur + self.update() + self.view_changed.emit(self._view_start, self._view_dur) + + # --- Qt events ---------------------------------------------------------- + def mousePressEvent(self, e): + if e.button() == Qt.MouseButton.LeftButton: + self._begin_drag_at_x(e.position().x()) + + def mouseMoveEvent(self, e): + self._drag_to_x(e.position().x()) + + def mouseReleaseEvent(self, e): + if e.button() == Qt.MouseButton.LeftButton: + self._end_drag() + + def wheelEvent(self, e): + dy = e.angleDelta().y() + if dy == 0: + return + factor = 0.8 if dy > 0 else 1.25 + self._zoom_at_x(e.position().x(), factor) + class MainWindow(QMainWindow): def __init__(self): diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index a53ee87..63006d0 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -414,6 +414,32 @@ def test_waveform_view_and_selection(win): assert w.selection() is None +def test_waveform_drag_emits_selection(win): + w = win._wave + w.resize(400, 96) + w.set_view(10.0, 4.0) + w.set_selection(11.0, 13.0) + got = [] + w.selection_changed.connect(lambda s, e: got.append((s, e))) + # new selection: press near t=10.5 (not on a handle), drag to t=13.5, release + w._begin_drag_at_x(w._t_to_px(10.5)) + w._drag_to_x(w._t_to_px(13.5)) + w._end_drag() + assert got, "selection_changed should fire on release" + s, e = w.selection() + assert 10.0 <= s < e <= 14.0 + + +def test_waveform_zoom_emits_view(win): + w = win._wave + w.resize(400, 96) + w.set_view(10.0, 4.0) + seen = [] + w.view_changed.connect(lambda s, d: seen.append((s, d))) + w._zoom_at_x(200, 0.5) # zoom in about the middle + assert seen and seen[-1][1] < 4.0 + + def test_audition_button_present_and_safe(win): from PyQt6.QtWidgets import QPushButton assert isinstance(win._btn_audio_play, QPushButton)