[pitivi] Restore the editor state when reopening a project



commit da454b991c6d3d94ab847dfde866923eb2cb1b6c
Author: Caleb Marcoux <cmeachtech56 gmail com>
Date:   Tue Jun 16 18:26:31 2020 -0500

    Restore the editor state when reopening a project
    
    Users want to resume editing with the same interface state.
    
    The playhead position, zoom level, scroll, and selected clips are now
    restored.
    
    We added editorstate.py which is used to save and restore these values.
    
    Fixes #2029

 pitivi/editorperspective.py        |  4 +-
 pitivi/editorstate.py              | 91 ++++++++++++++++++++++++++++++++++++++
 pitivi/project.py                  |  8 ++++
 pitivi/timeline/timeline.py        | 41 ++++++++++++++---
 pitivi/utils/timeline.py           | 10 ++---
 tests/common.py                    |  5 ++-
 tests/test_clipproperties.py       |  4 +-
 tests/test_clipproperties_color.py |  4 +-
 tests/test_editorstate.py          | 47 ++++++++++++++++++++
 tests/test_render.py               |  6 +--
 tests/test_undo_timeline.py        |  4 +-
 11 files changed, 203 insertions(+), 21 deletions(-)
---
diff --git a/pitivi/editorperspective.py b/pitivi/editorperspective.py
index 66468713..0c5790b2 100644
--- a/pitivi/editorperspective.py
+++ b/pitivi/editorperspective.py
@@ -28,6 +28,7 @@ from pitivi.clipproperties import ClipProperties
 from pitivi.configure import APPNAME
 from pitivi.configure import get_ui_dir
 from pitivi.dialogs.missingasset import MissingAssetDialog
+from pitivi.editorstate import EditorState
 from pitivi.effects import EffectListWidget
 from pitivi.interactiveintro import InteractiveIntro
 from pitivi.mediafilespreviewer import PreviewWidget
@@ -82,6 +83,7 @@ class EditorPerspective(Perspective, Loggable):
         self.settings = app.settings
 
         self.builder = Gtk.Builder()
+        self.editor_state = EditorState(app.project_manager)
 
         pm = self.app.project_manager
         pm.connect("new-project-loaded",
@@ -226,7 +228,7 @@ class EditorPerspective(Perspective, Loggable):
         self.mainhpaned.pack2(self.viewer, resize=True, shrink=False)
 
         # Now, the lower part: the timeline
-        self.timeline_ui = TimelineContainer(self.app)
+        self.timeline_ui = TimelineContainer(self.app, self.editor_state)
         self.toplevel_widget.pack2(self.timeline_ui, resize=True, shrink=False)
 
         self.intro = InteractiveIntro(self.app)
diff --git a/pitivi/editorstate.py b/pitivi/editorstate.py
new file mode 100644
index 00000000..963a66f2
--- /dev/null
+++ b/pitivi/editorstate.py
@@ -0,0 +1,91 @@
+# -*- coding: utf-8 -*-
+# Pitivi video editor
+# Copyright (c) 2020, Caleb Marcoux <caleb marcoux gmail com> (primary contact),
+# Copyright (c) 2020, Abby Brooks <abigail brooks huskers unl edu>,
+# Copyright (c) 2020, Won Choi <won choi 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/>.
+import json
+import os
+
+from gi.repository import GLib
+
+from pitivi.settings import xdg_config_home
+from pitivi.utils.loggable import Loggable
+
+
+class EditorState(Loggable):
+    """Pitivi editor state.
+
+    Loads the editor state for the current project from the editor state folder.
+
+    Widgets call get_value when they are ready to restore their state.
+    """
+
+    def __init__(self, project_manager):
+        Loggable.__init__(self)
+
+        self.project_manager = project_manager
+        self.conf_folder_path = xdg_config_home("editor_state")
+
+        self.project = None
+        self.conf_file_path = None
+        self._editor_state = {}
+        self.__state_saving_handle = 0
+
+        self.project_manager.connect("new-project-loaded",
+                                     self._new_project_loaded_cb)
+
+    def get_value(self, key):
+        """Get a value from the loaded editor state."""
+        return self._editor_state.get(key)
+
+    def set_value(self, key, value):
+        """Sets the given value in the EditorState."""
+        self._editor_state[key] = value
+        self.prepare_to_save()
+
+    def prepare_to_save(self):
+        if self.__state_saving_handle:
+            GLib.source_remove(self.__state_saving_handle)
+        self.__state_saving_handle = GLib.timeout_add(500, self._state_not_changing_anymore_cb, 
priority=GLib.PRIORITY_LOW)
+
+    def _state_not_changing_anymore_cb(self):
+        self.__state_saving_handle = 0
+        self.save_editor_state()
+
+    def _new_project_loaded_cb(self, project_manager, project):
+        self.project = project
+        self.conf_file_path = os.path.join(self.conf_folder_path, self.project.get_project_id() + ".conf")
+        self.load_editor_state()
+
+    def save_editor_state(self):
+        """Save the current editor state to the editor state file."""
+        self.log("Editor state saving.")
+
+        if self.conf_file_path:
+            with open(self.conf_file_path, "w") as file:
+                json.dump(self._editor_state, file)
+
+    def load_editor_state(self):
+        """Load an editor state file into the current editor state."""
+        self.log("Loading state from file: %s", self.conf_file_path)
+        try:
+            with open(self.conf_file_path, "r") as file:
+                try:
+                    self._editor_state = json.load(file)
+                except (json.decoder.JSONDecodeError, ValueError) as e:
+                    self.warning("Editor state could not be read: %s", e)
+        except FileNotFoundError:
+            return
diff --git a/pitivi/project.py b/pitivi/project.py
index 2aa6b698..c4b479fa 100644
--- a/pitivi/project.py
+++ b/pitivi/project.py
@@ -1775,6 +1775,14 @@ class Project(Loggable, GES.Project):
     def list_sources(self):
         return self.list_assets(GES.UriClip)
 
+    def get_project_id(self):
+        project_id = self.get_string("pitivi::project-id")
+        if not project_id:
+            project_id = uuid.uuid4().hex
+            self.set_string("pitivi::project-id", project_id)
+            self.log("Assigned new project id %s", project_id)
+        return project_id
+
     def release(self):
         res = 0
 
diff --git a/pitivi/timeline/timeline.py b/pitivi/timeline/timeline.py
index 4ce29da3..3e2f5d55 100644
--- a/pitivi/timeline/timeline.py
+++ b/pitivi/timeline/timeline.py
@@ -301,7 +301,7 @@ class Timeline(Gtk.EventBox, Zoomable, Loggable):
 
     __gtype_name__ = "PitiviTimeline"
 
-    def __init__(self, app, size_group):
+    def __init__(self, app, size_group, editor_state):
         Gtk.EventBox.__init__(self)
         Zoomable.__init__(self)
         Loggable.__init__(self)
@@ -309,6 +309,7 @@ class Timeline(Gtk.EventBox, Zoomable, Loggable):
         self.app = app
         self._project = None
         self.ges_timeline = None
+        self.editor_state = editor_state
 
         self.props.can_focus = False
 
@@ -429,6 +430,8 @@ class Timeline(Gtk.EventBox, Zoomable, Loggable):
 
         self.layout.layers_vbox.connect_after("size-allocate", self.__size_allocate_cb)
 
+        self.hadj.connect("value-changed", self.__hadj_value_changed_cb)
+
     def __size_allocate_cb(self, unused_widget, unused_allocation):
         """Handles the layers vbox size allocations."""
         if self.delayed_scroll:
@@ -550,6 +553,7 @@ class Timeline(Gtk.EventBox, Zoomable, Loggable):
             self.scroll_to_playhead(Gtk.Align.START)
         if not pipeline.playing():
             self.update_visible_overlays()
+            self.editor_state.set_value("playhead-position", position)
 
     def __snapping_started_cb(self, unused_timeline, unused_obj1, unused_obj2, position):
         """Handles a clip snap update operation."""
@@ -912,6 +916,9 @@ class Timeline(Gtk.EventBox, Zoomable, Loggable):
         y_diff = self._scroll_start_y - event.y
         self.vadj.set_value(self.vadj.get_value() + y_diff)
 
+    def __hadj_value_changed_cb(self, hadj):
+        self.editor_state.set_value("scroll", hadj.get_value())
+
     def update_position(self):
         for ges_layer in self.ges_timeline.get_layers():
             ges_layer.ui.update_position()
@@ -1211,6 +1218,7 @@ class Timeline(Gtk.EventBox, Zoomable, Loggable):
         self.zoomed_fitted = False
 
         self.update_position()
+        self.editor_state.set_value("zoom-level", Zoomable.get_current_zoom_level())
 
     def set_best_zoom_ratio(self, allow_zoom_in=False):
         """Sets the zoom level so that the entire timeline is in view."""
@@ -1432,12 +1440,13 @@ class Timeline(Gtk.EventBox, Zoomable, Loggable):
 class TimelineContainer(Gtk.Grid, Zoomable, Loggable):
     """Widget for zoom box, ruler, timeline, scrollbars and toolbar."""
 
-    def __init__(self, app):
+    def __init__(self, app, editor_state):
         Zoomable.__init__(self)
         Gtk.Grid.__init__(self)
         Loggable.__init__(self)
 
         self.app = app
+        self.editor_state = editor_state
         self._settings = self.app.settings
         self.shift_mask = False
         self.control_mask = False
@@ -1561,10 +1570,30 @@ class TimelineContainer(Gtk.Grid, Zoomable, Loggable):
             self.ruler.set_pipeline(project.pipeline)
             self.ruler.zoom_changed()
 
-            self.timeline.set_best_zoom_ratio(allow_zoom_in=True)
+            position = self.editor_state.get_value("playhead-position")
+            if position:
+                self._project.pipeline.simple_seek(position)
+
+            clip_names = self.editor_state.get_value("selection")
+            if clip_names:
+                clips = [self.ges_timeline.get_element(clip_name)
+                         for clip_name in clip_names]
+                self.timeline.selection.set_selection(clips, SELECT)
+
+            zoom_level = self.editor_state.get_value("zoom-level")
+            if zoom_level:
+                Zoomable.set_zoom_level(zoom_level)
+            else:
+                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")
 
+            scroll = self.editor_state.get_value("scroll")
+            if scroll:
+                # TODO: Figure out why self.scroll_to_pixel(scroll) which calls _scroll_to_pixel directly 
does not work.
+                GLib.idle_add(self._scroll_to_pixel, scroll)
+
     def update_actions(self):
         selection = self.timeline.selection
         selection_non_empty = bool(selection)
@@ -1589,7 +1618,7 @@ class TimelineContainer(Gtk.Grid, Zoomable, Loggable):
         self.zoom_box = ZoomBox(self)
         left_size_group.add_widget(self.zoom_box)
 
-        self.timeline = Timeline(self.app, left_size_group)
+        self.timeline = Timeline(self.app, left_size_group, self.editor_state)
 
         # Vertical Scrollbar. It will be displayed only when needed.
         self.vscrollbar = Gtk.Scrollbar(orientation=Gtk.Orientation.VERTICAL,
@@ -2144,9 +2173,11 @@ class TimelineContainer(Gtk.Grid, Zoomable, Loggable):
 
         self.timeline.set_best_zoom_ratio(allow_zoom_in=True)
 
-    def __selection_changed_cb(self, unused_selection):
+    def __selection_changed_cb(self, selection):
         """Handles selection changing."""
         self.update_actions()
+        clip_names = [clip.props.name for clip in selection]
+        self.editor_state.set_value("selection", clip_names)
 
     def _gaplessmode_toggled_cb(self, unused_action, unused_parameter):
         self._settings.timelineAutoRipple = self.gapless_button.get_active()
diff --git a/pitivi/utils/timeline.py b/pitivi/utils/timeline.py
index d4a85cef..f8798065 100644
--- a/pitivi/utils/timeline.py
+++ b/pitivi/utils/timeline.py
@@ -334,11 +334,11 @@ class EditingContext(GObject.Object, Loggable):
         res = self.focus.edit([], priority, self.mode, self.edge, int(position))
         if res:
             self.app.write_action("edit-container",
-                                container_name=self.focus.get_name(),
-                                position=float(position / Gst.SECOND),
-                                edit_mode=self.mode.value_nick,
-                                edge=self.edge.value_nick,
-                                new_layer_priority=int(priority))
+                                  container_name=self.focus.get_name(),
+                                  position=float(position / Gst.SECOND),
+                                  edit_mode=self.mode.value_nick,
+                                  edge=self.edge.value_nick,
+                                  new_layer_priority=int(priority))
 
             if self.with_video:
                 if self.edge == GES.Edge.EDGE_START:
diff --git a/tests/common.py b/tests/common.py
index 761beb2c..3d8abc89 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -36,6 +36,7 @@ from gi.repository import Gst
 from gi.repository import Gtk
 
 from pitivi.application import Pitivi
+from pitivi.editorstate import EditorState
 from pitivi.project import ProjectManager
 from pitivi.settings import GlobalSettings
 from pitivi.timeline.previewers import Previewer
@@ -90,6 +91,7 @@ def create_pitivi_mock(**settings):
     app = mock.MagicMock()
     app.write_action = mock.MagicMock(spec=Pitivi.write_action)
     app.settings = __create_settings(**settings)
+    app.gui.editor.editor_state = EditorState(app.project_manager)
     app.proxy_manager = ProxyManager(app)
 
     app.gui.editor.viewer.action_group = Gio.SimpleActionGroup()
@@ -114,6 +116,7 @@ def create_pitivi(**settings):
     app.gui.editor.viewer.action_group = Gio.SimpleActionGroup()
 
     app.settings = __create_settings(**settings)
+    app.gui.editor.editor_state = EditorState(app.project_manager)
 
     return app
 
@@ -123,7 +126,7 @@ def create_timeline_container(**settings):
     app.project_manager = ProjectManager(app)
     project = app.project_manager.new_blank_project()
 
-    timeline_container = TimelineContainer(app)
+    timeline_container = TimelineContainer(app, app.gui.editor.editor_state)
     timeline_container.set_project(project)
 
     timeline = timeline_container.timeline
diff --git a/tests/test_clipproperties.py b/tests/test_clipproperties.py
index 0e2c3ef0..083217ff 100644
--- a/tests/test_clipproperties.py
+++ b/tests/test_clipproperties.py
@@ -349,7 +349,7 @@ class ClipPropertiesTest(BaseTestUndoTimeline, BaseTestTimeline):
         common.create_main_loop().run(until_empty=True)
 
         from pitivi.timeline.timeline import TimelineContainer
-        timeline_container = TimelineContainer(self.app)
+        timeline_container = TimelineContainer(self.app, editor_state=self.app.gui.editor.editor_state)
         timeline_container.set_project(self.project)
         self.app.gui.editor.timeline_ui = timeline_container
 
@@ -374,7 +374,7 @@ class ClipPropertiesTest(BaseTestUndoTimeline, BaseTestTimeline):
         common.create_main_loop().run(until_empty=True)
 
         from pitivi.timeline.timeline import TimelineContainer
-        timeline_container = TimelineContainer(self.app)
+        timeline_container = TimelineContainer(self.app, editor_state=self.app.gui.editor.editor_state)
         timeline_container.set_project(self.project)
         self.app.gui.editor.timeline_ui = timeline_container
 
diff --git a/tests/test_clipproperties_color.py b/tests/test_clipproperties_color.py
index 17c75ff2..d87dc976 100644
--- a/tests/test_clipproperties_color.py
+++ b/tests/test_clipproperties_color.py
@@ -34,7 +34,7 @@ class ColorPropertiesTest(BaseTestUndoTimeline):
         common.create_main_loop().run(until_empty=True)
 
         from pitivi.timeline.timeline import TimelineContainer
-        timeline_container = TimelineContainer(self.app)
+        timeline_container = TimelineContainer(self.app, editor_state=self.app.gui.editor.editor_state)
         timeline_container.set_project(self.project)
         self.app.gui.editor.timeline_ui = timeline_container
 
@@ -59,7 +59,7 @@ class ColorPropertiesTest(BaseTestUndoTimeline):
         common.create_main_loop().run(until_empty=True)
 
         from pitivi.timeline.timeline import TimelineContainer
-        timeline_container = TimelineContainer(self.app)
+        timeline_container = TimelineContainer(self.app, editor_state=self.app.gui.editor.editor_state)
         timeline_container.set_project(self.project)
         self.app.gui.editor.timeline_ui = timeline_container
 
diff --git a/tests/test_editorstate.py b/tests/test_editorstate.py
new file mode 100644
index 00000000..e16d577f
--- /dev/null
+++ b/tests/test_editorstate.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+# Pitivi video editor
+# Copyright (c) 2020, Caleb Marcoux <caleb marcoux gmail com> (primary contact),
+# Copyright (c) 2020, Abby Brooks <abigail brooks huskers unl edu>,
+# Copyright (c) 2020, Won Choi <won choi 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.editorstate module."""
+import tempfile
+from unittest import mock
+
+from pitivi.editorstate import EditorState
+from tests import common
+
+
+class TestEditorState(common.TestCase):
+    """Tests the EditorState class."""
+
+    def test_save_load(self):
+        values = {
+            "playhead-position": 5000000000,
+            "zoom-level": 50,
+            "scroll": 50.0,
+            "selection": []
+        }
+        saved = EditorState(mock.Mock())
+        saved.conf_file_path = tempfile.NamedTemporaryFile().name
+        for key, value in values.items():
+            saved.set_value(key, value)
+        saved.save_editor_state()
+
+        loaded = EditorState(mock.Mock())
+        loaded.conf_file_path = saved.conf_file_path
+        loaded.load_editor_state()
+        for key, value in values.items():
+            self.assertEqual(loaded.get_value(key), value)
diff --git a/tests/test_render.py b/tests/test_render.py
index 73f6ecb0..04e312d1 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -307,7 +307,7 @@ class TestRender(BaseTestMediaLibrary):
             self.check_import([sample_name])
 
             project = self.app.project_manager.current_project
-            timeline_container = TimelineContainer(self.app)
+            timeline_container = TimelineContainer(self.app, editor_state=self.app.gui.editor.editor_state)
             timeline_container.set_project(project)
 
             assets = project.list_assets(GES.UriClip)
@@ -342,7 +342,7 @@ class TestRender(BaseTestMediaLibrary):
 
             project = self.app.project_manager.current_project
             proxy_manager = self.app.proxy_manager
-            timeline_container = TimelineContainer(self.app)
+            timeline_container = TimelineContainer(self.app, editor_state=self.app.gui.editor.editor_state)
             timeline_container.set_project(project)
             rendering_asset = None
 
@@ -383,7 +383,7 @@ class TestRender(BaseTestMediaLibrary):
 
             project = self.app.project_manager.current_project
             proxy_manager = self.app.proxy_manager
-            timeline_container = TimelineContainer(self.app)
+            timeline_container = TimelineContainer(self.app, editor_state=self.app.gui.editor.editor_state)
             timeline_container.set_project(project)
             rendering_asset = None
 
diff --git a/tests/test_undo_timeline.py b/tests/test_undo_timeline.py
index bbc51580..0e4493e2 100644
--- a/tests/test_undo_timeline.py
+++ b/tests/test_undo_timeline.py
@@ -51,7 +51,7 @@ class BaseTestUndoTimeline(common.TestCase):
 
     def setup_timeline_container(self):
         project = self.app.project_manager.current_project
-        self.timeline_container = TimelineContainer(self.app)
+        self.timeline_container = TimelineContainer(self.app, editor_state=self.app.gui.editor.editor_state)
         self.timeline_container.set_project(project)
 
         timeline = self.timeline_container.timeline
@@ -355,7 +355,7 @@ class TestLayerObserver(BaseTestUndoTimeline):
         layer3 = self.timeline.append_layer()
         self.assertEqual(self.timeline.get_layers(), [layer1, layer2, layer3])
 
-        timeline_ui = Timeline(app=self.app, size_group=mock.Mock())
+        timeline_ui = Timeline(app=self.app, size_group=mock.Mock(), 
editor_state=self.app.gui.editor.editor_state)
         timeline_ui.set_project(self.app.project_manager.current_project)
 
         # Click and drag a layer control box to move the layer.


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