[pitivi] viewer: Add composition guidelines



commit c24e0c14fbf8533535a16892ef302f4a1c15398e
Author: Daniel Rudebusch, Jessie Guo, and Jaden Goter <>
Date:   Fri Apr 17 14:30:32 2020 -0500

    viewer: Add composition guidelines
    
    Composition guidelines are simple lines that help with framing and
    position in an aesthetically pleasing way.
    
    Added a new overlay class (that is not attached to a source) to draw the
    composition guidelines. Also, add a menu in the viewer to select the
    guidelines.
    
    Fixes #2298

 pitivi/timeline/timeline.py     |   3 +
 pitivi/viewer/guidelines.py     | 179 ++++++++++++++++++++++++++++++++++++++++
 pitivi/viewer/overlay_stack.py  |   5 +-
 pitivi/viewer/viewer.py         |  34 +++++++-
 po/POTFILES.in                  |   1 +
 tests/common.py                 |   7 ++
 tests/test_viewer_guidelines.py |  77 +++++++++++++++++
 7 files changed, 303 insertions(+), 3 deletions(-)
---
diff --git a/pitivi/timeline/timeline.py b/pitivi/timeline/timeline.py
index c4ed37bb..db0d2eca 100644
--- a/pitivi/timeline/timeline.py
+++ b/pitivi/timeline/timeline.py
@@ -1814,6 +1814,9 @@ class TimelineContainer(Gtk.Grid, Zoomable, Loggable):
                                self.forward_one_second_action,
                                _("Seek forward one second"))
 
+        # Viewer actions.
+        self.timeline.layout.insert_action_group("viewer", self.app.gui.editor.viewer.action_group)
+
         self.update_actions()
 
     def _scroll_to_pixel(self, x):
diff --git a/pitivi/viewer/guidelines.py b/pitivi/viewer/guidelines.py
new file mode 100644
index 00000000..d6fd6b7f
--- /dev/null
+++ b/pitivi/viewer/guidelines.py
@@ -0,0 +1,179 @@
+# -*- coding: utf-8 -*-
+# Pitivi video editor
+# Copyright (c) 2020, Jaden Goter <jadengoter huskers unl edu>
+# Copyright (c) 2020, Jessie Guo <jessie guo huskers unl edu>
+# Copyright (c) 2020, Daniel Rudebusch <daniel rudebusch huskers unl edu>
+#
+# 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, see <http://www.gnu.org/licenses/>.
+from enum import Enum
+from gettext import gettext as _
+
+from gi.repository import Gtk
+
+from pitivi.utils.ui import SPACING
+
+
+class Guideline(Enum):
+    """Guideline types."""
+
+    @staticmethod
+    def __three_by_three_draw_func(cr, width, height):
+        for i in range(1, 3):
+            cr.move_to(i * width / 3, 0)
+            cr.line_to(i * width / 3, height)
+            cr.move_to(0, i * height / 3)
+            cr.line_to(width, i * height / 3)
+
+    @staticmethod
+    def __vertical_horizontal_center_draw_func(cr, width, height):
+        cr.move_to(width / 2, 0)
+        cr.line_to(width / 2, height)
+        cr.move_to(0, height / 2)
+        cr.line_to(width, height / 2)
+
+    @staticmethod
+    def __diagonals_draw_func(cr, width, height):
+        cr.move_to(0, 0)
+        cr.line_to(width, height)
+        cr.move_to(width, 0)
+        cr.line_to(0, height)
+
+    three_by_three = (_("3 by 3"), __three_by_three_draw_func)
+    vertical_horizontal_center = (_("Vertical/Horizontal"), __vertical_horizontal_center_draw_func)
+    diagonals = (_("Diagonals"), __diagonals_draw_func)
+
+    def __init__(self, label, func):
+        self.label = label
+        self.draw_func = func
+
+
+class GuidelinesPopover(Gtk.Popover):
+    """A popover for controlling the visible composition guidelines.
+
+    Attributes:
+        overlay (GuidelinesOverlay): The managed overlay showing the guidelines.
+        switches (dict): Maps the Guideline types to Gtk.Switch widgets.
+    """
+
+    def __init__(self):
+        Gtk.Popover.__init__(self)
+
+        self.switches = dict()
+
+        self.overlay = GuidelinesOverlay()
+        self._create_ui()
+
+        self._last_guidelines = {Guideline.three_by_three}
+
+    def _create_ui(self):
+        grid = Gtk.Grid()
+        grid.props.row_spacing = SPACING
+        grid.props.column_spacing = SPACING
+        grid.props.margin = SPACING * 2
+
+        label = Gtk.Label(_("Composition Guidelines"))
+        label.props.wrap = True
+        grid.attach(label, 0, 0, 2, 1)
+
+        grid.attach(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), 0, 1, 2, 1)
+
+        row = 1
+        for guideline in Guideline:
+            row += 1
+
+            label = Gtk.Label(guideline.label)
+            label.props.halign = Gtk.Align.START
+            label.props.wrap = True
+            label.props.xalign = 0
+            grid.attach(label, 0, row, 1, 1)
+
+            switch = Gtk.Switch()
+            switch.connect("state-set", self.__guideline_switch_state_set_cb, guideline)
+            grid.attach(switch, 1, row, 1, 1)
+
+            self.switches[guideline] = switch
+
+        grid.show_all()
+        self.add(grid)
+
+    def toggle(self):
+        """Toggle the visible guidelines on the managed overlay."""
+        # Keep a copy and restore it since the last active guidelines
+        # can be changed when the switches are toggled.
+        last_guidelines_copy = self._last_guidelines.copy()
+        try:
+            for guideline in last_guidelines_copy:
+                switch = self.switches[guideline]
+                switch.set_active(not switch.get_active())
+        finally:
+            self._last_guidelines = last_guidelines_copy
+
+    def __guideline_switch_state_set_cb(self, switch_widget, unused_parameter, guideline):
+        last_guidelines = {guideline
+                           for guideline in Guideline
+                           if self.switches[guideline].get_active()}
+        if last_guidelines:
+            self._last_guidelines = last_guidelines
+        if switch_widget.get_active():
+            self.overlay.add_guideline(guideline)
+        else:
+            self.overlay.remove_guideline(guideline)
+
+
+class GuidelinesOverlay(Gtk.DrawingArea):
+    """Overlay which draws the composition guidelines.
+
+    Attributes:
+        active_guidelines (set[Guideline]): The guidelines to be drawn.
+    """
+
+    def __init__(self):
+        Gtk.DrawingArea.__init__(self)
+
+        self.active_guidelines = set()
+
+        self.hide()
+        self.props.no_show_all = True
+
+    def add_guideline(self, guideline):
+        if guideline not in self.active_guidelines:
+            if not self.active_guidelines:
+                self.set_visible(True)
+            self.active_guidelines.add(guideline)
+            self.queue_draw()
+
+    def remove_guideline(self, guideline):
+        if guideline in self.active_guidelines:
+            self.active_guidelines.remove(guideline)
+            if not self.active_guidelines:
+                self.set_visible(False)
+            self.queue_draw()
+
+    def do_draw(self, cr):
+        width = self.get_allocated_width()
+        height = self.get_allocated_height()
+
+        # Draw black border.
+        cr.set_source_rgb(0, 0, 0)
+        cr.set_line_width(2)
+        for guideline in self.active_guidelines:
+            guideline.draw_func(cr, width, height)
+        cr.stroke()
+
+        # Draw blue line in middle.
+        cr.set_source_rgb(0.75, 1.0, 1.0)
+        cr.set_line_width(1)
+        for guideline in self.active_guidelines:
+            guideline.draw_func(cr, width, height)
+        cr.stroke()
diff --git a/pitivi/viewer/overlay_stack.py b/pitivi/viewer/overlay_stack.py
index 27a21700..fc0b1d95 100644
--- a/pitivi/viewer/overlay_stack.py
+++ b/pitivi/viewer/overlay_stack.py
@@ -28,7 +28,7 @@ from pitivi.viewer.title_overlay import TitleOverlay
 class OverlayStack(Gtk.Overlay, Loggable):
     """Manager for the viewer overlays."""
 
-    def __init__(self, app, sink_widget):
+    def __init__(self, app, sink_widget, guidelines_overlay):
         Gtk.Overlay.__init__(self)
         Loggable.__init__(self)
         self.__overlays = {}
@@ -61,6 +61,9 @@ class OverlayStack(Gtk.Overlay, Loggable):
         self.resize_status = Gtk.Label(name="resize_status")
         self.revealer.add(self.resize_status)
         self.add_overlay(self.revealer)
+
+        self.add_overlay(guidelines_overlay)
+
         sink_widget.connect("size-allocate", self.__sink_widget_size_allocate_cb)
 
     def __size_allocate_cb(self, widget, rectangle):
diff --git a/pitivi/viewer/viewer.py b/pitivi/viewer/viewer.py
index df5a7820..783d2f72 100644
--- a/pitivi/viewer/viewer.py
+++ b/pitivi/viewer/viewer.py
@@ -19,6 +19,7 @@ from gettext import gettext as _
 
 from gi.repository import Gdk
 from gi.repository import GES
+from gi.repository import Gio
 from gi.repository import GLib
 from gi.repository import GObject
 from gi.repository import Gst
@@ -29,8 +30,10 @@ from pitivi.utils.loggable import Loggable
 from pitivi.utils.pipeline import AssetPipeline
 from pitivi.utils.ui import SPACING
 from pitivi.utils.widgets import TimeWidget
+from pitivi.viewer.guidelines import GuidelinesPopover
 from pitivi.viewer.overlay_stack import OverlayStack
 
+
 GlobalSettings.add_config_section("viewer")
 GlobalSettings.add_config_option("viewerDocked", section="viewer",
                                  key="docked",
@@ -88,7 +91,9 @@ class ViewerContainer(Gtk.Box, Loggable):
         self.target = None
 
         self.overlay_stack = None
+        self.guidelines_popover = None
         self._create_ui()
+        self._create_actions()
 
         if not self.settings.viewerDocked:
             self.undock()
@@ -148,7 +153,11 @@ class ViewerContainer(Gtk.Box, Loggable):
     def __create_new_viewer(self):
         _, sink_widget = self.project.pipeline.create_sink()
 
-        self.overlay_stack = OverlayStack(self.app, sink_widget)
+        self.guidelines_popover = GuidelinesPopover()
+        self.guidelines_button.set_popover(self.guidelines_popover)
+        self.overlay_stack = OverlayStack(self.app,
+                                          sink_widget,
+                                          self.guidelines_popover.overlay)
         self.target = ViewerWidget(self.overlay_stack)
         self._reset_viewer_aspect_ratio(self.project)
 
@@ -241,9 +250,14 @@ class ViewerContainer(Gtk.Box, Loggable):
         bbox.set_margin_right(SPACING)
         self.pack_end(bbox, False, False, 0)
 
+        self.guidelines_button = Gtk.MenuButton.new()
+        self.guidelines_button.props.image = Gtk.Image.new_from_icon_name("view-grid-symbolic", 
Gtk.IconSize.BUTTON)
+        self.guidelines_button.set_relief(Gtk.ReliefStyle.NONE)
+        self.guidelines_button.set_tooltip_text(_("Select composition guidelines"))
+        bbox.pack_start(self.guidelines_button, False, False, 0)
+
         self.start_button = Gtk.Button.new_from_icon_name("media-skip-backward-symbolic",
                                                           Gtk.IconSize.BUTTON)
-
         self.start_button.connect("clicked", self._start_button_clicked_cb)
         self.start_button.set_relief(Gtk.ReliefStyle.NONE)
         self.start_button.set_tooltip_text(
@@ -320,6 +334,22 @@ class ViewerContainer(Gtk.Box, Loggable):
         self.buttons_container = bbox
         self.external_vbox.show_all()
 
+    def _create_actions(self):
+        self.action_group = Gio.SimpleActionGroup()
+        self.insert_action_group("viewer", self.action_group)
+        self.app.shortcuts.register_group("viewer", _("Viewer"), position=60)
+
+        self.toggle_guidelines_action = Gio.SimpleAction.new("toggle-composition-guidelines", None)
+        self.toggle_guidelines_action.connect("activate", self.__toggle_guidelines_cb)
+        self.action_group.add_action(self.toggle_guidelines_action)
+        self.app.shortcuts.add("viewer.toggle-composition-guidelines",
+                               ["<Primary><Shift>c"],
+                               self.toggle_guidelines_action,
+                               _("Toggle the currently selected composition guidelines"))
+
+    def __toggle_guidelines_cb(self, unused_action, unused_parameter):
+        self.guidelines_popover.toggle()
+
     def __corner_draw_cb(self, unused_widget, cr, lines, space, margin):
         cr.set_line_width(1)
 
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 23183a0a..eb08bbcc 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -42,6 +42,7 @@ pitivi/render.py
 pitivi/settings.py
 pitivi/tabsmanager.py
 pitivi/transitions.py
+pitivi/viewer/guidelines_popover.py
 pitivi/viewer/viewer.py
 
 pitivi/timeline/elements.py
diff --git a/tests/common.py b/tests/common.py
index c17511a9..761beb2c 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -30,6 +30,7 @@ from unittest import mock
 
 from gi.repository import Gdk
 from gi.repository import GES
+from gi.repository import Gio
 from gi.repository import GLib
 from gi.repository import Gst
 from gi.repository import Gtk
@@ -91,6 +92,8 @@ def create_pitivi_mock(**settings):
     app.settings = __create_settings(**settings)
     app.proxy_manager = ProxyManager(app)
 
+    app.gui.editor.viewer.action_group = Gio.SimpleActionGroup()
+
     # TODO: Get rid of Zoomable.app.
     Zoomable.app = app
 
@@ -106,8 +109,12 @@ def create_project():
 def create_pitivi(**settings):
     app = Pitivi()
     app._setup()
+
     app.gui = mock.Mock()
+    app.gui.editor.viewer.action_group = Gio.SimpleActionGroup()
+
     app.settings = __create_settings(**settings)
+
     return app
 
 
diff --git a/tests/test_viewer_guidelines.py b/tests/test_viewer_guidelines.py
new file mode 100644
index 00000000..d3823455
--- /dev/null
+++ b/tests/test_viewer_guidelines.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+# Pitivi video editor
+# Copyright (c) 2020, Jaden Goter <jadengoter huskers unl edu>
+# Copyright (c) 2020, Jessie Guo <jessie guo huskers unl edu>
+# Copyright (c) 2020, Daniel Rudebusch <daniel rudebusch huskers unl edu>
+#
+# 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, see <http://www.gnu.org/licenses/>.
+"""Tests for the pitivi.viewer.guidelines module."""
+# pylint: disable=protected-access,no-self-use,attribute-defined-outside-init
+from pitivi.viewer.guidelines import Guideline
+from pitivi.viewer.viewer import ViewerContainer
+from tests import common
+
+
+class GuidelinesPopoverTest(common.TestCase):
+    """Tests for the guidelines classes."""
+
+    def setup_viewer_widget(self):
+        """Sets a viewer widget up."""
+        self.app = common.create_pitivi()
+        self.viewer_container = ViewerContainer(self.app)
+        project = self.app.project_manager.new_blank_project()
+        self.viewer_container.set_project(project)
+
+    def _check_guidelines(self, guidelines, toggled_guidelines):
+        overlay = self.viewer_container.guidelines_popover.overlay
+
+        self.assertSetEqual(overlay.active_guidelines, set(guidelines))
+        self.assertEqual(overlay.get_visible(), bool(guidelines), guidelines)
+
+        self.viewer_container.toggle_guidelines_action.activate(None)
+        self.assertSetEqual(overlay.active_guidelines, set(toggled_guidelines))
+        self.assertEqual(overlay.get_visible(), bool(toggled_guidelines), guidelines)
+
+        self.viewer_container.toggle_guidelines_action.activate(None)
+        self.assertSetEqual(overlay.active_guidelines, set(guidelines))
+        self.assertEqual(overlay.get_visible(), bool(guidelines), guidelines)
+
+    def test_activate_deactivate_toggle(self):
+        self.setup_viewer_widget()
+        popover = self.viewer_container.guidelines_popover
+
+        self._check_guidelines([], [Guideline.three_by_three])
+
+        all_guidelines = set()
+        for guideline in Guideline:
+            popover.switches[guideline].set_active(True)
+            self._check_guidelines([guideline], [])
+
+            popover.switches[guideline].set_active(False)
+            self._check_guidelines([], [guideline])
+
+            all_guidelines.add(guideline)
+
+        for guideline in Guideline:
+            popover.switches[guideline].set_active(True)
+        self._check_guidelines(all_guidelines, [])
+
+        for guideline in Guideline:
+            popover.switches[guideline].set_active(False)
+            expected = set(all_guidelines)
+            expected.remove(guideline)
+            self._check_guidelines(expected, [])
+
+            popover.switches[guideline].set_active(True)
+            self._check_guidelines(all_guidelines, [])


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