Skip to content

Commit 7c6066e

Browse files
committed
Initial commit of new Zoom Slider widget, to replace the previous +/- buttons and simple zoom slider. This new widget draws a mini timeline preview, allows the user to select any portion of the timeline, and also pan/scroll around the timeline with great accuracy.
1 parent e8e53cf commit 7c6066e

File tree

8 files changed

+625
-78
lines changed

8 files changed

+625
-78
lines changed

src/classes/query.py

+5
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,11 @@ def get(**kwargs):
311311
""" Take any arguments given as filters, and find the first matching object """
312312
return QueryObject.get(Track, **kwargs)
313313

314+
def __lt__(self, other):
315+
return self.data.get('number', 0) < other.data.get('number', 0)
316+
317+
def __gt__(self, other):
318+
return self.data.get('number', 0) > other.data.get('number', 0)
314319

315320
class Effect(QueryObject):
316321
""" This class allows Effects to be queried, updated, and deleted from the project data. """

src/timeline/js/controllers.js

+31-20
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,6 @@ App.controller("TimelineCtrl", function ($scope) {
108108
var frames_per_second = $scope.project.fps.num / $scope.project.fps.den;
109109
var position_seconds = ((position_frames - 1) / frames_per_second);
110110

111-
// Center on the playhead if it has moved out of view and the timeline should follow it
112-
if ($scope.enable_playhead_follow && !$scope.isTimeVisible(position_seconds)) {
113-
$scope.centerOnTime(position_seconds);
114-
}
115-
116111
// Update internal scope (in seconds)
117112
$scope.movePlayhead(position_seconds);
118113
};
@@ -274,6 +269,13 @@ App.controller("TimelineCtrl", function ($scope) {
274269
cursor_time = parseFloat(horz_scroll_offset) / $scope.pixelsPerSecond;
275270
}
276271

272+
// Compare to previous scale (and ignore tiny differences)
273+
if (Math.abs(parseFloat(scaleVal) - $scope.project.scale) < 0.00001) {
274+
// Do not change scale if the value is this tiny
275+
// This can cause the Ruler $watch to fail, and will leave the Ruler blank
276+
return;
277+
}
278+
277279
$scope.$apply(function () {
278280
$scope.project.scale = parseFloat(scaleVal);
279281
$scope.pixelsPerSecond = parseFloat($scope.project.tick_pixels) / parseFloat($scope.project.scale);
@@ -284,6 +286,14 @@ App.controller("TimelineCtrl", function ($scope) {
284286
scrolling_tracks.scrollLeft(new_cursor_x);
285287
};
286288

289+
// Change the scale and apply to scope
290+
$scope.setScroll = function (normalizedScrollValue) {
291+
var timeline_length = Math.min(32767, $scope.getTimelineWidth(0));
292+
var scrolling_tracks = $("#scrolling_tracks");
293+
var horz_scroll_offset = normalizedScrollValue * timeline_length;
294+
scrolling_tracks.scrollLeft(horz_scroll_offset);
295+
};
296+
287297
// Scroll the timeline horizontally of a certain amount (scrol_value)
288298
$scope.scrollLeft = function (scroll_value) {
289299
var scrolling_tracks = $("#scrolling_tracks");
@@ -949,22 +959,20 @@ App.controller("TimelineCtrl", function ($scope) {
949959
var scrolling_tracks = $("#scrolling_tracks");
950960
var vert_scroll_offset = scrolling_tracks.scrollTop();
951961

952-
$scope.$apply(function () {
953-
// Loop through each layer
954-
for (var layer_index = 0; layer_index < $scope.project.layers.length; layer_index++) {
955-
var layer = $scope.project.layers[layer_index];
956-
957-
// Find element on screen (bound to this layer)
958-
var layer_elem = $("#track_" + layer.number);
959-
if (layer_elem.offset()) {
960-
// Update the top offset
961-
layer.y = layer_elem.offset().top + vert_scroll_offset;
962-
}
962+
// Loop through each layer
963+
for (var layer_index = 0; layer_index < $scope.project.layers.length; layer_index++) {
964+
var layer = $scope.project.layers[layer_index];
965+
966+
// Find element on screen (bound to this layer)
967+
var layer_elem = $("#track_" + layer.number);
968+
if (layer_elem.offset()) {
969+
// Update the top offset
970+
layer.y = layer_elem.offset().top + vert_scroll_offset;
963971
}
964-
// Update playhead height
965-
$scope.playhead_height = $("#track-container").height();
966-
$(".playhead-line").height($scope.playhead_height);
967-
});
972+
}
973+
// Update playhead height
974+
$scope.playhead_height = $("#track-container").height();
975+
$(".playhead-line").height($scope.playhead_height);
968976
};
969977

970978
// Sort clips and transitions by position
@@ -1390,6 +1398,9 @@ App.controller("TimelineCtrl", function ($scope) {
13901398
// Re-index Layer Y values
13911399
$scope.updateLayerIndex();
13921400

1401+
// Force a scroll event (from 1 to 0, to send the geometry to zoom slider)
1402+
$("#scrolling_tracks").scrollLeft(1);
1403+
13931404
// Scroll to top/left when loading a project
13941405
$("#scrolling_tracks").animate({
13951406
scrollTop: 0,

src/timeline/js/directives/clip.js

+6-5
Original file line numberDiff line numberDiff line change
@@ -146,12 +146,13 @@ App.directive("tlClip", function ($timeout) {
146146
}
147147
// Resize timeline if it's too small to contain all clips
148148
scope.resizeTimeline();
149-
150-
// update clip in Qt (very important =)
151-
if (scope.Qt) {
152-
timeline.update_clip_data(JSON.stringify(scope.clip), true, true, false);
153-
}
154149
});
150+
151+
// update clip in Qt (very important =)
152+
if (scope.Qt) {
153+
timeline.update_clip_data(JSON.stringify(scope.clip), true, true, false);
154+
}
155+
155156
//resize the audio canvas to match the new clip width
156157
if (scope.clip.show_audio) {
157158
//redraw audio as the resize cleared the canvas

src/timeline/js/directives/ruler.js

+12
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ App.directive("tlScrollableTracks", function () {
5353
$("#track_controls").scrollTop(element.scrollTop());
5454
$("#scrolling_ruler").scrollLeft(element.scrollLeft());
5555
$("#progress_container").scrollLeft(element.scrollLeft());
56+
57+
// Send scrollbar position to Qt
58+
if (scope.Qt) {
59+
// Calculate scrollbar positions (left and right edge of scrollbar)
60+
var timeline_length = Math.min(32767, scope.getTimelineWidth(0));
61+
var left_scrollbar_edge = scroll_left_pixels / timeline_length;
62+
var right_scrollbar_edge = (scroll_left_pixels + element.width()) / timeline_length;
63+
64+
// Send normalized scrollbar positions to Qt
65+
timeline.ScrollbarChanged([left_scrollbar_edge, right_scrollbar_edge, timeline_length, element.width()]);
66+
}
67+
5668
});
5769

5870
// Initialize panning when middle mouse is clicked

src/timeline/js/directives/transition.js

+5-6
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,13 @@ App.directive("tlTransition", function () {
121121
scope.transition.position = new_left;
122122
scope.transition.end -= delta_time;
123123
}
124-
125-
// update transition in Qt (very important =)
126-
if (scope.Qt) {
127-
timeline.update_transition_data(JSON.stringify(scope.transition), true, false);
128-
}
129-
130124
});
131125

126+
// update transition in Qt (very important =)
127+
if (scope.Qt) {
128+
timeline.update_transition_data(JSON.stringify(scope.transition), true, false);
129+
}
130+
132131
dragLoc = null;
133132

134133
},

src/windows/main_window.py

+28-21
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ class MainWindow(updates.UpdateWatcher, QMainWindow):
109109
FileUpdated = pyqtSignal(str)
110110
CaptionTextUpdated = pyqtSignal(str, object)
111111
CaptionTextLoaded = pyqtSignal(str, object)
112+
TimelineZoom = pyqtSignal(float) # Signal to zoom into timeline from zoom slider
113+
TimelineScrolled = pyqtSignal(list) # Scrollbar changed signal from timeline
114+
TimelineScroll = pyqtSignal(float) # Signal to force scroll timeline to specific point
115+
TimelineCenter = pyqtSignal() # Signal to force center scroll on playhead
116+
SelectionAdded = pyqtSignal(str, str, bool) # Signal to add a selection
117+
SelectionRemoved = pyqtSignal(str, str) # Signal to remove a selection
118+
SelectionChanged = pyqtSignal() # Signal after selections have been changed (added/removed)
112119

113120
# Docks are closable, movable and floatable
114121
docks_frozen = False
@@ -1869,10 +1876,10 @@ def actionRemoveMarker_trigger(self):
18691876
m.delete()
18701877

18711878
def actionTimelineZoomIn_trigger(self):
1872-
self.sliderZoom.setValue(self.sliderZoom.value() - self.sliderZoom.singleStep())
1879+
self.sliderZoomWidget.zoomIn()
18731880

18741881
def actionTimelineZoomOut_trigger(self):
1875-
self.sliderZoom.setValue(self.sliderZoom.value() + self.sliderZoom.singleStep())
1882+
self.sliderZoomWidget.zoomOut()
18761883

18771884
def actionFullscreen_trigger(self):
18781885
# Toggle fullscreen state (current state mask XOR WindowFullScreen)
@@ -2287,6 +2294,9 @@ def addSelection(self, item_id, item_type, clear_existing=False):
22872294
self.show_property_type = item_type
22882295
self.show_property_timer.start()
22892296

2297+
# Notify UI that selection has been potentially changed
2298+
self.SelectionChanged.emit()
2299+
22902300
# Remove from the selected items
22912301
def removeSelection(self, item_id, item_type):
22922302
# Remove existing selection (if any)
@@ -2321,6 +2331,9 @@ def removeSelection(self, item_id, item_type):
23212331
# Change selected item in properties view
23222332
self.show_property_timer.start()
23232333

2334+
# Notify UI that selection has been potentially changed
2335+
self.SelectionChanged.emit()
2336+
23242337
def selected_files(self):
23252338
""" Return a list of File objects for the Project Files dock's selection """
23262339
return self.files_model.selected_files()
@@ -2558,27 +2571,16 @@ def setup_toolbars(self):
25582571
self.caption_model_row = None
25592572

25602573
# Get project's initial zoom value
2561-
initial_scale = get_app().project.get("scale") or 15
2562-
# Round non-exponential scale down to next lowest power of 2
2563-
initial_zoom = secondsToZoom(initial_scale)
2564-
2565-
# Setup Zoom slider
2566-
self.sliderZoom = QSlider(Qt.Horizontal, self)
2567-
self.sliderZoom.setPageStep(1)
2568-
self.sliderZoom.setRange(0, 30)
2569-
self.sliderZoom.setValue(initial_zoom)
2570-
self.sliderZoom.setInvertedControls(True)
2571-
self.sliderZoom.resize(100, 16)
2572-
2573-
self.zoomScaleLabel = QLabel(
2574-
_("{} seconds").format(zoomToSeconds(self.sliderZoom.value()))
2575-
)
2574+
initial_scale = get_app().project.get("scale") or 15.0
2575+
2576+
# Setup Zoom Slider widget
2577+
from windows.views.zoom_slider import ZoomSlider
2578+
self.sliderZoomWidget = ZoomSlider(self)
2579+
self.sliderZoomWidget.setMinimumSize(200, 20)
2580+
self.sliderZoomWidget.setZoomFactor(initial_scale)
25762581

25772582
# add zoom widgets
2578-
self.timelineToolbar.addAction(self.actionTimelineZoomIn)
2579-
self.timelineToolbar.addWidget(self.sliderZoom)
2580-
self.timelineToolbar.addAction(self.actionTimelineZoomOut)
2581-
self.timelineToolbar.addWidget(self.zoomScaleLabel)
2583+
self.timelineToolbar.addWidget(self.sliderZoomWidget)
25822584

25832585
# Add timeline toolbar to web frame
25842586
self.frameWeb.addWidget(self.timelineToolbar)
@@ -2886,6 +2888,7 @@ def __init__(self, *args, mode=None):
28862888
self.preview_parent = PreviewParent()
28872889
self.preview_parent.Init(self, self.timeline_sync.timeline, self.videoPreview)
28882890
self.preview_thread = self.preview_parent.worker
2891+
self.sliderZoomWidget.connect_playback()
28892892

28902893
# Set pause callback
28912894
self.PauseSignal.connect(self.handlePausedVideo)
@@ -2956,6 +2959,10 @@ def __init__(self, *args, mode=None):
29562959
# Connect OpenProject Signal
29572960
self.OpenProjectSignal.connect(self.open_project)
29582961

2962+
# Connect Selection signals
2963+
self.SelectionAdded.connect(self.addSelection)
2964+
self.SelectionRemoved.connect(self.removeSelection)
2965+
29592966
# Show window
29602967
if self.mode != "unittest":
29612968
self.show()

src/windows/views/webview.py

+28-26
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,8 @@ def changed(self, action):
225225
# Reset the scale when loading new JSON
226226
if action.type == "load":
227227
# Set the scale again (to project setting)
228-
initial_scale = get_app().project.get("scale") or 15
229-
self.window.sliderZoom.setValue(secondsToZoom(initial_scale))
228+
initial_scale = get_app().project.get("scale") or 15.0
229+
self.window.sliderZoomWidget.setZoomFactor(initial_scale)
230230

231231
# The setValue() above doesn't trigger update_zoom when a project file is
232232
# loaded on the command line (too early?), so also call the JS directly
@@ -436,7 +436,7 @@ def ShowEffectMenu(self, effect_id=None):
436436
log.debug('ShowEffectMenu: %s' % effect_id)
437437

438438
# Set the selected clip (if needed)
439-
self.window.addSelection(effect_id, 'effect', True)
439+
self.addSelection(effect_id, 'effect', True)
440440

441441
menu = QMenu(self)
442442
# Properties
@@ -492,7 +492,7 @@ def ShowClipMenu(self, clip_id=None):
492492

493493
# Set the selected clip (if needed)
494494
if clip_id not in self.window.selected_clips:
495-
self.window.addSelection(clip_id, 'clip')
495+
self.addSelection(clip_id, 'clip')
496496
# Get list of selected clips
497497
clip_ids = self.window.selected_clips
498498
tran_ids = self.window.selected_transitions
@@ -2529,7 +2529,7 @@ def ShowTransitionMenu(self, tran_id=None):
25292529

25302530
# Set the selected transition (if needed)
25312531
if tran_id not in self.window.selected_transitions:
2532-
self.window.addSelection(tran_id, 'transition')
2532+
self.addSelection(tran_id, 'transition')
25332533
# Get list of all selected transitions
25342534
tran_ids = self.window.selected_transitions
25352535
clip_ids = self.window.selected_clips
@@ -2733,16 +2733,12 @@ def SetPlayheadFollow(self, enable_follow):
27332733
@pyqtSlot(str, str, bool)
27342734
def addSelection(self, item_id, item_type, clear_existing=False):
27352735
""" Add the selected item to the current selection """
2736-
2737-
# Add to main window
2738-
self.window.addSelection(item_id, item_type, clear_existing)
2736+
self.window.SelectionAdded.emit(item_id, item_type, clear_existing)
27392737

27402738
@pyqtSlot(str, str)
27412739
def removeSelection(self, item_id, item_type):
27422740
""" Remove the selected clip from the selection """
2743-
2744-
# Remove from main window
2745-
self.window.removeSelection(item_id, item_type)
2741+
self.window.SelectionRemoved.emit(item_id, item_type)
27462742

27472743
@pyqtSlot(str, str)
27482744
def qt_log(self, level="INFO", message=None):
@@ -2759,16 +2755,15 @@ def qt_log(self, level="INFO", message=None):
27592755
level = levels.get(level, logging.INFO)
27602756
self.log_fn(level, message)
27612757

2758+
def update_scroll(self, newScroll):
2759+
"""Force a scroll event on the timeline (i.e. the zoom slider is moving, so we need to scroll the timeline)"""
2760+
# Get access to timeline scope and set scale to new computed value
2761+
self.run_js(JS_SCOPE_SELECTOR + ".setScroll(" + str(newScroll) + ");")
2762+
27622763
# Handle changes to zoom level, update js
2763-
def update_zoom(self, newValue):
2764+
def update_zoom(self, newScale):
27642765
_ = get_app()._tr
27652766

2766-
# Convert slider value (passed in) to a scale (in seconds)
2767-
newScale = zoomToSeconds(newValue)
2768-
2769-
# Set zoom label
2770-
self.window.zoomScaleLabel.setText(_("{} seconds").format(newScale))
2771-
27722767
# Determine X coordinate of cursor (to center zoom on)
27732768
cursor_y = self.mapFromGlobal(self.cursor().pos()).y()
27742769
if cursor_y >= 0:
@@ -2795,13 +2790,13 @@ def update_zoom(self, newValue):
27952790
def wheelEvent(self, event):
27962791
if event.modifiers() & Qt.ControlModifier:
27972792
event.accept()
2798-
zoom = self.window.sliderZoom
2799-
# For each 120 (standard scroll unit) adjust the zoom slider
2800-
tick_scale = 120
2801-
steps = int(event.angleDelta().y() / tick_scale)
2802-
delta = zoom.pageStep() * steps
2803-
log.debug("Zooming by %d steps", -steps)
2804-
zoom.setValue(zoom.value() - delta)
2793+
2794+
# Modify zooms factor
2795+
if event.angleDelta().y() > 0:
2796+
get_app().window.sliderZoomWidget.zoomIn()
2797+
else:
2798+
get_app().window.sliderZoomWidget.zoomOut()
2799+
28052800
else:
28062801
super().wheelEvent(event)
28072802

@@ -2911,6 +2906,11 @@ def callback(self, data, callback_data):
29112906
self.run_js(JS_SCOPE_SELECTOR + ".getJavaScriptPosition({}, {});"
29122907
.format(event_position.x(), event_position.y()), partial(callback, self, data))
29132908

2909+
@pyqtSlot(list)
2910+
def ScrollbarChanged(self, new_positions):
2911+
"""Timeline scrollbars changed"""
2912+
get_app().window.TimelineScrolled.emit(new_positions)
2913+
29142914
# Resize timeline
29152915
@pyqtSlot(float)
29162916
def resizeTimeline(self, new_duration):
@@ -3180,7 +3180,9 @@ def __init__(self, window):
31803180
app.updates.add_listener(self)
31813181

31823182
# Connect zoom functionality
3183-
window.sliderZoom.valueChanged.connect(self.update_zoom)
3183+
window.TimelineZoom.connect(self.update_zoom)
3184+
window.TimelineScroll.connect(self.update_scroll)
3185+
window.TimelineCenter.connect(self.centerOnPlayhead)
31843186

31853187
# Connect waveform generation signal
31863188
window.WaveformReady.connect(self.Waveform_Ready)

0 commit comments

Comments
 (0)