From a993de615c0ac8fe13db41a8e5a3dac8b989543a Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 3 Jul 2026 02:04:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20timeline=20audio-mode=20=E2=80=94=20hid?= =?UTF-8?q?e=20clip=20span,=20draggable/resizable=20audio=20band?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 114 ++++++++++++++++++++++++++++++------- tests/test_ui_structure.py | 42 ++++++++++++++ 2 files changed, 137 insertions(+), 19 deletions(-) diff --git a/main.py b/main.py index e21aef1..4f28f76 100755 --- a/main.py +++ b/main.py @@ -1871,11 +1871,14 @@ class TimelineWidget(QWidget): autoclip_requested = pyqtSignal() # mouse back/side button # (index, new_start, new_end, old_start, old_end) scan_region_resized = pyqtSignal(int, float, float, float, float) + audio_region_changed = pyqtSignal(float, float) # audio band dragged/resized _SCROLLBAR_H = 8 # pixels reserved for the overview scrollbar _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_MIN_W = 0.05 # seconds — min audio-band width def __init__(self): super().__init__() @@ -1904,6 +1907,11 @@ class TimelineWidget(QWidget): # Manual "Extract audio area" band (start, end) — drawn as a distinct # teal dashed region so it reads apart from the blue clip selection. self._audio_region: tuple[float, float] | None = None + # Audio mode: hide the clip span and make the teal band draggable. + self._audio_mode = False + self._audio_drag: str | None = None # None | "left" | "right" | "move" + self._audio_drag_anchor = 0.0 + self._audio_drag_orig: tuple[float, float] | None = None # View window for zoom/pan. When _view_span <= 0 the full duration is shown. self._view_start: float = 0.0 @@ -2077,6 +2085,51 @@ class TimelineWidget(QWidget): self._audio_region = None self.update() + def set_audio_mode(self, on: bool) -> None: + self._audio_mode = bool(on) + self.update() + + def _audio_begin_drag_at_x(self, x: float) -> None: + if not self._audio_mode or self._audio_region is None: + return + 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 + if self._audio_drag is not None: + self._audio_drag_orig = self._audio_region + + def _audio_drag_to_x(self, x: float) -> None: + if self._audio_drag is None or self._audio_region is None: + return + t = max(0.0, min(self._pos_to_time(int(x)), self._duration)) + a0, a1 = self._audio_region + if self._audio_drag == "left": + a0 = min(t, a1 - self._AUDIO_MIN_W) + elif self._audio_drag == "right": + a1 = max(t, a0 + self._AUDIO_MIN_W) + else: # move + width = a1 - a0 + a0 = max(0.0, min(t - self._audio_drag_anchor, self._duration - width)) + a1 = a0 + width + self._audio_region = (a0, a1) + self.update() + + def _audio_end_drag(self) -> None: + if self._audio_drag is None: + return + self._audio_drag = None + if (self._audio_region is not None + and self._audio_region != self._audio_drag_orig): + self.audio_region_changed.emit(self._audio_region[0], self._audio_region[1]) + def set_play_position(self, t: float | None) -> None: # In lock mode, ignore mpv position updates while the user is dragging # — the async seek hasn't caught up yet, so mpv reports stale values. @@ -2286,13 +2339,14 @@ class TimelineWidget(QWidget): # ── selection region (full clip span) ───────────────────────── x_start = int(self._time_to_x(self._cursor)) - if not self._scan_mode: + if not self._scan_mode and not self._audio_mode: x_end = int(self._time_to_x(min(self._cursor + self._clip_span, self._duration))) sel_w = max(x_end - x_start, 1) p.fillRect(x_start, rh, sel_w, th, QColor(60, 130, 220, 90)) # ── playback progress fill ──────────────────────────────────── - if not self._scan_mode and self._play_pos is not None and self._play_pos > self._cursor: + if (not self._scan_mode and not self._audio_mode + and self._play_pos is not None and self._play_pos > self._cursor): prog_end = min(self._play_pos, self._cursor + self._clip_span, self._duration) x_prog = int(self._time_to_x(prog_end)) prog_w = max(x_prog - x_start, 0) @@ -2300,7 +2354,7 @@ class TimelineWidget(QWidget): p.fillRect(x_start, rh, prog_w, th, QColor(100, 200, 255, 60)) # left/right edges of selection - if not self._scan_mode: + if not self._scan_mode and not self._audio_mode: p.setPen(QPen(QColor(60, 130, 220, 180), 1)) p.drawLine(x_start, rh, x_start, h) p.drawLine(x_end, rh, x_end, h) @@ -2314,8 +2368,14 @@ class TimelineWidget(QWidget): aw = max(ax2 - ax1, 1) p.fillRect(ax1, rh, aw, th, QColor(0, 200, 180, 45)) p.setBrush(Qt.BrushStyle.NoBrush) - p.setPen(QPen(QColor(0, 220, 190), 1, Qt.PenStyle.DashLine)) - p.drawRect(ax1, rh + 1, aw, th - 2) + if self._audio_mode: + # Solid bright edges so the band reads as draggable/resizable. + p.setPen(QPen(QColor(0, 220, 190), 2)) + p.drawLine(ax1, rh, ax1, h) + p.drawLine(ax2, rh, ax2, h) + else: + p.setPen(QPen(QColor(0, 220, 190), 1, Qt.PenStyle.DashLine)) + p.drawRect(ax1, rh + 1, aw, th - 2) # ── ghost of the previous cursor position (undo-by-eye) ────────── if (not self._scan_mode and self._ghost_cursor is not None @@ -2363,13 +2423,14 @@ class TimelineWidget(QWidget): mx2 = int(self._time_to_x(min(t + span, self._duration))) if mx2 > mx1 and mx2 > 0 and mx1 < w: p.fillRect(mx1, rh, mx2 - mx1, th, self._c_span) - p.setPen(self._pen_span_tick) - ct = t + self._spread - while ct < t + span - 0.1: - cx = int(self._time_to_x(ct)) - if mx1 < cx < mx2: - p.drawLine(cx, rh, cx, rh + th) - ct += self._spread + if not self._audio_mode: + p.setPen(self._pen_span_tick) + ct = t + self._spread + while ct < t + span - 0.1: + cx = int(self._time_to_x(ct)) + if mx1 < cx < mx2: + p.drawLine(cx, rh, cx, rh + th) + ct += self._spread # ── export markers ──────────────────────────────────────────── p.setFont(self._marker_font) @@ -2404,13 +2465,14 @@ class TimelineWidget(QWidget): mx2 = int(self._time_to_x(min(t + span, self._duration))) if mx2 > mx: p.fillRect(mx, rh, mx2 - mx, th, dim) - p.setPen(tickpen) - ct = t + self._spread - while ct < t + span - 0.1: - cx = int(self._time_to_x(ct)) - if mx < cx < mx2: - p.drawLine(cx, rh, cx, rh + th) - ct += self._spread + if not self._audio_mode: + p.setPen(tickpen) + ct = t + self._spread + while ct < t + span - 0.1: + cx = int(self._time_to_x(ct)) + if mx < cx < mx2: + p.drawLine(cx, rh, cx, rh + th) + ct += self._spread p.setPen(pen) p.drawLine(mx, rh, mx, h) p.fillRect(mx, rh + 2, 14, 12, color) @@ -2508,6 +2570,12 @@ class TimelineWidget(QWidget): self._sb_drag_offset = thumb_w / 2 self.update() return + # Audio mode: dragging/resizing the teal band takes priority over seeking. + if (self._audio_mode and self._audio_region is not None + and event.button() == Qt.MouseButton.LeftButton): + self._audio_begin_drag_at_x(x) + if self._audio_drag is not None: + return # Middle button: drag pans (when zoomed); a click bumps the clip count. if event.button() == Qt.MouseButton.MiddleButton: self._mid_press_x = x @@ -2561,6 +2629,11 @@ class TimelineWidget(QWidget): x = event.position().x() w = self.width() + # Active audio-band drag (audio mode) — takes priority over seeking. + if self._audio_drag is not None and event.buttons(): + self._audio_drag_to_x(x) + return + # Active scrollbar drag if self._sb_drag and event.buttons() & Qt.MouseButton.LeftButton: new_x = x - self._sb_drag_offset @@ -2629,6 +2702,9 @@ class TimelineWidget(QWidget): self.cursor_changed.emit(self._cursor) def mouseReleaseEvent(self, event): + if self._audio_drag is not None: + self._audio_end_drag() + return if self._sb_drag and event.button() == Qt.MouseButton.LeftButton: self._sb_drag = False return diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index d5908d2..7e98eee 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -274,6 +274,48 @@ def test_audio_region_tracks_cursor_and_length(win): assert win._timeline._audio_region is None +def test_timeline_audio_mode_flag(win): + tl = win._timeline + tl.set_audio_mode(True) + assert tl._audio_mode is True + tl.set_audio_mode(False) + assert tl._audio_mode is False + + +def test_timeline_audio_band_drag_move(win): + tl = win._timeline + tl._duration = 20.0 + tl._view_start = 0.0; tl._view_span = 20.0 # full view + tl.resize(400, tl.height() or 80) + tl.set_audio_mode(True) + tl.set_audio_region(4.0, 8.0) # [4,8] + got = [] + tl.audio_region_changed.connect(lambda s, e: got.append((s, e))) + # grab the middle of the band and drag it +2s + mid_t = 6.0 + tl._audio_begin_drag_at_x(tl._time_to_x(mid_t)) + tl._audio_drag_to_x(tl._time_to_x(mid_t + 2.0)) + tl._audio_end_drag() + assert got, "audio_region_changed should fire on release" + s, e = tl._audio_region + assert abs((e - s) - 4.0) < 0.1 and s > 4.0 # same width, moved right + tl.set_audio_mode(False) + + +def test_timeline_audio_band_resize_right(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) + tl._audio_begin_drag_at_x(tl._time_to_x(8.0)) # grab right edge + tl._audio_drag_to_x(tl._time_to_x(10.0)) # drag to 10 + tl._audio_end_drag() + s, e = tl._audio_region + assert abs(s - 4.0) < 0.1 and abs(e - 10.0) < 0.2 + tl.set_audio_mode(False) + + def test_audio_deck_tab_exists(win): # The old "Scan" deck tab is now "Audio". assert hasattr(win, "_tab_audio")