clipproperties.py 37.9 KB
Newer Older
1
# -*- coding: utf-8 -*-
2
# Pitivi video editor
3
# Copyright (C) 2010 Thibault Saunier <tsaunier@gnome.org>
4
#
5 6 7 8 9 10 11 12 13 14 15 16
# 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
Hicham HAOUARI's avatar
Hicham HAOUARI committed
17 18
# Free Software Foundation, Inc., 51 Franklin St, Fifth Floor,
# Boston, MA 02110-1301, USA.
19
"""Widgets to control clips properties."""
20
import os
21 22
from gettext import gettext as _

23
from gi.repository import Gdk
24
from gi.repository import GES
25
from gi.repository import Gio
26
from gi.repository import GstController
27
from gi.repository import Gtk
28
from gi.repository import Pango
29

30
from pitivi.configure import get_ui_dir
31 32
from pitivi.effects import EffectsPropertiesManager
from pitivi.effects import HIDDEN_EFFECTS
33
from pitivi.undo.timeline import CommitTimelineFinalizingAction
34
from pitivi.utils.custom_effect_widgets import setup_custom_effect_widgets
35
from pitivi.utils.loggable import Loggable
36 37
from pitivi.utils.misc import disconnectAllByFunc
from pitivi.utils.pipeline import PipelineError
38 39
from pitivi.utils.ui import disable_scroll
from pitivi.utils.ui import EFFECT_TARGET_ENTRY
40
from pitivi.utils.ui import fix_infobar
41 42
from pitivi.utils.ui import PADDING
from pitivi.utils.ui import SPACING
43

44 45
(COL_ACTIVATED,
 COL_TYPE,
46
 COL_BIN_DESCRIPTION_TEXT,
47
 COL_NAME_TEXT,
48
 COL_DESC_TEXT,
49
 COL_TRACK_EFFECT) = list(range(6))
50

51

52 53 54
class ClipPropertiesError(Exception):
    pass

55

56
class ClipProperties(Gtk.ScrolledWindow, Loggable):
57
    """Widget for configuring the selected clip.
58

59 60
    Attributes:
        app (Pitivi): The app.
61 62
    """

63
    def __init__(self, app):
64
        Gtk.ScrolledWindow.__init__(self)
65
        Loggable.__init__(self)
66
        self.app = app
67

68 69 70 71 72 73 74 75 76 77
        self.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)

        viewport = Gtk.Viewport()
        viewport.set_shadow_type(Gtk.ShadowType.ETCHED_IN)
        viewport.show()
        self.add(viewport)

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        vbox.show()
        viewport.add(vbox)
78

79 80
        self.infobar_box = Gtk.Box()
        self.infobar_box.set_orientation(Gtk.Orientation.VERTICAL)
81
        self.infobar_box.show()
82
        vbox.pack_start(self.infobar_box, False, False, 0)
83

84 85
        transformation_expander = TransformationProperties(app)
        transformation_expander.set_vexpand(False)
86
        vbox.pack_start(transformation_expander, False, False, 0)
87 88 89

        self.effect_expander = EffectProperties(app, self)
        self.effect_expander.set_vexpand(False)
90
        vbox.pack_start(self.effect_expander, False, False, 0)
91

92
    def createInfoBar(self, text):
93
        """Creates an infobar to be displayed at the top."""
94
        label = Gtk.Label(label=text)
95
        label.set_line_wrap(True)
96
        infobar = Gtk.InfoBar()
97
        fix_infobar(infobar)
98
        infobar.props.message_type = Gtk.MessageType.OTHER
99 100 101
        infobar.get_content_area().add(label)
        self.infobar_box.pack_start(infobar, False, False, 0)
        return infobar
102

103

104
# pylint: disable=too-many-instance-attributes
105
class EffectProperties(Gtk.Expander, Loggable):
106
    """Widget for viewing a list of effects and configuring them.
107

108 109
    Attributes:
        app (Pitivi): The app.
110
        clip (GES.Clip): The clip being configured.
111 112
    """

113
    # pylint: disable=too-many-statements
114
    def __init__(self, app, clip_properties):
115
        Gtk.Expander.__init__(self)
116 117
        self.set_expanded(True)
        self.set_label(_("Effects"))
118
        Loggable.__init__(self)
119

120
        # Global variables related to effects
121
        self.app = app
122

123 124
        self._project = None
        self._selection = None
125
        self.clip = None
126
        self._effect_config_ui = None
127
        self.effects_properties_manager = EffectsPropertiesManager(app)
128
        setup_custom_effect_widgets(self.effects_properties_manager)
129
        self.clip_properties = clip_properties
130

131 132 133 134 135 136 137 138
        no_effect_label = Gtk.Label(
            _("To apply an effect to the clip, drag it from the Effect Library."))
        no_effect_label.set_line_wrap(True)
        self.no_effect_infobar = Gtk.InfoBar()
        fix_infobar(self.no_effect_infobar)
        self.no_effect_infobar.props.message_type = Gtk.MessageType.OTHER
        self.no_effect_infobar.get_content_area().add(no_effect_label)

139
        # The toolbar that will go between the list of effects and properties
140 141 142 143 144 145
        buttons_box = Gtk.ButtonBox()
        buttons_box.set_halign(Gtk.Align.END)
        buttons_box.set_margin_end(SPACING)
        buttons_box.props.margin_top = SPACING / 2

        remove_effect_button = Gtk.Button()
146 147
        remove_icon = Gtk.Image.new_from_icon_name("list-remove-symbolic",
                                                   Gtk.IconSize.BUTTON)
148 149 150
        remove_effect_button.set_image(remove_icon)
        remove_effect_button.set_always_show_image(True)
        remove_effect_button.set_label(_("Remove effect"))
151 152
        buttons_box.pack_start(remove_effect_button,
                               expand=False, fill=False, padding=0)
153

154 155 156
        # We need to specify Gtk.TreeDragSource because otherwise we are hitting
        # bug https://bugzilla.gnome.org/show_bug.cgi?id=730740.
        class EffectsListStore(Gtk.ListStore, Gtk.TreeDragSource):
157
            """Just a work around!"""
158
            # pylint: disable=non-parent-init-called
159 160
            def __init__(self, *args):
                Gtk.ListStore.__init__(self, *args)
161 162
                # Set the source index on the storemodel directly,
                # to avoid issues with the selection_data API.
163 164
                # FIXME: Work around
                # https://bugzilla.gnome.org/show_bug.cgi?id=737587
165 166
                self.source_index = None

167
            def do_drag_data_get(self, path, unused_selection_data):
168
                self.source_index = path.get_indices()[0]
169 170

        self.storemodel = EffectsListStore(bool, str, str, str, str, object)
171
        self.treeview = Gtk.TreeView(model=self.storemodel)
172
        self.treeview.set_property("has_tooltip", True)
173
        self.treeview.set_headers_visible(False)
174 175 176 177 178 179 180 181 182 183
        self.treeview.props.margin_top = SPACING
        self.treeview.props.margin_left = SPACING
        # Without this, the treeview hides the border of its parent.
        # I should file a bug about this.
        self.treeview.props.margin_right = 1

        activated_cell = Gtk.CellRendererToggle()
        activated_cell.props.xalign = 0
        activated_cell.props.xpad = 0
        activated_cell.connect("toggled", self._effectActiveToggleCb)
184
        self.treeview.insert_column_with_attributes(-1,
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204
                                                    _("Active"), activated_cell,
                                                    active=COL_ACTIVATED)

        type_col = Gtk.TreeViewColumn(_("Type"))
        type_col.set_spacing(SPACING)
        type_col.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
        type_cell = Gtk.CellRendererText()
        type_cell.props.xpad = PADDING
        type_col.pack_start(type_cell, expand=True)
        type_col.add_attribute(type_cell, "text", COL_TYPE)
        self.treeview.append_column(type_col)

        name_col = Gtk.TreeViewColumn(_("Effect name"))
        name_col.set_spacing(SPACING)
        name_cell = Gtk.CellRendererText()
        name_cell.props.xpad = PADDING
        name_cell.set_property("ellipsize", Pango.EllipsizeMode.END)
        name_col.pack_start(name_cell, expand=True)
        name_col.add_attribute(name_cell, "text", COL_NAME_TEXT)
        self.treeview.append_column(name_col)
205

206 207 208 209 210 211 212
        # Allow the entire expander to accept EFFECT_TARGET_ENTRY when
        # drag&dropping.
        self.drag_dest_set(Gtk.DestDefaults.DROP, [EFFECT_TARGET_ENTRY],
                           Gdk.DragAction.COPY)

        # Allow also the treeview to accept EFFECT_TARGET_ENTRY when
        # drag&dropping so the effect can be dragged at a specific position.
213 214 215 216 217 218 219
        self.treeview.enable_model_drag_dest([EFFECT_TARGET_ENTRY],
                                             Gdk.DragAction.COPY)

        # Enable reordering by drag&drop.
        self.treeview.enable_model_drag_source(Gdk.ModifierType.BUTTON1_MASK,
                                               [EFFECT_TARGET_ENTRY],
                                               Gdk.DragAction.MOVE)
220

221
        self.treeview_selection = self.treeview.get_selection()
222
        self.treeview_selection.set_mode(Gtk.SelectionMode.SINGLE)
223

224 225
        self._infobar = clip_properties.createInfoBar(
            _("Select a clip on the timeline to configure its associated effects"))
226
        self._infobar.show_all()
227

228
        # Prepare the main container widgets and lay out everything
229
        self._expander_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
230 231
        self._vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self._vbox.pack_start(self.treeview, expand=False, fill=False, padding=0)
232 233 234 235 236 237
        self._vbox.pack_start(buttons_box, expand=False, fill=False, padding=0)
        separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
        separator.set_margin_top(SPACING)
        separator.set_margin_left(SPACING)
        separator.set_margin_right(SPACING)
        self._vbox.pack_start(separator, expand=False, fill=False, padding=0)
238
        self._vbox.show_all()
239 240 241 242
        self._expander_box.pack_start(self.no_effect_infobar, expand=False, fill=False, padding=0)
        self._expander_box.pack_start(self._vbox, expand=False, fill=False, padding=0)
        self._expander_box.show_all()
        self.add(self._expander_box)
243
        self.hide()
244

245
        effects_actions_group = Gio.SimpleActionGroup()
246 247
        self.treeview.insert_action_group("clipproperties-effects", effects_actions_group)
        buttons_box.insert_action_group("clipproperties-effects", effects_actions_group)
248
        self.app.shortcuts.register_group("clipproperties-effects", _("Clip Effects"), position=60)
249

250
        self.remove_effect_action = Gio.SimpleAction.new("remove-effect", None)
251 252
        self.remove_effect_action.connect("activate", self._removeEffectCb)
        effects_actions_group.add_action(self.remove_effect_action)
253 254
        self.app.shortcuts.add("clipproperties-effects.remove-effect", ["Delete"],
                               _("Remove the selected effect"))
255
        self.remove_effect_action.set_enabled(False)
256
        remove_effect_button.set_action_name("clipproperties-effects.remove-effect")
257

258
        # Connect all the widget signals
259
        self.treeview_selection.connect("changed", self._treeviewSelectionChangedCb)
260 261 262 263 264 265
        self.connect("drag-motion", self._drag_motion_cb)
        self.connect("drag-leave", self._drag_leave_cb)
        self.connect("drag-data-received", self._drag_data_received_cb)
        self.treeview.connect("drag-motion", self._drag_motion_cb)
        self.treeview.connect("drag-leave", self._drag_leave_cb)
        self.treeview.connect("drag-data-received", self._drag_data_received_cb)
266
        self.treeview.connect("query-tooltip", self._treeViewQueryTooltipCb)
267
        self.app.project_manager.connect_after(
268
            "new-project-loaded", self._newProjectLoadedCb)
269
        self.connect('notify::expanded', self._expandedCb)
270

271
    def _newProjectLoadedCb(self, unused_app, project):
272 273 274 275 276
        if self._selection is not None:
            self._selection.disconnect_by_func(self._selectionChangedCb)
            self._selection = None
        self._project = project
        if project:
277
            self._selection = project.ges_timeline.ui.selection
278
            self._selection.connect('selection-changed', self._selectionChangedCb)
279
        self.__updateAll()
280

281
    def _selectionChangedCb(self, selection):
282 283 284
        if self.clip:
            self.clip.disconnect_by_func(self._trackElementAddedCb)
            self.clip.disconnect_by_func(self._trackElementRemovedCb)
285 286 287
            for track_element in self.clip.get_children(recursive=True):
                if isinstance(track_element, GES.BaseEffect):
                    self._disconnect_from_track_element(track_element)
288 289 290 291 292 293

        clips = list(selection.selected)
        self.clip = clips[0] if len(clips) == 1 else None
        if self.clip:
            self.clip.connect("child-added", self._trackElementAddedCb)
            self.clip.connect("child-removed", self._trackElementRemovedCb)
294 295 296
            for track_element in self.clip.get_children(recursive=True):
                if isinstance(track_element, GES.BaseEffect):
                    self._connect_to_track_element(track_element)
297
        self.__updateAll()
298

299
    def _trackElementAddedCb(self, unused_clip, track_element):
300
        if isinstance(track_element, GES.BaseEffect):
301
            self._connect_to_track_element(track_element)
302
            self.__updateAll()
303 304 305 306
            for path, row in enumerate(self.storemodel):
                if row[COL_TRACK_EFFECT] == track_element:
                    self.treeview_selection.select_path(path)
                    break
307

308 309
    def _connect_to_track_element(self, track_element):
        track_element.connect("notify::active", self._notify_active_cb)
310
        track_element.connect("notify::priority", self._notify_priority_cb)
311 312 313

    def _disconnect_from_track_element(self, track_element):
        track_element.disconnect_by_func(self._notify_active_cb)
314
        track_element.disconnect_by_func(self._notify_priority_cb)
315 316 317 318

    def _notify_active_cb(self, unused_track_element, unused_param_spec):
        self._updateTreeview()

319 320 321
    def _notify_priority_cb(self, unused_track_element, unused_param_spec):
        self._updateTreeview()

322
    def _trackElementRemovedCb(self, unused_clip, track_element):
323
        if isinstance(track_element, GES.BaseEffect):
324
            self._disconnect_from_track_element(track_element)
325
            self.__updateAll()
326

327
    def _removeEffectCb(self, unused_action, unused_param):
328 329
        selected = self.treeview_selection.get_selected()
        if not selected[1]:
330
            # Cannot remove nothing,
331
            return
332
        effect = self.storemodel.get_value(selected[1], COL_TRACK_EFFECT)
333 334 335 336 337 338
        selection_path = self.storemodel.get_path(selected[1])
        # Preserve selection in the tree view.
        next_selection_index = selection_path.get_indices()[0]
        effect_count = self.storemodel.iter_n_children()
        if effect_count - 1 == next_selection_index:
            next_selection_index -= 1
339
        self._removeEffect(effect)
340 341
        if next_selection_index >= 0:
            self.treeview_selection.select_path(next_selection_index)
342 343

    def _removeEffect(self, effect):
344 345
        pipeline = self._project.pipeline
        with self.app.action_log.started("remove effect",
346 347
                                         finalizing_action=CommitTimelineFinalizingAction(pipeline),
                                         toplevel=True):
348 349 350
            self.__remove_configuration_widget()
            self.effects_properties_manager.cleanCache(effect)
            effect.get_parent().remove(effect)
351

352 353
    def _drag_motion_cb(self, unused_widget, unused_drag_context, unused_x, unused_y, unused_timestamp):
        """Highlights some widgets to indicate it can receive drag&drop."""
354 355
        self.debug(
            "Something is being dragged in the clip properties' effects list")
356 357 358 359
        self.no_effect_infobar.drag_highlight()
        # It would be nicer to highlight only the treeview, but
        # it does not seem to have a visible effect.
        self._vbox.drag_highlight()
360

361 362 363
    def _drag_leave_cb(self, unused_widget, unused_drag_context, unused_timestamp):
        """Unhighlights the widgets which can receive drag&drop."""
        self.debug(
364
            "The item being dragged has left the clip properties' effects list")
365 366
        self.no_effect_infobar.drag_unhighlight()
        self._vbox.drag_unhighlight()
367

368
    # pylint: disable=too-many-arguments
369
    def _drag_data_received_cb(self, widget, drag_context, x, y, selection_data, unused_info, timestamp):
370
        if not self.clip:
371 372 373
            # Indicate that a drop will not be accepted.
            Gdk.drag_status(drag_context, 0, timestamp)
            return
374 375

        dest_row = self.treeview.get_dest_row_at_pos(x, y)
376 377 378
        if drag_context.get_suggested_action() == Gdk.DragAction.COPY:
            # An effect dragged probably from the effects list.
            factory_name = str(selection_data.get_data(), "UTF-8")
379 380 381 382 383
            if widget is self.treeview:
                drop_index = self.__get_new_effect_index(dest_row)
            else:
                drop_index = len(self.storemodel)
            self.debug("Effect dragged at position %s", drop_index)
384 385 386
            effect_info = self.app.effects.getInfo(factory_name)
            pipeline = self._project.pipeline
            with self.app.action_log.started("add effect",
387 388
                                             finalizing_action=CommitTimelineFinalizingAction(pipeline),
                                             toplevel=True):
389 390
                effect = self.clip.ui.add_effect(effect_info)
                if effect:
391
                    self.clip.set_top_effect_index(effect, drop_index)
392 393 394
        elif drag_context.get_suggested_action() == Gdk.DragAction.MOVE:
            # An effect dragged from the same treeview to change its position.
            # Source
395
            source_index, drop_index = self.__get_move_indexes(
396
                dest_row, self.treeview.get_model())
397
            self.__move_effect(self.clip, source_index, drop_index)
398

399 400
        drag_context.finish(True, False, timestamp)

401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431
    # pylint: disable=no-self-use
    def __get_new_effect_index(self, dest_row):
        # Target
        if dest_row:
            drop_path, drop_pos = dest_row
            drop_index = drop_path.get_indices()[0]
            if drop_pos != Gtk.TreeViewDropPosition.BEFORE:
                drop_index += 1
        else:
            # This should happen when dragging after the last row.
            drop_index = None

        return drop_index

    def __get_move_indexes(self, dest_row, model):
        source_index = self.storemodel.source_index
        self.storemodel.source_index = None

        # Target
        if dest_row:
            drop_path, drop_pos = dest_row
            drop_index = drop_path.get_indices()[0]
            drop_index = self.calculateEffectPriority(
                source_index, drop_index, drop_pos)
        else:
            # This should happen when dragging after the last row.
            drop_index = len(model) - 1

        return source_index, drop_index

    def __move_effect(self, clip, source_index, drop_index):
432 433 434 435 436 437
        if source_index == drop_index:
            # Noop.
            return
        # The paths are different.
        effects = clip.get_top_effects()
        effect = effects[source_index]
438
        pipeline = self._project.ges_timeline.get_parent()
439
        with self.app.action_log.started("move effect",
440 441
                                         finalizing_action=CommitTimelineFinalizingAction(pipeline),
                                         toplevel=True):
442
            clip.set_top_effect_index(effect, drop_index)
443

444 445
        new_path = Gtk.TreePath.new()
        new_path.append_index(drop_index)
446
        self.__updateAll(path=new_path)
447 448 449

    @staticmethod
    def calculateEffectPriority(source_index, drop_index, drop_pos):
450
        """Calculates where the effect from source_index will end up."""
451 452 453 454 455 456 457 458 459
        if drop_pos in (Gtk.TreeViewDropPosition.INTO_OR_BEFORE, Gtk.TreeViewDropPosition.INTO_OR_AFTER):
            return drop_index
        if drop_pos == Gtk.TreeViewDropPosition.BEFORE:
            if source_index < drop_index:
                return drop_index - 1
        elif drop_pos == Gtk.TreeViewDropPosition.AFTER:
            if source_index > drop_index:
                return drop_index + 1
        return drop_index
460

461
    def _effectActiveToggleCb(self, cellrenderertoggle, path):
462
        _iter = self.storemodel.get_iter(path)
463 464 465
        effect = self.storemodel.get_value(_iter, COL_TRACK_EFFECT)
        pipeline = self._project.ges_timeline.get_parent()
        with self.app.action_log.started("change active state",
466 467
                                         finalizing_action=CommitTimelineFinalizingAction(pipeline),
                                         toplevel=True):
468 469 470 471
            effect.props.active = not effect.props.active
        # This is not strictly necessary, but makes sure
        # the UI reflects the current status.
        cellrenderertoggle.set_active(effect.is_active())
472

473 474
    def _expandedCb(self, unused_expander, unused_params):
        self.__updateAll()
475

476
    def _treeViewQueryTooltipCb(self, view, x, y, keyboard_mode, tooltip):
477
        is_row, x, y, unused_model, path, tree_iter = view.get_tooltip_context(
478
            x, y, keyboard_mode)
479
        if not is_row:
480 481
            return False

482
        view.set_tooltip_row(tooltip, path)
483
        description = self.storemodel.get_value(tree_iter, COL_DESC_TEXT)
484 485
        bin_description = self.storemodel.get_value(
            tree_iter, COL_BIN_DESCRIPTION_TEXT)
486
        tooltip.set_text("%s\n%s" % (bin_description, description))
487 488
        return True

489
    def __updateAll(self, path=None):
490
        if self.clip:
491 492 493 494 495
            self.show()
            self._infobar.hide()
            self._updateTreeview()
            if path:
                self.treeview_selection.select_path(path)
496
        else:
497
            self.hide()
498
            self.__remove_configuration_widget()
499 500
            self.storemodel.clear()
            self._infobar.show()
501 502 503

    def _updateTreeview(self):
        self.storemodel.clear()
504
        for effect in self.clip.get_top_effects():
505 506
            if effect.props.bin_description in HIDDEN_EFFECTS:
                continue
507
            effect_info = self.app.effects.getInfo(effect.props.bin_description)
508 509 510 511 512 513 514
            to_append = [effect.props.active]
            track_type = effect.get_track_type()
            if track_type == GES.TrackType.AUDIO:
                to_append.append("Audio")
            elif track_type == GES.TrackType.VIDEO:
                to_append.append("Video")
            to_append.append(effect.props.bin_description)
515 516
            to_append.append(effect_info.human_name)
            to_append.append(effect_info.description)
517 518
            to_append.append(effect)
            self.storemodel.append(to_append)
519 520 521
        has_effects = len(self.storemodel) > 0
        self.no_effect_infobar.set_visible(not has_effects)
        self._vbox.set_visible(has_effects)
522

523
    def _treeviewSelectionChangedCb(self, unused_treeview):
524 525
        selection_is_emtpy = self.treeview_selection.count_selected_rows() == 0
        self.remove_effect_action.set_enabled(not selection_is_emtpy)
526

527 528 529
        self._updateEffectConfigUi()

    def _updateEffectConfigUi(self):
530
        model, tree_iter = self.treeview_selection.get_selected()
531
        if tree_iter:
532
            effect = model.get_value(tree_iter, COL_TRACK_EFFECT)
533 534
            self._showEffectConfigurationWidget(effect)
        else:
535
            self.__remove_configuration_widget()
536

537
    def __remove_configuration_widget(self):
538 539 540
        if not self._effect_config_ui:
            # Nothing to remove.
            return
541

542
        self._effect_config_ui.deactivate_keyframe_toggle_buttons()
543
        self._vbox.remove(self._effect_config_ui)
544
        self._effect_config_ui = None
545

546
    def _showEffectConfigurationWidget(self, effect):
547
        self.__remove_configuration_widget()
548 549
        self._effect_config_ui = self.effects_properties_manager.getEffectConfigurationUI(
            effect)
550 551
        if not self._effect_config_ui:
            return
552
        self._effect_config_ui.show()
553
        self._effect_config_ui.show_all()
554
        self._vbox.add(self._effect_config_ui)
555 556


557
class TransformationProperties(Gtk.Expander, Loggable):
558
    """Widget for configuring the placement and size of the clip."""
559

560 561 562
    __signals__ = {
        'selection-changed': []}

563
    def __init__(self, app):
564
        Gtk.Expander.__init__(self)
565
        Loggable.__init__(self)
566
        self.app = app
567 568
        self._project = None
        self._selection = None
569
        self.source = None
570
        self._selected_clip = None
571
        self.spin_buttons = {}
572
        self.spin_buttons_handler_ids = {}
573
        self.set_label(_("Transformation"))
574

575 576 577
        self.builder = Gtk.Builder()
        self.builder.add_from_file(os.path.join(get_ui_dir(),
                                                "cliptransformation.ui"))
578 579 580 581
        self.__control_bindings = {}
        # Used to make sure self.__control_bindings_changed doesn't get called
        # when bindings are changed from this class
        self.__own_bindings_change = False
582 583
        self.add(self.builder.get_object("transform_box"))
        self._initButtons()
584
        self.show_all()
585
        self.hide()
586

587
        self.app.project_manager.connect_after(
588
            "new-project-loaded", self._newProjectLoadedCb)
589 590
        self.app.project_manager.connect_after(
            "project-closed", self.__project_closed_cb)
591

592
    def _newProjectLoadedCb(self, unused_app, project):
593 594 595
        if self._selection is not None:
            self._selection.disconnect_by_func(self._selectionChangedCb)
            self._selection = None
596 597 598
        if self._project:
            self._project.pipeline.disconnect_by_func(self._position_cb)

599 600
        self._project = project
        if project:
601
            self._selection = project.ges_timeline.ui.selection
602
            self._selection.connect('selection-changed', self._selectionChangedCb)
603
            self._project.pipeline.connect("position", self._position_cb)
604

605 606 607
    def __project_closed_cb(self, unused_project_manager, unused_project):
        self._project = None

608 609 610 611
    def _initButtons(self):
        clear_button = self.builder.get_object("clear_button")
        clear_button.connect("clicked", self._defaultValuesCb)

612 613
        self._activate_keyframes_btn = self.builder.get_object("activate_keyframes_button")
        self._activate_keyframes_btn.connect("toggled", self.__show_keyframes_toggled_cb)
614

615 616 617
        self._next_keyframe_btn = self.builder.get_object("next_keyframe_button")
        self._next_keyframe_btn.connect("clicked", self.__go_to_keyframe, True)
        self._next_keyframe_btn.set_sensitive(False)
618

619 620 621
        self._prev_keyframe_btn = self.builder.get_object("prev_keyframe_button")
        self._prev_keyframe_btn.connect("clicked", self.__go_to_keyframe, False)
        self._prev_keyframe_btn.set_sensitive(False)
622 623 624

        self.__setup_spin_button("xpos_spinbtn", "posx")
        self.__setup_spin_button("ypos_spinbtn", "posy")
625

626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667
        self.__setup_spin_button("width_spinbtn", "width")
        self.__setup_spin_button("height_spinbtn", "height")

    def __get_keyframes_timestamps(self):
        keyframes_ts = []
        for prop in ["posx", "posy", "width", "height"]:
            prop_keyframes = self.__control_bindings[prop].props.control_source.get_all()
            keyframes_ts.extend([keyframe.timestamp for keyframe in prop_keyframes])

        return sorted(set(keyframes_ts))

    def __go_to_keyframe(self, unused_button, next_keyframe):
        assert self.__control_bindings
        start = self.source.props.start
        duration = self.source.props.duration
        in_point = self.source.props.in_point
        pipeline = self._project.pipeline
        position = pipeline.getPosition() - start + in_point
        seekval = start

        if in_point <= position <= in_point + duration:
            keyframes_ts = self.__get_keyframes_timestamps()

            for i in range(1, len(keyframes_ts)):
                if keyframes_ts[i - 1] <= position <= keyframes_ts[i]:
                    prev_kf_ts = keyframes_ts[i - 1]
                    kf_ts = keyframes_ts[i]
                    if next_keyframe:
                        if kf_ts == position:
                            try:
                                kf_ts = keyframes_ts[i + 1]
                            except IndexError:
                                pass
                        seekval = kf_ts + start - in_point
                    else:
                        seekval = prev_kf_ts + start - in_point
                    break
        if position > in_point + duration:
            seekval = start + duration
        pipeline.simple_seek(seekval)

    def __show_keyframes_toggled_cb(self, unused_button):
668
        if self._activate_keyframes_btn.props.active:
669 670 671 672 673
            self.__set_control_bindings()
        self.__update_keyframes_ui()

    def __update_keyframes_ui(self):
        if self.__source_uses_keyframes():
674
            self._activate_keyframes_btn.props.label = "◆"
675
        else:
676 677
            self._activate_keyframes_btn.props.label = "◇"
            self._activate_keyframes_btn.props.active = False
678

679 680 681
        if not self._activate_keyframes_btn.props.active:
            self._prev_keyframe_btn.set_sensitive(False)
            self._next_keyframe_btn.set_sensitive(False)
682
            if self.__source_uses_keyframes():
683
                self._activate_keyframes_btn.set_tooltip_text(_("Show keyframes"))
684
            else:
685
                self._activate_keyframes_btn.set_tooltip_text(_("Activate keyframes"))
686 687
            self.source.ui_element.showDefaultKeyframes()
        else:
688 689 690
            self._prev_keyframe_btn.set_sensitive(True)
            self._next_keyframe_btn.set_sensitive(True)
            self._activate_keyframes_btn.set_tooltip_text(_("Hide keyframes"))
691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752
            self.source.ui_element.showMultipleKeyframes(
                list(self.__control_bindings.values()))

    def __update_control_bindings(self):
        self.__control_bindings = {}
        if self.__source_uses_keyframes():
            self.__set_control_bindings()

    def __source_uses_keyframes(self):
        if self.source is None:
            return False

        for prop in ["posx", "posy", "width", "height"]:
            binding = self.source.get_control_binding(prop)
            if binding is None:
                return False

        return True

    def __remove_control_bindings(self):
        for propname, binding in self.__control_bindings.items():
            control_source = binding.props.control_source
            # control_source.unset_all() can't be used here as it doesn't emit
            # the 'value-removed' signal, so the undo system wouldn't notice
            # the removed keyframes
            keyframes_ts = [keyframe.timestamp for keyframe in control_source.get_all()]
            for ts in keyframes_ts:
                control_source.unset(ts)
            self.__own_bindings_change = True
            self.source.remove_control_binding(propname)
            self.__own_bindings_change = False
        self.__control_bindings = {}

    def __set_control_bindings(self):
        adding_kfs = not self.__source_uses_keyframes()

        if adding_kfs:
            self.app.action_log.begin("Transformation properties keyframes activate",
                                      toplevel=True)

        for prop in ["posx", "posy", "width", "height"]:
            binding = self.source.get_control_binding(prop)

            if not binding:
                control_source = GstController.InterpolationControlSource()
                control_source.props.mode = GstController.InterpolationMode.LINEAR
                self.__own_bindings_change = True
                self.source.set_control_source(control_source, prop, "direct-absolute")
                self.__own_bindings_change = False
                self.__set_default_keyframes_values(control_source, prop)

                binding = self.source.get_control_binding(prop)
            self.__control_bindings[prop] = binding

        if adding_kfs:
            self.app.action_log.commit("Transformation properties keyframes activate")

    def __set_default_keyframes_values(self, control_source, prop):
        res, val = self.source.get_child_property(prop)
        assert res
        control_source.set(self.source.props.in_point, val)
        control_source.set(self.source.props.in_point + self.source.props.duration, val)
753

754
    def _defaultValuesCb(self, unused_widget):
755 756 757 758 759 760 761 762 763 764
        with self.app.action_log.started("Transformation properties reset default",
                                         finalizing_action=CommitTimelineFinalizingAction(self._project.pipeline),
                                         toplevel=True):
            if self.__source_uses_keyframes():
                self.__remove_control_bindings()

            for prop in ["posx", "posy", "width", "height"]:
                self.source.set_child_property(prop, self.source.ui.default_position[prop])

        self.__update_keyframes_ui()
765

766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784
    def __get_source_property(self, prop):
        if self.__source_uses_keyframes():
            try:
                position = self._project.pipeline.getPosition()
                start = self.source.props.start
                in_point = self.source.props.in_point
                duration = self.source.props.duration

                # If the position is outside of the clip, take the property
                # value at the start/end (whichever is closer) of the clip.
                source_position = max(0, min(position - start, duration - 1)) + in_point
                value = self.__control_bindings[prop].get_value(source_position)
                res = value is not None
                return res, value
            except PipelineError:
                pass

        return self.source.get_child_property(prop)

785 786 787 788 789 790
    def _position_cb(self, unused_pipeline, unused_position):
        if not self.__source_uses_keyframes():
            return
        for prop in ["posx", "posy", "width", "height"]:
            self.__update_spin_btn(prop)
        # Keep the overlay stack in sync with the spin buttons values
791
        self.app.gui.editor.viewer.overlay_stack.update(self.source)
792

793
    def __source_property_changed_cb(self, unused_source, unused_element, param):
794 795 796 797 798
        self.__update_spin_btn(param.name)

    def __update_spin_btn(self, prop):
        assert self.source

799
        try:
800 801
            spin = self.spin_buttons[prop]
            spin_handler_id = self.spin_buttons_handler_ids[prop]
802 803
        except KeyError:
            return
804

805
        res, value = self.__get_source_property(prop)
806
        assert res
807
        if spin.get_value() != value:
808 809 810 811
            # Make sure self._onValueChangedCb doesn't get called here. If that
            # happens, we might have unintended keyframes added.
            with spin.handler_block(spin_handler_id):
                spin.set_value(value)
812

813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848
    def _control_bindings_changed(self, unused_track_element, unused_binding):
        if self.__own_bindings_change:
            # Do nothing if the change occurred from this class
            return

        self.__update_control_bindings()
        self.__update_keyframes_ui()

    def __set_prop(self, prop, value):
        assert self.source

        if self.__source_uses_keyframes():
            try:
                position = self._project.pipeline.getPosition()
                start = self.source.props.start
                in_point = self.source.props.in_point
                duration = self.source.props.duration
                if position < start or position > start + duration:
                    return
                source_position = position - start + in_point

                with self.app.action_log.started(
                        "Transformation property change",
                        finalizing_action=CommitTimelineFinalizingAction(self._project.pipeline),
                        toplevel=True):
                    self.__control_bindings[prop].props.control_source.set(source_position, value)
            except PipelineError:
                self.warning("Could not get pipeline position")
                return
        else:
            with self.app.action_log.started("Transformation property change",
                                             finalizing_action=CommitTimelineFinalizingAction(self._project.pipeline),
                                             toplevel=True):
                self.source.set_child_property(prop, value)

    def __setup_spin_button(self, widget_name, property_name):
849
        """Creates a SpinButton for editing a property value."""
850
        spinbtn = self.builder.get_object(widget_name)
851
        handler_id = spinbtn.connect("value-changed", self._onValueChangedCb, property_name)
852
        disable_scroll(spinbtn)
853
        self.spin_buttons[property_name] = spinbtn
854
        self.spin_buttons_handler_ids[property_name] = handler_id
855 856

    def _onValueChangedCb(self, spinbtn, prop):
857 858
        if not self.source:
            return
859

860
        value = spinbtn.get_value()
861

862 863 864 865
        res, cvalue = self.__get_source_property(prop)
        if not res:
            return

866
        if value != cvalue:
867
            self.__set_prop(prop, value)
868
            self.app.gui.editor.viewer.overlay_stack.update(self.source)
869

870
    def __set_source(self, source):
871 872
        if self.source:
            try:
873 874
                self.source.disconnect_by_func(self.__source_property_changed_cb)
                disconnectAllByFunc(self.source, self._control_bindings_changed)
875 876
            except TypeError:
                pass
877 878
        self.source = source
        if self.source:
879
            self.__update_control_bindings()
880 881
            for prop in self.spin_buttons:
                self.__update_spin_btn(prop)
882 883 884 885
            self.__update_keyframes_ui()
            self.source.connect("deep-notify", self.__source_property_changed_cb)
            self.source.connect("control-binding-added", self._control_bindings_changed)
            self.source.connect("control-binding-removed", self._control_bindings_changed)
886

887
    def _selectionChangedCb(self, unused_timeline):
888 889
        if len(self._selection) == 1:
            clip = list(self._selection)[0]
890 891
            source = clip.find_track_element(None, GES.VideoSource)
            if source:
892
                self._selected_clip = clip
893
                self.__set_source(source)
894
                self.app.gui.editor.viewer.overlay_stack.select(source)
895 896
                self.show()
                return
897

898 899 900
        # Deselect
        if self._selected_clip:
            self._selected_clip = None
901 902
            self._project.pipeline.commit_timeline()
        self.__set_source(None)
903
        self.hide()