widgets.py 58.3 KB
Newer Older
1
# -*- coding: utf-8 -*-
Jeff Fortin Tam's avatar
Jeff Fortin Tam committed
2
# Pitivi video editor
3
4
5
6
7
8
9
10
11
12
13
14
15
# Copyright (c) 2005, Edward Hervey <bilboed@bilboed.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
16
# License along with this program; if not, see <http://www.gnu.org/licenses/>.
Alexandru Băluț's avatar
Alexandru Băluț committed
17
"""Classes and routines for creating widgets from `Gst.Element`s."""
18
import math
19
import os
20
import re
21
from gettext import gettext as _
22
23

from gi.repository import Gdk
24
from gi.repository import GdkPixbuf
25
26
27
from gi.repository import GES
from gi.repository import GLib
from gi.repository import GObject
28
from gi.repository import Gst
29
from gi.repository import GstController
30
from gi.repository import Gtk
31
from gi.repository import Pango
32
33

from pitivi.configure import get_ui_dir
34
from pitivi.utils.loggable import Loggable
35
from pitivi.utils.misc import is_valid_file
36
from pitivi.utils.timeline import Zoomable
37
38
39
40
from pitivi.utils.ui import beautify_length
from pitivi.utils.ui import disable_scroll
from pitivi.utils.ui import SPACING
from pitivi.utils.ui import time_to_string
41

42
43
44

ZOOM_SLIDER_PADDING = SPACING * 4 / 5

Thibault Saunier's avatar
Thibault Saunier committed
45

46
class DynamicWidget(Loggable):
Alexandru Băluț's avatar
Alexandru Băluț committed
47
    """Abstract widget providing a way to get, set and observe properties."""
48

49
    def __init__(self, default):
50
        super().__init__()
51
        self.default = default
52

53
    def connect_value_changed(self, callback, *args):
54
55
        raise NotImplementedError

56
    def set_widget_value(self, value):
57
58
        raise NotImplementedError

59
    def get_widget_value(self):
60
61
        raise NotImplementedError

62
    def get_widget_default(self):
63
64
        return self.default

65
    def set_widget_to_default(self):
66
        if self.default is not None:
67
            self.set_widget_value(self.default)
68

Thibault Saunier's avatar
Thibault Saunier committed
69

70
class DefaultWidget(Gtk.Label):
71
72
    """When all hope fails...."""

Alexandru Băluț's avatar
Alexandru Băluț committed
73
    def __init__(self):
74
        Gtk.Label.__init__(self, _("Implement Me"))
75
76
        self.props.halign = Gtk.Align.START

77
    def connect_value_changed(self, callback, *args):
78
79
        pass

80
    def set_widget_value(self, value):
81
        pass
82

83
    def get_widget_value(self):
84
85
        pass

86
    def set_widget_to_default(self):
87
88
        pass

89

90
class TextWidget(Gtk.Box, DynamicWidget):
Alexandru Băluț's avatar
Alexandru Băluț committed
91
    """Widget for entering text.
92

93
    A Gtk.Entry which emits a "value-changed" signal only when its input is
94
    valid (matches the provided regex). If the input is invalid, a warning
95
96
97
98
99
100
    icon is displayed.

    You can also connect to the "activate" signal if you don't want to watch
    for live changes, but it will only be emitted if the input is valid when
    the user presses Enter.
    """
101
102
103

    __gtype_name__ = 'TextWidget'
    __gsignals__ = {
104
105
        "value-changed": (GObject.SignalFlags.RUN_LAST, None, (),),
        "activate": (GObject.SignalFlags.RUN_LAST, None, (),)
106
107
    }

108
    def __init__(self, matches=None, choices=None, default=None, combobox=False, widget=None):
109
110
111
112
        if not default:
            # In the case of text widgets, a blank default is an empty string
            default = ""

113
        Gtk.Box.__init__(self)
114
115
        DynamicWidget.__init__(self, default)

116
        self.set_orientation(Gtk.Orientation.HORIZONTAL)
117
118
        self.set_border_width(0)
        self.set_spacing(0)
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
        if widget is None:
            if choices:
                self.combo = Gtk.ComboBoxText.new_with_entry()
                self.text = self.combo.get_child()
                self.combo.show()
                self.pack_start(self.combo, expand=False, fill=False, padding=0)
                for choice in choices:
                    self.combo.append_text(choice)
            elif combobox:
                self.combo = Gtk.ComboBox.new_with_entry()
                self.text = self.combo.get_child()
                self.combo.show()
                self.pack_start(self.combo, expand=False, fill=False, padding=0)
            else:
                self.text = Gtk.Entry()
                self.text.show()
                self.pack_start(self.text, expand=False, fill=False, padding=0)
136
        else:
137
138
            self.text = widget

139
140
        self.matches = None
        self.last_valid = None
141
        self.valid = False
142
        self.send_signal = True
Alexandru Băluț's avatar
Alexandru Băluț committed
143
144
        self.text.connect("changed", self.__text_changed_cb)
        self.text.connect("activate", self.__activate_cb)
145
        if matches:
146
            if isinstance(matches, str):
147
148
149
                self.matches = re.compile(matches)
            else:
                self.matches = matches
Alexandru Băluț's avatar
Alexandru Băluț committed
150
            self.__text_changed_cb(None)
151

152
    def connect_value_changed(self, callback, *args):
153
154
        return self.connect("value-changed", callback, *args)

155
    def set_widget_value(self, value, send_signal=True):
156
        self.send_signal = send_signal
157
158
        self.text.set_text(value)

159
    def get_widget_value(self):
160
161
162
163
        if self.matches:
            return self.last_valid
        return self.text.get_text()

164
    def add_choices(self, choices):
165
166
167
        for choice in choices:
            self.combo.append_text(choice)

Alexandru Băluț's avatar
Alexandru Băluț committed
168
    def __text_changed_cb(self, unused_widget):
169
170
        text = self.text.get_text()
        if self.matches:
171
            if self._filter(text):
172
                self.last_valid = text
173
174
                if self.send_signal:
                    self.emit("value-changed")
175
                if not self.valid:
176
                    self.text.set_icon_from_icon_name(1, None)
177
178
179
                self.valid = True
            else:
                if self.valid:
180
                    self.text.set_icon_from_icon_name(1, "dialog-warning")
181
                self.valid = False
182
        elif self.send_signal:
183
184
            self.emit("value-changed")

185
186
        self.send_signal = True

Alexandru Băluț's avatar
Alexandru Băluț committed
187
    def __activate_cb(self, unused_widget):
188
189
190
        if self.matches and self.send_signal:
            self.emit("activate")

191
192
193
194
195
196
    def _filter(self, text):
        match = self.matches.match(text)
        if match is not None:
            return True
        return False

197
198
199
200
201
    def set_width_chars(self, width):
        """Allows setting the width of the text entry widget for compactness."""
        self.text.set_width_chars(width)


202
class NumericWidget(Gtk.Box, DynamicWidget):
Alexandru Băluț's avatar
Alexandru Băluț committed
203
    """Widget for entering a number.
204

Alexandru Băluț's avatar
Alexandru Băluț committed
205
    Contains both a Gtk.Scale and a Gtk.SpinButton for adjusting the value.
206
    The SpinButton is always displayed, while the Scale only appears if both
Alexandru Băluț's avatar
Alexandru Băluț committed
207
208
209
210
211
212
    lower and upper bounds are defined.

    Args:
        upper (Optional[int]): The upper limit for this widget.
        lower (Optional[int]): The lower limit for this widget.
    """
213

yatinmaan's avatar
yatinmaan committed
214
    def __init__(self, upper=None, lower=None, default=None, adjustment=None, width_chars=None):
215
        Gtk.Box.__init__(self)
216
        DynamicWidget.__init__(self, default)
217

218
        self.set_orientation(Gtk.Orientation.HORIZONTAL)
Alexandru Băluț's avatar
Alexandru Băluț committed
219
        self.set_spacing(SPACING)
220
        self._type = None
221
        self.spinner = None
222
        self.handler_id = None
223

224
225
226
        if adjustment:
            self.adjustment = adjustment
            return
227
228
        reasonable_limit = 5000
        with_slider = (lower is not None and lower > -reasonable_limit and
229
                       upper is not None and upper < reasonable_limit)
230
231
        self.adjustment = Gtk.Adjustment()
        if upper is None:
232
            upper = GLib.MAXINT32
233
        if lower is None:
234
235
236
            lower = GLib.MININT32
        self.adjustment.props.lower = lower
        self.adjustment.props.upper = upper
237

238
        self.spinner = Gtk.SpinButton(adjustment=self.adjustment)
yatinmaan's avatar
yatinmaan committed
239
240
        if width_chars:
            self.spinner.props.width_chars = width_chars
241
242
243
        self.pack_start(self.spinner, expand=False, fill=False, padding=0)
        self.spinner.show()

244
        if with_slider:
245
246
            self.slider = Gtk.Scale.new(
                Gtk.Orientation.HORIZONTAL, self.adjustment)
247
            self.pack_start(self.slider, expand=False, fill=False, padding=0)
248
            self.slider.show()
249
            self.slider.set_size_request(width=100, height=-1)
250
            self.slider.props.draw_value = False
251
252
253
254
255
256
            # Abuse GTK3's progressbar "fill level" feature to provide
            # a visual indication of the default value on property sliders.
            if default is not None:
                self.slider.set_restrict_to_fill_level(False)
                self.slider.set_fill_level(float(default))
                self.slider.set_show_fill_level(True)
257

258
    def block_signals(self):
259
260
        if self.handler_id:
            self.adjustment.handler_block(self.handler_id)
261
262

    def unblock_signals(self):
263
264
        if self.handler_id:
            self.adjustment.handler_unblock(self.handler_id)
265

266
    def connect_value_changed(self, callback, *args):
267
        self.handler_id = self.adjustment.connect("value-changed", callback, *args)
268

269
    def get_widget_value(self):
270
271
272
        if self._type:
            return self._type(self.adjustment.get_value())

273
274
        return self.adjustment.get_value()

275
    def set_widget_value(self, value):
276
        type_ = type(value)
277
278
279
        if self._type is None:
            self._type = type_

280
        if type_ == int:
281
282
283
            step = 1.0
            page = 10.0
        elif type_ == float:
284
285
            step = 0.01
            page = 0.1
286
287
            if self.spinner:
                self.spinner.props.digits = 2
288
289
290
291
292
        else:
            raise Exception('Unsupported property type: %s' % type_)
        lower = min(self.adjustment.props.lower, value)
        upper = max(self.adjustment.props.upper, value)
        self.adjustment.configure(value, lower, upper, step, page, 0)
293

Thibault Saunier's avatar
Thibault Saunier committed
294

295
class TimeWidget(TextWidget, DynamicWidget):
Alexandru Băluț's avatar
Alexandru Băluț committed
296
    """Widget for entering a time value.
297

Alexandru Băluț's avatar
Alexandru Băluț committed
298
    Accepts timecode formats or a frame number (integer).
299
    """
Alexandru Băluț's avatar
Alexandru Băluț committed
300

301
302
303
    # The "frame number" match rule is ^([0-9]+)$ (with a + to require 1 digit)
    # The "timecode" rule is ^([0-9]:[0-5][0-9]:[0-5][0-9])\.[0-9][0-9][0-9]$"
    # Combining the two, we get:
304
    VALID_REGEX = re.compile(
305
        r"^([0-9]+)$|^([0-9]:)?([0-5][0-9]:[0-5][0-9])\.[0-9][0-9][0-9]$")
306

307
308
309
310
    __gtype_name__ = 'TimeWidget'

    def __init__(self, default=None):
        DynamicWidget.__init__(self, default)
311
        TextWidget.__init__(self, self.VALID_REGEX)
312
        TextWidget.set_width_chars(self, 10)
313
        self._framerate = None
314
        self.text.connect("focus-out-event", self._focus_out_cb)
315

316
317
    def get_widget_value(self):
        timecode = TextWidget.get_widget_value(self)
318

319
        if ":" in timecode:
320
321
322
323
324
325
326
            parts = timecode.split(":")
            if len(parts) == 2:
                hh = 0
                mm, end = parts
            else:
                hh, mm, end = parts
            ss, millis = end.split(".")
327
328
329
            nanosecs = int(hh) * 3.6 * 10e12 \
                + int(mm) * 6 * 10e10 \
                + int(ss) * 10e9 \
330
                + int(millis) * 10e6
331
332
333
334
335
336
            nanosecs = nanosecs / 10  # Compensate the 10 factor of e notation
        else:
            # We were given a frame number. Convert from the project framerate.
            frame_no = int(timecode)
            nanosecs = frame_no / float(self._framerate) * Gst.SECOND
        return int(nanosecs)
337

Alexandru Băluț's avatar
Alexandru Băluț committed
338
339
340
    def set_widget_value(self, time_nanos, send_signal=True):
        self.default = time_nanos
        timecode = time_to_string(time_nanos)
341
342
        if timecode.startswith("0:"):
            timecode = timecode[2:]
343
        TextWidget.set_widget_value(self, timecode, send_signal=send_signal)
344

345
346
347
    def _focus_out_cb(self, widget, event):
        """Reset the text to display the current position of the playhead."""
        if self.default is not None:
348
            self.set_widget_value(self.default)
349

350
    def connect_activate_event(self, activate_cb):
351
        return self.connect("activate", activate_cb)
352

353
    def connect_focus_events(self, focus_in_cb, focus_out_cb):
354
355
356
        focus_in_handler_id = self.text.connect("focus-in-event", focus_in_cb)
        focus_out_handler_id = self.text.connect("focus-out-event", focus_out_cb)
        return [focus_in_handler_id, focus_out_handler_id]
357

358
    def set_framerate(self, framerate):
359
360
        self._framerate = framerate

361

362
class FractionWidget(TextWidget, DynamicWidget):
Alexandru Băluț's avatar
Alexandru Băluț committed
363
    """Widget for entering a fraction."""
364

365
    fraction_regex = re.compile(
366
        r"^([0-9]*(\.[0-9]+)?)(([:/][0-9]*(\.[0-9]+)?)|M)?$")
367
368
    __gtype_name__ = 'FractionWidget'

369
    def __init__(self, presets=None, default=None):
370
371
        DynamicWidget.__init__(self, default)

372
373
        flow = float("-Infinity")
        fhigh = float("Infinity")
374
375
        choices = []
        if presets:
376
            for preset in presets:
377
                if isinstance(preset, str):
378
                    strval = preset
379
                    preset = self._parse_text(preset)
380
381
                else:
                    strval = "%g:%g" % (preset.num, preset.denom)
382
                if flow <= float(preset) <= fhigh:
383
                    choices.append(strval)
384
385
386
387
388
389
        self.low = flow
        self.high = fhigh
        TextWidget.__init__(self, self.fraction_regex, choices)

    def _filter(self, text):
        if TextWidget._filter(self, text):
390
            value = self._parse_text(text)
391
392
393
394
            if self.low <= float(value) and float(value) <= self.high:
                return True
        return False

395
    def add_presets(self, presets):
396
397
        choices = []
        for preset in presets:
398
            if isinstance(preset, str):
399
                strval = preset
400
                preset = self._parse_text(preset)
401
402
            else:
                strval = "%g:%g" % (preset.num, preset.denom)
403
            if self.low <= float(preset) <= self.high:
404
                choices.append(strval)
405

406
        self.add_choices(choices)
407

408
    def set_widget_value(self, value):
409
        if isinstance(value, str):
410
            value = self._parse_text(value)
411
        elif not hasattr(value, "denom"):
412
            value = Gst.Fraction(value)
413
414
        if value.denom == 1001:
            text = "%dM" % (value.num / 1000)
415
        else:
416
            text = "%d:%d" % (value.num, value.denom)
417
418

        self.text.set_text(text)
419

420
    def get_widget_value(self):
421
        if self.last_valid:
422
            return self._parse_text(self.last_valid)
423
        return Gst.Fraction(1, 1)
424

425
    @classmethod
426
    def _parse_text(cls, text):
427
        match = cls.fraction_regex.match(text)
428
429
430
431
432
        groups = match.groups()
        num = 1.0
        denom = 1.0
        if groups[0]:
            num = float(groups[0])
433
434
435
436
437
438
        if groups[2]:
            if groups[2] == "M":
                num = num * 1000
                denom = 1001
            elif groups[2][1:]:
                denom = float(groups[2][1:])
439
        return Gst.Fraction(num, denom)
440

Thibault Saunier's avatar
Thibault Saunier committed
441

442
class ToggleWidget(Gtk.Box, DynamicWidget):
Alexandru Băluț's avatar
Alexandru Băluț committed
443
    """Widget for entering an on/off value."""
444

445
    def __init__(self, default=None, switch_button=None):
446
        Gtk.Box.__init__(self)
447
        DynamicWidget.__init__(self, default)
448
        self.props.valign = Gtk.Align.CENTER
449
450
451
452
        if switch_button is None:
            self.switch_button = Gtk.Switch()
            self.pack_start(self.switch_button, expand=False, fill=False, padding=0)
            self.switch_button.show()
453
        else:
454
            self.switch_button = switch_button
455
            self.set_widget_to_default()
456

457
    def connect_value_changed(self, callback, *args):
458
459
460
461
        def callback_wrapper(switch_button, unused_state):
            callback(switch_button, *args)

        self.switch_button.connect("state-set", callback_wrapper)
462

463
    def set_widget_value(self, value):
464
        self.switch_button.set_active(value)
465

466
    def get_widget_value(self):
467
        return self.switch_button.get_active()
468

Thibault Saunier's avatar
Thibault Saunier committed
469

470
class ChoiceWidget(Gtk.Box, DynamicWidget):
Alexandru Băluț's avatar
Alexandru Băluț committed
471
    """Widget for making a choice between a list of named values."""
472

473
    def __init__(self, choices, default=None):
474
        Gtk.Box.__init__(self)
475
        DynamicWidget.__init__(self, default)
476
477
        self.choices = None
        self.values = None
478
        self.set_orientation(Gtk.Orientation.HORIZONTAL)
479
        self.contents = Gtk.ComboBoxText()
480
        self.pack_start(self.contents, expand=False, fill=False, padding=0)
481
        self.set_choices(choices)
482
        self.contents.show()
483
        cell = self.contents.get_cells()[0]
484
        cell.props.ellipsize = Pango.EllipsizeMode.END
485

486
    def connect_value_changed(self, callback, *args):
487
488
        return self.contents.connect("changed", callback, *args)

489
    def set_widget_value(self, value):
490
491
        try:
            self.contents.set_active(self.values.index(value))
Alexandru Băluț's avatar
Alexandru Băluț committed
492
493
        except ValueError as e:
            raise ValueError("%r not in %r" % (value, self.values)) from e
494

495
    def get_widget_value(self):
496
497
        return self.values[self.contents.get_active()]

498
    def set_choices(self, choices):
499
500
        self.choices = [choice[0] for choice in choices]
        self.values = [choice[1] for choice in choices]
501
502
        model = Gtk.ListStore(str)
        self.contents.set_model(model)
Alexandru Băluț's avatar
Alexandru Băluț committed
503
        for choice, unused_value in choices:
504
505
506
507
508
509
            self.contents.append_text(_(choice))
        if len(choices) <= 1:
            self.contents.set_sensitive(False)
        else:
            self.contents.set_sensitive(True)

Thibault Saunier's avatar
Thibault Saunier committed
510

511
class PathWidget(Gtk.FileChooserButton, DynamicWidget):
Alexandru Băluț's avatar
Alexandru Băluț committed
512
    """Widget for entering a path."""
513

514
515
516
    __gtype_name__ = 'PathWidget'

    __gsignals__ = {
Alexandru Băluț's avatar
Alexandru Băluț committed
517
        "value-changed": (GObject.SignalFlags.RUN_LAST, None, ()),
518
519
    }

520
    def __init__(self, action=Gtk.FileChooserAction.OPEN, default=None):
521
        DynamicWidget.__init__(self, default)
522
        self.dialog = Gtk.FileChooserDialog(action=action)
523
524
        self.dialog.add_buttons(
            Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)
525
        self.dialog.set_default_response(Gtk.ResponseType.OK)
526
        Gtk.FileChooserButton.__init__(self, dialog=self.dialog)
527
        self.dialog.connect("response", self._response_cb)
528
529
        self.uri = ""

530
    def connect_value_changed(self, callback, *args):
531
532
        return self.connect("value-changed", callback, *args)

533
    def set_widget_value(self, value):
534
535
536
        self.set_uri(value)
        self.uri = value

537
    def get_widget_value(self):
538
539
        return self.uri

540
    def _response_cb(self, unused_dialog, response):
541
        if response == Gtk.ResponseType.CLOSE:
542
543
544
545
            self.uri = self.get_uri()
            self.emit("value-changed")
            self.dialog.hide()

Thibault Saunier's avatar
Thibault Saunier committed
546

547
class ColorWidget(Gtk.ColorButton, DynamicWidget):
548

549
    def __init__(self, default=None):
550
        Gtk.ColorButton.__init__(self)
551
        DynamicWidget.__init__(self, default)
552

553
    def connect_value_changed(self, callback, *args):
554
555
        self.connect("color-set", callback, *args)

556
    def set_widget_value(self, value):
557
        self.set_rgba(value)
558

559
    def get_widget_value(self):
560
        return self.get_rgba()
561

Thibault Saunier's avatar
Thibault Saunier committed
562

563
class FontWidget(Gtk.FontButton, DynamicWidget):
564

Thibault Saunier's avatar
Thibault Saunier committed
565
    def __init__(self, default=None):
566
        Gtk.FontButton.__init__(self)
567
        DynamicWidget.__init__(self, default)
568
569
        self.set_use_font(True)

570
    def connect_value_changed(self, callback, *args):
571
572
        self.connect("font-set", callback, *args)

573
    def set_widget_value(self, font_name):
574
575
        self.set_font_name(font_name)

576
    def get_widget_value(self):
577
578
        return self.get_font_name()

Thibault Saunier's avatar
Thibault Saunier committed
579

580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
class InputValidationWidget(Gtk.Box, DynamicWidget):
    """Widget for validating the input of another widget.

    It shows a warning sign if the input is not valid and rolls back to
    the default widget value (which should always be valid).

    Args:
        widget (DynamicWidget): widget whose input needs validation.
        validation_function (function): function which receives the input of the
            widget and returns True iff the input is valid.
    """

    def __init__(self, widget, validation_function):
        Gtk.Box.__init__(self)
        DynamicWidget.__init__(self, widget.default)
        self._widget = widget
        self._validation_function = validation_function
        self._warning_sign = Gtk.Image.new_from_icon_name("dialog-warning", 3)

        self.set_orientation(Gtk.Orientation.HORIZONTAL)
        self.pack_start(self._widget, expand=False, fill=False, padding=0)
        self.pack_start(self._warning_sign, expand=False, fill=False, padding=SPACING)
        self._warning_sign.set_no_show_all(True)

604
        self._widget.connect_value_changed(self._widget_value_changed_cb)
605

606
607
    def connect_value_changed(self, callback, *args):
        return self._widget.connect_value_changed(callback, args)
608

609
610
    def set_widget_value(self, value):
        self._widget.set_widget_value(value)
611

612
613
    def get_widget_value(self):
        value = self._widget.get_widget_value()
614
615
        if self._validation_function(value):
            return value
616
        return self.get_widget_default()
617
618

    def _widget_value_changed_cb(self, unused_widget):
619
        value = self._widget.get_widget_value()
620
621
622
623
624
625
        if self._validation_function(value):
            self._warning_sign.hide()
        else:
            self._warning_sign.show()


626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
def make_widget_wrapper(prop, widget):
    """Creates a wrapper child of DynamicWidget for @widget."""
    # Respect Object hierarchy here
    if isinstance(widget, Gtk.SpinButton):
        widget_adjustment = widget.get_adjustment()
        widget_lower = widget_adjustment.props.lower
        widget_upper = widget_adjustment.props.upper
        return NumericWidget(upper=widget_upper, lower=widget_lower, adjustment=widget_adjustment, default=prop.default_value)
    elif isinstance(widget, Gtk.Entry):
        return TextWidget(widget=widget)
    elif isinstance(widget, Gtk.Range):
        widget_adjustment = widget.get_adjustment()
        widget_lower = widget_adjustment.props.lower
        widget_upper = widget_adjustment.props.upper
        return NumericWidget(upper=widget_upper, lower=widget_lower, adjustment=widget_adjustment, default=prop.default_value)
641
    elif isinstance(widget, Gtk.Switch):
642
643
644
        return ToggleWidget(prop.default_value, widget)
    else:
        Loggable().fixme("%s has not been wrapped into a Dynamic Widget", widget)
645
        return None
646
647


648
class GstElementSettingsWidget(Gtk.Box, Loggable):
649
    """Widget to modify the properties of a Gst.Element.
650

651
652
653
654
655
    Can be used to configure an effect or an encoder, etc.

    Args:
        controllable (bool): Whether the properties being controlled by
            keyframes is allowed.
656
657
    """

658
659
660
661
662
663
    # Dictionary that maps tuples of (element_name, property_name) to a
    # validation function.
    INPUT_VALIDATION_FUNCTIONS = {
        ("x264enc", "multipass-cache-file"): is_valid_file
    }

664
665
666
667
668
669
    # Dictionary that references the GstCaps field to expose in the UI
    # for a well known set of elements.
    CAP_FIELDS_TO_EXPOSE = {
        "x264enc": {"profile": Gst.ValueList(["high", "main", "baseline"])}
    }

670
    def __init__(self, element, props_to_ignore=("name",), controllable=True):
671
        Gtk.Box.__init__(self)
672
        Loggable.__init__(self)
673
674
        self.element = element
        self.ignore = props_to_ignore
675
        self.properties = {}
676
677
        # Maps caps fields to the corresponding widgets.
        self.caps_widgets = {}
678
        self.__controllable = controllable
679
        self.set_orientation(Gtk.Orientation.VERTICAL)
680
681
682
683
684
        self.__bindings_by_keyframe_button = {}
        self.__widgets_by_keyframe_button = {}
        self.__widgets_by_reset_button = {}
        self._unhandled_properties = []
        self.uncontrolled_properties = {}
685
        self.updating_property = False
686

687
    def deactivate_keyframe_toggle_buttons(self):
Alexandru Băluț's avatar
Alexandru Băluț committed
688
        """Makes sure the keyframe togglebuttons are deactivated."""
689
        self.log("Deactivating all keyframe toggle buttons")
Alexandru Băluț's avatar
Alexandru Băluț committed
690
        for keyframe_button in self.__widgets_by_keyframe_button:
691
692
693
694
695
696
            if keyframe_button.get_active():
                # Deactivate the button. The only effect should be that
                # the keyframe curve will control again the default property.
                keyframe_button.set_active(False)
                # There can be only one active keyframes button.
                break
697

698
699
    def show_widget(self, widget):
        self.pack_start(widget, True, True, 0)
700
        disable_scroll(widget)
701
702
        self.show_all()

703
    def map_builder(self, builder):
704
705
706
707
708
709
710
711
712
713
714
715
716
        """Maps the GStreamer element's properties to corresponding widgets in @builder.

        Prop control widgets should be named "element_name::prop_name", where:
        - element_name is the gstreamer element (ex: the "alpha" effect)
        - prop_name is the name of one of a particular property of the element
        If present, a reset button corresponding to the property will be used
        (the button must be named similarly, with "::reset" after the prop name)
        A button named reset_all_button can also be provided and will be used as
        a fallback for each property without an individual reset button.
        Similarly, the keyframe control button corresponding to the property (if controllable)
        can be used whose name is to be "element_name::prop_name::keyframe".
        """
        reset_all_button = builder.get_object("reset_all_button")
717
        for prop in self._get_properties():
718
719
720
721
722
723
724
725
726
727
728
729
730
731
            widget_name = prop.owner_type.name + "::" + prop.name
            widget = builder.get_object(widget_name)
            if widget is None:
                self._unhandled_properties.append(prop)
                self.warning("No custom widget found for %s property \"%s\"" %
                             (prop.owner_type.name, prop.name))
            else:
                reset_name = widget_name + "::" + "reset"
                reset_widget = builder.get_object(reset_name)
                if not reset_widget:
                    # If reset_all_button is not found, it will be None
                    reset_widget = reset_all_button
                keyframe_name = widget_name + "::" + "keyframe"
                keyframe_widget = builder.get_object(keyframe_name)
732
                self.add_property_widget(prop, widget, reset_widget, keyframe_widget)
733

734
    def add_property_widget(self, prop, widget, to_default_btn=None, keyframe_btn=None):
735
736
737
738
        """Connects an element property to a GTK Widget.

        Optionally, a reset button widget can also be provided.
        Unless you want to connect each widget individually, you should be using
739
        the "map_builder" method instead.
740
741
742
743
744
745
746
747
748
749
750
751
        """
        if isinstance(widget, DynamicWidget):
            # if the widget is already a DynamicWidget we use it as is
            dynamic_widget = widget
        else:
            # if the widget is not dynamic we try to create a wrapper around it
            # so we can control it with the standardized DynamicWidget API
            dynamic_widget = make_widget_wrapper(prop, widget)

        if dynamic_widget:
            self.properties[prop] = dynamic_widget

Alexandru Băluț's avatar
Alexandru Băluț committed
752
            self.element.connect("notify::" + prop.name, self._property_changed_cb,
753
754
755
756
757
758
759
760
761
762
                                 dynamic_widget)
            # The "reset to default" button associated with this property
            if isinstance(to_default_btn, Gtk.Button):
                self.__widgets_by_reset_button[to_default_btn] = widget
                to_default_btn.connect("clicked", self.__reset_to_default_clicked_cb, dynamic_widget, keyframe_btn)
            elif to_default_btn is not None:
                self.warning("to_default_btn should be Gtk.Button or None, got %s", to_default_btn)

            # The "keyframe toggle" button associated with this property
            if not isinstance(widget, (ToggleWidget, ChoiceWidget)):
Alexandru Băluț's avatar
Alexandru Băluț committed
763
                res, element, unused_pspec = self.element.lookup_child(prop.name)
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
                assert res
                binding = GstController.DirectControlBinding.new(
                    element, prop.name,
                    GstController.InterpolationControlSource())
                if binding.pspec:
                    # The prop can be controlled (keyframed).
                    if isinstance(keyframe_btn, Gtk.ToggleButton):
                        keyframe_btn.connect("toggled", self.__keyframes_toggled_cb, prop)
                        self.__widgets_by_keyframe_button[keyframe_btn] = widget
                        prop_binding = self.element.get_control_binding(prop.name)
                        self.__bindings_by_keyframe_button[keyframe_btn] = prop_binding
                        self.__display_controlled(keyframe_btn, bool(prop_binding))
                    elif keyframe_btn is not None:
                        self.warning("keyframe_btn should be Gtk.ToggleButton or None, got %s", to_default_btn)
        else:
            # If we add a non-standard widget, the creator of the widget is
            # responsible for handling its behaviour "by hand"
            self.info("Can not wrap widget %s for property %s" % (widget, prop))
            # We still keep a ref to that widget, "just in case"
            self.uncontrolled_properties[prop] = widget

        if hasattr(prop, "blurb"):
            widget.set_tooltip_text(prop.blurb)

788
    def _get_properties(self):
789
790
791
792
793
794
        if isinstance(self.element, GES.BaseEffect):
            props = self.element.list_children_properties()
        else:
            props = GObject.list_properties(self.element)
        return [prop for prop in props if prop.name not in self.ignore]

795
796
797
798
799
800
801
802
803
804
805
    def __add_widget_to_grid(self, grid, nick, widget, y):
        if isinstance(widget, ToggleWidget):
            widget.set_label(nick)
            grid.attach(widget, 0, y, 2, 1)
        else:
            text = _("%(preference_label)s:") % {"preference_label": nick}
            label = Gtk.Label(label=text)
            label.props.yalign = 0.5
            grid.attach(label, 0, y, 1, 1)
            grid.attach(widget, 1, y, 1, 1)

806
    def add_widgets(self, create_property_widget_func, values=None, with_reset_button=False, caps_values=None):
Alexandru Băluț's avatar
Alexandru Băluț committed
807
        """Prepares a Gtk.Grid containing the property widgets of an element.
808
809

        Each property is on a separate row.
810
811
        A row is typically a label followed by the widget and a reset button.

812
        If there are no properties, returns a "No properties" label.
813
814

        Args:
815
816
            create_property_widget_func (function): The function that creates
                the widget for an effect property.
817
818
819
820
            values (dict): The current values of the element props, by name.
                If empty, the default values will be used.
            with_reset_button (bool): Whether to show a reset button for each
                property.
Alexandru Băluț's avatar
Alexandru Băluț committed
821
            caps_values (Optional[dict]): Map of caps fields to their values.
822
        """
823
        values = values or {}
824
        self.info("element: %s, use values: %s", self.element, values)
825
        self.properties.clear()
826
827
        self.__bindings_by_keyframe_button = {}
        self.__widgets_by_keyframe_button = {}
828
829
        is_effect = isinstance(self.element, GES.Effect)
        if is_effect:
830
831
            props = [prop for prop in self.element.list_children_properties()
                     if prop.name not in self.ignore]
832
        else:
833
834
            props = [prop for prop in GObject.list_properties(self.element)
                     if prop.name not in self.ignore]
Thibault Saunier's avatar