feat: waveform drag-select + wheel zoom (selection_changed/view_changed)
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user