diff --git a/main.py b/main.py index 2c64ac2..7e7db51 100755 --- a/main.py +++ b/main.py @@ -1897,7 +1897,7 @@ class TimelineWidget(QWidget): _RULER_H = 22 # pixels reserved for the time ruler _HANDLE_H = 8 # height of the playhead triangle _EDGE_PX = 3 # pixel tolerance for edge hit detection - _AUDIO_EDGE_PX = 6 # pixel tolerance for audio-band edge grab + _AUDIO_EDGE_PX = 10 # pixel tolerance for audio-band edge grab _AUDIO_MIN_W = 0.05 # seconds — min audio-band width def __init__(self): @@ -2109,22 +2109,38 @@ class TimelineWidget(QWidget): self._audio_mode = bool(on) self.update() + def _audio_hit_at_x(self, x: float) -> str | None: + if not self._audio_mode or self._audio_region is None: + return None + a0, a1 = self._audio_region + ax0 = self._time_to_x(a0) + ax1 = self._time_to_x(a1) + if abs(x - ax0) <= self._AUDIO_EDGE_PX: + return "left" + if abs(x - ax1) <= self._AUDIO_EDGE_PX: + return "right" + if ax0 < x < ax1: + return "move" + return "create" + def _audio_begin_drag_at_x(self, x: float) -> None: if not self._audio_mode or self._audio_region is None: return + hit = self._audio_hit_at_x(x) a0, a1 = self._audio_region - ax0 = self._time_to_x(a0); ax1 = self._time_to_x(a1) - if abs(x - ax0) <= self._AUDIO_EDGE_PX: - self._audio_drag = "left" - elif abs(x - ax1) <= self._AUDIO_EDGE_PX: - self._audio_drag = "right" - elif ax0 < x < ax1: - self._audio_drag = "move" - self._audio_drag_anchor = self._pos_to_time(int(x)) - a0 - else: - self._audio_drag = None + t = max(0.0, min(self._pos_to_time(int(x)), self._duration)) + self._audio_drag = hit + self._audio_drag_orig = self._audio_region if hit is not None else None + if hit == "move": + self._audio_drag_anchor = t - a0 + elif hit == "create": + self._audio_drag_anchor = t + width = max(self._AUDIO_MIN_W, a1 - a0) + a0 = max(0.0, min(t, max(0.0, self._duration - width))) + self._audio_region = (a0, a0 + width) + self.update() if self._audio_drag is not None: - self._audio_drag_orig = self._audio_region + return def _audio_drag_to_x(self, x: float) -> None: if self._audio_drag is None or self._audio_region is None: @@ -2135,10 +2151,19 @@ class TimelineWidget(QWidget): a0 = min(t, a1 - self._AUDIO_MIN_W) elif self._audio_drag == "right": a1 = max(t, a0 + self._AUDIO_MIN_W) - else: # move + elif self._audio_drag == "move": width = a1 - a0 a0 = max(0.0, min(t - self._audio_drag_anchor, self._duration - width)) a1 = a0 + width + else: # create + anchor = self._audio_drag_anchor + a0 = max(0.0, min(anchor, t)) + a1 = min(self._duration, max(anchor, t)) + if a1 - a0 < self._AUDIO_MIN_W: + if a1 + self._AUDIO_MIN_W <= self._duration: + a1 = a0 + self._AUDIO_MIN_W + else: + a0 = max(0.0, a1 - self._AUDIO_MIN_W) self._audio_region = (a0, a1) self.update() @@ -2393,6 +2418,8 @@ class TimelineWidget(QWidget): p.setPen(QPen(QColor(0, 220, 190), 2)) p.drawLine(ax1, rh, ax1, h) p.drawLine(ax2, rh, ax2, h) + p.fillRect(ax1 - 4, rh, 8, th, QColor(0, 220, 190, 120)) + p.fillRect(ax2 - 4, rh, 8, th, QColor(0, 220, 190, 120)) else: p.setPen(QPen(QColor(0, 220, 190), 1, Qt.PenStyle.DashLine)) p.drawRect(ax1, rh + 1, aw, th - 2) @@ -2700,6 +2727,16 @@ class TimelineWidget(QWidget): mods = event.modifiers() if (mods & Qt.KeyboardModifier.ShiftModifier) and self._hit_scan_edge(x): self.setCursor(Qt.CursorShape.SizeHorCursor) + elif self._audio_mode and self._audio_region is not None: + hit = self._audio_hit_at_x(x) + if hit in ("left", "right"): + self.setCursor(Qt.CursorShape.SizeHorCursor) + elif hit == "move": + self.setCursor(Qt.CursorShape.OpenHandCursor) + elif hit == "create": + self.setCursor(Qt.CursorShape.CrossCursor) + else: + self.unsetCursor() else: self.unsetCursor() diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index 00b9636..1918875 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -321,6 +321,56 @@ def test_timeline_audio_band_resize_right(win): tl.set_audio_mode(False) +def test_timeline_audio_click_outside_moves_region_start(win): + tl = win._timeline + win._file_path = "/x/v.mp4" + tl._duration = 20.0 + tl._view_start = 0.0 + tl._view_span = 20.0 + tl.resize(400, tl.height() or 80) + tl.set_audio_mode(True) + tl.set_audio_region(4.0, 8.0) + got = [] + tl.audio_region_changed.connect(lambda s, e: got.append((s, e))) + tl._audio_begin_drag_at_x(tl._time_to_x(12.0)) + tl._audio_end_drag() + assert got[-1] == tl._audio_region + s, e = tl._audio_region + assert abs(s - 12.0) < 0.1 + assert abs((e - s) - 4.0) < 0.1 + + +def test_timeline_audio_drag_empty_creates_region(win): + tl = win._timeline + win._file_path = "/x/v.mp4" + tl._duration = 20.0 + tl._view_start = 0.0 + tl._view_span = 20.0 + tl.resize(400, tl.height() or 80) + tl.set_audio_mode(True) + tl.set_audio_region(4.0, 8.0) + tl._audio_begin_drag_at_x(tl._time_to_x(12.0)) + tl._audio_drag_to_x(tl._time_to_x(15.0)) + tl._audio_end_drag() + s, e = tl._audio_region + assert abs(s - 12.0) < 0.1 + assert abs(e - 15.0) < 0.1 + + +def test_timeline_audio_hover_cursor_state(win): + tl = win._timeline + tl._duration = 20.0 + tl._view_start = 0.0 + tl._view_span = 20.0 + tl.resize(400, tl.height() or 80) + tl.set_audio_mode(True) + tl.set_audio_region(4.0, 8.0) + assert tl._audio_hit_at_x(tl._time_to_x(4.0)) == "left" + assert tl._audio_hit_at_x(tl._time_to_x(8.0)) == "right" + assert tl._audio_hit_at_x(tl._time_to_x(6.0)) == "move" + assert tl._audio_hit_at_x(tl._time_to_x(12.0)) == "create" + + def test_timeline_audio_mode_follows_deck(win): win._control_deck.setCurrentWidget(win._tab_audio) assert win._timeline._audio_mode is True