ruler.py 15.5 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 170 171 172 173 174
        self._color_subtle = Gdk.RGBA(
            *[(x * 3 + y * 2) / 10
              for x, y in ((color_normal.red, color_insensitive.red),
                           (color_normal.green, color_insensitive.green),
                           (color_normal.blue, color_insensitive.blue))])

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

178 179
        return False

180
    def do_draw(self, context):
181 182 183
        if self.pixbuf is None:
            self.info('No buffer to paint')
            return False
Edward Hervey's avatar
Edward Hervey committed
184

185
        pixbuf = self.pixbuf
186

187 188 189 190 191 192
        # 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()
193

194 195
        context.set_source_surface(self.pixbuf, 0.0, 0.0)
        context.paint()
196

Edward Hervey's avatar
Edward Hervey committed
197 198
        return False

Edward Hervey's avatar
Edward Hervey committed
199
    def do_button_press_event(self, event):
200 201 202
        if not self._pipeline:
            return False

203 204
        button = event.button
        if button == 3 or (button == 1 and self.app.settings.leftClickAlsoSeeks):
205
            self.debug("button pressed at x:%d", event.x)
206 207
            position = self.pixelToNs(event.x + self.pixbuf_offset)
            self._pipeline.simple_seek(position)
208
            self.__set_tooltip_text(position, True)
209
        return False
Edward Hervey's avatar
Edward Hervey committed
210 211

    def do_button_release_event(self, event):
212 213 214
        button = event.button
        if button == 3 or (button == 1 and self.app.settings.leftClickAlsoSeeks):
            self.debug("button released at x:%d", event.x)
215
            self.app.gui.editor.focusTimeline()
216 217
            position = self.pixelToNs(event.x + self.pixbuf_offset)
            self.__set_tooltip_text(position)
Edward Hervey's avatar
Edward Hervey committed
218 219 220
        return False

    def do_motion_notify_event(self, event):
221 222 223
        if not self._pipeline:
            return False

224
        position = self.pixelToNs(event.x + self.pixbuf_offset)
225 226 227 228

        seek_mask = Gdk.ModifierType.BUTTON3_MASK
        if self.app.settings.leftClickAlsoSeeks:
            seek_mask |= Gdk.ModifierType.BUTTON1_MASK
229 230 231

        seeking = event.state & seek_mask
        if seeking:
232
            self.debug("motion at event.x %d", event.x)
233
            self._pipeline.simple_seek(position)
234
        self.__set_tooltip_text(position, seeking)
235

Edward Hervey's avatar
Edward Hervey committed
236 237
        return False

238
    def do_scroll_event(self, event):
239
        self.timeline.timeline.do_scroll_event(event)
240

241
    def setProjectFrameRate(self, rate):
242
        """Sets the lowest scale based on the specified project framerate."""
243
        self.frame_rate = rate
244 245
        self.ns_per_frame = float(Gst.SECOND / self.frame_rate)
        self.scales = (2 / rate, 5 / rate, 10 / rate) + SCALES
246

247 248 249 250 251 252 253 254 255 256
    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)

257
# Drawing methods
258

259 260 261
    def drawBackground(self, context):
        width = context.get_target().get_width()
        height = context.get_target().get_height()
262 263
        style_context = self.app.gui.get_style_context()
        Gtk.render_background(style_context, context, 0, 0, width, height)
264

265
    def drawRuler(self, context):
266
        context.set_font_face(NORMAL_FONT)
267
        context.set_font_size(NORMAL_FONT_SIZE)
268

269
        spacing, interval_seconds, ticks = self._getSpacing(context)
270
        offset = self.pixbuf_offset % spacing
271
        self.drawFrameBoundaries(context)
272 273
        self.drawTicks(context, offset, spacing, interval_seconds, ticks)
        self.drawTimes(context, offset, spacing, interval_seconds)
274

275
    def _getSpacing(self, context):
276 277 278
        # 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
279
        zoom = Zoomable.zoomratio
280 281 282 283
        for interval_seconds, ticks in SCALES:
            interval_width = interval_seconds * zoom
            if interval_width >= min_interval_width:
                return interval_width, interval_seconds, ticks
284 285
        raise Exception(
            "Failed to find an interval size for textwidth:%s, zoomratio:%s" %
286
            (min_interval_width, Zoomable.zoomratio))
287

288
    def drawTicks(self, context, offset, spacing, interval_seconds, ticks):
289
        for tick_interval, height_ratio in reversed(ticks):
290 291
            count_per_interval = interval_seconds / tick_interval
            space = spacing / count_per_interval
292
            if space < MIN_TICK_SPACING_PIXELS:
293
                break
294
            paintpos = 0.5 - offset
295 296 297 298 299

            color = (self._color_normal if height_ratio == 1
                     else self._color_subtle)

            set_cairo_color(context, color)
300
            while paintpos < context.get_target().get_width():
301 302
                self._drawTick(context, paintpos, height_ratio)
                paintpos += space
303

304
    def _drawTick(self, context, paintpos, height_ratio):
305 306 307
        # 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()
308
        y = int(target_height * (1 - height_ratio))
309 310 311 312 313 314
        context.set_line_width(1)
        context.move_to(paintpos, y)
        context.line_to(paintpos, target_height)
        context.close_path()
        context.stroke()

315
    def drawTimes(self, context, offset, spacing, interval_seconds):
316
        # figure out what the optimal offset is
317
        interval = int(Gst.SECOND * interval_seconds)
318
        current_time = self.pixelToNs(self.pixbuf_offset)
319
        paintpos = TIMES_LEFT_MARGIN_PIXELS
320
        if offset > 0:
321
            current_time = current_time - (current_time % interval) + interval
322 323
            paintpos += spacing - offset

324
        set_cairo_color(context, self._color_normal)
325
        y_bearing = context.text_extents("0")[1]
326 327 328 329 330 331 332

        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:]

333 334 335
        previous = split(time_to_string(max(0, current_time - interval)))
        width = context.get_target().get_width()
        while paintpos < width:
336
            context.move_to(int(paintpos), 1 - y_bearing)
337
            current = split(time_to_string(int(current_time)))
338
            millis = current_time % Gst.SECOND > 0
339
            self._drawTime(context, current, previous, millis)
340
            previous = current
341
            paintpos += spacing
342
            current_time += interval
343

344 345
    def _drawTime(self, context, current, previous, millis):
        hour = int(current[0])
346
        for index, (element, previous_element) in enumerate(zip(current, previous)):
347
            if index <= 1 and not hour:
348
                # Don't draw hour if 0.
349
                continue
350 351 352 353 354 355 356 357
            if millis:
                # Draw only the millis.
                if index < 5:
                    continue
            else:
                # Don't draw the millis.
                if index == 5:
                    break
358 359 360 361
            if element == previous_element:
                color = self._color_dimmed
            else:
                color = self._color_normal
362
            set_cairo_color(context, color)
363
            # Display the millis with a smaller font.
364 365 366
            small = index >= 5
            if small:
                context.set_font_size(SMALL_FONT_SIZE)
367
            context.show_text(element)
368 369
            if small:
                context.set_font_size(NORMAL_FONT_SIZE)
370

371
    def drawFrameBoundaries(self, context):
372 373 374 375 376 377
        """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.
378
        """
379
        frame_width = self.nsToPixel(self.ns_per_frame)
380
        if not frame_width >= FRAME_MIN_WIDTH_PIXELS:
381 382 383
            return

        offset = self.pixbuf_offset % frame_width
384
        height = context.get_target().get_height()
385
        y = int(height - FRAME_HEIGHT_PIXELS)
386

387 388
        frame_num = int(
            self.pixelToNs(self.pixbuf_offset) * float(self.frame_rate) / Gst.SECOND)
389
        paintpos = self.pixbuf_offset - offset
390
        max_pos = context.get_target().get_width() + self.pixbuf_offset
391
        while paintpos < max_pos:
392 393
            paintpos = self.nsToPixel(
                1 / float(self.frame_rate) * Gst.SECOND * frame_num)
394 395 396 397 398
            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()
399
            frame_num += 1
400

401
    def drawPosition(self, context):
402
        """Draws the top part of the playhead.
403 404 405 406

        This should be in sync with the playhead drawn by the timeline.
        See Timeline.__draw_playhead().
        """
407
        height = self.pixbuf.get_height()
408 409 410 411 412

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

413 414 415
        # 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
416
        set_cairo_color(context, PLAYHEAD_COLOR)
417 418 419

        context.set_line_width(PLAYHEAD_WIDTH)
        context.move_to(xpos, y)
420
        context.line_to(xpos, height)
Edward Hervey's avatar
Edward Hervey committed
421
        context.stroke()
422

423 424 425 426 427 428 429
        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()