ruler.py 15.1 KB
Newer Older
1
# -*- coding: utf-8 -*-
2
# Pitivi video editor
Edward Hervey's avatar
Edward Hervey committed
3
# Copyright (c) 2006, Edward Hervey <bilboed@bilboed.com>
4
# Copyright (c) 2014, Alex Băluț <alexandru.balut@gmail.com>
Edward Hervey's avatar
Edward Hervey committed
5 6 7 8 9 10 11 12 13 14 15 16 17
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program; if not, write to the
Hicham HAOUARI's avatar
Hicham HAOUARI committed
18 19
# Free Software Foundation, Inc., 51 Franklin St, Fifth Floor,
# Boston, MA 02110-1301, USA.
20
from gettext import gettext as _
21

22
import cairo
23 24
from gi.repository import Gdk
from gi.repository import Gst
25
from gi.repository import Gtk
26

27
from pitivi.utils.loggable import Loggable
28
from pitivi.utils.timeline import Zoomable
29
from pitivi.utils.ui import beautify_length
Thibault Saunier's avatar
Thibault Saunier committed
30
from pitivi.utils.ui import gtk_style_context_get_color
31
from pitivi.utils.ui import NORMAL_FONT
32
from pitivi.utils.ui import PLAYHEAD_COLOR
33 34 35
from pitivi.utils.ui import PLAYHEAD_WIDTH
from pitivi.utils.ui import set_cairo_color
from pitivi.utils.ui import time_to_string
Edward Hervey's avatar
Edward Hervey committed
36

37

38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
# Tuples of:
# - an interval lengths in seconds for which a timestamp will be displayed
# - how the ticks should be displayed for this interval:
#   (count per interval, height ratio) tuples.
SCALES = (
    (0.1, ((0.1, 1.0), (0.05, .5), (0.01, .25))),
    (0.2, ((0.2, 1.0), (0.1, .5), (0.05, .25))),
    (0.5, ((0.5, 1.0), (0.1, .25))),

    # 1 second.
    (1, ((1, 1.0), (0.5, .5), (0.1, .25))),
    (2, ((2, 1.0), (1, .5), (0.5, .25))),
    (5, ((5, 1.0), (1, .25))),
    (10, ((10, 1.0), (5, .5), (1, .25))),
    (20, ((20, 1.0), (10, .5), (1, .25))),
    (30, ((30, 1.0), (10, .5), (1, .25))),

    # 1 minute.
    (60, ((60, 1.0), (30, .5), (15, .25))),
    # 2 minutes.
    (120, ((120, 1.0), (60, .5), (30, .25))),
    # 5 minutes.
    (300, ((300, 1.0), (60, .25))),
    # 10 minutes.
    (600, ((600, 1.0), (300, .5), (60, .25))),
    # 30 minutes.
    (1800, ((1800, 1.0), (900, .5), (450, .25))),

    # 1 hour.
    (3600, ((3600, 1.0), (1800, .75), (900, .5))),
)
69 70

# The minimum distance between adjacent ticks.
71
MIN_TICK_SPACING_PIXELS = 6
72 73 74 75 76 77 78 79 80

# For displaying the times a bit to the right.
TIMES_LEFT_MARGIN_PIXELS = 3

# The minimum width for a frame to be displayed.
FRAME_MIN_WIDTH_PIXELS = 5
# How short it should be.
FRAME_HEIGHT_PIXELS = 5

81 82 83
NORMAL_FONT_SIZE = 13
SMALL_FONT_SIZE = 11

84

85
class ScaleRuler(Gtk.DrawingArea, Zoomable, Loggable):
86
    """Widget for displaying the ruler.
87 88 89

    Displays a series of consecutive intervals. For each interval its beginning
    time is shown. If zoomed in enough, shows the frames in alternate colors.
90

91 92 93 94
    Attributes:
        timeline (TimelineContainer): The timeline container used to handle
            scroll events.
        _pipeline (Pipeline): The pipeline of the project.
95
    """
Edward Hervey's avatar
Edward Hervey committed
96

97
    def __init__(self, timeline):
98
        Gtk.DrawingArea.__init__(self)
99
        Zoomable.__init__(self)
100
        Loggable.__init__(self)
101
        self.log("Creating new ScaleRuler")
102

103
        self.timeline = timeline
104
        self._pipeline = None
105 106
        hadj = timeline.timeline.hadj
        hadj.connect("value-changed", self._hadj_value_changed_cb)
107
        self.add_events(Gdk.EventMask.POINTER_MOTION_MASK |
108 109
                        Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK |
                        Gdk.EventMask.SCROLL_MASK)
110

111
        self.pixbuf = None
112

113
        # all values are in pixels
114 115
        self.pixbuf_offset = 0
        self.pixbuf_offset_painted = 0
116

117
        self.position = 0  # In nanoseconds
118 119
        self.frame_rate = Gst.Fraction(1 / 1)
        self.ns_per_frame = float(1 / self.frame_rate) * Gst.SECOND
120

121 122
        self.scales = SCALES

123 124 125
    def _hadj_value_changed_cb(self, hadj):
        """Handles the adjustment value change."""
        self.pixbuf_offset = hadj.get_value()
126
        self.queue_draw()
127

128
# Zoomable interface override
129

130
    def zoomChanged(self):
131
        self.queue_draw()
132

133
# Timeline position changed method
Edward Hervey's avatar
Edward Hervey committed
134

135
    def setPipeline(self, pipeline):
136 137
        self._pipeline = pipeline
        self._pipeline.connect('position', self.timelinePositionCb)
138

139
    def timelinePositionCb(self, unused_pipeline, position):
140
        self.position = position
141
        self.queue_draw()
142

143
# Gtk.Widget overrides
144 145 146 147

    def do_configure_event(self, unused_event):
        width = self.get_allocated_width()
        height = self.get_allocated_height()
148
        self.debug("Configuring, height %d, width %d", width, height)
149 150 151 152 153 154 155

        # Destroy previous buffer
        if self.pixbuf is not None:
            self.pixbuf.finish()
            self.pixbuf = None

        # Create a new buffer
156
        self.pixbuf = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
157

Thibault Saunier's avatar
Thibault Saunier committed
158
        context = self.app.gui.get_style_context()
159

Thibault Saunier's avatar
Thibault Saunier committed
160 161
        color_normal = gtk_style_context_get_color(context, Gtk.StateFlags.NORMAL)
        color_insensitive = gtk_style_context_get_color(context, Gtk.StateFlags.BACKDROP)
162 163 164 165 166 167 168
        self._color_normal = color_normal
        self._color_dimmed = Gdk.RGBA(
            *[(x * 3 + y * 2) / 5
              for x, y in ((color_normal.red, color_insensitive.red),
                           (color_normal.green, color_insensitive.green),
                           (color_normal.blue, color_insensitive.blue))])

169
        # Two colors with high contrast.
Thibault Saunier's avatar
Thibault Saunier committed
170
        self._color_frame = gtk_style_context_get_color(context, Gtk.StateFlags.LINK)
171

172 173
        return False

174
    def do_draw(self, context):
175 176 177
        if self.pixbuf is None:
            self.info('No buffer to paint')
            return False
Edward Hervey's avatar
Edward Hervey committed
178

179
        pixbuf = self.pixbuf
180

181 182 183 184 185 186
        # Draw on a temporary context and then copy everything.
        drawing_context = cairo.Context(pixbuf)
        self.drawBackground(drawing_context)
        self.drawRuler(drawing_context)
        self.drawPosition(drawing_context)
        pixbuf.flush()
187

188 189
        context.set_source_surface(self.pixbuf, 0.0, 0.0)
        context.paint()
190

Edward Hervey's avatar
Edward Hervey committed
191 192
        return False

Edward Hervey's avatar
Edward Hervey committed
193
    def do_button_press_event(self, event):
194 195 196
        if not self._pipeline:
            return False

197 198
        button = event.button
        if button == 3 or (button == 1 and self.app.settings.leftClickAlsoSeeks):
199
            self.debug("button pressed at x:%d", event.x)
200 201
            position = self.pixelToNs(event.x + self.pixbuf_offset)
            self._pipeline.simple_seek(position)
202
            self.__set_tooltip_text(position, True)
203
        return False
Edward Hervey's avatar
Edward Hervey committed
204 205

    def do_button_release_event(self, event):
206 207 208
        button = event.button
        if button == 3 or (button == 1 and self.app.settings.leftClickAlsoSeeks):
            self.debug("button released at x:%d", event.x)
209
            self.app.gui.editor.focusTimeline()
210 211
            position = self.pixelToNs(event.x + self.pixbuf_offset)
            self.__set_tooltip_text(position)
Edward Hervey's avatar
Edward Hervey committed
212 213 214
        return False

    def do_motion_notify_event(self, event):
215 216 217
        if not self._pipeline:
            return False

218
        position = self.pixelToNs(event.x + self.pixbuf_offset)
219 220 221 222

        seek_mask = Gdk.ModifierType.BUTTON3_MASK
        if self.app.settings.leftClickAlsoSeeks:
            seek_mask |= Gdk.ModifierType.BUTTON1_MASK
223 224 225

        seeking = event.state & seek_mask
        if seeking:
226
            self.debug("motion at event.x %d", event.x)
227
            self._pipeline.simple_seek(position)
228
        self.__set_tooltip_text(position, seeking)
229

Edward Hervey's avatar
Edward Hervey committed
230 231
        return False

232
    def do_scroll_event(self, event):
233
        self.timeline.timeline.do_scroll_event(event)
234

235
    def setProjectFrameRate(self, rate):
236
        """Sets the lowest scale based on the specified project framerate."""
237
        self.frame_rate = rate
238 239
        self.ns_per_frame = float(Gst.SECOND / self.frame_rate)
        self.scales = (2 / rate, 5 / rate, 10 / rate) + SCALES
240

241 242 243 244 245 246 247 248 249 250
    def __set_tooltip_text(self, position, seeking=False):
        """Updates the tooltip."""
        if seeking:
            timeline_duration = self.timeline.ges_timeline.props.duration
            if position > timeline_duration:
                position = timeline_duration
        human_time = beautify_length(position)
        cur_frame = int(position / self.ns_per_frame) + 1
        self.set_tooltip_text(human_time + "\n" + _("Frame #%d") % cur_frame)

251
# Drawing methods
252

253 254 255
    def drawBackground(self, context):
        width = context.get_target().get_width()
        height = context.get_target().get_height()
256 257
        style_context = self.app.gui.get_style_context()
        Gtk.render_background(style_context, context, 0, 0, width, height)
258

259
    def drawRuler(self, context):
260
        context.set_font_face(NORMAL_FONT)
261
        context.set_font_size(NORMAL_FONT_SIZE)
262

263
        spacing, interval_seconds, ticks = self._getSpacing(context)
264
        offset = self.pixbuf_offset % spacing
265
        self.drawFrameBoundaries(context)
266 267
        self.drawTicks(context, offset, spacing, interval_seconds, ticks)
        self.drawTimes(context, offset, spacing, interval_seconds)
268

269
    def _getSpacing(self, context):
270 271 272
        # The longest timestamp we display is 0:00:00 because
        # when we display millis, they are displayed by themselves.
        min_interval_width = context.text_extents("0:00:00")[2] * 1.3
273
        zoom = Zoomable.zoomratio
274 275 276 277
        for interval_seconds, ticks in SCALES:
            interval_width = interval_seconds * zoom
            if interval_width >= min_interval_width:
                return interval_width, interval_seconds, ticks
278 279
        raise Exception(
            "Failed to find an interval size for textwidth:%s, zoomratio:%s" %
280
            (min_interval_width, Zoomable.zoomratio))
281

282 283 284 285
    def drawTicks(self, context, offset, spacing, interval_seconds, ticks):
        for tick_interval, height_ratio in ticks:
            count_per_interval = interval_seconds / tick_interval
            space = spacing / count_per_interval
286
            if space < MIN_TICK_SPACING_PIXELS:
287
                break
288
            paintpos = 0.5 - offset
289
            set_cairo_color(context, self._color_normal)
290
            while paintpos < context.get_target().get_width():
291 292
                self._drawTick(context, paintpos, height_ratio)
                paintpos += space
293

294
    def _drawTick(self, context, paintpos, height_ratio):
295 296 297
        # We need to use 0.5 pixel offsets to get a sharp 1 px line in cairo
        paintpos = int(paintpos - 0.5) + 0.5
        target_height = context.get_target().get_height()
298
        y = int(target_height * (1 - height_ratio))
299 300 301 302 303 304
        context.set_line_width(1)
        context.move_to(paintpos, y)
        context.line_to(paintpos, target_height)
        context.close_path()
        context.stroke()

305
    def drawTimes(self, context, offset, spacing, interval_seconds):
306
        # figure out what the optimal offset is
307
        interval = int(Gst.SECOND * interval_seconds)
308
        current_time = self.pixelToNs(self.pixbuf_offset)
309
        paintpos = TIMES_LEFT_MARGIN_PIXELS
310
        if offset > 0:
311
            current_time = current_time - (current_time % interval) + interval
312 313
            paintpos += spacing - offset

314
        set_cairo_color(context, self._color_normal)
315
        y_bearing = context.text_extents("0")[1]
316 317 318 319 320 321 322

        def split(x):
            # Seven elements: h : mm : ss . mmm
            # Using negative indices because the first element (hour)
            # can have a variable length.
            return x[:-10], x[-10], x[-9:-7], x[-7], x[-6:-4], x[-4], x[-3:]

323 324 325
        previous = split(time_to_string(max(0, current_time - interval)))
        width = context.get_target().get_width()
        while paintpos < width:
326
            context.move_to(int(paintpos), 1 - y_bearing)
327
            current = split(time_to_string(int(current_time)))
328
            millis = current_time % Gst.SECOND > 0
329
            self._drawTime(context, current, previous, millis)
330
            previous = current
331
            paintpos += spacing
332
            current_time += interval
333

334 335
    def _drawTime(self, context, current, previous, millis):
        hour = int(current[0])
336
        for index, (element, previous_element) in enumerate(zip(current, previous)):
337
            if index <= 1 and not hour:
338
                # Don't draw hour if 0.
339
                continue
340 341 342 343 344 345 346 347
            if millis:
                # Draw only the millis.
                if index < 5:
                    continue
            else:
                # Don't draw the millis.
                if index == 5:
                    break
348 349 350 351
            if element == previous_element:
                color = self._color_dimmed
            else:
                color = self._color_normal
352
            set_cairo_color(context, color)
353
            # Display the millis with a smaller font.
354 355 356
            small = index >= 5
            if small:
                context.set_font_size(SMALL_FONT_SIZE)
357
            context.show_text(element)
358 359
            if small:
                context.set_font_size(NORMAL_FONT_SIZE)
360

361
    def drawFrameBoundaries(self, context):
362 363 364 365 366 367
        """Draws the alternating rectangles that represent the project frames.

        These are drawn only at high zoom levels.

        These are based on the project's framerate settings, not the actual
        frames on the assets.
368
        """
369
        frame_width = self.nsToPixel(self.ns_per_frame)
370
        if not frame_width >= FRAME_MIN_WIDTH_PIXELS:
371 372 373
            return

        offset = self.pixbuf_offset % frame_width
374
        height = context.get_target().get_height()
375
        y = int(height - FRAME_HEIGHT_PIXELS)
376

377 378
        frame_num = int(
            self.pixelToNs(self.pixbuf_offset) * float(self.frame_rate) / Gst.SECOND)
379
        paintpos = self.pixbuf_offset - offset
380
        max_pos = context.get_target().get_width() + self.pixbuf_offset
381
        while paintpos < max_pos:
382 383
            paintpos = self.nsToPixel(
                1 / float(self.frame_rate) * Gst.SECOND * frame_num)
384 385 386 387 388
            if frame_num % 2:
                set_cairo_color(context, self._color_frame)
                context.rectangle(
                    0.5 + paintpos - self.pixbuf_offset, y, frame_width, height)
                context.fill()
389
            frame_num += 1
390

391
    def drawPosition(self, context):
392
        """Draws the top part of the playhead.
393 394 395 396

        This should be in sync with the playhead drawn by the timeline.
        See Timeline.__draw_playhead().
        """
397
        height = self.pixbuf.get_height()
398 399 400 401 402

        semi_width = 4
        semi_height = int(semi_width * 1.61803)
        y = int(3 * height / 4)

403 404 405
        # Add 0.5 so that the line center is at the middle of the pixel,
        # without this the line appears blurry.
        xpos = self.nsToPixel(self.position) - self.pixbuf_offset + 0.5
406
        set_cairo_color(context, PLAYHEAD_COLOR)
407 408 409

        context.set_line_width(PLAYHEAD_WIDTH)
        context.move_to(xpos, y)
410
        context.line_to(xpos, height)
Edward Hervey's avatar
Edward Hervey committed
411
        context.stroke()
412

413 414 415 416 417 418 419
        context.set_line_width(PLAYHEAD_WIDTH * 2)
        context.move_to(xpos, y)
        context.line_to(xpos + semi_width, y - semi_height)
        context.line_to(xpos, y - semi_height * 2)
        context.line_to(xpos - semi_width, y - semi_height)
        context.close_path()
        context.stroke()