feat: add timeline zoom and pan for precise edge editing

Ctrl+scroll zooms the timeline view around the mouse. Middle-mouse drag
pans when zoomed. Scrolling all the way out clamps back to full view.

While dragging a scan region edge with Shift, the view auto-pans when
the mouse approaches the widget border so you can extend a region past
the visible range.

All paint and hit-test paths now route through _time_to_x / _pos_to_time
helpers backed by a _view_start / _view_span window, so existing
interactions (seek, marker click, edge resize, keyframe context menu)
all adapt naturally to the zoom level.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 15:41:56 +02:00
parent c8bc629419
commit 73396659dc
+148 -60
View File
@@ -1681,12 +1681,20 @@ class TimelineWidget(QWidget):
self._locked = False # when True, clicks scrub playback, not cursor self._locked = False # when True, clicks scrub playback, not cursor
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = [] self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = []
self._markers: list[tuple[float, int, str]] = [] self._markers: list[tuple[float, int, str]] = []
self._hover_cache: list[tuple[float, str]] = [] # (t/duration, path)
# (start, end, score, orig_start, orig_end) # (start, end, score, orig_start, orig_end)
self._scan_regions: list[tuple[float, float, float, float, float]] = [] self._scan_regions: list[tuple[float, float, float, float, float]] = []
self._scan_neg_times: set[float] = set() self._scan_neg_times: set[float] = set()
self._active_scan_region: tuple[float, float] | None = None self._active_scan_region: 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
self._view_span: float = 0.0
self._MIN_VIEW_SPAN = 0.25 # seconds — hard floor on zoom-in
# Middle-mouse pan state
self._pan_active = False
self._pan_start_x = 0.0
self._pan_start_view = 0.0
# Waveform data (numpy array of 0-1 peak values, or None) # Waveform data (numpy array of 0-1 peak values, or None)
self._waveform = None self._waveform = None
@@ -1719,7 +1727,8 @@ class TimelineWidget(QWidget):
self._duration = duration self._duration = duration
self._cursor = 0.0 self._cursor = 0.0
self._play_pos = None self._play_pos = None
self._rebuild_hover_cache() self._view_start = 0.0
self._view_span = 0.0
self.update() self.update()
def set_waveform(self, peaks) -> None: def set_waveform(self, peaks) -> None:
@@ -1743,7 +1752,6 @@ class TimelineWidget(QWidget):
def set_markers(self, markers: list[tuple[float, int, str]]) -> None: def set_markers(self, markers: list[tuple[float, int, str]]) -> None:
"""markers: list of (start_time, number, output_path)""" """markers: list of (start_time, number, output_path)"""
self._markers = markers self._markers = markers
self._rebuild_hover_cache()
self.update() self.update()
def set_scan_regions(self, regions: list, neg_times: set[float] | None = None) -> None: def set_scan_regions(self, regions: list, neg_times: set[float] | None = None) -> None:
@@ -1787,30 +1795,44 @@ class TimelineWidget(QWidget):
self._crop_keyframes = kfs self._crop_keyframes = kfs
self.update() self.update()
def _rebuild_hover_cache(self) -> None: def _view_span_eff(self) -> float:
"""Pre-compute (pixel_x_fraction, output_path) for hover detection.""" """Current visible time span (falls back to full duration)."""
if self._duration > 0: if self._view_span > 0:
self._hover_cache = [ return self._view_span
(t / self._duration, path) return self._duration if self._duration > 0 else 1.0
for (t, _num, path) in self._markers
] def _time_to_x(self, t: float) -> float:
else: """Map a time (seconds) to pixel x in the current view window."""
self._hover_cache: list[tuple[float, str]] = [] w = self.width()
if w <= 0 or self._duration <= 0:
return 0.0
return (t - self._view_start) / self._view_span_eff() * w
def _pos_to_time(self, x: int) -> float: def _pos_to_time(self, x: int) -> float:
if self._duration <= 0 or self.width() <= 0: if self._duration <= 0 or self.width() <= 0:
return 0.0 return 0.0
ratio = max(0.0, min(1.0, x / self.width())) t = self._view_start + (x / self.width()) * self._view_span_eff()
return ratio * self._duration return max(0.0, min(t, self._duration))
def _clamp_view(self) -> None:
"""Keep the view window inside [0, duration]."""
if self._duration <= 0:
self._view_start = 0.0
self._view_span = 0.0
return
if self._view_span <= 0 or self._view_span >= self._duration:
self._view_start = 0.0
self._view_span = 0.0
return
self._view_start = max(0.0, min(self._view_start, self._duration - self._view_span))
def _hit_scan_edge(self, x: float) -> tuple[int, str] | None: def _hit_scan_edge(self, x: float) -> tuple[int, str] | None:
"""Return (region_index, 'left'|'right') if x is near a scan region edge.""" """Return (region_index, 'left'|'right') if x is near a scan region edge."""
if not self._scan_regions or self._duration <= 0: if not self._scan_regions or self._duration <= 0:
return None return None
w = self.width()
for i, (start, end, score, os_, oe) in enumerate(self._scan_regions): for i, (start, end, score, os_, oe) in enumerate(self._scan_regions):
x1 = start / self._duration * w x1 = self._time_to_x(start)
x2 = end / self._duration * w x2 = self._time_to_x(end)
if abs(x - x1) <= self._EDGE_PX: if abs(x - x1) <= self._EDGE_PX:
return (i, "left") return (i, "left")
if abs(x - x2) <= self._EDGE_PX: if abs(x - x2) <= self._EDGE_PX:
@@ -1842,9 +1864,11 @@ class TimelineWidget(QWidget):
return return
# ── time ruler ticks & labels ───────────────────────────────── # ── time ruler ticks & labels ─────────────────────────────────
# Pick a tick interval so we get ~8-12 major ticks across the width # Pick a tick interval so we get ~8-12 major ticks across the view
raw_step = self._duration / 10.0 view_span = self._view_span_eff()
for candidate in (0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300): view_end = self._view_start + view_span
raw_step = view_span / 10.0
for candidate in (0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300):
if candidate >= raw_step: if candidate >= raw_step:
major_step = candidate major_step = candidate
break break
@@ -1854,14 +1878,21 @@ class TimelineWidget(QWidget):
minor_step = major_step / 5.0 minor_step = major_step / 5.0
p.setFont(self._ruler_font) p.setFont(self._ruler_font)
t = 0.0 # Start at the first minor tick ≥ view_start
while t <= self._duration + minor_step * 0.1: first_tick = (int(self._view_start / minor_step)) * minor_step
rx = int(t / self._duration * w) if first_tick < self._view_start:
is_major = (round(t / major_step) * major_step - t) < minor_step * 0.1 first_tick += minor_step
t = first_tick
while t <= view_end + minor_step * 0.1:
rx = int(self._time_to_x(t))
is_major = abs(round(t / major_step) * major_step - t) < minor_step * 0.1
if is_major: if is_major:
p.setPen(self._ruler_pen) p.setPen(self._ruler_pen)
p.drawLine(rx, rh - 10, rx, rh) p.drawLine(rx, rh - 10, rx, rh)
# label # label — include decimals when zoomed in tight
if major_step < 1.0:
label = f"{t:.2f}s"
else:
mins = int(t) // 60 mins = int(t) // 60
secs = int(t) % 60 secs = int(t) % 60
label = f"{mins}:{secs:02d}" if mins else f"{secs}s" label = f"{mins}:{secs:02d}" if mins else f"{secs}s"
@@ -1887,30 +1918,33 @@ class TimelineWidget(QWidget):
p.setBrush(QColor(80, 180, 80, 50)) p.setBrush(QColor(80, 180, 80, 50))
from PyQt6.QtGui import QPolygonF from PyQt6.QtGui import QPolygonF
from PyQt6.QtCore import QPointF from PyQt6.QtCore import QPointF
# Only iterate peaks overlapping the view window — keeps zoomed-in detail sharp.
peak_dt = self._duration / n
i_start = max(0, int(self._view_start / peak_dt) - 1)
i_end = min(n, int((self._view_start + view_span) / peak_dt) + 2)
pts = [] pts = []
# Top half (positive peaks) for i in range(i_start, i_end):
for i in range(n): x = self._time_to_x(i * peak_dt)
x = i * w / n
y = mid_y - self._waveform[i] * half_h y = mid_y - self._waveform[i] * half_h
pts.append(QPointF(x, y)) pts.append(QPointF(x, y))
# Bottom half (mirror) for i in range(i_end - 1, i_start - 1, -1):
for i in range(n - 1, -1, -1): x = self._time_to_x(i * peak_dt)
x = i * w / n
y = mid_y + self._waveform[i] * half_h y = mid_y + self._waveform[i] * half_h
pts.append(QPointF(x, y)) pts.append(QPointF(x, y))
if pts:
p.drawPolygon(QPolygonF(pts)) p.drawPolygon(QPolygonF(pts))
# ── selection region (full clip span) ───────────────────────── # ── selection region (full clip span) ─────────────────────────
x_start = int(self._cursor / self._duration * w) x_start = int(self._time_to_x(self._cursor))
if not self._scan_mode: if not self._scan_mode:
x_end = int(min(self._cursor + self._clip_span, self._duration) / self._duration * w) x_end = int(self._time_to_x(min(self._cursor + self._clip_span, self._duration)))
sel_w = max(x_end - x_start, 1) sel_w = max(x_end - x_start, 1)
p.fillRect(x_start, rh, sel_w, th, QColor(60, 130, 220, 90)) p.fillRect(x_start, rh, sel_w, th, QColor(60, 130, 220, 90))
# ── playback progress fill ──────────────────────────────────── # ── 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 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) prog_end = min(self._play_pos, self._cursor + self._clip_span, self._duration)
x_prog = int(prog_end / self._duration * w) x_prog = int(self._time_to_x(prog_end))
prog_w = max(x_prog - x_start, 0) prog_w = max(x_prog - x_start, 0)
if prog_w > 0: if prog_w > 0:
p.fillRect(x_start, rh, prog_w, th, QColor(100, 200, 255, 60)) p.fillRect(x_start, rh, prog_w, th, QColor(100, 200, 255, 60))
@@ -1924,12 +1958,12 @@ class TimelineWidget(QWidget):
# ── scan regions ────────────────────────────────────────────── # ── scan regions ──────────────────────────────────────────────
if self._scan_regions and self._duration > 0: if self._scan_regions and self._duration > 0:
for (start, end, score, os_, oe) in self._scan_regions: for (start, end, score, os_, oe) in self._scan_regions:
x1 = int(start / self._duration * w) x1 = int(self._time_to_x(start))
x2 = int(end / self._duration * w) x2 = int(self._time_to_x(end))
alpha = int(40 + score * 80) # 40120 opacity alpha = int(40 + score * 80) # 40120 opacity
# Grey ghost for trimmed portions # Grey ghost for trimmed portions
ox1 = int(os_ / self._duration * w) ox1 = int(self._time_to_x(os_))
ox2 = int(oe / self._duration * w) ox2 = int(self._time_to_x(oe))
if ox1 < x1: if ox1 < x1:
p.fillRect(ox1, rh, x1 - ox1, h - rh, QColor(120, 120, 120, 40)) p.fillRect(ox1, rh, x1 - ox1, h - rh, QColor(120, 120, 120, 40))
if ox2 > x2: if ox2 > x2:
@@ -1947,8 +1981,8 @@ class TimelineWidget(QWidget):
# Active region highlight (bright yellow outline) # Active region highlight (bright yellow outline)
if self._active_scan_region is not None: if self._active_scan_region is not None:
a_start, a_end = self._active_scan_region a_start, a_end = self._active_scan_region
ax1 = int(a_start / self._duration * w) ax1 = int(self._time_to_x(a_start))
ax2 = int(a_end / self._duration * w) ax2 = int(self._time_to_x(a_end))
p.setBrush(Qt.BrushStyle.NoBrush) p.setBrush(Qt.BrushStyle.NoBrush)
p.setPen(QPen(QColor(255, 210, 0), 2)) p.setPen(QPen(QColor(255, 210, 0), 2))
p.drawRect(ax1, rh + 1, max(ax2 - ax1, 1), h - rh - 2) p.drawRect(ax1, rh + 1, max(ax2 - ax1, 1), h - rh - 2)
@@ -1956,7 +1990,9 @@ class TimelineWidget(QWidget):
# ── export markers ──────────────────────────────────────────── # ── export markers ────────────────────────────────────────────
p.setFont(self._marker_font) p.setFont(self._marker_font)
for (t, num, _path) in self._markers: for (t, num, _path) in self._markers:
mx = int(t / self._duration * w) mx = int(self._time_to_x(t))
if mx < -20 or mx > w + 20:
continue
p.setPen(self._marker_pen) p.setPen(self._marker_pen)
p.drawLine(mx, rh, mx, h) p.drawLine(mx, rh, mx, h)
# small filled rectangle label # small filled rectangle label
@@ -1972,7 +2008,7 @@ class TimelineWidget(QWidget):
p.drawLine(x_start, rh, x_start, h) p.drawLine(x_start, rh, x_start, h)
# Playback position (bright green) # Playback position (bright green)
if self._play_pos is not None and self._play_pos >= 0: if self._play_pos is not None and self._play_pos >= 0:
px = int(self._play_pos / self._duration * w) px = int(self._time_to_x(self._play_pos))
p.setPen(QPen(QColor(80, 255, 80, 220), 2)) p.setPen(QPen(QColor(80, 255, 80, 220), 2))
p.drawLine(px, rh, px, h) p.drawLine(px, rh, px, h)
@@ -1985,7 +2021,7 @@ class TimelineWidget(QWidget):
kt = kf[0] kt = kf[0]
rp = kf[3] if len(kf) > 3 else False rp = kf[3] if len(kf) > 3 else False
rs = kf[4] if len(kf) > 4 else False rs = kf[4] if len(kf) > 4 else False
kx = int(kt / self._duration * w) kx = int(self._time_to_x(kt))
d = 4 # half-size of diamond d = 4 # half-size of diamond
ky = h - d - 2 # near bottom of track ky = h - d - 2 # near bottom of track
if rp and rs: if rp and rs:
@@ -2037,6 +2073,13 @@ class TimelineWidget(QWidget):
def mousePressEvent(self, event): def mousePressEvent(self, event):
x = event.position().x() x = event.position().x()
# Middle-mouse drag pans the view window.
if event.button() == Qt.MouseButton.MiddleButton and self._view_span > 0:
self._pan_active = True
self._pan_start_x = x
self._pan_start_view = self._view_start
self.setCursor(Qt.CursorShape.ClosedHandCursor)
return
# Check for scan region edge drag — require Shift to avoid accidental resizes # Check for scan region edge drag — require Shift to avoid accidental resizes
mods = event.modifiers() mods = event.modifiers()
if mods & Qt.KeyboardModifier.ShiftModifier: if mods & Qt.KeyboardModifier.ShiftModifier:
@@ -2055,11 +2098,8 @@ class TimelineWidget(QWidget):
from PyQt6.QtCore import Qt as _Qt from PyQt6.QtCore import Qt as _Qt
if event.button() == _Qt.MouseButton.LeftButton: if event.button() == _Qt.MouseButton.LeftButton:
x = event.position().x() x = event.position().x()
if self._hover_cache: for (t, _num, output_path) in self._markers:
w = self.width() if abs(x - self._time_to_x(t)) <= 10:
for (frac, output_path) in self._hover_cache:
if abs(x - frac * w) <= 10:
t = frac * self._duration
self.marker_clicked.emit(t, output_path) self.marker_clicked.emit(t, output_path)
if not self._locked: if not self._locked:
self._seek(x) self._seek(x)
@@ -2069,9 +2109,30 @@ class TimelineWidget(QWidget):
def mouseMoveEvent(self, event): def mouseMoveEvent(self, event):
x = event.position().x() x = event.position().x()
w = self.width()
# Active edge drag # Active middle-mouse pan
if self._pan_active and event.buttons() & Qt.MouseButton.MiddleButton:
dx = x - self._pan_start_x
dt = -dx / max(w, 1) * self._view_span_eff()
self._view_start = self._pan_start_view + dt
self._clamp_view()
self.update()
return
# Active edge drag (with auto-pan near borders when zoomed)
if self._drag_idx is not None and event.buttons(): if self._drag_idx is not None and event.buttons():
if self._view_span > 0:
margin = 20
if x < margin:
self._view_start = max(0.0, self._view_start - self._view_span * 0.05)
self._clamp_view()
elif x > w - margin:
self._view_start = min(
self._duration - self._view_span,
self._view_start + self._view_span * 0.05,
)
self._clamp_view()
t = self._pos_to_time(int(x)) t = self._pos_to_time(int(x))
r = self._scan_regions[self._drag_idx] r = self._scan_regions[self._drag_idx]
start, end, score, os_, oe = r start, end, score, os_, oe = r
@@ -2091,11 +2152,9 @@ class TimelineWidget(QWidget):
else: else:
self.unsetCursor() self.unsetCursor()
# Check marker hover using pre-computed fractions. # Marker hover tooltip
if self._hover_cache: for (t, _num, output_path) in self._markers:
w = self.width() if abs(x - self._time_to_x(t)) <= 8:
for (frac, output_path) in self._hover_cache:
if abs(x - frac * w) <= 8:
QToolTip.showText(QCursor.pos(), os.path.basename(output_path), self) QToolTip.showText(QCursor.pos(), os.path.basename(output_path), self)
if event.buttons(): if event.buttons():
self._seek(x) self._seek(x)
@@ -2111,6 +2170,10 @@ class TimelineWidget(QWidget):
self.cursor_changed.emit(self._cursor) self.cursor_changed.emit(self._cursor)
def mouseReleaseEvent(self, event): def mouseReleaseEvent(self, event):
if self._pan_active and event.button() == Qt.MouseButton.MiddleButton:
self._pan_active = False
self.unsetCursor()
return
if self._drag_idx is not None: if self._drag_idx is not None:
# Emit resize signal with old and new bounds # Emit resize signal with old and new bounds
idx = self._drag_idx idx = self._drag_idx
@@ -2124,24 +2187,49 @@ class TimelineWidget(QWidget):
self._seek_timer.stop() self._seek_timer.stop()
self._emit_seek() self._emit_seek()
def wheelEvent(self, event):
"""Ctrl+wheel zooms the view around the mouse. Plain wheel is ignored
so the parent scroll area (if any) can consume it."""
if not (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
super().wheelEvent(event)
return
if self._duration <= 0 or self.width() <= 0:
return
delta = event.angleDelta().y()
if delta == 0:
return
factor = 1.25 if delta > 0 else 1.0 / 1.25
mx = event.position().x()
t_mouse = self._pos_to_time(int(mx))
current_span = self._view_span_eff()
new_span = current_span / factor
new_span = max(self._MIN_VIEW_SPAN, min(new_span, self._duration))
if new_span >= self._duration:
self._view_start = 0.0
self._view_span = 0.0
else:
frac = mx / self.width()
self._view_start = t_mouse - frac * new_span
self._view_span = new_span
self._clamp_view()
self.update()
event.accept()
def contextMenuEvent(self, event): def contextMenuEvent(self, event):
if self._duration <= 0: if self._duration <= 0:
return return
x = event.pos().x() x = event.pos().x()
w = self.width()
# Check keyframe diamonds first. # Check keyframe diamonds first.
hit_kf_time = None hit_kf_time = None
for kf in self._crop_keyframes: for kf in self._crop_keyframes:
kt = kf[0] kt = kf[0]
kx = kt / self._duration * w if abs(x - self._time_to_x(kt)) <= 8:
if abs(x - kx) <= 8:
hit_kf_time = kt hit_kf_time = kt
break break
# Check export markers. # Check export markers.
hit_path = None hit_path = None
if self._hover_cache: for (t, _num, output_path) in self._markers:
for (frac, output_path) in self._hover_cache: if abs(x - self._time_to_x(t)) <= 10:
if abs(x - frac * w) <= 10:
hit_path = output_path hit_path = output_path
break break
from PyQt6.QtWidgets import QMenu from PyQt6.QtWidgets import QMenu