ruler.py 14.1 KB
Newer Older
1
# -*- coding: utf-8 -*-
2
# Pitivi video editor
Edward Hervey's avatar
Edward Hervey committed
3
#
4
#       pitivi/timeline/ruler.py
Edward Hervey's avatar
Edward Hervey committed
5 6
#
# Copyright (c) 2006, Edward Hervey <bilboed@bilboed.com>
7
# Copyright (c) 2014, Alex Băluț <alexandru.balut@gmail.com>
Edward Hervey's avatar
Edward Hervey committed
8 9 10 11 12 13 14 15 16 17 18 19 20
#
# 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
21 22
# Free Software Foundation, Inc., 51 Franklin St, Fifth Floor,
# Boston, MA 02110-1301, USA.
Edward Hervey's avatar
Edward Hervey committed
23

Edward Hervey's avatar
Edward Hervey committed
24 25 26
"""
Widget for the complex view ruler
"""
27
import cairo
28

29 30 31
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import Gst
32
from gi.repository import GLib
33 34
from gi.repository import GObject

35 36
from gettext import gettext as _

37
from pitivi.utils.pipeline import Seeker
38
from pitivi.utils.timeline import Zoomable
39
from pitivi.utils.loggable import Loggable
40
from pitivi.utils.ui import time_to_string, beautify_length
Edward Hervey's avatar
Edward Hervey committed
41

42 43 44 45
# Color #393f3f stolen from the dark variant of Adwaita.
# There's *no way* to get the GTK3 theme's bg color there (it's always black)
RULER_BACKGROUND_COLOR = (57, 63, 63)

46

47
def setCairoColor(context, color):
48 49 50
    if type(color) is Gdk.RGBA:
        cairo_color = (float(color.red), float(color.green), float(color.blue))
    elif type(color) is tuple:
51 52 53
        # Cairo's set_source_rgb function expects values from 0.0 to 1.0
        cairo_color = map(lambda x: max(0, min(1, x / 255.0)), color)
    else:
54 55
        raise Exception("Unexpected color parameter: %s, %s" % (type(color), color))
    context.set_source_rgb(*cairo_color)
56 57


58
class ScaleRuler(Gtk.DrawingArea, Zoomable, Loggable):
Edward Hervey's avatar
Edward Hervey committed
59

Edward Hervey's avatar
Edward Hervey committed
60
    __gsignals__ = {
61 62 63 64
        "button-press-event": "override",
        "button-release-event": "override",
        "motion-notify-event": "override",
        "scroll-event": "override",
65 66
        "seek": (GObject.SignalFlags.RUN_LAST, None,
                [GObject.TYPE_UINT64])
67
    }
Edward Hervey's avatar
Edward Hervey committed
68

69 70
    border = 0
    min_tick_spacing = 3
71
    scale = [0, 0, 0, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 3600]
72
    subdivide = ((1, 1.0), (2, 0.5), (10, .25))
73

74
    def __init__(self, instance, hadj):
75
        Gtk.DrawingArea.__init__(self)
76
        Zoomable.__init__(self)
77
        Loggable.__init__(self)
78
        self.log("Creating new ScaleRuler")
79 80 81 82 83 84

        # Allows stealing focus from other GTK widgets, prevent accidents:
        self.props.can_focus = True
        self.connect("focus-in-event", self._focusInCb)
        self.connect("focus-out-event", self._focusOutCb)

85 86
        self.app = instance
        self._seeker = Seeker()
87 88
        self.hadj = hadj
        hadj.connect("value-changed", self._hadjValueChangedCb)
89 90 91
        self.add_events(Gdk.EventMask.POINTER_MOTION_MASK |
            Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK |
            Gdk.EventMask.SCROLL_MASK)
92

93
        self.pixbuf = None
94

95
        # all values are in pixels
96 97 98 99
        self.pixbuf_offset = 0
        self.pixbuf_offset_painted = 0
        # This is the number of width we allocate for the pixbuf
        self.pixbuf_multiples = 4
100

101
        self.position = 0  # In nanoseconds
Edward Hervey's avatar
Edward Hervey committed
102
        self.pressed = False
103 104
        self.min_frame_spacing = 5.0
        self.frame_height = 5.0
105 106
        self.frame_rate = Gst.Fraction(1 / 1)
        self.ns_per_frame = float(1 / self.frame_rate) * Gst.SECOND
107 108
        self.connect('draw', self.drawCb)
        self.connect('configure-event', self.configureEventCb)
109
        self.callback_id = None
110
        self.callback_id_scroll = None
111
        self.set_size_request(0, 25)
112

113 114 115 116 117 118 119 120 121 122
        style = self.get_style_context()
        color_normal = style.get_color(Gtk.StateFlags.NORMAL)
        color_insensitive = style.get_color(Gtk.StateFlags.INSENSITIVE)
        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))])

123 124 125 126 127 128 129 130
    def _focusInCb(self, unused_widget, unused_arg):
        self.log("Ruler has grabbed focus")
        self.app.gui.timeline_ui.setActionsSensitivity(True)

    def _focusOutCb(self, unused_widget, unused_arg):
        self.log("Ruler has lost focus")
        self.app.gui.timeline_ui.setActionsSensitivity(False)

131
    def _hadjValueChangedCb(self, hadj):
132
        self.pixbuf_offset = self.hadj.get_value()
133
        if self.callback_id_scroll is not None:
134 135
            GLib.source_remove(self.callback_id_scroll)
        self.callback_id_scroll = GLib.timeout_add(100, self._maybeUpdate)
136

137
## Zoomable interface override
138

139
    def _maybeUpdate(self):
140
        self.queue_draw()
141
        self.callback_id = None
142
        self.callback_id_scroll = None
143 144 145 146
        return False

    def zoomChanged(self):
        if self.callback_id is not None:
147 148
            GLib.source_remove(self.callback_id)
        self.callback_id = GLib.timeout_add(100, self._maybeUpdate)
149

150
## timeline position changed method
Edward Hervey's avatar
Edward Hervey committed
151

Edward Hervey's avatar
Edward Hervey committed
152
    def timelinePositionChanged(self, value, unused_frame=None):
Edward Hervey's avatar
Edward Hervey committed
153
        self.position = value
154
        self.queue_draw()
155

156
## Gtk.Widget overrides
157
    def configureEventCb(self, widget, event, data=None):
158 159 160
        width = widget.get_allocated_width()
        height = widget.get_allocated_height()
        self.debug("Configuring, height %d, width %d", width, height)
161 162 163 164 165 166 167

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

        # Create a new buffer
168
        self.pixbuf = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
169 170 171

        return False

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

177
        pixbuf = self.pixbuf
178

179 180 181 182 183 184
        # 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()
185

186 187
        context.set_source_surface(self.pixbuf, 0.0, 0.0)
        context.paint()
188

Edward Hervey's avatar
Edward Hervey committed
189 190
        return False

Edward Hervey's avatar
Edward Hervey committed
191
    def do_button_press_event(self, event):
192
        self.debug("button pressed at x:%d", event.x)
Edward Hervey's avatar
Edward Hervey committed
193
        self.pressed = True
194
        position = self.pixelToNs(event.x + self.pixbuf_offset)
195
        self._seeker.seek(position, on_idle=True)
Edward Hervey's avatar
Edward Hervey committed
196 197 198
        return True

    def do_button_release_event(self, event):
199
        self.debug("button released at x:%d", event.x)
200
        self.grab_focus()  # Prevent other widgets from being confused
Edward Hervey's avatar
Edward Hervey committed
201 202 203 204
        self.pressed = False
        return False

    def do_motion_notify_event(self, event):
205
        position = self.pixelToNs(event.x + self.pixbuf_offset)
Edward Hervey's avatar
Edward Hervey committed
206
        if self.pressed:
207
            self.debug("motion at event.x %d", event.x)
208
            self._seeker.seek(position, on_idle=True)
209 210 211 212

        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))
Edward Hervey's avatar
Edward Hervey committed
213 214
        return False

215
    def do_scroll_event(self, event):
216
        if event.scroll.state & Gdk.ModifierType.CONTROL_MASK:
217
            # Control + scroll = zoom
218
            if event.scroll.direction == Gdk.ScrollDirection.UP:
219
                Zoomable.zoomIn()
220
                self.app.gui.timeline_ui.zoomed_fitted = False
221
            elif event.scroll.direction == Gdk.ScrollDirection.DOWN:
222
                Zoomable.zoomOut()
223
                self.app.gui.timeline_ui.zoomed_fitted = False
224 225
        else:
            # No modifier key held down, just scroll
226 227
            if (event.scroll.direction == Gdk.ScrollDirection.UP
            or event.scroll.direction == Gdk.ScrollDirection.LEFT):
228
                self.app.gui.timeline_ui.scroll_left()
229 230
            elif (event.scroll.direction == Gdk.ScrollDirection.DOWN
            or event.scroll.direction == Gdk.ScrollDirection.RIGHT):
231
                self.app.gui.timeline_ui.scroll_right()
232

233
    def setProjectFrameRate(self, rate):
234 235 236
        """
        Set the lowest scale based on project framerate
        """
237
        self.frame_rate = rate
238
        self.ns_per_frame = float(1 / self.frame_rate) * Gst.SECOND
239 240 241
        self.scale[0] = float(2 / rate)
        self.scale[1] = float(5 / rate)
        self.scale[2] = float(10 / rate)
242

243 244
## Drawing methods

245
    def drawBackground(self, context):
246
        style = self.get_style_context()
247 248 249 250 251
        setCairoColor(context, RULER_BACKGROUND_COLOR)
        width = context.get_target().get_width()
        height = context.get_target().get_height()
        context.rectangle(0, 0, width, height)
        context.fill()
252
        offset = int(self.nsToPixel(Gst.CLOCK_TIME_NONE)) - self.pixbuf_offset
253
        if offset > 0:
254 255 256
            setCairoColor(context, style.get_background_color(Gtk.StateFlags.ACTIVE))
            context.rectangle(0, 0, int(offset), context.get_target().get_height())
            context.fill()
257

258
    def drawRuler(self, context):
259
        # FIXME use system defaults
260 261 262
        context.set_font_face(cairo.ToyFontFace("Cantarell"))
        context.set_font_size(13)
        textwidth = context.text_extents(time_to_string(0))[2]
263

264 265
        for scale in self.scale:
            spacing = Zoomable.zoomratio * scale
266 267 268
            if spacing >= textwidth * 1.5:
                break

269
        offset = self.pixbuf_offset % spacing
270 271 272
        self.drawFrameBoundaries(context)
        self.drawTicks(context, offset, spacing, scale)
        self.drawTimes(context, offset, spacing, scale)
273

274
    def drawTick(self, context, paintpos, height):
275
        # We need to use 0.5 pixel offsets to get a sharp 1 px line in cairo
276
        paintpos = int(paintpos - 0.5) + 0.5
277
        height = int(context.get_target().get_height() * (1 - height))
278
        style = self.get_style_context()
279
        setCairoColor(context, style.get_color(Gtk.StateFlags.NORMAL))
280 281 282 283 284 285 286
        context.set_line_width(1)
        context.move_to(paintpos, height)
        context.line_to(paintpos, context.get_target().get_height())
        context.close_path()
        context.stroke()

    def drawTicks(self, context, offset, spacing, scale):
287 288 289 290
        for subdivide, height in self.subdivide:
            spc = spacing / float(subdivide)
            if spc < self.min_tick_spacing:
                break
291 292
            paintpos = -spacing + 0.5
            paintpos += spacing - offset
293 294
            while paintpos < context.get_target().get_width():
                self.drawTick(context, paintpos, height)
295 296
                paintpos += spc

297
    def drawTimes(self, context, offset, spacing, scale):
298
        # figure out what the optimal offset is
299
        interval = long(Gst.SECOND * scale)
300
        seconds = self.pixelToNs(self.pixbuf_offset)
301 302 303 304 305
        paintpos = float(self.border) + 2
        if offset > 0:
            seconds = seconds - (seconds % interval) + interval
            paintpos += spacing - offset

306 307 308 309
        state = Gtk.StateFlags.NORMAL
        style = self.get_style_context()
        setCairoColor(context, style.get_color(state))
        y_bearing = context.text_extents("0")[1]
310 311 312 313 314 315 316 317

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

        previous_time = split(time_to_string(max(0, seconds - interval)))
318 319
        while paintpos < context.get_target().get_width():
            context.move_to(int(paintpos), 1 - y_bearing)
320 321 322
            current_time = split(time_to_string(long(seconds)))
            self._drawTime(context, current_time, previous_time)
            previous_time = current_time
323 324 325
            paintpos += spacing
            seconds += interval

326 327 328 329 330 331 332 333 334
    def _drawTime(self, context, current, previous):
        for element, previous_element in zip(current, previous):
            if element == previous_element:
                color = self._color_dimmed
            else:
                color = self._color_normal
            setCairoColor(context, color)
            context.show_text(element)

335
    def drawFrameBoundaries(self, context):
336 337 338 339 340
        """
        Draw the alternating rectangles that represent the project frames at
        high zoom levels. These are based on the framerate set in the project
        settings, not the actual frames on a video codec level.
        """
341
        frame_width = self.nsToPixel(self.ns_per_frame)
342 343 344 345
        if not frame_width >= self.min_frame_spacing:
            return

        offset = self.pixbuf_offset % frame_width
346
        height = context.get_target().get_height()
347 348 349 350
        y = int(height - self.frame_height)
        # INSENSITIVE is a dark shade of gray, but lacks contrast
        # SELECTED will be bright blue and more visible to represent frames
        style = self.get_style_context()
351 352
        states = [style.get_background_color(Gtk.StateFlags.ACTIVE),
                  style.get_background_color(Gtk.StateFlags.SELECTED)]
353

354
        frame_num = int(self.pixelToNs(self.pixbuf_offset) * float(self.frame_rate) / Gst.SECOND)
355
        paintpos = self.pixbuf_offset - offset
356
        max_pos = context.get_target().get_width() + self.pixbuf_offset
357
        while paintpos < max_pos:
358
            paintpos = self.nsToPixel(1 / float(self.frame_rate) * Gst.SECOND * frame_num)
359 360 361
            setCairoColor(context, states[(frame_num + 1) % 2])
            context.rectangle(0.5 + paintpos - self.pixbuf_offset, y, frame_width, height)
            context.fill()
362
            frame_num += 1
363

364
    def drawPosition(self, context):
Edward Hervey's avatar
Edward Hervey committed
365
        # a simple RED line will do for now
366
        xpos = self.nsToPixel(self.position) + self.border - self.pixbuf_offset
Edward Hervey's avatar
Edward Hervey committed
367
        context.save()
368
        context.set_line_width(1.5)
Edward Hervey's avatar
Edward Hervey committed
369 370
        context.set_source_rgb(1.0, 0, 0)
        context.move_to(xpos, 0)
371
        context.line_to(xpos, context.get_target().get_height())
Edward Hervey's avatar
Edward Hervey committed
372 373
        context.stroke()
        context.restore()