Commit 82dfa9f3 authored by Millan Castro's avatar Millan Castro

markers: add markers system and tests

parent f0c10c30
Pipeline #108190 passed with stage
in 17 minutes and 19 seconds
......@@ -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):
......
# -*- 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 true is the marker is marked as 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 markers"""
def __init__(self, timeline):
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 = timeline.app
hadj = timeline.timeline.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 = 10
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)
self.app.project_manager.connect_after(
"project-closed", self.__project_closed_cb)
def __project_closed_cb(self, unused_project_manager, unused_project):
for marker in self.layout.get_children():
self.layout.remove(marker)
@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
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)
self.marker_new = None
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 menu to edit markers 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.pack_start(self.text_view, False, True, SPACING * 2)
self.add(vbox)
self.set_position(Gtk.PositionType.TOP)
self.connect("closed", self._save_text_cb)
text_buffer = self.text_view.get_buffer()
text = self.marker.comment
if text:
text_buffer.set_text(text)
self.set_relative_to(self.marker)
self.show_all()
def _save_text_cb(self, unused_element):
buffer = self.text_view.get_buffer()
if buffer.props.text != self.marker.comment:
with self.app.action_log.started("new comment", toplevel=True):
self.marker.comment = buffer.props.text
self.marker.selected = False
......@@ -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
......@@ -1587,12 +1588,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)
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)
......@@ -2042,6 +2046,8 @@ class TimelineContainer(Gtk.Grid, Zoomable, Loggable):
self.ruler.zoomChanged()
self._update_ruler(project.videorate)
self.markers.markers_container = self.ges_timeline.get_marker_list("markers")
self.timeline.set_best_zoom_ratio(allow_zoom_in=True)
self.timeline.update_snapping_distance()
......
# -*- 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):
"Remove 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
self.ges_marker = ges_marker
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
......@@ -506,6 +506,7 @@ class LayerRemoved(UndoableAction):
def asScenarioAction(self):
st = Gst.Structure.new_empty("remove-layer")
st.set_value("priority", self.ges_layer.props.priority)
return st
......@@ -860,7 +861,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 +870,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)
......
......@@ -19,7 +19,9 @@
"""Undo/redo."""
import contextlib
from gi.repository import GES
from gi.repository import GObject
from gi.repository import Gst
from pitivi.utils.loggable import Loggable
......@@ -366,20 +368,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 +397,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 +408,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):
......
......@@ -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 = """
......
# -*- 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 markers module."""
from unittest import mock
from gi.repository import Gdk
from gi.repository import Gtk
from pitivi.timeline.timeline import TimelineContainer
from pitivi.utils.timeline import Zoomable
from tests import common
class BaseTestUndoTimeline(common.TestCase):
"""Base class"""
def setUp(self):
"""setUp method"""
super(BaseTestUndoTimeline, self).setUp()
self.app = common.create_pitivi()
project = self.app.project_manager.new_blank_project()
self.timeline = project.ges_timeline
self.action_log = self.app.action_log
self.timeline_container = TimelineContainer(self.app)
def setup_timeline_container(self):
"""setup timelone method"""
project = self.app.project_manager.current_project
self.timeline_container.setProject(project)
timeline = self.timeline_container.timeline
timeline.app.project_manager.current_project = project
timeline.get_parent = mock.MagicMock(return_value=self.timeline_container)
class TestMarkers(BaseTestUndoTimeline):
"""Class for markers tests"""
def assert_markers(self, ges_marker_list, expected_properties):
"""Checks the actual markers with the expected markers"""
positions = [ges_marker.props.position for ges_marker in ges_marker_list.get_markers()]
expected_positions = [properties[0] for properties in expected_properties]
self.assertListEqual(positions, expected_positions)
comments = [ges_marker.get_string("comment") for ges_marker in ges_marker_list.get_markers()]
expected_comments = [properties[1] for properties in expected_properties]
self.assertListEqual(comments, expected_comments)
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)