Commit 4fc25453 authored by Millan Castro's avatar Millan Castro Committed by Alexandru Băluț

timeline: Add a markers bar above the ruler

Fixes #739
parent f218843f
Pipeline #109604 passed with stages
in 22 minutes and 51 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 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
......@@ -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)
......
# -*- 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
......@@ -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)
......
......@@ -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):
......
......@@ -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 = """
......
......@@ -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
......
......@@ -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):
......
......@@ -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()
......
# -*- 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.