1
# -*- coding: utf-8 -*-
4
# pitivi/timeline/ruler.py
6
# Copyright (c) 2006, Edward Hervey <bilboed@bilboed.com>
7
# Copyright (c) 2014, Alex Băluț <alexandru.balut@gmail.com>
9
# This program is free software; you can redistribute it and/or
10
# modify it under the terms of the GNU Lesser General Public
11
# License as published by the Free Software Foundation; either
12
# version 2.1 of the License, or (at your option) any later version.
14
# This program is distributed in the hope that it will be useful,
15
# but WITHOUT ANY WARRANTY; without even the implied warranty of
16
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17
# Lesser General Public License for more details.
19
# You should have received a copy of the GNU Lesser General Public
20
# License along with this program; if not, write to the
21
# Free Software Foundation, Inc., 51 Franklin St, Fifth Floor,
22
# Boston, MA 02110-1301, USA.
26
from gi.repository import Gtk
27
from gi.repository import Gdk
28
from gi.repository import Gst
29
from gi.repository import GLib
30
from gi.repository import GObject
32
from gettext import gettext as _
34
from pitivi.utils.pipeline import Seeker
35
from pitivi.utils.timeline import Zoomable
36
from pitivi.utils.loggable import Loggable
37
from pitivi.utils.ui import NORMAL_FONT, PLAYHEAD_COLOR, PLAYHEAD_WIDTH, set_cairo_color, time_to_string, beautify_length
39
# A series of valid interval lengths in seconds.
40
SCALES = (0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 3600)
42
# The minimum distance between adjacent ticks.
43
MIN_TICK_SPACING_PIXELS = 3
44
# (count per interval, height ratio) tuples determining how the ticks appear.
45
TICK_TYPES = ((1, 1.0), (2, 0.5), (10, .25))
47
# For displaying the times a bit to the right.
48
TIMES_LEFT_MARGIN_PIXELS = 3
50
# The minimum width for a frame to be displayed.
51
FRAME_MIN_WIDTH_PIXELS = 5
52
# How short it should be.
53
FRAME_HEIGHT_PIXELS = 5
59
class ScaleRuler(Gtk.DrawingArea, Zoomable, Loggable):
61
Widget for displaying the ruler.
63
Displays a series of consecutive intervals. For each interval its beginning
64
time is shown. If zoomed in enough, shows the frames in alternate colors.
68
"button-press-event": "override",
69
"button-release-event": "override",
70
"motion-notify-event": "override",
71
"scroll-event": "override",
72
"seek": (GObject.SignalFlags.RUN_LAST, None,
73
[GObject.TYPE_UINT64])
76
def __init__(self, timeline, hadj):
77
Gtk.DrawingArea.__init__(self)
78
Zoomable.__init__(self)
79
Loggable.__init__(self)
80
self.log("Creating new ScaleRuler")
82
# Allows stealing focus from other GTK widgets, prevent accidents:
83
self.props.can_focus = True
84
self.connect("focus-in-event", self._focusInCb)
85
self.connect("focus-out-event", self._focusOutCb)
87
self.timeline = timeline
88
self._background_color = timeline.get_style_context().lookup_color('theme_bg_color')[1]
89
self._seeker = Seeker()
91
hadj.connect("value-changed", self._hadjValueChangedCb)
92
self.add_events(Gdk.EventMask.POINTER_MOTION_MASK |
93
Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK |
94
Gdk.EventMask.SCROLL_MASK)
98
# all values are in pixels
99
self.pixbuf_offset = 0
100
self.pixbuf_offset_painted = 0
101
# This is the number of width we allocate for the pixbuf
102
self.pixbuf_multiples = 4
104
self.position = 0 # In nanoseconds
106
self.frame_rate = Gst.Fraction(1 / 1)
107
self.ns_per_frame = float(1 / self.frame_rate) * Gst.SECOND
108
self.connect('draw', self.drawCb)
109
self.connect('configure-event', self.configureEventCb)
110
self.callback_id = None
111
self.callback_id_scroll = None
112
self.set_size_request(0, 25)
114
style = self.get_style_context()
115
color_normal = style.get_color(Gtk.StateFlags.NORMAL)
116
color_insensitive = style.get_color(Gtk.StateFlags.INSENSITIVE)
117
self._color_normal = color_normal
118
self._color_dimmed = Gdk.RGBA(
119
*[(x * 3 + y * 2) / 5
120
for x, y in ((color_normal.red, color_insensitive.red),
121
(color_normal.green, color_insensitive.green),
122
(color_normal.blue, color_insensitive.blue))])
126
def _focusInCb(self, unused_widget, unused_arg):
127
self.log("Ruler has grabbed focus")
128
self.timeline.setActionsSensitivity(True)
130
def _focusOutCb(self, unused_widget, unused_arg):
131
self.log("Ruler has lost focus")
132
self.timeline.setActionsSensitivity(False)
134
def _hadjValueChangedCb(self, unused_arg):
135
self.pixbuf_offset = self.hadj.get_value()
136
if self.callback_id_scroll is not None:
137
GLib.source_remove(self.callback_id_scroll)
138
self.callback_id_scroll = GLib.timeout_add(100, self._maybeUpdate)
140
## Zoomable interface override
142
def _maybeUpdate(self):
144
self.callback_id = None
145
self.callback_id_scroll = None
148
def zoomChanged(self):
149
if self.callback_id is not None:
150
GLib.source_remove(self.callback_id)
151
self.callback_id = GLib.timeout_add(100, self._maybeUpdate)
153
## timeline position changed method
155
def setPipeline(self, pipeline):
156
pipeline.connect('position', self.timelinePositionCb)
158
def timelinePositionCb(self, unused_pipeline, position):
159
self.position = position
162
## Gtk.Widget overrides
163
def configureEventCb(self, widget, unused_event, unused_data=None):
164
width = widget.get_allocated_width()
165
height = widget.get_allocated_height()
166
self.debug("Configuring, height %d, width %d", width, height)
168
# Destroy previous buffer
169
if self.pixbuf is not None:
173
# Create a new buffer
174
self.pixbuf = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
178
def drawCb(self, unused_widget, context):
179
if self.pixbuf is None:
180
self.info('No buffer to paint')
185
# Draw on a temporary context and then copy everything.
186
drawing_context = cairo.Context(pixbuf)
187
self.drawBackground(drawing_context)
188
self.drawRuler(drawing_context)
189
self.drawPosition(drawing_context)
192
context.set_source_surface(self.pixbuf, 0.0, 0.0)
197
def do_button_press_event(self, event):
198
self.debug("button pressed at x:%d", event.x)
200
position = self.pixelToNs(event.x + self.pixbuf_offset)
201
self._seeker.seek(position, on_idle=True)
204
def do_button_release_event(self, event):
205
self.debug("button released at x:%d", event.x)
206
self.grab_focus() # Prevent other widgets from being confused
210
def do_motion_notify_event(self, event):
211
position = self.pixelToNs(event.x + self.pixbuf_offset)
213
self.debug("motion at event.x %d", event.x)
214
self._seeker.seek(position, on_idle=True)
216
human_time = beautify_length(position)
217
cur_frame = int(position / self.ns_per_frame) + 1
218
self.set_tooltip_text(human_time + "\n" + _("Frame #%d" % cur_frame))
221
def do_scroll_event(self, event):
222
if event.scroll.state & Gdk.ModifierType.CONTROL_MASK:
223
# Control + scroll = zoom
224
if event.scroll.direction == Gdk.ScrollDirection.UP:
226
self.timeline.zoomed_fitted = False
227
elif event.scroll.direction == Gdk.ScrollDirection.DOWN:
229
self.timeline.zoomed_fitted = False
231
# No modifier key held down, just scroll
232
if (event.scroll.direction == Gdk.ScrollDirection.UP
233
or event.scroll.direction == Gdk.ScrollDirection.LEFT):
234
self.timeline.scroll_left()
235
elif (event.scroll.direction == Gdk.ScrollDirection.DOWN
236
or event.scroll.direction == Gdk.ScrollDirection.RIGHT):
237
self.timeline.scroll_right()
239
def setProjectFrameRate(self, rate):
241
Set the lowest scale based on project framerate
243
self.frame_rate = rate
244
self.ns_per_frame = float(1 / self.frame_rate) * Gst.SECOND
245
self.scales = (float(2 / rate), float(5 / rate), float(10 / rate)) + SCALES
249
def drawBackground(self, context):
250
style = self.get_style_context()
251
set_cairo_color(context, self._background_color)
252
width = context.get_target().get_width()
253
height = context.get_target().get_height()
254
context.rectangle(0, 0, width, height)
256
offset = int(self.nsToPixel(Gst.CLOCK_TIME_NONE)) - self.pixbuf_offset
258
set_cairo_color(context, style.get_background_color(Gtk.StateFlags.ACTIVE))
259
context.rectangle(0, 0, int(offset), context.get_target().get_height())
262
def drawRuler(self, context):
263
context.set_font_face(NORMAL_FONT)
264
context.set_font_size(NORMAL_FONT_SIZE)
266
spacing, scale = self._getSpacing(context)
267
offset = self.pixbuf_offset % spacing
268
self.drawFrameBoundaries(context)
269
self.drawTicks(context, offset, spacing)
270
self.drawTimes(context, offset, spacing, scale)
272
def _getSpacing(self, context):
273
textwidth = context.text_extents(time_to_string(0))[2]
274
zoom = Zoomable.zoomratio
275
for scale in self.scales:
276
spacing = scale * zoom
277
if spacing >= textwidth * 1.5:
278
return spacing, scale
279
raise Exception("Failed to find an interval size for textwidth:%s, zoomratio:%s" % (textwidth, Zoomable.zoomratio))
281
def drawTicks(self, context, offset, spacing):
282
for count_per_interval, height_ratio in TICK_TYPES:
283
space = float(spacing) / count_per_interval
284
if space < MIN_TICK_SPACING_PIXELS:
286
paintpos = 0.5 - offset
287
set_cairo_color(context, self._color_normal)
288
while paintpos < context.get_target().get_width():
289
self._drawTick(context, paintpos, height_ratio)
292
def _drawTick(self, context, paintpos, height_ratio):
293
# We need to use 0.5 pixel offsets to get a sharp 1 px line in cairo
294
paintpos = int(paintpos - 0.5) + 0.5
295
target_height = context.get_target().get_height()
296
y = int(target_height * (1 - height_ratio))
297
context.set_line_width(1)
298
context.move_to(paintpos, y)
299
context.line_to(paintpos, target_height)
303
def drawTimes(self, context, offset, spacing, scale):
304
# figure out what the optimal offset is
305
interval = long(Gst.SECOND * scale)
306
current_time = self.pixelToNs(self.pixbuf_offset)
307
paintpos = TIMES_LEFT_MARGIN_PIXELS
309
current_time = current_time - (current_time % interval) + interval
310
paintpos += spacing - offset
312
state = Gtk.StateFlags.NORMAL
313
style = self.get_style_context()
314
set_cairo_color(context, style.get_color(state))
315
y_bearing = context.text_extents("0")[1]
319
# Seven elements: h : mm : ss . mmm
320
# Using negative indices because the first element (hour)
321
# can have a variable length.
322
return x[:-10], x[-10], x[-9:-7], x[-7], x[-6:-4], x[-4], x[-3:]
324
previous = split(time_to_string(max(0, current_time - interval)))
325
width = context.get_target().get_width()
326
while paintpos < width:
327
context.move_to(int(paintpos), 1 - y_bearing)
328
current = split(time_to_string(long(current_time)))
329
self._drawTime(context, current, previous, millis)
332
current_time += interval
334
def _drawTime(self, context, current, previous, millis):
335
hour = int(current[0])
336
for index, (element, previous_element) in enumerate(zip(current, previous)):
337
if index <= 1 and not hour:
339
if index >= 5 and not millis:
341
if element == previous_element:
342
color = self._color_dimmed
344
color = self._color_normal
345
set_cairo_color(context, color)
346
# Display the millis with a smaller font
349
context.set_font_size(SMALL_FONT_SIZE)
350
context.show_text(element)
352
context.set_font_size(NORMAL_FONT_SIZE)
354
def drawFrameBoundaries(self, context):
356
Draw the alternating rectangles that represent the project frames at
357
high zoom levels. These are based on the framerate set in the project
358
settings, not the actual frames on a video codec level.
360
frame_width = self.nsToPixel(self.ns_per_frame)
361
if not frame_width >= FRAME_MIN_WIDTH_PIXELS:
364
offset = self.pixbuf_offset % frame_width
365
height = context.get_target().get_height()
366
y = int(height - FRAME_HEIGHT_PIXELS)
367
# INSENSITIVE is a dark shade of gray, but lacks contrast
368
# SELECTED will be bright blue and more visible to represent frames
369
style = self.get_style_context()
370
states = [style.get_background_color(Gtk.StateFlags.ACTIVE),
371
style.get_background_color(Gtk.StateFlags.SELECTED)]
373
frame_num = int(self.pixelToNs(self.pixbuf_offset) * float(self.frame_rate) / Gst.SECOND)
374
paintpos = self.pixbuf_offset - offset
375
max_pos = context.get_target().get_width() + self.pixbuf_offset
376
while paintpos < max_pos:
377
paintpos = self.nsToPixel(1 / float(self.frame_rate) * Gst.SECOND * frame_num)
378
set_cairo_color(context, states[(frame_num + 1) % 2])
379
context.rectangle(0.5 + paintpos - self.pixbuf_offset, y, frame_width, height)
383
def drawPosition(self, context):
384
# Add 0.5 so that the line center is at the middle of the pixel,
385
# without this the line appears blurry.
386
xpos = self.nsToPixel(self.position) - self.pixbuf_offset + 0.5
387
context.set_line_width(PLAYHEAD_WIDTH + 2)
388
set_cairo_color(context, PLAYHEAD_COLOR)
389
context.move_to(xpos, 0)
390
context.line_to(xpos, context.get_target().get_height())