[pitivi] timeline: Add clip markers



commit 474e8e2260188d9886bb7201b379b4a12f17a904
Author: Piotrek BrzeziƄski <thewildtree outlook com>
Date:   Sat Aug 7 20:22:12 2021 +0200

    timeline: Add clip markers
    
    Uses the existing MarkerBox as a base,
    adds the ability to display and manage markers
    on each source visible on the timeline,
    moves marker-related actions to the TimelineContainer,
    as well as extends tests coverage.

 data/pixmaps/clip-marker-hover.png  | Bin 0 -> 629 bytes
 data/pixmaps/clip-marker-select.png | Bin 0 -> 630 bytes
 data/pixmaps/clip-marker.png        | Bin 0 -> 616 bytes
 pitivi/check.py                     |   2 +-
 pitivi/clipproperties.py            |   4 +-
 pitivi/timeline/elements.py         |  86 +++++++++----
 pitivi/timeline/markers.py          | 235 ++++++++++++++++++++++++------------
 pitivi/timeline/timeline.py         | 105 +++++++++++++++-
 pitivi/utils/ui.py                  |  44 +++++++
 pitivi/utils/widgets.py             |  10 +-
 tests/test_effects.py               |   4 +-
 tests/test_timeline_markers.py      | 137 +++++++++++++++++++--
 12 files changed, 505 insertions(+), 122 deletions(-)
---
diff --git a/data/pixmaps/clip-marker-hover.png b/data/pixmaps/clip-marker-hover.png
new file mode 100644
index 000000000..eb9216a8a
Binary files /dev/null and b/data/pixmaps/clip-marker-hover.png differ
diff --git a/data/pixmaps/clip-marker-select.png b/data/pixmaps/clip-marker-select.png
new file mode 100644
index 000000000..1a36bb9ed
Binary files /dev/null and b/data/pixmaps/clip-marker-select.png differ
diff --git a/data/pixmaps/clip-marker.png b/data/pixmaps/clip-marker.png
new file mode 100644
index 000000000..f061dc8f5
Binary files /dev/null and b/data/pixmaps/clip-marker.png differ
diff --git a/pitivi/check.py b/pitivi/check.py
index 9c429b26d..74abbc0ae 100644
--- a/pitivi/check.py
+++ b/pitivi/check.py
@@ -426,7 +426,7 @@ def initialize_modules():
 # a specific version requirement, they have the "None" value.
 
 GST_API_VERSION = "1.0"
-GST_VERSION = "1.17.90"  # FIXME Remove check in proxy.py once we bump to > 1.19
+GST_VERSION = "1.17.90"  # FIXME Remove checks in proxy.py and utils/markers.py once we bump to >= 1.19.2
 GTK_API_VERSION = "3.0"
 GLIB_API_VERSION = "2.0"
 HARD_DEPENDENCIES = [GICheck(version_required="3.20.0"),
diff --git a/pitivi/clipproperties.py b/pitivi/clipproperties.py
index 59bb166c9..d847f4e9e 100644
--- a/pitivi/clipproperties.py
+++ b/pitivi/clipproperties.py
@@ -1036,12 +1036,12 @@ class TransformationProperties(Gtk.Expander, Loggable):
             else:
                 self._activate_keyframes_btn.set_tooltip_text(
                     _("Activate keyframes"))
-            self.source.ui_element.show_default_keyframes()
+            self.source.ui.show_default_keyframes()
         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.show_multiple_keyframes(
+            self.source.ui.show_multiple_keyframes(
                 list(self.__control_bindings.values()))
 
     def __update_control_bindings(self):
diff --git a/pitivi/timeline/elements.py b/pitivi/timeline/elements.py
index ad0c8076a..b6f8c1940 100644
--- a/pitivi/timeline/elements.py
+++ b/pitivi/timeline/elements.py
@@ -35,6 +35,8 @@ from matplotlib.lines import Line2D
 
 from pitivi.configure import get_pixmap_dir
 from pitivi.effects import ALLOWED_ONLY_ONCE_EFFECTS
+from pitivi.timeline.markers import ClipMarkersBox
+from pitivi.timeline.markers import Marker
 from pitivi.timeline.previewers import AudioPreviewer
 from pitivi.timeline.previewers import ImagePreviewer
 from pitivi.timeline.previewers import TitlePreviewer
@@ -50,7 +52,7 @@ from pitivi.utils.timeline import Selected
 from pitivi.utils.timeline import UNSELECT
 from pitivi.utils.timeline import Zoomable
 from pitivi.utils.ui import EFFECT_TARGET_ENTRY
-from pitivi.utils.ui import set_children_state_recurse
+from pitivi.utils.ui import set_children_state_except
 from pitivi.utils.ui import unset_children_state_recurse
 
 KEYFRAME_LINE_HEIGHT = 2
@@ -654,9 +656,6 @@ class TimelineElement(Gtk.Layout, Zoomable, Loggable):
         self.__width = 0
         self.__height = 0
 
-        # Needed for effect's keyframe toggling
-        self._ges_elem.ui_element = self
-
         self.props.vexpand = True
 
         self.__previewer = self._get_previewer()
@@ -667,6 +666,9 @@ class TimelineElement(Gtk.Layout, Zoomable, Loggable):
         if self.__background:
             self.add(self.__background)
 
+        self.markers = ClipMarkersBox(self.app, self._ges_elem)
+        self.add(self.markers)
+
         self.keyframe_curve = None
         self.__controlled_property = None
         self.show_all()
@@ -685,22 +687,18 @@ class TimelineElement(Gtk.Layout, Zoomable, Loggable):
         if self.__previewer:
             self.__previewer.release()
 
+        if self.markers:
+            self.markers.release()
+
     # Public API
     def set_size(self, width, height):
         width = max(0, width)
         self.set_size_request(width, height)
 
-        if self.__previewer:
-            self.__previewer.set_size_request(width, height)
-
-        if self.__background:
-            self.__background.set_size_request(width, height)
-
-        if self.keyframe_curve:
-            self.keyframe_curve.set_size_request(width, height)
-
-        self.__width = width
-        self.__height = height
+        if self.__width != width or self.__height != height:
+            self.__width = width
+            self.__height = height
+            self.update_sizes_and_positions()
 
     def show_keyframes(self, ges_elem, prop):
         self.__set_keyframes(ges_elem, prop)
@@ -771,7 +769,7 @@ class TimelineElement(Gtk.Layout, Zoomable, Loggable):
         self.keyframe_curve.connect("leave", self.__curve_leave_cb)
         self.keyframe_curve.set_size_request(self.__width, self.__height)
         self.keyframe_curve.show()
-        self.__update_keyframe_curve_visibility()
+        self.__update_keyframe_curve()
 
     def __create_control_binding(self, element):
         """Creates the required ControlBinding and keyframes."""
@@ -806,23 +804,47 @@ class TimelineElement(Gtk.Layout, Zoomable, Loggable):
             if project.pipeline.get_simple_state() != Gst.State.PLAYING:
                 self.propagate_draw(self.keyframe_curve, cr)
 
+        if self.markers and self.markers.is_drawable():
+            self.propagate_draw(self.markers, cr)
+
     # Callbacks
     def __selected_changed_cb(self, unused_selected, selected):
         if not self.keyframe_curve and self.__controlled_property and \
                 selected and len(self.timeline.selection) == 1:
             self.__create_keyframe_curve()
 
-        if self.keyframe_curve:
-            self.__update_keyframe_curve_visibility()
-
         if self.__previewer:
             self.__previewer.set_selected(selected)
 
-    def __update_keyframe_curve_visibility(self):
+        self.update_sizes_and_positions()
+
+    def update_sizes_and_positions(self):
+        markers_height = self.markers.props.height_request
+        width = self.__width
+        height = self.__height
+
+        if self.__background:
+            self.__background.set_size_request(width, height)
+
+        if self.__previewer:
+            self.__previewer.set_size_request(width, height)
+
+        if self.markers:
+            self.markers.set_size_request(width, markers_height)
+
+        # Prevent keyframe curve from overlapping onto markers.
+        if self.keyframe_curve:
+            self.keyframe_curve.set_size_request(self.__width, self.__height - markers_height)
+            self.__update_keyframe_curve()
+
+    def __update_keyframe_curve(self):
         """Updates the keyframes widget visibility by adding or removing it."""
         if self._ges_elem.selected and len(self.timeline.selection) == 1:
+            markers_height = self.markers.props.height_request
             if not self.keyframe_curve.get_parent():
-                self.add(self.keyframe_curve)
+                self.put(self.keyframe_curve, 0, markers_height)
+            else:
+                self.move(self.keyframe_curve, 0, markers_height)
         else:
             self.remove(self.keyframe_curve)
 
@@ -1277,6 +1299,15 @@ class Clip(Gtk.EventBox, Zoomable, Loggable):
                 parent_height != self._current_parent_height or \
                 layer != self._current_parent:
 
+            offset_px = self.ns_to_pixel(self.ges_clip.props.in_point)
+
+            for ges_timeline_element in self.ges_clip.get_children(False):
+                if not ges_timeline_element.ui:
+                    continue
+
+                if ges_timeline_element.ui.markers:
+                    ges_timeline_element.ui.markers.offset = offset_px
+
             layer.move(self, x, y)
             self.set_size_request(width, height)
 
@@ -1374,7 +1405,7 @@ class Clip(Gtk.EventBox, Zoomable, Loggable):
         if (event.type == Gdk.EventType.ENTER_NOTIFY and
                 event.mode == Gdk.CrossingMode.NORMAL and
                 not self.timeline.scrubbing):
-            set_children_state_recurse(self, Gtk.StateFlags.PRELIGHT)
+            set_children_state_except(self, Gtk.StateFlags.PRELIGHT, Marker)
             for handle in self.handles:
                 handle.enlarge()
         elif (event.type == Gdk.EventType.LEAVE_NOTIFY and
@@ -1440,6 +1471,17 @@ class SourceClip(Clip):
 
         self.get_style_context().add_class("Clip")
 
+    def _add_child(self, ges_timeline_element):
+        super()._add_child(ges_timeline_element)
+
+        # In some cases a GESEffect is added here,
+        # so we have to limit the markers initialization to GESSources.
+        if not isinstance(ges_timeline_element, GES.Source):
+            return
+
+        if not hasattr(ges_timeline_element, "markers_manager"):
+            ges_timeline_element.markers_manager = MarkerListManager(self.app, ges_timeline_element)
+
     def _remove_child(self, ges_timeline_element):
         if ges_timeline_element.ui:
             self._elements_container.remove(ges_timeline_element.ui)
diff --git a/pitivi/timeline/markers.py b/pitivi/timeline/markers.py
index ae6c0f6dc..024c2a4a4 100644
--- a/pitivi/timeline/markers.py
+++ b/pitivi/timeline/markers.py
@@ -15,26 +15,24 @@
 # You should have received a copy of the GNU Lesser General Public
 # License along with this program; if not, see <http://www.gnu.org/licenses/>.
 """Markers display and management."""
-from gettext import gettext as _
 from typing import Optional
 
 from gi.repository import Gdk
-from gi.repository import Gio
-from gi.repository import GLib
 from gi.repository import Gtk
 
 from pitivi.utils.loggable import Loggable
-from pitivi.utils.pipeline import PipelineError
 from pitivi.utils.timeline import Zoomable
 from pitivi.utils.ui import SPACING
 
-MARKER_WIDTH = 10
+TIMELINE_MARKER_SIZE = 10
+CLIP_MARKER_HEIGHT = 12
+CLIP_MARKER_WIDTH = 10
 
 
 class Marker(Gtk.EventBox, Loggable):
     """Widget representing a marker."""
 
-    def __init__(self, ges_marker):
+    def __init__(self, ges_marker, class_name, width, height):
         Gtk.EventBox.__init__(self)
         Loggable.__init__(self)
 
@@ -44,8 +42,10 @@ class Marker(Gtk.EventBox, Loggable):
         self.ges_marker = ges_marker
         self.ges_marker.ui = self
         self.position_ns = self.ges_marker.props.position
+        self.width = width
+        self.height = height
 
-        self.get_style_context().add_class("Marker")
+        self.get_style_context().add_class(class_name)
         self.ges_marker.connect("notify-meta", self._notify_meta_cb)
 
         self._selected = False
@@ -54,10 +54,10 @@ class Marker(Gtk.EventBox, Loggable):
         return Gtk.SizeRequestMode.CONSTANT_SIZE
 
     def do_get_preferred_height(self):
-        return MARKER_WIDTH, MARKER_WIDTH
+        return self.height, self.height
 
     def do_get_preferred_width(self):
-        return MARKER_WIDTH, MARKER_WIDTH
+        return self.width, self.width
 
     def do_enter_notify_event(self, unused_event):
         self.set_state_flags(Gtk.StateFlags.PRELIGHT, clear=False)
@@ -117,8 +117,8 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable):
         self.props.hexpand = True
         self.props.valign = Gtk.Align.START
 
-        self.offset = 0
-        self.props.height_request = MARKER_WIDTH
+        self._offset = 0
+        self.props.height_request = TIMELINE_MARKER_SIZE
 
         self.__markers_container = None
         self.marker_moving = None
@@ -128,55 +128,16 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable):
                         Gdk.EventMask.BUTTON_PRESS_MASK |
                         Gdk.EventMask.BUTTON_RELEASE_MASK)
 
-        self._create_actions()
-
-    def _create_actions(self):
-        self.action_group = Gio.SimpleActionGroup()
-        self.insert_action_group("markers", self.action_group)
-        self.app.shortcuts.register_group("markers", _("Markers"), position=70)
-
-        self.add_marker_action = Gio.SimpleAction.new("marker-add", GLib.VariantType("mx"))
-        self.add_marker_action.connect("activate", self._add_marker_cb)
-        self.action_group.add_action(self.add_marker_action)
-        self.app.shortcuts.add("markers.marker-add(@mx nothing)", ["<Primary><Shift>m"],
-                               self.add_marker_action,
-                               _("Add a marker"))
-
-        self.seek_backward_marker_action = Gio.SimpleAction.new("seek-backward-marker", None)
-        self.seek_backward_marker_action.connect("activate", self._seek_backward_marker_cb)
-        self.action_group.add_action(self.seek_backward_marker_action)
-        self.app.shortcuts.add("markers.seek-backward-marker", ["<Alt>Left"],
-                               self.seek_backward_marker_action,
-                               _("Seek to the first marker before the playhead"))
-
-        self.seek_forward_marker_action = Gio.SimpleAction.new("seek-forward-marker", None)
-        self.seek_forward_marker_action.connect("activate", self._seek_forward_marker_cb)
-        self.action_group.add_action(self.seek_forward_marker_action)
-        self.app.shortcuts.add("markers.seek-forward-marker", ["<Alt>Right"],
-                               self.seek_forward_marker_action,
-                               _("Seek to the first marker after the playhead"))
-
-    def _seek_backward_marker_cb(self, action, param):
-        current_position = self.app.project_manager.current_project.pipeline.get_position(fails=False)
-        position = self.first_marker(before=current_position)
-        if position is None:
-            return
-
-        self.app.project_manager.current_project.pipeline.simple_seek(position)
-        self.app.gui.editor.timeline_ui.timeline.scroll_to_playhead(align=Gtk.Align.CENTER, 
when_not_in_view=True)
-
-    def _seek_forward_marker_cb(self, action, param):
-        current_position = self.app.project_manager.current_project.pipeline.get_position(fails=False)
-        position = self.first_marker(after=current_position)
-        if position is None:
-            return
-
-        self.app.project_manager.current_project.pipeline.simple_seek(position)
-        self.app.gui.editor.timeline_ui.timeline.scroll_to_playhead(align=Gtk.Align.CENTER, 
when_not_in_view=True)
-
     def first_marker(self, before: Optional[int] = None, after: Optional[int] = None) -> Optional[int]:
+        """Returns position of the closest marker found before or after the given timestamp.
+
+        None is returned if no such marker is found.
+        """
         assert (after is not None) != (before is not None)
 
+        if not self.markers_container:
+            return None
+
         if after is not None:
             start = after + 1
             end = self.app.project_manager.current_project.ges_timeline.props.duration
@@ -188,7 +149,7 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable):
             return None
 
         markers_positions = list([ges_marker.props.position
-                                  for ges_marker in self.__markers_container.get_markers()
+                                  for ges_marker in self.markers_container.get_markers()
                                   if start <= ges_marker.props.position < end])
         if not markers_positions:
             return None
@@ -198,19 +159,12 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable):
         else:
             return max(markers_positions)
 
-    def _add_marker_cb(self, action, param):
-        maybe = param.get_maybe()
-        if maybe:
-            position = maybe.get_int64()
-        else:
-            try:
-                position = self.app.project_manager.current_project.pipeline.get_position(fails=False)
-            except PipelineError:
-                self.warning("Could not get pipeline position")
-                return
+    def add_at_timeline_time(self, position):
+        """Adds a marker at the given timeline position."""
+        if not self.markers_container:
+            return
 
-        with self.app.action_log.started("Added marker", toplevel=True):
-            self.__markers_container.add(position)
+        self.markers_container.add(position)
 
     @property
     def markers_container(self):
@@ -223,6 +177,8 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable):
             for marker in self.layout.get_children():
                 self.layout.remove(marker)
             self.__markers_container.disconnect_by_func(self._marker_added_cb)
+            self.__markers_container.disconnect_by_func(self._marker_removed_cb)
+            self.__markers_container.disconnect_by_func(self._marker_moved_cb)
 
         self.__markers_container = ges_markers_container
         if self.__markers_container:
@@ -231,6 +187,24 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable):
             self.__markers_container.connect("marker-removed", self._marker_removed_cb)
             self.__markers_container.connect("marker-moved", self._marker_moved_cb)
 
+    def release(self):
+        if self.__markers_container:
+            self.__markers_container.disconnect_by_func(self._marker_added_cb)
+            self.__markers_container.disconnect_by_func(self._marker_removed_cb)
+            self.__markers_container.disconnect_by_func(self._marker_moved_cb)
+
+    @property
+    def offset(self):
+        return self._offset
+
+    @offset.setter
+    def offset(self, value):
+        if self.offset == value:
+            return
+
+        self._offset = value
+        self._update_position()
+
     def __create_marker_widgets(self):
         markers = self.__markers_container.get_markers()
 
@@ -241,17 +215,19 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable):
     def _hadj_value_changed_cb(self, hadj):
         """Handles the adjustment value change."""
         self.offset = hadj.get_value()
-        self._update_position()
 
     def zoom_changed(self):
         self._update_position()
 
     def _update_position(self):
         for marker in self.layout.get_children():
-            position = self.ns_to_pixel(marker.position) - self.offset - MARKER_WIDTH / 2
+            position = self.ns_to_pixel(marker.position) - self.offset - marker.width / 2
             self.layout.move(marker, position, 0)
 
     def do_button_press_event(self, event):
+        if not self.markers_container:
+            return False
+
         event_widget = Gtk.get_event_widget(event)
         button = event.button
         if button == Gdk.BUTTON_PRIMARY:
@@ -269,11 +245,15 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable):
 
             else:
                 position = self.pixel_to_ns(event.x + self.offset)
-                param = GLib.Variant.new_maybe(GLib.VariantType("x"), GLib.Variant.new_int64(position))
-                self.add_marker_action.activate(param)
-                self.marker_new.selected = True
+                with self.app.action_log.started("Added marker", toplevel=True):
+                    self.__markers_container.add(position)
+                    self.marker_new.selected = True
+        return True
 
     def do_button_release_event(self, event):
+        if not self.markers_container:
+            return False
+
         button = event.button
         event_widget = Gtk.get_event_widget(event)
         if button == Gdk.BUTTON_PRIMARY:
@@ -281,28 +261,42 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable):
                 self.marker_moving.selected = False
                 self.marker_moving = None
                 self.app.action_log.commit("Move marker")
+                return True
             elif self.marker_new:
                 self.marker_new.selected = False
                 self.marker_new = None
+                return True
 
         elif button == Gdk.BUTTON_SECONDARY and isinstance(event_widget, Marker):
             with self.app.action_log.started("Removed marker", toplevel=True):
                 self.__markers_container.remove(event_widget.ges_marker)
+                return True
+
+        return False
 
     def do_motion_notify_event(self, event):
+        if not self.markers_container:
+            return False
+
         event_widget = Gtk.get_event_widget(event)
         if event_widget is self.marker_moving:
             event_x, unused_y = event_widget.translate_coordinates(self, event.x, event.y)
             event_x = max(0, event_x)
             position_ns = self.pixel_to_ns(event_x + self.offset)
             self.__markers_container.move(self.marker_moving.ges_marker, position_ns)
+            return True
+
+        return False
 
     def _marker_added_cb(self, unused_markers, position, ges_marker):
         self._add_marker(position, ges_marker)
 
+    def _create_marker(self, ges_marker):
+        return Marker(ges_marker, "Marker", TIMELINE_MARKER_SIZE, TIMELINE_MARKER_SIZE)
+
     def _add_marker(self, position, ges_marker):
-        marker = Marker(ges_marker)
-        x = self.ns_to_pixel(position) - self.offset - MARKER_WIDTH / 2
+        marker = self._create_marker(ges_marker)
+        x = self.ns_to_pixel(position) - self.offset - marker.width / 2
         self.layout.put(marker, x, 0)
         marker.show()
         self.marker_new = marker
@@ -322,7 +316,7 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable):
         self._move_marker(position, ges_marker)
 
     def _move_marker(self, position, ges_marker):
-        x = self.ns_to_pixel(position) - self.offset - MARKER_WIDTH / 2
+        x = self.ns_to_pixel(position) - self.offset - ges_marker.ui.width / 2
         self.layout.move(ges_marker.ui, x, 0)
 
 
@@ -359,3 +353,88 @@ class MarkerPopover(Gtk.Popover):
             with self.app.action_log.started("marker comment", toplevel=True):
                 self.marker.comment = buffer.props.text
         self.marker.selected = False
+
+
+class ClipMarkersBox(MarkersBox):
+    def __init__(self, app, ges_elem, hadj=None):
+        super().__init__(app, hadj=hadj)
+        self.ges_elem = ges_elem
+        # ges_elem is a GESSource, but we need a GESClip to convert timestamps
+        self.ges_clip = self.ges_elem.get_parent()
+        # Initially hide the box - only show once a marker container is set
+        self.props.height_request = 0
+        self.get_style_context().add_class("ClipMarkersBox")
+
+    def _create_marker(self, ges_marker):
+        return Marker(ges_marker, "ClipMarker", CLIP_MARKER_WIDTH, CLIP_MARKER_HEIGHT)
+
+    def __internal_to_timeline(self, timestamp):
+        return self.ges_clip.get_timeline_time_from_internal_time(self.ges_elem, timestamp)
+
+    def __timeline_to_internal(self, timestamp):
+        return self.ges_clip.get_internal_time_from_timeline_time(self.ges_elem, timestamp)
+
+    def first_marker(self, before: Optional[int] = None, after: Optional[int] = None) -> Optional[int]:
+        assert (after is not None) != (before is not None)
+
+        if not self.markers_container:
+            return None
+
+        # Limit search to visible markers
+        clip_start = self.ges_clip.props.start
+        clip_end = self.ges_clip.props.start + self.ges_clip.props.duration
+
+        if after is not None:
+            start = max(after + 1, clip_start)
+            end = clip_end
+        else:
+            start = clip_start
+            end = min(before, clip_end)
+
+        if start >= end:
+            return None
+
+        markers_positions = [self.__internal_to_timeline(marker.props.position)
+                             for marker in self.markers_container.get_markers()
+                             if start <= self.__internal_to_timeline(marker.props.position) < end]
+        if not markers_positions:
+            return None
+
+        if after is not None:
+            return min(markers_positions)
+        else:
+            return max(markers_positions)
+
+    def add_at_timeline_time(self, position):
+        if not self.markers_container:
+            return
+
+        start = self.ges_clip.props.start
+        inpoint = self.ges_clip.props.in_point
+        duration = self.ges_clip.props.duration
+
+        # Prevent timestamp conversion failing due to negative result.
+        if position < start:
+            return
+
+        internal_end = self.__timeline_to_internal(start + duration)
+        timestamp = self.__timeline_to_internal(position)
+
+        # Check if marker would land in the 'visible' part of the clip.
+        if not inpoint <= timestamp <= internal_end:
+            return
+
+        self.markers_container.add(timestamp)
+
+    @MarkersBox.markers_container.setter
+    def markers_container(self, ges_markers_container):
+        MarkersBox.markers_container.fset(self, ges_markers_container)
+
+        # Hide the box when no list is selected.
+        height = CLIP_MARKER_HEIGHT if ges_markers_container else 0
+        self.props.height_request = height
+        # Let the parent TimelineElement know to update sizes accordingly.
+        parent_el = self.get_parent()
+        if parent_el is not None:
+            parent_el.update_sizes_and_positions()
+            parent_el.queue_draw()
diff --git a/pitivi/timeline/timeline.py b/pitivi/timeline/timeline.py
index b92845339..d94dc118f 100644
--- a/pitivi/timeline/timeline.py
+++ b/pitivi/timeline/timeline.py
@@ -45,7 +45,9 @@ from pitivi.timeline.previewers import Previewer
 from pitivi.timeline.ruler import TimelineScaleRuler
 from pitivi.undo.timeline import CommitTimelineFinalizingAction
 from pitivi.utils.loggable import Loggable
+from pitivi.utils.markers import GES_MARKERS_SNAPPABLE
 from pitivi.utils.misc import asset_get_duration
+from pitivi.utils.pipeline import PipelineError
 from pitivi.utils.timeline import EditingContext
 from pitivi.utils.timeline import SELECT
 from pitivi.utils.timeline import Selection
@@ -72,6 +74,19 @@ from pitivi.utils.widgets import ZoomBox
 SEPARATOR_ACCEPTING_DROP_INTERVAL_MS = 1000
 
 
+GlobalSettings.add_config_option('markersSnappableByDefault',
+                                 section="user-interface",
+                                 key="markers-snappable-default",
+                                 default=False,
+                                 notify=False)
+
+if GES_MARKERS_SNAPPABLE:
+    PreferencesDialog.add_toggle_preference('markersSnappableByDefault',
+                                            section="timeline",
+                                            label=_("Markers magnetic by default"),
+                                            description=_(
+                                                "Whether markers created on new clips will be snapping 
targets by default."))
+
 GlobalSettings.add_config_option('edgeSnapDeadband',
                                  section="user-interface",
                                  key="edge-snap-deadband",
@@ -1909,7 +1924,26 @@ class TimelineContainer(Gtk.Grid, Zoomable, Loggable):
                                _("Seek forward one second"))
 
         # Markers actions.
-        self.timeline.layout.insert_action_group("markers", self.markers.action_group)
+        self.add_marker_action = Gio.SimpleAction.new("marker-add", None)
+        self.add_marker_action.connect("activate", self._add_marker_cb)
+        navigation_group.add_action(self.add_marker_action)
+        self.app.shortcuts.add("navigation.marker-add", ["<Primary><Shift>m"],
+                               self.add_marker_action,
+                               _("Add a marker"))
+
+        self.seek_backward_marker_action = Gio.SimpleAction.new("seek-backward-marker", None)
+        self.seek_backward_marker_action.connect("activate", self._seek_backward_marker_cb)
+        navigation_group.add_action(self.seek_backward_marker_action)
+        self.app.shortcuts.add("navigation.seek-backward-marker", ["<Alt>Left"],
+                               self.seek_backward_marker_action,
+                               _("Seek to the first marker before the playhead"))
+
+        self.seek_forward_marker_action = Gio.SimpleAction.new("seek-forward-marker", None)
+        self.seek_forward_marker_action.connect("activate", self._seek_forward_marker_cb)
+        navigation_group.add_action(self.seek_forward_marker_action)
+        self.app.shortcuts.add("navigation.seek-forward-marker", ["<Alt>Right"],
+                               self.seek_forward_marker_action,
+                               _("Seek to the first marker after the playhead"))
 
         # Viewer actions.
         self.timeline.layout.insert_action_group("viewer", self.app.gui.editor.viewer.action_group)
@@ -2320,3 +2354,72 @@ class TimelineContainer(Gtk.Grid, Zoomable, Loggable):
         win.set_transient_for(self.app.gui)
 
         win.show_all()
+
+    def __get_current_marker_boxes(self):
+        # Return a list of the selected elements' marker boxes
+        sources = self.timeline.selection.get_selected_track_elements()
+        if sources:
+            return [source.ui.markers for source in sources]
+
+        # Else focus on timeline markers
+        return [self.markers]
+
+    def __find_closest_marker(self, containers, before=None, after=None):
+        if not containers:
+            return None
+
+        position = before if before else after
+        timestamps = []
+        for container in containers:
+            timestamp = container.first_marker(before, after)
+            if timestamp is not None:
+                timestamps.append(timestamp)
+
+        if not timestamps:
+            return None
+
+        closest_timestamp = min(timestamps, key=lambda timestamp: abs(position - timestamp))
+        return closest_timestamp
+
+    def _add_marker_cb(self, action, param):
+        try:
+            position = self.app.project_manager.current_project.pipeline.get_position(fails=False)
+        except PipelineError:
+            self.warning("Could not get pipeline position")
+            return
+
+        containers = self.__get_current_marker_boxes()
+
+        with self.app.action_log.started("Added marker", toplevel=True):
+            for marker_container in containers:
+                marker_container.add_at_timeline_time(position)
+
+    def _seek_backward_marker_cb(self, action, param):
+        try:
+            timeline_position = self.app.project_manager.current_project.pipeline.get_position(fails=False)
+        except PipelineError:
+            self.warning("Could not get pipeline position")
+            return
+
+        containers = self.__get_current_marker_boxes()
+        position = self.__find_closest_marker(containers, before=timeline_position)
+        if position is None:
+            return
+
+        self.app.project_manager.current_project.pipeline.simple_seek(position)
+        self.app.gui.editor.timeline_ui.timeline.scroll_to_playhead(align=Gtk.Align.CENTER, 
when_not_in_view=True)
+
+    def _seek_forward_marker_cb(self, action, param):
+        try:
+            timeline_position = self.app.project_manager.current_project.pipeline.get_position(fails=False)
+        except PipelineError:
+            self.warning("Could not get pipeline position")
+            return
+
+        containers = self.__get_current_marker_boxes()
+        position = self.__find_closest_marker(containers, after=timeline_position)
+        if position is None:
+            return
+
+        self.app.project_manager.current_project.pipeline.simple_seek(position)
+        self.app.gui.editor.timeline_ui.timeline.scroll_to_playhead(align=Gtk.Align.CENTER, 
when_not_in_view=True)
diff --git a/pitivi/utils/ui.py b/pitivi/utils/ui.py
index 129b31d90..56094139e 100644
--- a/pitivi/utils/ui.py
+++ b/pitivi/utils/ui.py
@@ -294,10 +294,40 @@ EDITOR_PERSPECTIVE_CSS = """
         background-image: url('%(marker_hovered)s');
     }
 
+    .ClipMarkersBox {
+        transition: 0.15s ease-out;
+        opacity: 0.7;
+    }
+
+    .ClipMarkersBox:hover {
+        background-color: rgba(0, 0, 0, 0.15);
+        opacity: 0.85;
+    }
+
+    .ClipMarkersBox:selected {
+        background-color: rgb(0, 0, 0);
+        opacity: 1;
+    }
+
+    .ClipMarker {
+        background-image: url('%(clip_marker_unselected)s');
+    }
+
+    .ClipMarker:hover {
+        background-image: url('%(clip_marker_hovered)s');
+    }
+
+    .ClipMarker:selected {
+        background-image: url('%(clip_marker_selected)s');
+    }
+
 """ % ({
     'clip_border_width': CLIP_BORDER_WIDTH,
     'marker_hovered': os.path.join(get_pixmap_dir(), "marker-hover.png"),
     'marker_unselected': os.path.join(get_pixmap_dir(), "marker-unselect.png"),
+    'clip_marker_unselected': os.path.join(get_pixmap_dir(), "clip-marker.png"),
+    'clip_marker_hovered': os.path.join(get_pixmap_dir(), "clip-marker-hover.png"),
+    'clip_marker_selected': os.path.join(get_pixmap_dir(), "clip-marker-select.png"),
     'trimbar_focused': os.path.join(get_pixmap_dir(), "trimbar-focused.png"),
     'trimbar_normal': os.path.join(get_pixmap_dir(), "trimbar-normal.png")})
 
@@ -840,6 +870,7 @@ def alter_style_class(style_class, target_widget, css_style):
 
 
 def set_children_state_recurse(widget, state):
+    """Sets the provided state on all children of the given widget."""
     widget.set_state_flags(state, False)
     for child in widget.get_children():
         child.set_state_flags(state, False)
@@ -847,7 +878,20 @@ def set_children_state_recurse(widget, state):
             set_children_state_recurse(child, state)
 
 
+def set_children_state_except(widget, state, *ignored_types):
+    """Sets the provided state on all children of the widget, except those of given types."""
+    widget.set_state_flags(state, False)
+    for child in widget.get_children():
+        if any(isinstance(child, klass) for klass in ignored_types):
+            continue
+
+        child.set_state_flags(state, False)
+        if isinstance(child, Gtk.Container):
+            set_children_state_except(child, state, ignored_types)
+
+
 def unset_children_state_recurse(widget, state):
+    """Unsets the provided state on all children of the given widget."""
     widget.unset_state_flags(state)
     for child in widget.get_children():
         child.unset_state_flags(state)
diff --git a/pitivi/utils/widgets.py b/pitivi/utils/widgets.py
index 7aec460dd..9a92d1453 100644
--- a/pitivi/utils/widgets.py
+++ b/pitivi/utils/widgets.py
@@ -1019,11 +1019,11 @@ class GstElementSettingsWidget(Gtk.Box, Loggable):
             return
 
         if active:
-            track_element.ui_element.show_keyframes(self.element, prop)
+            track_element.ui.show_keyframes(self.element, prop)
             binding = self.element.get_control_binding(prop.name)
             self.__bindings_by_keyframe_button[keyframe_button] = binding
         else:
-            track_element.ui_element.show_default_keyframes()
+            track_element.ui.show_default_keyframes()
 
     def __reset_to_default_clicked_cb(self, unused_button, widget,
                                       keyframe_button=None):
@@ -1037,7 +1037,7 @@ class GstElementSettingsWidget(Gtk.Box, Loggable):
                     track_element = self.__get_track_element_of_same_type(
                         self.element)
                     if track_element:
-                        track_element.ui_element.show_default_keyframes()
+                        track_element.ui.show_default_keyframes()
                 self.__set_keyframe_active(keyframe_button, False)
                 self.__display_controlled(keyframe_button, False)
 
@@ -1046,8 +1046,8 @@ class GstElementSettingsWidget(Gtk.Box, Loggable):
     def __get_track_element_of_same_type(self, effect):
         track_type = effect.get_track_type()
         for track_element in effect.get_parent().get_children(False):
-            if hasattr(track_element, "ui_element") and \
-                    track_element.get_track_type() == track_type:
+            if hasattr(track_element, "ui") and \
+                    track_element.get_track_type() == track_type and track_element != effect:
                 return track_element
         self.warning("Failed to find track element of type %s", track_type)
         return None
diff --git a/tests/test_effects.py b/tests/test_effects.py
index 72dba9955..a27c464a8 100644
--- a/tests/test_effects.py
+++ b/tests/test_effects.py
@@ -144,10 +144,10 @@ class EffectsPropertiesManagerTest(common.TestCase):
 
         # Control the self.prop property on the timeline
         prop_keyframe_button.set_active(True)
-        self.assertEqual(track_element.ui_element._TimelineElement__controlled_property, self.prop)
+        self.assertEqual(track_element.ui._TimelineElement__controlled_property, self.prop)
         # Revert to controlling the default property
         prop_keyframe_button.set_active(False)
-        self.assertNotEqual(track_element.ui_element._TimelineElement__controlled_property, self.prop)
+        self.assertNotEqual(track_element.ui._TimelineElement__controlled_property, self.prop)
 
     def test_prop_reset(self):
         """Checks the reset button resets the property."""
diff --git a/tests/test_timeline_markers.py b/tests/test_timeline_markers.py
index eb65451b5..87d3ef95d 100644
--- a/tests/test_timeline_markers.py
+++ b/tests/test_timeline_markers.py
@@ -19,6 +19,7 @@
 from unittest import mock
 
 from gi.repository import Gdk
+from gi.repository import GES
 from gi.repository import Gtk
 
 from pitivi.utils.timeline import Zoomable
@@ -173,14 +174,128 @@ class TestMarkers(common.TestCase):
         marker_box.markers_container.add(12)
         self.assert_markers(markers, [(10, None), (12, None)])
 
-        self.check_seek(marker_box.seek_forward_marker_action, 9, 10)
-        self.check_seek(marker_box.seek_forward_marker_action, 10, 12)
-        self.check_seek(marker_box.seek_forward_marker_action, 11, 12)
-        self.check_seek(marker_box.seek_forward_marker_action, 12, None)
-        self.check_seek(marker_box.seek_forward_marker_action, 13, None)
-
-        self.check_seek(marker_box.seek_backward_marker_action, 9, None)
-        self.check_seek(marker_box.seek_backward_marker_action, 10, None)
-        self.check_seek(marker_box.seek_backward_marker_action, 11, 10)
-        self.check_seek(marker_box.seek_backward_marker_action, 12, 10)
-        self.check_seek(marker_box.seek_backward_marker_action, 13, 12)
+        self.check_seek(self.timeline_container.seek_forward_marker_action, 9, 10)
+        self.check_seek(self.timeline_container.seek_forward_marker_action, 10, 12)
+        self.check_seek(self.timeline_container.seek_forward_marker_action, 11, 12)
+        self.check_seek(self.timeline_container.seek_forward_marker_action, 12, None)
+        self.check_seek(self.timeline_container.seek_forward_marker_action, 13, None)
+
+        self.check_seek(self.timeline_container.seek_backward_marker_action, 9, None)
+        self.check_seek(self.timeline_container.seek_backward_marker_action, 10, None)
+        self.check_seek(self.timeline_container.seek_backward_marker_action, 11, 10)
+        self.check_seek(self.timeline_container.seek_backward_marker_action, 12, 10)
+        self.check_seek(self.timeline_container.seek_backward_marker_action, 13, 12)
+
+    @common.setup_timeline
+    def test_seeking_with_clips(self):
+        """Checks the seeking actions with clip markers present."""
+        self.timeline.append_layer()
+        timeline = self.timeline_container.timeline
+        clip1 = self.add_clip(self.timeline.layers[0], start=0, duration=30)
+        clip2 = self.add_clip(self.timeline.layers[1], start=10, duration=20)
+
+        markers1 = next(common.get_clip_children(
+            clip1, GES.TrackType.VIDEO)).ui.markers.markers_container
+        markers2 = next(common.get_clip_children(
+            clip2, GES.TrackType.VIDEO)).ui.markers.markers_container
+
+        markers1.add(5)
+        markers1.add(25)
+        markers2.add(5)
+        markers2.add(15)
+
+        forward_seek = self.timeline_container.seek_forward_marker_action
+        backward_seek = self.timeline_container.seek_backward_marker_action
+
+        timeline.selection.select([clip1])
+        self.check_seek(forward_seek, 0, 5)
+        self.check_seek(forward_seek, 5, 25)
+        self.check_seek(forward_seek, 25, None)
+
+        timeline.selection.select([clip2])
+        self.check_seek(forward_seek, 25, None)
+        self.check_seek(backward_seek, 25, 15)
+        self.check_seek(backward_seek, 15, None)
+
+        timeline.selection.select([])
+        self.check_seek(forward_seek, 15, None)
+        self.check_seek(backward_seek, 15, None)
+
+        # When multiple clips are selected, take all their markers into consideration.
+        timeline.selection.select([clip1, clip2])
+        self.check_seek(forward_seek, 15, 25)
+        self.check_seek(backward_seek, 25, 15)
+        self.check_seek(backward_seek, 15, 5)
+
+        # Trim first 10 seconds of clip2, 'cutting off' its first marker.
+        clip2.trim(20)
+        self.check_seek(forward_seek, 5, 25)
+
+        # Add a marker "outside" clip1 and check if it's correctly ignored.
+        markers1.add(50)
+        self.check_seek(forward_seek, 25, None)
+
+        # Add a timeline marker which should be ignored while clips are selected.
+        timeline_markers = self.timeline_container.markers.markers_container
+        timeline_markers.add(28)
+        self.check_seek(forward_seek, 25, None)
+
+        # Unselect clips and seek again.
+        timeline.selection.select([])
+        self.check_seek(forward_seek, 25, 28)
+
+    def perform_at_timeline_position(self, action, position):
+        pipeline = self.project.pipeline
+        with mock.patch.object(pipeline, "get_position") as get_position:
+            get_position.return_value = position
+            action.activate()
+
+    @common.setup_timeline
+    def test_add_marker_action(self):
+        """Checks marker adding shortcut behaviour."""
+        self.timeline.append_layer()
+        timeline = self.timeline_container.timeline
+        add_action = self.timeline_container.add_marker_action
+        clip1 = self.add_clip(self.timeline.layers[0], start=0, duration=20)
+        clip2 = self.add_clip(self.timeline.layers[1], start=10, duration=20)
+
+        timeline_markers = self.timeline_container.markers.markers_container
+        markers1 = next(common.get_clip_children(
+            clip1, GES.TrackType.VIDEO)).ui.markers.markers_container
+        markers2 = next(common.get_clip_children(
+            clip2, GES.TrackType.VIDEO)).ui.markers.markers_container
+
+        # No clips selected - should add a marker to the timeline.
+        self.perform_at_timeline_position(add_action, 15)
+        self.assert_markers(timeline_markers, [(15, None)])
+
+        # Multiple clips selected - add marker to all of them.
+        timeline.selection.select([clip1, clip2])
+        self.perform_at_timeline_position(add_action, 15)
+        self.assert_markers(markers1, [(15, None)])
+        self.assert_markers(markers2, [(5, None)])
+
+        # Adding a marker 'outside' of one clip should fail, but still add to other selected clips.
+        self.perform_at_timeline_position(add_action, 5)
+        self.assert_markers(markers1, [(5, None), (15, None)])
+        self.assert_markers(markers2, [(5, None)])
+
+        self.perform_at_timeline_position(add_action, 25)
+        self.assert_markers(markers1, [(5, None), (15, None)])
+        self.assert_markers(markers2, [(5, None), (15, None)])
+
+        # Make sure nothing was added to the timeline.
+        self.assert_markers(timeline_markers, [(15, None)])
+
+        # One clip selected - make sure no other clips are affected.
+        timeline.selection.select([clip1])
+        self.perform_at_timeline_position(add_action, 10)
+        self.assert_markers(markers1, [(5, None), (10, None), (15, None)])
+        self.assert_markers(markers2, [(5, None), (15, None)])
+
+        timeline.selection.select([clip2])
+        self.perform_at_timeline_position(add_action, 20)
+        self.assert_markers(markers1, [(5, None), (10, None), (15, None)])
+        self.assert_markers(markers2, [(5, None), (10, None), (15, None)])
+
+        self.assert_markers(timeline_markers, [(15, None)])


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