[pitivi] Keyframes for transformation properties.



commit 900ebc4e5c1daaf191358912d92deb4b5d28d445
Author: Stefan Popa <stefanpopa2209 gmail com>
Date:   Sun May 28 22:39:49 2017 +0300

    Keyframes for transformation properties.
    
    Added the possibility to add/remove keyframes on transformation
    properties + visual keyframe curve.
    
    When adding or moving a keyframe, we don't use the matplotlib position
    anymore. Instead, we compute the position the same way we do for the seek
    logic, to make sure the playhead seeks precisely on the added/moved keyframe.
    This change led to some other changes in the unit test which tested the
    keyframe curve. More precisely, we needed to compute the position in pixels
    for the click events, as that is how the seek logic received it.
    
    Differential Revision: https://phabricator.freedesktop.org/D1766

 data/ui/cliptransformation.ui   |  147 +++++++++++++++-------
 pitivi/clipproperties.py        |  260 +++++++++++++++++++++++++++++++++++----
 pitivi/timeline/elements.py     |  249 +++++++++++++++++++++++++++-----------
 pitivi/timeline/timeline.py     |    1 -
 pitivi/undo/timeline.py         |   72 +++++++++---
 tests/test_timeline_elements.py |   87 ++++++++------
 6 files changed, 623 insertions(+), 193 deletions(-)
---
diff --git a/data/ui/cliptransformation.ui b/data/ui/cliptransformation.ui
index 3be718b..1609258 100644
--- a/data/ui/cliptransformation.ui
+++ b/data/ui/cliptransformation.ui
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<!-- Generated with glade 3.19.0 -->
+<!-- Generated with glade 3.20.0 -->
 <interface>
   <requires lib="gtk+" version="3.10"/>
   <object class="GtkActionGroup" id="actiongroup1"/>
@@ -9,6 +9,11 @@
     <property name="step_increment">1</property>
     <property name="page_increment">10</property>
   </object>
+  <object class="GtkImage" id="icon_reset1">
+    <property name="visible">True</property>
+    <property name="can_focus">False</property>
+    <property name="icon_name">edit-clear-all-symbolic</property>
+  </object>
   <object class="GtkAdjustment" id="position_x_adjustment">
     <property name="lower">-9999999999</property>
     <property name="upper">9999999999</property>
@@ -37,18 +42,7 @@
     <property name="margin_bottom">6</property>
     <property name="row_spacing">6</property>
     <property name="column_spacing">6</property>
-    <child>
-      <object class="GtkLabel" id="label10">
-        <property name="visible">True</property>
-        <property name="can_focus">False</property>
-        <property name="label" translatable="yes">X:</property>
-        <property name="xalign">0</property>
-      </object>
-      <packing>
-        <property name="left_attach">0</property>
-        <property name="top_attach">0</property>
-      </packing>
-    </child>
+    <property name="column_homogeneous">True</property>
     <child>
       <object class="GtkLabel" id="label11">
         <property name="visible">True</property>
@@ -65,7 +59,7 @@
       <object class="GtkSpinButton" id="xpos_spinbtn">
         <property name="visible">True</property>
         <property name="can_focus">True</property>
-        <property name="halign">start</property>
+        <property name="halign">end</property>
         <property name="invisible_char">•</property>
         <property name="progress_pulse_step">1</property>
         <property name="adjustment">position_x_adjustment</property>
@@ -74,13 +68,14 @@
       <packing>
         <property name="left_attach">1</property>
         <property name="top_attach">0</property>
+        <property name="width">3</property>
       </packing>
     </child>
     <child>
       <object class="GtkSpinButton" id="ypos_spinbtn">
         <property name="visible">True</property>
         <property name="can_focus">True</property>
-        <property name="halign">start</property>
+        <property name="halign">end</property>
         <property name="invisible_char">•</property>
         <property name="progress_pulse_step">1</property>
         <property name="adjustment">position_y_adjustment</property>
@@ -89,34 +84,7 @@
       <packing>
         <property name="left_attach">1</property>
         <property name="top_attach">1</property>
-      </packing>
-    </child>
-    <child>
-      <object class="GtkButtonBox" id="buttonbox1">
-        <property name="visible">True</property>
-        <property name="can_focus">False</property>
-        <property name="margin_top">6</property>
-        <property name="spacing">6</property>
-        <property name="layout_style">end</property>
-        <child>
-          <object class="GtkButton" id="clear_button">
-            <property name="label">Reset all</property>
-            <property name="use_action_appearance">False</property>
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="receives_default">True</property>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">0</property>
-          </packing>
-        </child>
-      </object>
-      <packing>
-        <property name="left_attach">0</property>
-        <property name="top_attach">4</property>
-        <property name="width">2</property>
+        <property name="width">3</property>
       </packing>
     </child>
     <child>
@@ -147,7 +115,7 @@
       <object class="GtkSpinButton" id="width_spinbtn">
         <property name="visible">True</property>
         <property name="can_focus">True</property>
-        <property name="halign">start</property>
+        <property name="halign">end</property>
         <property name="invisible_char">•</property>
         <property name="input_purpose">digits</property>
         <property name="adjustment">width_adjustment</property>
@@ -156,13 +124,14 @@
       <packing>
         <property name="left_attach">1</property>
         <property name="top_attach">2</property>
+        <property name="width">3</property>
       </packing>
     </child>
     <child>
       <object class="GtkSpinButton" id="height_spinbtn">
         <property name="visible">True</property>
         <property name="can_focus">True</property>
-        <property name="halign">start</property>
+        <property name="halign">end</property>
         <property name="invisible_char">•</property>
         <property name="adjustment">height_adjustment</property>
         <property name="numeric">True</property>
@@ -170,6 +139,94 @@
       <packing>
         <property name="left_attach">1</property>
         <property name="top_attach">3</property>
+        <property name="width">3</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="label10">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="label" translatable="yes">X:</property>
+        <property name="xalign">0</property>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">0</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkButtonBox">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="layout_style">expand</property>
+        <child>
+          <object class="GtkButton" id="prev_keyframe_button">
+            <property name="label" translatable="yes">&lt;</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="focus_on_click">False</property>
+            <property name="receives_default">True</property>
+            <property name="tooltip_text" translatable="yes">Previous keyframe</property>
+            <property name="relief">none</property>
+          </object>
+          <packing>
+            <property name="expand">True</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="next_keyframe_button">
+            <property name="label" translatable="yes">&gt;</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="focus_on_click">False</property>
+            <property name="receives_default">True</property>
+            <property name="tooltip_text" translatable="yes">Next keyframe</property>
+            <property name="relief">none</property>
+          </object>
+          <packing>
+            <property name="expand">True</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkToggleButton" id="activate_keyframes_button">
+            <property name="label" translatable="yes">◇</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="focus_on_click">False</property>
+            <property name="receives_default">True</property>
+            <property name="relief">none</property>
+          </object>
+          <packing>
+            <property name="expand">True</property>
+            <property name="fill">True</property>
+            <property name="position">2</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="clear_button">
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="focus_on_click">False</property>
+            <property name="receives_default">True</property>
+            <property name="tooltip_text" translatable="yes">Reset to default values</property>
+            <property name="image">icon_reset1</property>
+            <property name="relief">none</property>
+          </object>
+          <packing>
+            <property name="expand">True</property>
+            <property name="fill">True</property>
+            <property name="position">3</property>
+          </packing>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">4</property>
+        <property name="width">4</property>
       </packing>
     </child>
   </object>
diff --git a/pitivi/clipproperties.py b/pitivi/clipproperties.py
index 7e22dc1..5218051 100644
--- a/pitivi/clipproperties.py
+++ b/pitivi/clipproperties.py
@@ -23,6 +23,7 @@ from gettext import gettext as _
 from gi.repository import Gdk
 from gi.repository import GES
 from gi.repository import Gio
+from gi.repository import GstController
 from gi.repository import Gtk
 from gi.repository import Pango
 
@@ -31,6 +32,8 @@ from pitivi.effects import EffectsPropertiesManager
 from pitivi.effects import HIDDEN_EFFECTS
 from pitivi.undo.timeline import CommitTimelineFinalizingAction
 from pitivi.utils.loggable import Loggable
+from pitivi.utils.misc import disconnectAllByFunc
+from pitivi.utils.pipeline import PipelineError
 from pitivi.utils.ui import disable_scroll
 from pitivi.utils.ui import EFFECT_TARGET_ENTRY
 from pitivi.utils.ui import fix_infobar
@@ -533,10 +536,13 @@ class TransformationProperties(Gtk.Expander, Loggable):
         self.builder = Gtk.Builder()
         self.builder.add_from_file(os.path.join(get_ui_dir(),
                                                 "cliptransformation.ui"))
-
+        self.__control_bindings = {}
+        # Used to make sure self.__control_bindings_changed doesn't get called
+        # when bindings are changed from this class
+        self.__own_bindings_change = False
         self.add(self.builder.get_object("transform_box"))
-        self.show_all()
         self._initButtons()
+        self.show_all()
         self.hide()
 
         self.app.project_manager.connect_after(
@@ -555,37 +561,235 @@ class TransformationProperties(Gtk.Expander, Loggable):
         clear_button = self.builder.get_object("clear_button")
         clear_button.connect("clicked", self._defaultValuesCb)
 
-        self.__setupSpinButton("xpos_spinbtn", "posx")
-        self.__setupSpinButton("ypos_spinbtn", "posy")
+        self.__activate_keyframes_btn = self.builder.get_object("activate_keyframes_button")
+        self.__activate_keyframes_btn.connect("toggled", self.__show_keyframes_toggled_cb)
+
+        self.__next_keyframe_btn = self.builder.get_object("next_keyframe_button")
+        self.__next_keyframe_btn.connect("clicked", self.__go_to_keyframe, True)
+        self.__next_keyframe_btn.set_sensitive(False)
+
+        self.__prev_keyframe_btn = self.builder.get_object("prev_keyframe_button")
+        self.__prev_keyframe_btn.connect("clicked", self.__go_to_keyframe, False)
+        self.__prev_keyframe_btn.set_sensitive(False)
+
+        self.__setup_spin_button("xpos_spinbtn", "posx")
+        self.__setup_spin_button("ypos_spinbtn", "posy")
 
-        self.__setupSpinButton("width_spinbtn", "width")
-        self.__setupSpinButton("height_spinbtn", "height")
+        self.__setup_spin_button("width_spinbtn", "width")
+        self.__setup_spin_button("height_spinbtn", "height")
+
+    def __get_keyframes_timestamps(self):
+        keyframes_ts = []
+        for prop in ["posx", "posy", "width", "height"]:
+            prop_keyframes = self.__control_bindings[prop].props.control_source.get_all()
+            keyframes_ts.extend([keyframe.timestamp for keyframe in prop_keyframes])
+
+        return sorted(set(keyframes_ts))
+
+    def __go_to_keyframe(self, unused_button, next_keyframe):
+        assert self.__control_bindings
+        start = self.source.props.start
+        duration = self.source.props.duration
+        in_point = self.source.props.in_point
+        pipeline = self._project.pipeline
+        position = pipeline.getPosition() - start + in_point
+        seekval = start
+
+        if in_point <= position <= in_point + duration:
+            keyframes_ts = self.__get_keyframes_timestamps()
+
+            for i in range(1, len(keyframes_ts)):
+                if keyframes_ts[i - 1] <= position <= keyframes_ts[i]:
+                    prev_kf_ts = keyframes_ts[i - 1]
+                    kf_ts = keyframes_ts[i]
+                    if next_keyframe:
+                        if kf_ts == position:
+                            try:
+                                kf_ts = keyframes_ts[i + 1]
+                            except IndexError:
+                                pass
+                        seekval = kf_ts + start - in_point
+                    else:
+                        seekval = prev_kf_ts + start - in_point
+                    break
+        if position > in_point + duration:
+            seekval = start + duration
+        pipeline.simple_seek(seekval)
+
+    def __show_keyframes_toggled_cb(self, unused_button):
+        if self.__activate_keyframes_btn.props.active:
+            self.__set_control_bindings()
+        self.__update_keyframes_ui()
+
+    def __update_keyframes_ui(self):
+        if self.__source_uses_keyframes():
+            self.__activate_keyframes_btn.props.label = "◆"
+        else:
+            self.__activate_keyframes_btn.props.label = "◇"
+            self.__activate_keyframes_btn.props.active = False
+
+        if not self.__activate_keyframes_btn.props.active:
+            self.__prev_keyframe_btn.set_sensitive(False)
+            self.__next_keyframe_btn.set_sensitive(False)
+            if self.__source_uses_keyframes():
+                self.__activate_keyframes_btn.set_tooltip_text(_("Show keyframes"))
+            else:
+                self.__activate_keyframes_btn.set_tooltip_text(_("Activate keyframes"))
+            self.source.ui_element.showDefaultKeyframes()
+        else:
+            self.__prev_keyframe_btn.set_sensitive(True)
+            self.__next_keyframe_btn.set_sensitive(True)
+            self.__activate_keyframes_btn.set_tooltip_text(_("Hide keyframes"))
+            self.source.ui_element.showMultipleKeyframes(
+                list(self.__control_bindings.values()))
+
+    def __update_control_bindings(self):
+        self.__control_bindings = {}
+        if self.__source_uses_keyframes():
+            self.__set_control_bindings()
+
+    def __source_uses_keyframes(self):
+        if self.source is None:
+            return False
+
+        for prop in ["posx", "posy", "width", "height"]:
+            binding = self.source.get_control_binding(prop)
+            if binding is None:
+                return False
+
+        return True
+
+    def __remove_control_bindings(self):
+        for propname, binding in self.__control_bindings.items():
+            control_source = binding.props.control_source
+            # control_source.unset_all() can't be used here as it doesn't emit
+            # the 'value-removed' signal, so the undo system wouldn't notice
+            # the removed keyframes
+            keyframes_ts = [keyframe.timestamp for keyframe in control_source.get_all()]
+            for ts in keyframes_ts:
+                control_source.unset(ts)
+            self.__own_bindings_change = True
+            self.source.remove_control_binding(propname)
+            self.__own_bindings_change = False
+        self.__control_bindings = {}
+
+    def __set_control_bindings(self):
+        adding_kfs = not self.__source_uses_keyframes()
+
+        if adding_kfs:
+            self.app.action_log.begin("Transformation properties keyframes activate",
+                                      toplevel=True)
+
+        for prop in ["posx", "posy", "width", "height"]:
+            binding = self.source.get_control_binding(prop)
+
+            if not binding:
+                control_source = GstController.InterpolationControlSource()
+                control_source.props.mode = GstController.InterpolationMode.LINEAR
+                self.__own_bindings_change = True
+                self.source.set_control_source(control_source, prop, "direct-absolute")
+                self.__own_bindings_change = False
+                self.__set_default_keyframes_values(control_source, prop)
+
+                binding = self.source.get_control_binding(prop)
+            self.__control_bindings[prop] = binding
+
+        if adding_kfs:
+            self.app.action_log.commit("Transformation properties keyframes activate")
+
+    def __set_default_keyframes_values(self, control_source, prop):
+        res, val = self.source.get_child_property(prop)
+        assert res
+        control_source.set(self.source.props.in_point, val)
+        control_source.set(self.source.props.in_point + self.source.props.duration, val)
 
     def _defaultValuesCb(self, unused_widget):
-        for name, spinbtn in list(self.spin_buttons.items()):
-            spinbtn.set_value(self.source.ui.default_position[name])
+        with self.app.action_log.started("Transformation properties reset default",
+                                         
finalizing_action=CommitTimelineFinalizingAction(self._project.pipeline),
+                                         toplevel=True):
+            if self.__source_uses_keyframes():
+                self.__remove_control_bindings()
+
+            for prop in ["posx", "posy", "width", "height"]:
+                self.source.set_child_property(prop, self.source.ui.default_position[prop])
+
+        self.__update_keyframes_ui()
 
-    def __sourcePropertyChangedCb(self, unused_source, unused_element, param):
+    def __get_source_property(self, prop):
+        if self.__source_uses_keyframes():
+            try:
+                position = self._project.pipeline.getPosition()
+                start = self.source.props.start
+                in_point = self.source.props.in_point
+                duration = self.source.props.duration
+
+                # If the position is outside of the clip, take the property
+                # value at the start/end (whichever is closer) of the clip.
+                source_position = max(0, min(position - start, duration - 1)) + in_point
+                value = self.__control_bindings[prop].get_value(source_position)
+                res = value is not None
+                return res, value
+            except PipelineError:
+                pass
+
+        return self.source.get_child_property(prop)
+
+    def __source_property_changed_cb(self, unused_source, unused_element, param):
         try:
             spin = self.spin_buttons[param.name]
         except KeyError:
             return
 
-        res, value = self.source.get_child_property(param.name)
+        res, value = self.__get_source_property(param.name)
         assert res
         if spin.get_value() != value:
             spin.set_value(value)
 
-    def _updateSpinButtons(self):
+    def _control_bindings_changed(self, unused_track_element, unused_binding):
+        if self.__own_bindings_change:
+            # Do nothing if the change occurred from this class
+            return
+
+        self.__update_control_bindings()
+        self.__update_keyframes_ui()
+
+    def _update_spin_buttons(self):
         for name, spinbtn in list(self.spin_buttons.items()):
             res, value = self.source.get_child_property(name)
             assert res
             spinbtn.set_value(value)
 
-    def __setupSpinButton(self, widget_name, property_name):
+    def __set_prop(self, prop, value):
+        assert self.source
+
+        if self.__source_uses_keyframes():
+            try:
+                position = self._project.pipeline.getPosition()
+                start = self.source.props.start
+                in_point = self.source.props.in_point
+                duration = self.source.props.duration
+                if position < start or position > start + duration:
+                    return
+                source_position = position - start + in_point
+
+                with self.app.action_log.started(
+                        "Transformation property change",
+                        finalizing_action=CommitTimelineFinalizingAction(self._project.pipeline),
+                        toplevel=True):
+                    self.__control_bindings[prop].props.control_source.set(source_position, value)
+            except PipelineError:
+                self.warning("Could not get pipeline position")
+                return
+        else:
+            with self.app.action_log.started("Transformation property change",
+                                             
finalizing_action=CommitTimelineFinalizingAction(self._project.pipeline),
+                                             toplevel=True):
+                self.source.set_child_property(prop, value)
+
+    def __setup_spin_button(self, widget_name, property_name):
         """Creates a SpinButton for editing a property value."""
         spinbtn = self.builder.get_object(widget_name)
-        spinbtn.connect("output", self._onValueChangedCb, property_name)
+        spinbtn.connect("value-changed", self._onValueChangedCb, property_name)
         disable_scroll(spinbtn)
         self.spin_buttons[property_name] = spinbtn
 
@@ -595,25 +799,29 @@ class TransformationProperties(Gtk.Expander, Loggable):
 
         value = spinbtn.get_value()
 
-        res, cvalue = self.source.get_child_property(prop)
-        assert res
+        res, cvalue = self.__get_source_property(prop)
+        if not res:
+            return
+
         if value != cvalue:
-            with self.app.action_log.started("Transformation property change",
-                                             
finalizing_action=CommitTimelineFinalizingAction(self._project.pipeline),
-                                             toplevel=True):
-                self.source.set_child_property(prop, value)
+            self.__set_prop(prop, value)
             self.app.gui.viewer.overlay_stack.update(self.source)
 
-    def __setSource(self, source):
+    def __set_source(self, source):
         if self.source:
             try:
-                self.source.disconnect_by_func(self.__sourcePropertyChangedCb)
+                self.source.disconnect_by_func(self.__source_property_changed_cb)
+                disconnectAllByFunc(self.source, self._control_bindings_changed)
             except TypeError:
                 pass
         self.source = source
         if self.source:
-            self._updateSpinButtons()
-            self.source.connect("deep-notify", self.__sourcePropertyChangedCb)
+            self._update_spin_buttons()
+            self.__update_control_bindings()
+            self.__update_keyframes_ui()
+            self.source.connect("deep-notify", self.__source_property_changed_cb)
+            self.source.connect("control-binding-added", self._control_bindings_changed)
+            self.source.connect("control-binding-removed", self._control_bindings_changed)
 
     def _selectionChangedCb(self, unused_timeline):
         if len(self._selection) == 1:
@@ -621,7 +829,7 @@ class TransformationProperties(Gtk.Expander, Loggable):
             source = clip.find_track_element(None, GES.VideoSource)
             if source:
                 self._selected_clip = clip
-                self.__setSource(source)
+                self.__set_source(source)
                 self.app.gui.viewer.overlay_stack.select(source)
                 self.show()
                 return
@@ -629,6 +837,6 @@ class TransformationProperties(Gtk.Expander, Loggable):
         # Deselect
         if self._selected_clip:
             self._selected_clip = None
-            self._project.pipeline.flushSeek()
-        self.__setSource(None)
+            self._project.pipeline.commit_timeline()
+        self.__set_source(None)
         self.hide()
diff --git a/pitivi/timeline/elements.py b/pitivi/timeline/elements.py
index be108e1..e06a18f 100644
--- a/pitivi/timeline/elements.py
+++ b/pitivi/timeline/elements.py
@@ -94,11 +94,9 @@ class KeyframeCurve(FigureCanvas, Loggable):
         FigureCanvas.__init__(self, figure)
         Loggable.__init__(self)
 
-        self.__timeline = timeline
+        self._timeline = timeline
         self.__source = binding.props.control_source
-        self.__source.connect("value-added", self.__controlSourceChangedCb)
-        self.__source.connect("value-removed", self.__controlSourceChangedCb)
-        self.__source.connect("value-changed", self.__controlSourceChangedCb)
+        self._connect_sources()
         self.__propertyName = binding.props.name
         self.__paramspec = binding.pspec
         self.get_style_context().add_class("KeyframeCurve")
@@ -108,8 +106,8 @@ class KeyframeCurve(FigureCanvas, Loggable):
 
         # Curve values, basically separating source.get_values() timestamps
         # and values.
-        self.__line_xs = []
-        self.__line_ys = []
+        self._line_xs = []
+        self._line_ys = []
 
         # facecolor to None for transparency
         self._ax = figure.add_axes([0, 0, 1, 1], facecolor='None')
@@ -143,7 +141,7 @@ class KeyframeCurve(FigureCanvas, Loggable):
                                     alpha=KEYFRAME_LINE_ALPHA,
                                     c=KEYFRAME_LINE_COLOR,
                                     linewidth=KEYFRAME_LINE_HEIGHT, zorder=1)[0]
-        self.__updatePlots()
+        self._update_plots()
 
         # Drag and drop logic
         # Whether the clicked keyframe or line has been dragged.
@@ -168,54 +166,81 @@ class KeyframeCurve(FigureCanvas, Loggable):
     def release(self):
         disconnectAllByFunc(self, self.__heightRequestCb)
         disconnectAllByFunc(self, self.__gtkMotionEventCb)
-        disconnectAllByFunc(self, self.__controlSourceChangedCb)
+        disconnectAllByFunc(self, self._controlSourceChangedCb)
 
-    # Private methods
-    def __computeYlim(self):
-        height = self.props.height_request
-
-        if height <= 0:
-            return
+    def _connect_sources(self):
+        self.__source.connect("value-added", self._controlSourceChangedCb)
+        self.__source.connect("value-removed", self._controlSourceChangedCb)
+        self.__source.connect("value-changed", self._controlSourceChangedCb)
 
-        ylim_min = -(KEYFRAME_LINE_HEIGHT / height)
-        ylim_max = (self.__ylim_max * height) / (height - KEYFRAME_LINE_HEIGHT)
-        self._ax.set_ylim(ylim_min, ylim_max)
-
-    def __heightRequestCb(self, unused_self, unused_pspec):
-        self.__computeYlim()
-
-    def __updatePlots(self):
+    def _update_plots(self):
         values = self.__source.get_all()
         if len(values) < 2:
             # No plot for less than two points.
             return
 
-        self.__line_xs = []
-        self.__line_ys = []
+        self._line_xs = []
+        self._line_ys = []
         for value in values:
-            self.__line_xs.append(value.timestamp)
-            self.__line_ys.append(value.value)
+            self._line_xs.append(value.timestamp)
+            self._line_ys.append(value.value)
+
+        self._populate_lines()
 
-        self._ax.set_xlim(self.__line_xs[0], self.__line_xs[-1])
+    def _populate_lines(self):
+        self._ax.set_xlim(self._line_xs[0], self._line_xs[-1])
         self.__computeYlim()
 
-        arr = numpy.array((self.__line_xs, self.__line_ys))
+        arr = numpy.array((self._line_xs, self._line_ys))
         arr = arr.transpose()
         self.__keyframes.set_offsets(arr)
-        self.__line.set_xdata(self.__line_xs)
-        self.__line.set_ydata(self.__line_ys)
+        self.__line.set_xdata(self._line_xs)
+        self.__line.set_ydata(self._line_ys)
         self.queue_draw()
 
+    # Private methods
+    def __computeYlim(self):
+        height = self.props.height_request
+
+        if height <= 0:
+            return
+
+        ylim_min = -(KEYFRAME_LINE_HEIGHT / height)
+        ylim_max = (self.__ylim_max * height) / (height - KEYFRAME_LINE_HEIGHT)
+        self._ax.set_ylim(ylim_min, ylim_max)
+
+    def __heightRequestCb(self, unused_self, unused_pspec):
+        self.__computeYlim()
+
     def __maybeCreateKeyframe(self, event):
         line_contains = self.__line.contains(event)[0]
         keyframe_existed = self.__keyframes.contains(event)[0]
         if line_contains and not keyframe_existed:
-            res, value = self.__source.control_source_get_value(event.xdata)
-            assert res
-            self.debug("Create keyframe at (%lf, %lf)", event.xdata, value)
-            with self.__timeline.app.action_log.started("Keyframe added",
-                                                        toplevel=True):
-                self.__source.set(event.xdata, value)
+            self._create_keyframe(event.xdata)
+
+    def _create_keyframe(self, timestamp):
+        res, value = self.__source.control_source_get_value(timestamp)
+        assert res
+        self.debug("Create keyframe at (%lf, %lf)", timestamp, value)
+        with self._timeline.app.action_log.started("Keyframe added",
+                                                   toplevel=True):
+            self.__source.set(timestamp, value)
+
+    def _remove_keyframe(self, timestamp):
+        self.debug("Removing keyframe at timestamp %lf", timestamp)
+        with self._timeline.app.action_log.started("Remove keyframe",
+                                                   toplevel=True):
+            self.__source.unset(timestamp)
+
+    def _move_keyframe(self, source_timestamp, dest_timestamp, dest_value):
+        self.__source.unset(source_timestamp)
+        self.__source.set(dest_timestamp, dest_value)
+
+    def _move_keyframe_line(self, line, y_dest_value, y_start_value):
+        delta = y_dest_value - y_start_value
+        for offset, value in line:
+            value = max(self.__ylim_min, min(value + delta, self.__ylim_max))
+            self.__source.set(offset, value)
 
     def toggle_keyframe(self, offset):
         """Sets or unsets the keyframe at the specified offset."""
@@ -231,9 +256,9 @@ class KeyframeCurve(FigureCanvas, Loggable):
             self.__source.set(offset, value)
 
     # Callbacks
-    def __controlSourceChangedCb(self, unused_control_source, unused_timed_value):
-        self.__updatePlots()
-        self.__timeline.ges_timeline.get_parent().commit_timeline()
+    def _controlSourceChangedCb(self, unused_control_source, unused_timed_value):
+        self._update_plots()
+        self._timeline.ges_timeline.get_parent().commit_timeline()
 
     def __gtkMotionEventCb(self, unused_widget, unused_event):
         # We need to do this here, because Matplotlib's callbacks can't stop
@@ -245,7 +270,7 @@ class KeyframeCurve(FigureCanvas, Loggable):
     def _eventCb(self, unused_element, event):
         if event.type == Gdk.EventType.LEAVE_NOTIFY:
             cursor = NORMAL_CURSOR
-            self.__timeline.get_window().set_cursor(cursor)
+            self._timeline.get_window().set_cursor(cursor)
         return False
 
     def _mpl_button_press_event_cb(self, event):
@@ -268,18 +293,15 @@ class KeyframeCurve(FigureCanvas, Loggable):
                 # Rollback the last operation if it is "Move keyframe".
                 # This is needed because a double-click also triggers a
                 # BUTTON_PRESS event which starts a "Move keyframe" operation
-                self.__timeline.app.action_log.try_rollback("Move keyframe")
+                self._timeline.app.action_log.try_rollback("Move keyframe")
                 self.__offset = None
 
                 # A keyframe has been double-clicked, remove it.
-                self.debug("Removing keyframe at timestamp %lf", offset)
-                with self.__timeline.app.action_log.started("Remove keyframe",
-                                                            toplevel=True):
-                    self.__source.unset(offset)
+                self._remove_keyframe(offset)
             else:
                 # Remember the clicked frame for drag&drop.
-                self.__timeline.app.action_log.begin("Move keyframe",
-                                                     toplevel=True)
+                self._timeline.app.action_log.begin("Move keyframe",
+                                                    toplevel=True)
                 self.__offset = offset
                 self.handling_motion = True
             return
@@ -288,8 +310,8 @@ class KeyframeCurve(FigureCanvas, Loggable):
         if result[0]:
             # The line has been clicked.
             self.debug("The keyframe curve has been clicked")
-            self.__timeline.app.action_log.begin("Move keyframe curve segment",
-                                                 toplevel=True)
+            self._timeline.app.action_log.begin("Move keyframe curve segment",
+                                                toplevel=True)
             x = event.xdata
             offsets = self.__keyframes.get_offsets()
             keyframes = offsets[:, 0]
@@ -305,20 +327,16 @@ class KeyframeCurve(FigureCanvas, Loggable):
             if self.__offset is not None:
                 self.__dragged = True
                 keyframe_ts = self.__computeKeyframeNewTimestamp(event)
-                self.__source.unset(int(self.__offset))
-
                 ydata = max(self.__ylim_min, min(event.ydata, self.__ylim_max))
-                self.__source.set(keyframe_ts, ydata)
+
+                self._move_keyframe(int(self.__offset), keyframe_ts, ydata)
                 self.__offset = keyframe_ts
-                self.__update_tooltip(event)
+                self._update_tooltip(event)
                 hovering = True
             elif self.__clicked_line:
                 self.__dragged = True
                 ydata = max(self.__ylim_min, min(event.ydata, self.__ylim_max))
-                delta = ydata - self.__ydata_drag_start
-                for offset, value in self.__clicked_line:
-                    value = max(self.__ylim_min, min(value + delta, self.__ylim_max))
-                    self.__source.set(offset, value)
+                self._move_keyframe_line(self.__clicked_line, ydata, self.__ydata_drag_start)
                 hovering = True
             else:
                 hovering = self.__line.contains(event)[0]
@@ -327,7 +345,7 @@ class KeyframeCurve(FigureCanvas, Loggable):
 
         if hovering:
             cursor = DRAG_CURSOR
-            self.__update_tooltip(event)
+            self._update_tooltip(event)
             if not self.__hovered:
                 self.emit("enter")
                 self.__hovered = True
@@ -335,21 +353,38 @@ class KeyframeCurve(FigureCanvas, Loggable):
             cursor = NORMAL_CURSOR
             if self.__hovered:
                 self.emit("leave")
-                self.__update_tooltip(None)
+                self._update_tooltip(None)
                 self.__hovered = False
 
-        self.__timeline.get_window().set_cursor(cursor)
+        self._timeline.get_window().set_cursor(cursor)
 
     def _mpl_button_release_event_cb(self, event):
         if event.button != 1:
             return
 
+        # In order to make sure we seek to the exact position where we added a
+        # new keyframe, we don't use matplotlib's event.xdata, but rather
+        # compute it the same way we do for the seek logic.
+        event_widget = Gtk.get_event_widget(event.guiEvent)
+        x, unused_y = event_widget.translate_coordinates(self._timeline.layout.layers_vbox,
+                                                         event.x, event.y)
+        ges_clip = self._timeline.selection.getSingleClip(GES.Clip)
+        event.xdata = Zoomable.pixelToNs(x) - ges_clip.props.start + ges_clip.props.in_point
+
         if self.__offset is not None:
+            # If dragging a keyframe, make sure the keyframe ends up exactly
+            # where the mouse was released. Otherwise, the playhead will not
+            # seek exactly on the keyframe.
+            if self.__dragged:
+                if event.ydata is not None:
+                    keyframe_ts = self.__computeKeyframeNewTimestamp(event)
+                    ydata = max(self.__ylim_min, min(event.ydata, self.__ylim_max))
+                    self._move_keyframe(int(self.__offset), keyframe_ts, ydata)
             self.debug("Keyframe released")
-            self.__timeline.app.action_log.commit("Move keyframe")
+            self._timeline.app.action_log.commit("Move keyframe")
         elif self.__clicked_line:
             self.debug("Line released")
-            self.__timeline.app.action_log.commit("Move keyframe curve segment")
+            self._timeline.app.action_log.commit("Move keyframe curve segment")
 
             if not self.__dragged:
                 # The keyframe line was clicked, but not dragged
@@ -361,7 +396,7 @@ class KeyframeCurve(FigureCanvas, Loggable):
         self.__clicked_line = ()
         self.__dragged = False
 
-    def __update_tooltip(self, event):
+    def _update_tooltip(self, event):
         """Sets or clears the tooltip showing info about the hovered line."""
         markup = None
         if event:
@@ -370,7 +405,7 @@ class KeyframeCurve(FigureCanvas, Loggable):
             if self.__offset is not None:
                 xdata = self.__offset
             else:
-                xdata = max(self.__line_xs[0], min(event.xdata, self.__line_xs[-1]))
+                xdata = max(self._line_xs[0], min(event.xdata, self._line_xs[-1]))
             res, value = self.__source.control_source_get_value(xdata)
             assert res
             pmin = self.__paramspec.minimum
@@ -409,6 +444,72 @@ class KeyframeCurve(FigureCanvas, Loggable):
         return event.xdata
 
 
+class MultipleKeyframeCurve(KeyframeCurve):
+    """Keyframe curve which controls multiple properties at once."""
+
+    def __init__(self, timeline, bindings):
+        self.__bindings = bindings
+
+        super().__init__(timeline, bindings[0])
+
+    def _connect_sources(self):
+        for binding in self.__bindings:
+            source = binding.props.control_source
+            source.connect("value-added", self._controlSourceChangedCb)
+            source.connect("value-removed", self._controlSourceChangedCb)
+            source.connect("value-changed", self._controlSourceChangedCb)
+
+    def _update_plots(self):
+        timestamps = []
+        for binding in self.__bindings:
+            ts = [value.timestamp for value in binding.props.control_source.get_all()]
+            timestamps.extend(ts)
+        timestamps = sorted(list(set(timestamps)))
+
+        if len(timestamps) < 2:
+            # No plot for less than two points.
+            return
+
+        self._line_xs = []
+        self._line_ys = []
+        for timestamp in timestamps:
+            self._line_xs.append(timestamp)
+            self._line_ys.append(0.5)
+
+        self._populate_lines()
+
+    def _create_keyframe(self, timestamp):
+        with self._timeline.app.action_log.started("Add keyframe",
+                                                   toplevel=True):
+            for binding in self.__bindings:
+                binding.props.control_source.set(timestamp, binding.get_value(timestamp))
+
+    def _remove_keyframe(self, timestamp):
+        with self._timeline.app.action_log.started("Remove keyframe",
+                                                   toplevel=True):
+            for binding in self.__bindings:
+                binding.props.control_source.unset(timestamp)
+
+    def _move_keyframe(self, source_timestamp, dest_timestamp, unused_dest_value):
+        if source_timestamp == dest_timestamp:
+            return
+
+        for binding in self.__bindings:
+            dest_value = binding.get_value(source_timestamp)
+            binding.props.control_source.set(dest_timestamp, dest_value)
+            binding.props.control_source.unset(source_timestamp)
+
+    def _move_keyframe_line(self, line, y_dest_value, y_start_value):
+        pass
+
+    def _update_tooltip(self, event):
+        markup = None
+        if event:
+            if not event.xdata:
+                return
+            markup = _("Timestamp: %s") % Gst.TIME_ARGS(event.xdata)
+        self.set_tooltip_markup(markup)
+
 class TimelineElement(Gtk.Layout, Zoomable, Loggable):
     __gsignals__ = {
         # Signal the keyframes curve are being hovered
@@ -477,13 +578,18 @@ class TimelineElement(Gtk.Layout, Zoomable, Loggable):
 
     def showKeyframes(self, ges_elem, prop):
         self.__setKeyframes(ges_elem, prop)
-        self.__create_keyframe_curve(ges_elem)
+        binding = ges_elem.get_control_binding(prop.name)
+        self.__create_keyframe_curve([binding])
 
     def showDefaultKeyframes(self, lazy_render=False):
         self.__setKeyframes(self._ges_elem, self._getDefaultMixingProperty())
         if not lazy_render:
             self.__create_keyframe_curve()
 
+    def showMultipleKeyframes(self, bindings):
+        self.__controlledProperty = None
+        self.__create_keyframe_curve(bindings)
+
     def __setKeyframes(self, ges_elem, prop):
         self.__removeKeyframes()
         self.__controlledProperty = prop
@@ -523,14 +629,17 @@ class TimelineElement(Gtk.Layout, Zoomable, Loggable):
             assert source.set(inpoint, val)
             assert source.set(inpoint + self._ges_elem.props.duration, val)
 
-    def __create_keyframe_curve(self, ges_elem=None):
+    def __create_keyframe_curve(self, bindings=[]):
         """Creates required keyframe curve."""
         self.__removeKeyframes()
-        if not ges_elem:
-            ges_elem = self._ges_elem
+        if not bindings:
+            bindings = [self._ges_elem.get_control_binding(self.__controlledProperty.name)]
+
+        if len(bindings) == 1:
+            self.keyframe_curve = KeyframeCurve(self.timeline, bindings[0])
+        else:
+            self.keyframe_curve = MultipleKeyframeCurve(self.timeline, bindings)
 
-        binding = ges_elem.get_control_binding(self.__controlledProperty.name)
-        self.keyframe_curve = KeyframeCurve(self.timeline, binding)
         self.keyframe_curve.connect("enter", self.__curveEnterCb)
         self.keyframe_curve.connect("leave", self.__curveLeaveCb)
         self.keyframe_curve.set_size_request(self.__width, self.__height)
diff --git a/pitivi/timeline/timeline.py b/pitivi/timeline/timeline.py
index 6d02d1a..67077dc 100644
--- a/pitivi/timeline/timeline.py
+++ b/pitivi/timeline/timeline.py
@@ -864,7 +864,6 @@ class Timeline(Gtk.EventBox, Zoomable, Loggable):
                 # The preview clips have not been created yet.
                 self.__create_clips(x, y)
             self.__drag_update(x, y)
-
         Gdk.drag_status(context, Gdk.DragAction.COPY, timestamp)
         return True
 
diff --git a/pitivi/undo/timeline.py b/pitivi/undo/timeline.py
index 2e986b3..12d46a6 100644
--- a/pitivi/undo/timeline.py
+++ b/pitivi/undo/timeline.py
@@ -19,6 +19,7 @@
 from gi.repository import GES
 from gi.repository import GObject
 from gi.repository import Gst
+from gi.repository import GstController
 
 from pitivi.effects import PROPS_TO_IGNORE
 from pitivi.undo.undo import Action
@@ -584,22 +585,54 @@ class KeyframeChangedAction(UndoableAction):
         self.control_source.set(time, value)
 
 
-class ControlSourceSetAction(Action):
+class ControlSourceSetAction(UndoableAction):
 
-    def __init__(self, action_info):
-        Action.__init__(self)
-        self.action_info = action_info
+    def __init__(self, track_element, binding):
+        UndoableAction.__init__(self)
+        self.track_element = track_element
+        self.control_source = binding.props.control_source
+        self.property_name = binding.props.name
+        self.binding_type = "direct-absolute" if binding.props.absolute else "direct"
+
+    def do(self):
+        self.track_element.set_control_source(self.control_source,
+                                              self.property_name, self.binding_type)
+
+    def undo(self):
+        assert self.track_element.remove_control_binding(self.property_name)
 
     def asScenarioAction(self):
         st = Gst.Structure.new_empty("set-control-source")
-        for key, value in self.action_info.items():
-            st.set_value(key, value)
-        st.set_value("binding-type", "direct")
+        st.set_value("element-name", self.track_element.get_name())
+        st.set_value("property-name", self.property_name)
+        st.set_value("binding-type", self.binding_type)
         st.set_value("source-type", "interpolation")
         st.set_value("interpolation-mode", "linear")
         return st
 
 
+class ControlSourceRemoveAction(UndoableAction):
+
+    def __init__(self, track_element, binding):
+        UndoableAction.__init__(self)
+        self.track_element = track_element
+        self.control_source = binding.props.control_source
+        self.property_name = binding.props.name
+        self.binding_type = "direct-absolute" if binding.props.absolute else "direct"
+
+    def do(self):
+        assert self.track_element.remove_control_binding(self.property_name)
+
+    def undo(self):
+        self.track_element.set_control_source(self.control_source,
+                                              self.property_name, self.binding_type)
+
+    def asScenarioAction(self):
+        st = Gst.Structure.new_empty("remove-control-source")
+        st.set_value("element-name", self.track_element.get_name())
+        st.set_value("property-name", self.property_name)
+        return st
+
 class LayerObserver(MetaContainerObserver, Loggable):
     """Monitors a Layer and reports UndoableActions.
 
@@ -654,11 +687,14 @@ class LayerObserver(MetaContainerObserver, Loggable):
         clip_observer = self.clip_observers.pop(ges_clip)
         clip_observer.release()
 
-    def _controlBindingAddedCb(self, track_element, binding):
+    def _control_binding_added_cb(self, track_element, binding):
         self._connectToControlSource(track_element, binding)
-        action_info = {"element-name": track_element.get_name(),
-                       "property-name": binding.props.name}
-        action = ControlSourceSetAction(action_info)
+        action = ControlSourceSetAction(track_element, binding)
+        self.action_log.push(action)
+
+    def _control_binding_removed_cb(self, track_element, binding):
+        self._disconnectFromControlSource(binding)
+        action = ControlSourceRemoveAction(track_element, binding)
         self.action_log.push(action)
 
     def _connectToTrackElement(self, track_element):
@@ -677,13 +713,18 @@ class LayerObserver(MetaContainerObserver, Loggable):
         for prop, binding in track_element.get_all_control_bindings().items():
             self._connectToControlSource(track_element, binding)
         track_element.connect("control-binding-added",
-                              self._controlBindingAddedCb)
+                              self._control_binding_added_cb)
+        track_element.connect("control-binding-removed",
+                              self._control_binding_removed_cb)
         if isinstance(track_element, GES.BaseEffect) or \
                 isinstance(track_element, GES.VideoSource):
             observer = TrackElementObserver(track_element, self.action_log)
             self.track_element_observers[track_element] = observer
 
     def _disconnectFromTrackElement(self, track_element):
+        if not isinstance(track_element, GES.VideoTransition):
+            track_element.disconnect_by_func(self._control_binding_added_cb)
+            track_element.disconnect_by_func(self._control_binding_removed_cb)
         for prop, binding in track_element.get_all_control_bindings().items():
             self._disconnectFromControlSource(binding)
         observer = self.track_element_observers.pop(track_element, None)
@@ -695,9 +736,10 @@ class LayerObserver(MetaContainerObserver, Loggable):
         control_source = binding.props.control_source
         action_info = {"element-name": track_element.get_name(),
                        "property-name": binding.props.name}
-        observer = ControlSourceObserver(control_source, self.action_log,
-                                         action_info)
-        self.keyframe_observers[control_source] = observer
+        if control_source not in self.keyframe_observers:
+            observer = ControlSourceObserver(control_source, self.action_log,
+                                             action_info)
+            self.keyframe_observers[control_source] = observer
 
     def _disconnectFromControlSource(self, binding):
         control_source = binding.props.control_source
diff --git a/tests/test_timeline_elements.py b/tests/test_timeline_elements.py
index 3b954f7..dfa848c 100644
--- a/tests/test_timeline_elements.py
+++ b/tests/test_timeline_elements.py
@@ -24,10 +24,13 @@ from unittest import TestCase
 from gi.overrides import GObject
 from gi.repository import Gdk
 from gi.repository import GES
+from gi.repository import Gst
+from gi.repository import Gtk
 from matplotlib.backend_bases import MouseEvent
 
 from pitivi.timeline.elements import GES_TYPE_UI_TYPE
 from pitivi.undo.undo import UndoableActionLog
+from pitivi.utils.timeline import Zoomable
 from tests.common import create_test_clip
 from tests.common import create_timeline_container
 from tests.test_timeline_timeline import BaseTestTimeline
@@ -42,13 +45,13 @@ class TestKeyframeCurve(BaseTestTimeline):
         timeline_container.app.action_log = UndoableActionLog()
         timeline = timeline_container.timeline
         ges_layer = timeline.ges_timeline.append_layer()
-        ges_clip1 = self.add_clip(ges_layer, 0)
-        ges_clip2 = self.add_clip(ges_layer, 10)
-        ges_clip3 = self.add_clip(ges_layer, 20, inpoint=100)
+        ges_clip1 = self.add_clip(ges_layer, 0, duration=2*Gst.SECOND)
+        ges_clip2 = self.add_clip(ges_layer, 10, duration=2*Gst.SECOND)
+        ges_clip3 = self.add_clip(ges_layer, 20, inpoint=100, duration=2*Gst.SECOND)
         # For variety, add TitleClip to the list of clips.
         ges_clip4 = create_test_clip(GES.TitleClip)
         ges_clip4.props.start = 30
-        ges_clip4.props.duration = 4.5
+        ges_clip4.props.duration = int(0.9 * Gst.SECOND)
         ges_layer.add_clip(ges_clip4)
 
         self.check_keyframe_toggle(ges_clip1, timeline_container)
@@ -56,10 +59,8 @@ class TestKeyframeCurve(BaseTestTimeline):
         self.check_keyframe_toggle(ges_clip3, timeline_container)
         self.check_keyframe_toggle(ges_clip4, timeline_container)
 
-        self.check_keyframe_ui_toggle(ges_clip1, timeline_container)
-        self.check_keyframe_ui_toggle(ges_clip2, timeline_container)
-        self.check_keyframe_ui_toggle(ges_clip3, timeline_container)
-        self.check_keyframe_ui_toggle(ges_clip4, timeline_container)
+        for ges_clip in [ges_clip1, ges_clip2, ges_clip3, ges_clip4]:
+            self.check_keyframe_ui_toggle(ges_clip, timeline_container)
 
     def check_keyframe_toggle(self, ges_clip, timeline_container):
         """Checks keyframes toggling on the specified clip."""
@@ -122,9 +123,12 @@ class TestKeyframeCurve(BaseTestTimeline):
         """Checks keyframes toggling by click events."""
         timeline = timeline_container.timeline
 
+        start = ges_clip.props.start
+        start_px = Zoomable.nsToPixel(start)
         inpoint = ges_clip.props.in_point
         duration = ges_clip.props.duration
-        offsets = (1, int(duration / 2), int(duration) - 1)
+        duration_px = Zoomable.nsToPixel(duration)
+        offsets_px = (1, int(duration_px / 2), int(duration_px) - 1)
         timeline.selection.select([ges_clip])
 
         ges_video_source = ges_clip.find_track_element(None, GES.VideoSource)
@@ -135,29 +139,36 @@ class TestKeyframeCurve(BaseTestTimeline):
         values = [item.timestamp for item in control_source.get_all()]
         self.assertEqual(values, [inpoint, inpoint + duration])
 
-        # Add keyframes.
-        for offset in offsets:
+        # Add keyframes by simulating mouse clicks.
+        for offset_px in offsets_px:
+            offset = Zoomable.pixelToNs(start_px + offset_px) - start
             xdata, ydata = inpoint + offset, 1
             x, y = keyframe_curve._ax.transData.transform((xdata, ydata))
 
             event = MouseEvent(
-                name = "button_press_event",
-                canvas = keyframe_curve,
-                x = x,
-                y = y,
-                button = 1
+                name="button_press_event",
+                canvas=keyframe_curve,
+                x=x,
+                y=y,
+                button=1
             )
-            event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_PRESS)
-            keyframe_curve._mpl_button_press_event_cb(event)
-            event.name = "button_release_event"
-            event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_RELEASE)
-            keyframe_curve._mpl_button_release_event_cb(event)
+            keyframe_curve.translate_coordinates = \
+                mock.Mock(return_value=(start_px+offset_px, None))
+
+            with mock.patch.object(Gtk, "get_event_widget") as get_event_widget:
+                get_event_widget.return_value = keyframe_curve
+                event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_PRESS)
+                keyframe_curve._mpl_button_press_event_cb(event)
+                event.name = "button_release_event"
+                event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_RELEASE)
+                keyframe_curve._mpl_button_release_event_cb(event)
 
             values = [item.timestamp for item in control_source.get_all()]
             self.assertIn(inpoint + offset, values)
 
-        # Remove keyframes.
-        for offset in offsets:
+        # Remove keyframes by simulating mouse double-clicks.
+        for offset_px in offsets_px:
+            offset = Zoomable.pixelToNs(start_px + offset_px) - start
             xdata, ydata = inpoint + offset, 1
             x, y = keyframe_curve._ax.transData.transform((xdata, ydata))
 
@@ -168,19 +179,23 @@ class TestKeyframeCurve(BaseTestTimeline):
                 y=y,
                 button=1
             )
-            event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_PRESS)
-            keyframe_curve._mpl_button_press_event_cb(event)
-            event.name = "button_release_event"
-            event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_RELEASE)
-            keyframe_curve._mpl_button_release_event_cb(event)
-            event.name = "button_press_event"
-            event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_PRESS)
-            keyframe_curve._mpl_button_press_event_cb(event)
-            event.guiEvent = Gdk.Event.new(Gdk.EventType._2BUTTON_PRESS)
-            keyframe_curve._mpl_button_press_event_cb(event)
-            event.name = "button_release_event"
-            event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_RELEASE)
-            keyframe_curve._mpl_button_release_event_cb(event)
+            keyframe_curve.translate_coordinates = \
+                mock.Mock(return_value=(start_px + offset_px, None))
+            with mock.patch.object(Gtk, "get_event_widget") as get_event_widget:
+                get_event_widget.return_value = keyframe_curve
+                event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_PRESS)
+                keyframe_curve._mpl_button_press_event_cb(event)
+                event.name = "button_release_event"
+                event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_RELEASE)
+                keyframe_curve._mpl_button_release_event_cb(event)
+                event.name = "button_press_event"
+                event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_PRESS)
+                keyframe_curve._mpl_button_press_event_cb(event)
+                event.guiEvent = Gdk.Event.new(Gdk.EventType._2BUTTON_PRESS)
+                keyframe_curve._mpl_button_press_event_cb(event)
+                event.name = "button_release_event"
+                event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_RELEASE)
+                keyframe_curve._mpl_button_release_event_cb(event)
 
             values = [item.timestamp for item in control_source.get_all()]
             self.assertNotIn(inpoint + offset, values)


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]