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:
@@ -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) # 40–120 opacity
|
alpha = int(40 + score * 80) # 40–120 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
|
||||||
|
|||||||
Reference in New Issue
Block a user