[pitivi] timeline: Add a markers bar above the ruler



commit 4fc254539f261cb1928db18327a7334220e303f3
Author: Millan Castro <m castrovilarino gmail com>
Date:   Fri Jun 14 09:08:48 2019 +0200

    timeline: Add a markers bar above the ruler
    
    Fixes #739

 data/pixmaps/marker-hover.png    | Bin 0 -> 1003 bytes
 data/pixmaps/marker-unselect.png | Bin 0 -> 1016 bytes
 data/pixmaps/marker.png          | Bin 0 -> 1003 bytes
 pitivi/project.py                |   3 +
 pitivi/timeline/markers.py       | 276 +++++++++++++++++++++++++++++++++++++++
 pitivi/timeline/timeline.py      |  18 ++-
 pitivi/undo/markers.py           | 152 +++++++++++++++++++++
 pitivi/undo/timeline.py          |   4 +-
 pitivi/undo/undo.py              |  23 +++-
 pitivi/utils/ui.py               |  21 ++-
 po/POTFILES.in                   |   1 +
 tests/common.py                  |  12 ++
 tests/test_project.py            |   6 +
 tests/test_timeline_markers.py   | 156 ++++++++++++++++++++++
 tests/test_undo_markers.py       | 238 +++++++++++++++++++++++++++++++++
 15 files changed, 895 insertions(+), 15 deletions(-)
---
diff --git a/data/pixmaps/marker-hover.png b/data/pixmaps/marker-hover.png
new file mode 100644
index 00000000..e9e59165
Binary files /dev/null and b/data/pixmaps/marker-hover.png differ
diff --git a/data/pixmaps/marker-unselect.png b/data/pixmaps/marker-unselect.png
new file mode 100644
index 00000000..60e827ed
Binary files /dev/null and b/data/pixmaps/marker-unselect.png differ
diff --git a/data/pixmaps/marker.png b/data/pixmaps/marker.png
new file mode 100644
index 00000000..e9e59165
Binary files /dev/null and b/data/pixmaps/marker.png differ
diff --git a/pitivi/project.py b/pitivi/project.py
index e6b7993f..b7da243a 100644
--- a/pitivi/project.py
+++ b/pitivi/project.py
@@ -1580,6 +1580,9 @@ class Project(Loggable, GES.Project):
             self.warning("Failed to set the pipeline's timeline: %s", self.ges_timeline)
             return False
 
+        if self.ges_timeline.get_marker_list("markers") is None:
+            self.ges_timeline.set_marker_list("markers", GES.MarkerList.new())
+
         return True
 
     def update_restriction_caps(self):
diff --git a/pitivi/timeline/markers.py b/pitivi/timeline/markers.py
new file mode 100644
index 00000000..0d22003b
--- /dev/null
+++ b/pitivi/timeline/markers.py
@@ -0,0 +1,276 @@
+# -*- coding: utf-8 -*-
+# Pitivi video editor
+# Copyright (c) 2019, Millan Castro <m castrovilarino gmail com>
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this program; if not, write to the
+# Free Software Foundation, Inc., 51 Franklin St, Fifth Floor,
+# Boston, MA 02110-1301, USA.
+"""Markers display and management."""
+from gi.repository import Gdk
+from gi.repository import Gtk
+
+from pitivi.utils.loggable import Loggable
+from pitivi.utils.timeline import Zoomable
+from pitivi.utils.ui import SPACING
+
+MARKER_WIDTH = 10
+
+
+# pylint: disable=too-many-instance-attributes
+class Marker(Gtk.EventBox, Loggable):
+    """Widget representing a marker"""
+
+    def __init__(self, ges_marker):
+        Gtk.EventBox.__init__(self)
+        Loggable.__init__(self)
+
+        self.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK |
+                        Gdk.EventMask.LEAVE_NOTIFY_MASK)
+
+        self.ges_marker = ges_marker
+        self.ges_marker.ui = self
+        self.position_ns = self.ges_marker.props.position
+
+        self.get_style_context().add_class("Marker")
+        self.ges_marker.connect("notify-meta", self._notify_meta_cb)
+
+        self._selected = False
+
+    # pylint: disable=arguments-differ
+    def do_get_request_mode(self):
+        return Gtk.SizeRequestMode.CONSTANT_SIZE
+
+    def do_get_preferred_height(self):
+        return MARKER_WIDTH, MARKER_WIDTH
+
+    def do_get_preferred_width(self):
+        return MARKER_WIDTH, MARKER_WIDTH
+
+    def do_enter_notify_event(self, unused_event):
+        self.set_state_flags(Gtk.StateFlags.PRELIGHT, clear=False)
+
+    def do_leave_notify_event(self, unused_event):
+        self.unset_state_flags(Gtk.StateFlags.PRELIGHT)
+
+    def _notify_meta_cb(self, unused_ges_marker, item, value):
+        self.set_tooltip_text(self.comment)
+
+    @property
+    def position(self):
+        """Returns the position of the marker, in nanoseconds."""
+        return self.ges_marker.props.position
+
+    @property
+    def comment(self):
+        """Returns a comment from ges_marker."""
+        return self.ges_marker.get_string("comment")
+
+    @comment.setter
+    def comment(self, text):
+        if text == self.comment:
+            return
+        self.ges_marker.set_string("comment", text)
+
+    @property
+    def selected(self):
+        """Returns whether the marker is selected."""
+        return self._selected
+
+    @selected.setter
+    def selected(self, selected):
+        self._selected = selected
+        if self._selected:
+            self.set_state_flags(Gtk.StateFlags.SELECTED, clear=False)
+        else:
+            self.unset_state_flags(Gtk.StateFlags.SELECTED)
+
+
+class MarkersBox(Gtk.EventBox, Zoomable, Loggable):
+    """Container for displaying and managing markers."""
+
+    def __init__(self, app, hadj=None):
+        Gtk.EventBox.__init__(self)
+        Zoomable.__init__(self)
+        Loggable.__init__(self)
+
+        self.layout = Gtk.Layout()
+        self.add(self.layout)
+        self.get_style_context().add_class("MarkersBox")
+
+        self.app = app
+
+        if hadj:
+            hadj.connect("value-changed", self._hadj_value_changed_cb)
+        self.props.hexpand = True
+        self.props.valign = Gtk.Align.START
+
+        self.offset = 0
+        self.props.height_request = MARKER_WIDTH
+
+        self.__markers_container = None
+        self.marker_moving = None
+        self.marker_new = None
+
+        self.add_events(Gdk.EventMask.POINTER_MOTION_MASK |
+                        Gdk.EventMask.BUTTON_PRESS_MASK |
+                        Gdk.EventMask.BUTTON_RELEASE_MASK)
+
+    @property
+    def markers_container(self):
+        """Gets the GESMarkerContainer."""
+        return self.__markers_container
+
+    @markers_container.setter
+    def markers_container(self, ges_markers_container):
+        if self.__markers_container:
+            for marker in self.layout.get_children():
+                self.layout.remove(marker)
+            self.__markers_container.disconnect_by_func(self._marker_added_cb)
+
+        self.__markers_container = ges_markers_container
+        if self.__markers_container:
+            self.__create_marker_widgets()
+            self.__markers_container.connect("marker-added", self._marker_added_cb)
+            self.__markers_container.connect("marker-removed", self._marker_removed_cb)
+            self.__markers_container.connect("marker-moved", self._marker_moved_cb)
+
+    def __create_marker_widgets(self):
+        markers = self.__markers_container.get_markers()
+
+        for ges_marker in markers:
+            position = ges_marker.props.position
+            self._add_marker(position, ges_marker)
+
+    def _hadj_value_changed_cb(self, hadj):
+        """Handles the adjustment value change."""
+        self.offset = hadj.get_value()
+        self._update_position()
+
+    def zoomChanged(self):
+        self._update_position()
+
+    def _update_position(self):
+        for marker in self.layout.get_children():
+            position = self.nsToPixel(marker.position) - self.offset - MARKER_WIDTH / 2
+            self.layout.move(marker, position, 0)
+
+    # pylint: disable=arguments-differ
+    def do_button_press_event(self, event):
+        event_widget = Gtk.get_event_widget(event)
+        button = event.button
+        if button == Gdk.BUTTON_PRIMARY:
+            if isinstance(event_widget, Marker):
+                if event.type == Gdk.EventType.BUTTON_PRESS:
+                    self.marker_moving = event_widget
+                    self.marker_moving.selected = True
+                    self.app.action_log.begin("Move marker", toplevel=True)
+
+                elif event.type == Gdk.EventType.DOUBLE_BUTTON_PRESS:
+                    self.marker_moving = None
+                    self.app.action_log.rollback()
+                    marker_popover = MarkerPopover(self.app, event_widget)
+                    marker_popover.popup()
+
+            else:
+                position = self.pixelToNs(event.x + self.offset)
+                with self.app.action_log.started("Added marker", toplevel=True):
+                    self.__markers_container.add(position)
+                self.marker_new.selected = True
+
+    def do_button_release_event(self, event):
+        button = event.button
+        event_widget = Gtk.get_event_widget(event)
+        if button == Gdk.BUTTON_PRIMARY:
+            if self.marker_moving:
+                self.marker_moving.selected = False
+                self.marker_moving = None
+                self.app.action_log.commit("Move marker")
+            elif self.marker_new:
+                self.marker_new.selected = False
+                self.marker_new = None
+
+        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)
+
+    def do_motion_notify_event(self, event):
+        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.pixelToNs(event_x + self.offset)
+            self.__markers_container.move(self.marker_moving.ges_marker, position_ns)
+
+    def _marker_added_cb(self, unused_markers, position, ges_marker):
+        self._add_marker(position, ges_marker)
+
+    def _add_marker(self, position, ges_marker):
+        marker = Marker(ges_marker)
+        x = self.nsToPixel(position) - self.offset - MARKER_WIDTH / 2
+        self.layout.put(marker, x, 0)
+        marker.show()
+        self.marker_new = marker
+
+    def _marker_removed_cb(self, unused_markers, ges_marker):
+        self._remove_marker(ges_marker)
+
+    def _remove_marker(self, ges_marker):
+        if not ges_marker.ui:
+            return
+
+        self.layout.remove(ges_marker.ui)
+        ges_marker.ui = None
+
+    def _marker_moved_cb(self, unused_markers, position, ges_marker):
+        self._move_marker(position, ges_marker)
+
+    def _move_marker(self, position, ges_marker):
+        x = self.nsToPixel(position) - self.offset - MARKER_WIDTH / 2
+        self.layout.move(ges_marker.ui, x, 0)
+
+
+class MarkerPopover(Gtk.Popover):
+    """A popover to edit a marker's metadata."""
+
+    def __init__(self, app, marker):
+        Gtk.Popover.__init__(self)
+
+        self.app = app
+
+        self.text_view = Gtk.TextView()
+        self.text_view.set_size_request(100, -1)
+
+        self.marker = marker
+
+        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+        vbox.props.margin = SPACING
+        vbox.pack_start(self.text_view, False, True, 0)
+        self.add(vbox)
+
+        text = self.marker.comment
+        if text:
+            text_buffer = self.text_view.get_buffer()
+            text_buffer.set_text(text)
+
+        self.set_position(Gtk.PositionType.TOP)
+        self.set_relative_to(self.marker)
+        self.show_all()
+
+    # pylint: disable=arguments-differ
+    def do_closed(self):
+        buffer = self.text_view.get_buffer()
+        if buffer.props.text != self.marker.comment:
+            with self.app.action_log.started("marker comment", toplevel=True):
+                self.marker.comment = buffer.props.text
+        self.marker.selected = False
diff --git a/pitivi/timeline/timeline.py b/pitivi/timeline/timeline.py
index 7ee765c5..01e0d47b 100644
--- a/pitivi/timeline/timeline.py
+++ b/pitivi/timeline/timeline.py
@@ -38,6 +38,7 @@ from pitivi.timeline.elements import TrimHandle
 from pitivi.timeline.layer import Layer
 from pitivi.timeline.layer import LayerControls
 from pitivi.timeline.layer import SpacedSeparator
+from pitivi.timeline.markers import MarkersBox
 from pitivi.timeline.previewers import Previewer
 from pitivi.timeline.ruler import ScaleRuler
 from pitivi.undo.timeline import CommitTimelineFinalizingAction
@@ -1539,6 +1540,7 @@ class TimelineContainer(Gtk.Grid, Zoomable, Loggable):
                 pass  # We were not connected no problem
 
             self.timeline._pipeline = None
+            self.markers.markers_container = None
 
         self._project = project
 
@@ -1558,6 +1560,7 @@ class TimelineContainer(Gtk.Grid, Zoomable, Loggable):
 
             self.timeline.set_best_zoom_ratio(allow_zoom_in=True)
             self.timeline.update_snapping_distance()
+            self.markers.markers_container = project.ges_timeline.get_marker_list("markers")
 
     def updateActions(self):
         selection = self.timeline.selection
@@ -1607,12 +1610,15 @@ class TimelineContainer(Gtk.Grid, Zoomable, Loggable):
         self.gapless_button = builder.get_object("gapless_button")
         self.gapless_button.set_active(self._settings.timelineAutoRipple)
 
-        self.attach(zoom_box, 0, 0, 1, 1)
-        self.attach(self.ruler, 1, 0, 1, 1)
-        self.attach(self.timeline, 0, 1, 2, 1)
-        self.attach(self.vscrollbar, 2, 1, 1, 1)
-        self.attach(hscrollbar, 1, 2, 1, 1)
-        self.attach(self.toolbar, 3, 1, 1, 1)
+        self.markers = MarkersBox(self.app, hadj=self.timeline.hadj)
+
+        self.attach(self.markers, 1, 0, 1, 1)
+        self.attach(zoom_box, 0, 1, 1, 1)
+        self.attach(self.ruler, 1, 1, 1, 1)
+        self.attach(self.timeline, 0, 2, 2, 1)
+        self.attach(self.vscrollbar, 2, 2, 1, 1)
+        self.attach(hscrollbar, 1, 3, 1, 1)
+        self.attach(self.toolbar, 3, 2, 1, 1)
 
         self.set_margin_top(SPACING)
 
diff --git a/pitivi/undo/markers.py b/pitivi/undo/markers.py
new file mode 100644
index 00000000..0ec7b55b
--- /dev/null
+++ b/pitivi/undo/markers.py
@@ -0,0 +1,152 @@
+# -*- coding: utf-8 -*-
+# Pitivi video editor
+# Copyright (c) 2019, Millan Castro <m castrovilarino gmail com>
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this program; if not, write to the
+# Free Software Foundation, Inc., 51 Franklin St, Fifth Floor,
+# Boston, MA 02110-1301, USA.
+"""Undo/redo markers"""
+from gi.repository import Gst
+
+from pitivi.undo.undo import MetaContainerObserver
+from pitivi.undo.undo import UndoableAutomaticObjectAction
+from pitivi.utils.loggable import Loggable
+
+
+class MarkerListObserver(Loggable):
+    """Monitors a MarkerList and reports UndoableActions.
+
+    Args:
+        ges_marker_list (GES.MarkerList): The markerlist to observe.
+
+    Attributes:
+        action_log (UndoableActionLog): The action log where to report actions.
+    """
+
+    def __init__(self, ges_marker_list, action_log):
+        Loggable.__init__(self)
+
+        self.action_log = action_log
+
+        self.markers_position = {}
+        self.marker_observers = {}
+
+        ges_marker_list.connect("marker-added", self._marker_added_cb)
+        ges_marker_list.connect("marker-removed", self._marker_removed_cb)
+        ges_marker_list.connect("marker-moved", self._marker_moved_cb)
+
+        ges_markers = ges_marker_list.get_markers()
+        for ges_marker in ges_markers:
+            self._connect(ges_marker)
+
+    def _connect(self, ges_marker):
+        marker_observer = MetaContainerObserver(ges_marker, self.action_log)
+        self.marker_observers[ges_marker] = marker_observer
+        self.markers_position[ges_marker] = ges_marker.props.position
+
+    def _marker_added_cb(self, ges_marker_list, position, ges_marker):
+        action = MarkerAdded(ges_marker_list, ges_marker)
+        self.action_log.push(action)
+        self._connect(ges_marker)
+
+    def _marker_removed_cb(self, ges_marker_list, ges_marker):
+        action = MarkerRemoved(ges_marker_list, ges_marker)
+        self.action_log.push(action)
+        marker_observer = self.marker_observers.pop(ges_marker)
+        marker_observer.release()
+        del self.markers_position[ges_marker]
+
+    def _marker_moved_cb(self, ges_marker_list, position, ges_marker):
+        old_position = self.markers_position[ges_marker]
+        action = MarkerMoved(ges_marker_list, ges_marker, old_position)
+        self.action_log.push(action)
+        self.markers_position[ges_marker] = ges_marker.props.position
+
+
+# pylint: disable=abstract-method, too-many-ancestors
+class MarkerAction(UndoableAutomaticObjectAction):
+    """Base class for add and remove marker actions"""
+
+    def __init__(self, ges_marker_list, ges_marker):
+        UndoableAutomaticObjectAction.__init__(self, ges_marker)
+        self.ges_marker_list = ges_marker_list
+        self.position = ges_marker.props.position
+        self.ges_marker = ges_marker
+
+    def add(self):
+        """Adds a marker and updates the auto-object."""
+        ges_marker = self.ges_marker_list.add(self.position)
+        comment = self.auto_object.get_string("comment")
+        if comment:
+            ges_marker.set_string("comment", comment)
+        UndoableAutomaticObjectAction.update_object(self.auto_object, ges_marker)
+
+    def remove(self):
+        """Removes the marker represented by the auto_object."""
+        self.ges_marker_list.remove(self.auto_object)
+
+
+class MarkerAdded(MarkerAction):
+    """Action for added markers."""
+
+    def do(self):
+        self.add()
+
+    def undo(self):
+        self.remove()
+
+    def asScenarioAction(self):
+        st = Gst.Structure.new_empty("add-marker")
+        return st
+
+
+class MarkerRemoved(MarkerAction):
+    """Action for removed markers."""
+
+    def do(self):
+        self.remove()
+
+    def undo(self):
+        self.add()
+
+    def asScenarioAction(self):
+        st = Gst.Structure.new_empty("remove-marker")
+        return st
+
+
+class MarkerMoved(UndoableAutomaticObjectAction):
+    """Action for moved markers."""
+
+    def __init__(self, ges_marker_list, ges_marker, old_position):
+        UndoableAutomaticObjectAction.__init__(self, ges_marker)
+        self.ges_marker_list = ges_marker_list
+        self.new_position = ges_marker.props.position
+        self.old_position = old_position
+
+    def do(self):
+        self.ges_marker_list.move(self.auto_object, self.new_position)
+
+    def undo(self):
+        self.ges_marker_list.move(self.auto_object, self.old_position)
+
+    def asScenarioAction(self):
+        st = Gst.Structure.new_empty("move-marker")
+        return st
+
+    def expand(self, action):
+        if not isinstance(action, MarkerMoved):
+            return False
+
+        self.new_position = action.new_position
+        return True
diff --git a/pitivi/undo/timeline.py b/pitivi/undo/timeline.py
index 67d139fc..a2d27e34 100644
--- a/pitivi/undo/timeline.py
+++ b/pitivi/undo/timeline.py
@@ -860,7 +860,7 @@ class GroupObserver(Loggable):
         self.action_log.push(action)
 
 
-class TimelineObserver(Loggable):
+class TimelineObserver(MetaContainerObserver, Loggable):
     """Monitors a project's timeline and reports UndoableActions.
 
     Attributes:
@@ -869,12 +869,14 @@ class TimelineObserver(Loggable):
     """
 
     def __init__(self, ges_timeline, action_log):
+        MetaContainerObserver.__init__(self, ges_timeline, action_log)
         Loggable.__init__(self)
         self.ges_timeline = ges_timeline
         self.action_log = action_log
 
         self.layer_observers = {}
         self.group_observers = {}
+
         for ges_layer in ges_timeline.get_layers():
             self._connect_to_layer(ges_layer)
 
diff --git a/pitivi/undo/undo.py b/pitivi/undo/undo.py
index 3fade910..b088e271 100644
--- a/pitivi/undo/undo.py
+++ b/pitivi/undo/undo.py
@@ -19,6 +19,7 @@
 """Undo/redo."""
 import contextlib
 
+from gi.repository import GES
 from gi.repository import GObject
 
 from pitivi.utils.loggable import Loggable
@@ -366,20 +367,19 @@ class UndoableActionLog(GObject.Object, Loggable):
         return False
 
 
-class MetaChangedAction(UndoableAction):
+class MetaChangedAction(UndoableAutomaticObjectAction):
 
     def __init__(self, meta_container, item, current_value, new_value):
-        UndoableAction.__init__(self)
-        self.meta_container = meta_container
+        UndoableAutomaticObjectAction.__init__(self, meta_container)
         self.item = item
         self.old_value = current_value
         self.new_value = new_value
 
     def do(self):
-        self.meta_container.set_meta(self.item, self.new_value)
+        self.auto_object.set_meta(self.item, self.new_value)
 
     def undo(self):
-        self.meta_container.set_meta(self.item, self.old_value)
+        self.auto_object.set_meta(self.item, self.old_value)
 
 
 class MetaContainerObserver(GObject.Object):
@@ -396,8 +396,10 @@ class MetaContainerObserver(GObject.Object):
 
         self.metas = {}
 
+        self.marker_list_observers = {}
+
         def set_meta(unused_meta_container, item, value):
-            self.metas[item] = value
+            self.__update_meta(item, value)
         meta_container.foreach(set_meta)
 
         meta_container.connect("notify-meta", self._notify_meta_cb)
@@ -405,13 +407,20 @@ class MetaContainerObserver(GObject.Object):
     def _notify_meta_cb(self, meta_container, item, value):
         current_value = self.metas.get(item)
         action = MetaChangedAction(meta_container, item, current_value, value)
-        self.metas[item] = value
+        self.__update_meta(item, value)
         self.action_log.push(action)
 
     def release(self):
         self.meta_container.disconnect_by_func(self._notify_meta_cb)
         self.meta_container = None
 
+    def __update_meta(self, item, value):
+        self.metas[item] = value
+        if isinstance(self.metas[item], GES.MarkerList):
+            from pitivi.undo.markers import MarkerListObserver
+            observer = MarkerListObserver(self.metas[item], self.action_log)
+            self.marker_list_observers[self.metas[item]] = observer
+
 
 class PropertyChangedAction(UndoableAutomaticObjectAction):
 
diff --git a/pitivi/utils/ui.py b/pitivi/utils/ui.py
index fea22797..d6d8a756 100644
--- a/pitivi/utils/ui.py
+++ b/pitivi/utils/ui.py
@@ -222,10 +222,29 @@ EDITOR_PERSPECTIVE_CSS = """
 
     .Marquee {
         background-color: rgba(224, 224, 224, 0.7);
+        color: rgba(224, 224, 224, 1);
+    }
+
+    .MarkersBox {
+        background-color: rgba(224, 224, 224, 0);
+    }
+
+    .Marker {
+        background-image: url('%(marker_unselected)s');
+    }
+
+    .Marker:hover {
+        background-image: url('%(marker_hovered)s');
+    }
+
+    .Marker:selected {
+        background-image: url('%(marker_hovered)s');
     }
 
 """ % ({'trimbar_normal': os.path.join(get_pixmap_dir(), "trimbar-normal.png"),
-        'trimbar_focused': os.path.join(get_pixmap_dir(), "trimbar-focused.png")})
+        'trimbar_focused': os.path.join(get_pixmap_dir(), "trimbar-focused.png"),
+        'marker_unselected': os.path.join(get_pixmap_dir(), "marker-unselect.png"),
+        'marker_hovered': os.path.join(get_pixmap_dir(), "marker-hover.png")})
 
 
 PREFERENCES_CSS = """
diff --git a/po/POTFILES.in b/po/POTFILES.in
index c4129f0e..49d32d65 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -43,6 +43,7 @@ pitivi/viewer/viewer.py
 
 pitivi/timeline/elements.py
 pitivi/timeline/layer.py
+pitivi/timeline/markers.py
 pitivi/timeline/ruler.py
 pitivi/timeline/timeline.py
 
diff --git a/tests/common.py b/tests/common.py
index 9bbfb1fa..9da25bf2 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -305,6 +305,18 @@ class TestCase(unittest.TestCase, Loggable):
         self.assertTrue(caps1.is_equal(caps2),
                         "%s != %s" % (caps1.to_string(), caps2.to_string()))
 
+    def assert_markers(self, ges_marker_list, expected_properties):
+        """Asserts the content of a GES.MarkerList."""
+        markers = ges_marker_list.get_markers()
+        expected_positions = [properties[0] for properties in expected_properties]
+        expected_comments = [properties[1] for properties in expected_properties]
+
+        positions = [ges_marker.props.position for ges_marker in markers]
+        self.assertListEqual(positions, expected_positions)
+
+        comments = [ges_marker.get_string("comment") for ges_marker in markers]
+        self.assertListEqual(comments, expected_comments)
+
 
 @contextlib.contextmanager
 def created_project_file(asset_uri):
diff --git a/tests/test_project.py b/tests/test_project.py
index b18cc845..5bc58475 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -192,6 +192,12 @@ class TestProjectManager(common.TestCase):
         project = args[0]
         self.assertTrue(project is self.manager.current_project)
 
+    def test_marker_container(self):
+        project = self.manager.new_blank_project()
+        self.assertIsNotNone(project)
+        self.assertIsNotNone(project.ges_timeline)
+        self.assertIsNotNone(project.ges_timeline.get_marker_list("markers"))
+
     def testSaveProject(self):
         self.manager.new_blank_project()
 
diff --git a/tests/test_timeline_markers.py b/tests/test_timeline_markers.py
new file mode 100644
index 00000000..f1245139
--- /dev/null
+++ b/tests/test_timeline_markers.py
@@ -0,0 +1,156 @@
+# -*- coding: utf-8 -*-
+# Pitivi video editor
+# Copyright (c) 2009, Alessandro Decina <alessandro d gmail com>
+# Copyright (c) 2014, Alex Băluț <alexandru balut gmail com>
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this program; if not, write to the
+# Free Software Foundation, Inc., 51 Franklin St, Fifth Floor,
+# Boston, MA 02110-1301, USA.
+"""Tests for the timeline.markers module."""
+from unittest import mock
+
+from gi.repository import Gdk
+from gi.repository import Gtk
+
+from pitivi.utils.timeline import Zoomable
+from tests.test_undo_timeline import BaseTestUndoTimeline
+
+
+class TestMarkers(BaseTestUndoTimeline):
+    """Class for markers tests"""
+
+    def test_marker_added_ui(self):
+        "Checks the add marker ui"
+        self.setup_timeline_container()
+        markers = self.timeline.get_marker_list("markers")
+        marker_box = self.timeline_container.markers
+        marker_box.markers_container = markers
+
+        x = 100
+        event = mock.Mock(spec=Gdk.EventButton)
+        event.x = x
+        event.y = 1
+        event.button = Gdk.BUTTON_PRIMARY
+
+        with mock.patch.object(Gtk, "get_event_widget") as get_event_widget:
+            get_event_widget.return_value = marker_box
+            event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_PRESS)
+            marker_box.do_button_press_event(event)
+            event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_RELEASE)
+            marker_box.do_button_release_event(event)
+
+        position = Zoomable.pixelToNs(event.x)
+        self.assert_markers(markers, [(position, None)])
+
+    def test_marker_removed_ui(self):
+        "Checks the remove marker ui"
+        self.setup_timeline_container()
+        markers = self.timeline.get_marker_list("markers")
+        marker_box = self.timeline_container.markers
+        marker_box.markers_container = markers
+
+        x = 200
+        position = Zoomable.pixelToNs(x)
+        marker = marker_box.markers_container.add(position)
+        self.assert_markers(markers, [(position, None)])
+
+        event = mock.Mock(spec=Gdk.EventButton)
+        event.x = x
+        event.y = 1
+        event.button = Gdk.BUTTON_SECONDARY
+
+        with mock.patch.object(Gtk, "get_event_widget") as get_event_widget:
+            get_event_widget.return_value = marker.ui
+            event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_PRESS)
+            marker_box.do_button_press_event(event)
+            event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_RELEASE)
+            marker_box.do_button_release_event(event)
+
+        self.assert_markers(markers, [])
+
+    def test_marker_moved_ui(self):
+        """Checks the move marker UI."""
+        self.setup_timeline_container()
+        markers = self.timeline.get_marker_list("markers")
+        marker_box = self.timeline_container.markers
+        marker_box.markers_container = markers
+
+        x1 = 300
+        position1 = Zoomable.pixelToNs(x1)
+        marker = marker_box.markers_container.add(position1)
+        self.assert_markers(markers, [(position1, None)])
+
+        x2 = 400
+        position2 = Zoomable.pixelToNs(x2)
+
+        event = mock.Mock(spec=Gdk.EventButton)
+        event.x = x2
+        event.y = 1
+        event.type = Gdk.EventType.BUTTON_PRESS
+        event.button = Gdk.BUTTON_PRIMARY
+
+        with mock.patch.object(Gtk, "get_event_widget") as get_event_widget:
+            get_event_widget.return_value = marker.ui
+            event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_PRESS)
+            marker_box.do_button_press_event(event)
+
+            with mock.patch.object(marker.ui, "translate_coordinates") as translate_coordinates:
+                translate_coordinates.return_value = (x2, 0)
+                marker_box.do_motion_notify_event(event)
+                marker_box.do_button_release_event(event)
+
+        self.assert_markers(markers, [(position2, None)])
+
+    # pylint: disable=unbalanced-tuple-unpacking
+    def test_marker_comment_ui(self):
+        """Checks the comments marker UI."""
+        self.setup_timeline_container()
+        markers = self.timeline.get_marker_list("markers")
+        marker_box = self.timeline_container.markers
+        marker_box.markers_container = markers
+
+        x = 500
+        position = Zoomable.pixelToNs(x)
+        marker = marker_box.markers_container.add(position)
+        self.assert_markers(markers, [(position, None)])
+
+        event = mock.Mock(spec=Gdk.EventButton)
+        event.x = x
+        event.y = 1
+        event.type = Gdk.EventType.BUTTON_PRESS
+        event.button = Gdk.BUTTON_PRIMARY
+
+        with mock.patch.object(Gtk, "get_event_widget") as get_event_widget:
+            get_event_widget.return_value = marker.ui
+
+            def popup(markerpopover):
+                text_buffer = markerpopover.text_view.get_buffer()
+                text_buffer.set_text("com")
+                text_buffer.set_text("comment")
+                markerpopover.popdown()
+            original_popover_menu = Gtk.Popover.popup
+            Gtk.Popover.popup = popup
+            try:
+                event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_PRESS)
+                marker_box.do_button_press_event(event)
+                event.guiEvent = Gdk.Event.new(Gdk.EventType.DOUBLE_BUTTON_PRESS)
+                event.type = Gdk.EventType.DOUBLE_BUTTON_PRESS
+                marker_box.do_button_press_event(event)
+            finally:
+                Gtk.Popover.popup = original_popover_menu
+
+        stack, = self.action_log.undo_stacks
+        self.assertEqual(len(stack.done_actions), 1, stack.done_actions)
+
+        self.assert_markers(markers, [(position, "comment")])
diff --git a/tests/test_undo_markers.py b/tests/test_undo_markers.py
new file mode 100644
index 00000000..c85984d3
--- /dev/null
+++ b/tests/test_undo_markers.py
@@ -0,0 +1,238 @@
+# -*- coding: utf-8 -*-
+# Pitivi video editor
+# Copyright (c) 2019, Millan Castro <m castrovilarino gmail com>
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this program; if not, write to the
+# Free Software Foundation, Inc., 51 Franklin St, Fifth Floor,
+# Boston, MA 02110-1301, USA.
+"""Tests for the undo.markers module."""
+import tempfile
+
+from gi.repository import Gst
+
+from tests import common
+from tests.test_undo_timeline import BaseTestUndoTimeline
+
+
+class TestMarkers(BaseTestUndoTimeline):
+    """Tests for the various classes."""
+
+    def test_marker_added(self):
+        """Checks marker addition."""
+        self.setup_timeline_container()
+        markers = self.timeline.get_marker_list("markers")
+
+        with self.action_log.started("Added marker"):
+            marker1 = markers.add(10)
+        self.assert_markers(markers, [(10, None)])
+
+        with self.action_log.started("new comment"):
+            marker1.set_string("comment", "comment 1")
+        self.assert_markers(markers, [(10, "comment 1")])
+
+        for _ in range(4):
+            self.action_log.undo()
+            self.assert_markers(markers, [(10, None)])
+
+            self.action_log.undo()
+            self.assert_markers(markers, [])
+
+            self.action_log.redo()
+            self.assert_markers(markers, [(10, None)])
+
+            self.action_log.redo()
+            self.assert_markers(markers, [(10, "comment 1")])
+
+        with self.action_log.started("Added marker"):
+            marker2 = markers.add(20)
+        self.assert_markers(markers, [(10, "comment 1"), (20, None)])
+
+        with self.action_log.started("new comment"):
+            marker2.set_string("comment", "comment 2")
+        self.assert_markers(markers, [(10, "comment 1"), (20, "comment 2")])
+
+        for _ in range(4):
+            self.action_log.undo()
+            self.action_log.undo()
+            self.action_log.undo()
+            self.action_log.undo()
+            self.assert_markers(markers, [])
+
+            self.action_log.redo()
+            self.assert_markers(markers, [(10, None)])
+
+            self.action_log.redo()
+            self.assert_markers(markers, [(10, "comment 1")])
+
+            self.action_log.redo()
+            self.assert_markers(markers, [(10, "comment 1"), (20, None)])
+
+            self.action_log.redo()
+            self.assert_markers(markers, [(10, "comment 1"), (20, "comment 2")])
+
+    def test_marker_removed(self):
+        """Checks marker removal."""
+        self.setup_timeline_container()
+        markers = self.timeline.get_marker_list("markers")
+        marker1 = markers.add(10)
+        marker2 = markers.add(20)
+
+        with self.action_log.started("Removed marker"):
+            markers.remove(marker1)
+        self.assert_markers(markers, [(20, None)])
+
+        with self.action_log.started("Removed marker"):
+            markers.remove(marker2)
+        self.assert_markers(markers, [])
+
+        for _ in range(4):
+            self.action_log.undo()
+            self.assert_markers(markers, [(20, None)])
+
+            self.action_log.undo()
+            self.assert_markers(markers, [(10, None), (20, None)])
+
+            self.action_log.redo()
+            self.assert_markers(markers, [(20, None)])
+
+            self.action_log.redo()
+            self.assert_markers(markers, [])
+
+    def test_marker_moved(self):
+        """Checks marker moving."""
+        self.setup_timeline_container()
+        markers = self.timeline.get_marker_list("markers")
+        marker1 = markers.add(10)
+        markers.add(20)
+
+        with self.action_log.started("Move marker"):
+            markers.move(marker1, 40)
+            markers.move(marker1, 30)
+
+        stack, = self.action_log.undo_stacks
+        self.assertEqual(len(stack.done_actions), 1, stack.done_actions)
+
+        self.assert_markers(markers, [(20, None), (30, None)])
+
+        for _ in range(4):
+            self.action_log.undo()
+            self.assert_markers(markers, [(10, None), (20, None)])
+
+            self.action_log.redo()
+            self.assert_markers(markers, [(20, None), (30, None)])
+
+    def test_marker_comment(self):
+        """Checks marker comment."""
+        self.setup_timeline_container()
+
+        markers = self.timeline.get_marker_list("markers")
+
+        with self.action_log.started("Added marker"):
+            marker1 = markers.add(10)
+        with self.action_log.started("Added marker"):
+            marker2 = markers.add(20)
+
+        self.assert_markers(markers, [(10, None), (20, None)])
+
+        with self.action_log.started("new comment"):
+            marker1.set_string("comment", "comment 1")
+        with self.action_log.started("new comment"):
+            marker2.set_string("comment", "comment 2")
+
+        self.assert_markers(markers, [(10, "comment 1"), (20, "comment 2")])
+
+        for _ in range(4):
+            self.action_log.undo()
+            self.assert_markers(markers, [(10, "comment 1"), (20, None)])
+
+            self.action_log.undo()
+            self.assert_markers(markers, [(10, None), (20, None)])
+
+            self.action_log.undo()
+            self.assert_markers(markers, [(10, None)])
+
+            self.action_log.undo()
+            self.assert_markers(markers, [])
+
+            self.action_log.redo()
+            self.assert_markers(markers, [(10, None)])
+
+            self.action_log.redo()
+            self.assert_markers(markers, [(10, None), (20, None)])
+
+            self.action_log.redo()
+            self.assert_markers(markers, [(10, "comment 1"), (20, None)])
+
+            self.action_log.redo()
+            self.assert_markers(markers, [(10, "comment 1"), (20, "comment 2")])
+
+    def test_marker_load_project(self):
+        """Checks marker addition."""
+        # TODO: When there is nothing connected to closing-project,
+        # the default reply is "False", which means "abort saving". It should mean
+        # "OK" to get rid off the handler.  The meaning of the default (False)
+        # must be changed
+        def closing(unused_manager, unused_project):
+            return True
+
+        def loaded_cb(project, timeline):
+            mainloop.quit()
+
+        markers = self.timeline.get_marker_list("markers")
+        markers.add(10)
+        markers.add(20)
+        self.assert_markers(markers, [(10, None), (20, None)])
+
+        project_uri = Gst.filename_to_uri(tempfile.NamedTemporaryFile().name)
+        self.app.project_manager.saveProject(project_uri)
+        self.app.project_manager.connect("closing-project", closing)
+
+        self.app.project_manager.closeRunningProject()
+        project = self.app.project_manager.new_blank_project()
+        markers = project.ges_timeline.get_marker_list("markers")
+        self.assert_markers(markers, [])
+
+        self.app.project_manager.closeRunningProject()
+        project = self.app.project_manager.load_project(project_uri)
+        project.connect("loaded", loaded_cb)
+        mainloop = common.create_main_loop()
+        mainloop.run()
+        self.action_log = self.app.action_log
+
+        markers = project.ges_timeline.get_marker_list("markers")
+        self.assert_markers(markers, [(10, None), (20, None)])
+
+        ges_markers = markers.get_markers()
+        marker1, marker2 = ges_markers
+
+        with self.action_log.started("new comment"):
+            marker1.set_string("comment", "comment 1")
+        self.assert_markers(markers, [(10, "comment 1"), (20, None)])
+
+        with self.action_log.started("new comment"):
+            marker2.set_string("comment", "comment 2")
+        self.assert_markers(markers, [(10, "comment 1"), (20, "comment 2")])
+
+        for _ in range(4):
+            self.action_log.undo()
+            self.assert_markers(markers, [(10, "comment 1"), (20, None)])
+
+            self.action_log.undo()
+            self.assert_markers(markers, [(10, None), (20, None)])
+
+            self.action_log.redo()
+            self.assert_markers(markers, [(10, "comment 1"), (20, None)])
+
+            self.action_log.redo()
+            self.assert_markers(markers, [(10, "comment 1"), (20, "comment 2")])



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