1# -*- coding: utf-8 -*-
2# This file is part of MyPaint.
3# Copyright (C) 2019 by the MyPaint Development Team
4#
5# This program is free software; you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation; either version 2 of the License, or
8# (at your option) any later version.
9
10from logging import getLogger
11
12from lib.gibindings import Gtk
13
14from . import compatconfig as config
15
16from .compatconfig import C1X, C2X, COMPAT_SETTINGS, DEFAULT_COMPAT
17
18import lib.eotf
19from lib.layer.data import BackgroundLayer
20from lib.meta import Compatibility, PREREL, MYPAINT_VERSION
21from lib.modes import MODE_STRINGS, set_default_mode
22from lib.mypaintlib import CombineNormal, CombineSpectralWGM
23from lib.mypaintlib import combine_mode_get_info
24from lib.gettext import C_
25
26logger = getLogger(__name__)
27
28FILE_WARNINGS = {
29    Compatibility.INCOMPATIBLE: 'ui.file_compat_warning_severe',
30    Compatibility.PARTIALLY: 'ui.file_compat_warning_mild',
31}
32
33_FILE_OPEN_OPTIONS = [
34    ('', C_("File Load Compat Options", "Based on file")),
35    (C1X, C_("Prefs Dialog|Compatibility", "1.x")),
36    (C2X, C_("Prefs Dialog|Compatibility", "2.x")),
37]
38
39FILE_WARNING_MSGS = {
40    Compatibility.INCOMPATIBLE: C_(
41        "file compatibility warning",
42        # TRANSLATORS: This is probably a rare warning, and it will not
43        # TRANSLATORS: really be shown at all before the release of 3.0
44        u"“{filename}” was saved with <b>MyPaint {new_version}</b>."
45        " It may be <b>incompatible</b> with <b>MyPaint {current_version}</b>."
46        "\n\n"
47        "Editing this file with this version of MyPaint is not guaranteed"
48        " to work, and may even result in crashes."
49        "\n\n"
50        "It is <b>strongly recommended</b> to upgrade to <b>MyPaint"
51        " {new_version}</b> or newer if you want to edit this file!"),
52    Compatibility.PARTIALLY: C_(
53        "file compatibility warning",
54        u"“{filename}” was saved with <b>MyPaint {new_version}</b>. "
55        "It may not be fully compatible with <b>Mypaint {current_version}</b>."
56        "\n\n"
57        "Saving it with this version of MyPaint may result in data"
58        " that is only supported by the newer version being lost."
59        "\n\n"
60        "To be safe you should upgrade to MyPaint {new_version} or newer."),
61}
62
63OPEN_ANYWAY = C_(
64    "file compatibility question",
65    "Do you want to open this file anyway?"
66)
67
68_PIGMENT_OP = combine_mode_get_info(CombineSpectralWGM)['name']
69
70
71def has_pigment_layers(elem):
72    """Check if the layer stack xml contains a pigment layer
73
74    Has to be done before any layers are loaded, since the
75    correct eotf value needs to set before loading the tiles.
76    """
77    # Ignore the composite op of the background.
78    # We only need to check for the namespaced attribute, as
79    # any file containing the non-namespaced counterpart was
80    # created prior (version-wise) to pigment layers.
81    bg_attr = BackgroundLayer.ORA_BGTILE_ATTR
82    if elem.get(bg_attr, None):
83        return False
84    op = elem.attrib.get('composite-op', None)
85    return op == _PIGMENT_OP or any([has_pigment_layers(c) for c in elem])
86
87
88def incompatible_ora_cb(app):
89    def cb(comp_type, prerel, filename, target_version):
90        """ Internal: callback that may show a confirmation/warning dialog
91
92        Unless disabled in settings, when a potentially
93        incompatible ora is opened, a warning dialog is
94        shown, allowing users to cancel the loading.
95
96        """
97        if comp_type == Compatibility.FULLY:
98            return True
99        logger.warning(
100            "Loaded file “{filename}” may be {compat_desc}!\n"
101            "App version: {version}, File version: {file_version}".format(
102                filename=filename,
103                compat_desc=Compatibility.DESC[comp_type],
104                version=lib.meta.MYPAINT_VERSION,
105                file_version=target_version
106            ))
107        if prerel and comp_type > Compatibility.INCOMPATIBLE and PREREL != '':
108            logger.info("Warning dialog skipped in prereleases.")
109            return True
110        return incompatible_ora_warning_dialog(
111            comp_type, prerel, filename, target_version, app)
112    return cb
113
114
115def incompatible_ora_warning_dialog(
116        comp_type, prerel, filename, target_version, app):
117    # Skip the dialog if the user has disabled the warning
118    # for this level of incompatibility
119    warn = app.preferences.get(FILE_WARNINGS[comp_type], True)
120    if not warn:
121        return True
122
123    # Toggle allowing users to disable future warnings directly
124    # in the dialog, this is configurable in the settings too.
125    # The checkbutton code is pretty much copied from the filehandling
126    # save-to-scrap checkbutton; a lot of duplication.
127    skip_warning_text = C_(
128        "Version compat warning toggle",
129        u"Don't show this warning again"
130    )
131    skip_warning_button = Gtk.CheckButton.new()
132    skip_warning_button.set_label(skip_warning_text)
133    skip_warning_button.set_hexpand(False)
134    skip_warning_button.set_vexpand(False)
135    skip_warning_button.set_halign(Gtk.Align.END)
136    skip_warning_button.set_margin_top(12)
137    skip_warning_button.set_margin_bottom(12)
138    skip_warning_button.set_margin_start(12)
139    skip_warning_button.set_margin_end(12)
140    skip_warning_button.set_can_focus(False)
141
142    def skip_warning_toggled(checkbut):
143        app.preferences[FILE_WARNINGS[comp_type]] = not checkbut.get_active()
144        app.preferences_window.compat_preferences.update_ui()
145    skip_warning_button.connect("toggled", skip_warning_toggled)
146
147    def_msg = "Invalid key, report this! key={key}".format(key=comp_type)
148    msg_markup = FILE_WARNING_MSGS.get(comp_type, def_msg).format(
149        filename=filename,
150        new_version=target_version,
151        current_version=MYPAINT_VERSION
152    ) + "\n\n" + OPEN_ANYWAY
153    d = Gtk.MessageDialog(
154        transient_for=app.drawWindow,
155        buttons=Gtk.ButtonsType.NONE,
156        modal=True,
157        message_type=Gtk.MessageType.WARNING,
158    )
159    d.set_markup(msg_markup)
160
161    vbox = d.get_content_area()
162    vbox.set_spacing(0)
163    vbox.set_margin_top(12)
164    vbox.pack_start(skip_warning_button, False, True, 0)
165
166    d.add_button(Gtk.STOCK_NO, Gtk.ResponseType.REJECT)
167    d.add_button(Gtk.STOCK_YES, Gtk.ResponseType.ACCEPT)
168    d.set_default_response(Gtk.ResponseType.REJECT)
169
170    # Without this, the check button takes initial focus
171    def show_checkbut(*args):
172        skip_warning_button.show()
173        skip_warning_button.set_can_focus(True)
174    d.connect("show", show_checkbut)
175
176    response = d.run()
177    d.destroy()
178    return response == Gtk.ResponseType.ACCEPT
179
180
181class CompatFileBehavior(config.CompatFileBehaviorConfig):
182    """ Holds data and functions related to per-file choice of compat mode
183    """
184    _CFBC = config.CompatFileBehaviorConfig
185    _OPTIONS = [
186        _CFBC.ALWAYS_1X,
187        _CFBC.ALWAYS_2X,
188        _CFBC.UNLESS_PIGMENT_LAYER_1X,
189    ]
190    _LABELS = {
191        _CFBC.ALWAYS_1X: (
192            C_(
193                "Prefs Dialog|Compatibility",
194                # TRANSLATORS: One of the options for the
195                # TRANSLATORS: "When Not Specified in File"
196                # TRANSLATORS: compatibility setting.
197                "Always open in 1.x mode"
198            )
199        ),
200        _CFBC.ALWAYS_2X: (
201            C_(
202                "Prefs Dialog|Compatibility",
203                # TRANSLATORS: One of the options for the
204                # TRANSLATORS: "When Not Specified in File"
205                # TRANSLATORS: compatibility setting.
206                "Always open in 2.x mode"
207            )
208        ),
209        _CFBC.UNLESS_PIGMENT_LAYER_1X: (
210            C_(
211                "Prefs Dialog|Compatibility",
212                # TRANSLATORS: One of the options for the
213                # TRANSLATORS: "When Not Specified in File"
214                # TRANSLATORS: compatibility setting.
215                "Open in 1.x mode unless file contains pigment layers"
216            )
217        ),
218    }
219
220    def __init__(self, combobox, prefs):
221        self.combo = combobox
222        self.prefs = prefs
223        options_store = Gtk.ListStore()
224        options_store.set_column_types((str, str))
225        for option in self._OPTIONS:
226            options_store.append((option, self._LABELS[option]))
227        combobox.set_model(options_store)
228
229        cell = Gtk.CellRendererText()
230        combobox.pack_start(cell, True)
231        combobox.add_attribute(cell, 'text', 1)
232        self.update_ui()
233        combobox.connect('changed', self.changed_cb)
234
235    def update_ui(self):
236        self.combo.set_active_id(self.prefs[self.SETTING])
237
238    def changed_cb(self, combo):
239        active_id = self.combo.get_active_id()
240        self.prefs[self.SETTING] = active_id
241
242    @staticmethod
243    def get_compat_mode(setting, root_elem, default):
244        """ Get the compat mode to use for a file
245
246        The decision is based on the given file behavior setting
247        and the layer stack xml.
248        """
249        # If more options are added, rewrite to use separate classes.
250        if setting == CompatFileBehavior.ALWAYS_1X:
251            return C1X
252        elif setting == CompatFileBehavior.ALWAYS_2X:
253            return C2X
254        elif setting == CompatFileBehavior.UNLESS_PIGMENT_LAYER_1X:
255            if has_pigment_layers(root_elem):
256                logger.info("Pigment layer found!")
257                return C2X
258            else:
259                return C1X
260        else:
261            msg = "Unknown file compat setting: {setting}, using default mode."
262            logger.warning(msg.format(setting=setting))
263            return default
264
265
266class CompatibilityPreferences:
267    """ A single instance should be a part of the preference window
268
269    This class handles preferences related to the compatibility modes
270    and their settings.
271    """
272
273    def __init__(self, app, builder):
274        self.app = app
275        self._builder = builder
276        # Widget references
277        getobj = builder.get_object
278        # Default compat mode choice radio buttons
279        self.default_radio_1_x = getobj('compat_1_x_radiobutton')
280        self.default_radio_2_x = getobj('compat_2_x_radiobutton')
281        # For each mode, choice for whether pigment is on or off by default
282        self.pigment_switch_1_x = getobj('pigment_setting_switch_1_x')
283        self.pigment_switch_2_x = getobj('pigment_setting_switch_2_x')
284        # For each mode, choice of which layer type is the default
285        self.pigment_radio_1_x = getobj('def_new_layer_pigment_1_x')
286        self.pigment_radio_2_x = getobj('def_new_layer_pigment_2_x')
287        self.normal_radio_1_x = getobj('def_new_layer_normal_1_x')
288        self.normal_radio_2_x = getobj('def_new_layer_normal_2_x')
289
290        def file_warning_cb(level):
291            def cb(checkbut):
292                app.preferences[FILE_WARNINGS[level]] = checkbut.get_active()
293            return cb
294
295        self.file_warning_mild = getobj('file_compat_warning_mild')
296        self.file_warning_mild.connect(
297            "toggled", file_warning_cb(Compatibility.PARTIALLY))
298
299        self.file_warning_severe = getobj('file_compat_warning_severe')
300        self.file_warning_severe.connect(
301            "toggled", file_warning_cb(Compatibility.INCOMPATIBLE))
302
303        self.compat_file_behavior = CompatFileBehavior(
304            getobj('compat_file_behavior_combobox'), self.app.preferences)
305        # Initialize widgets and callbacks
306        self.setup_layer_type_strings()
307        self.setup_widget_callbacks()
308
309    def setup_widget_callbacks(self):
310        """ Hook up callbacks for switches and radiobuttons
311        """
312        # Convenience wrapper - here it is enough to act when toggling on,
313        # so ignore callbacks triggered by radio buttons being toggled off.
314        def ignore_detoggle(cb_func):
315            def cb(btn, *args):
316                if btn.get_active():
317                    cb_func(btn, *args)
318            return cb
319
320        # Connect default layer type toggles
321        layer_type_cb = ignore_detoggle(self.set_compat_layer_type_cb)
322        self.normal_radio_1_x.connect('toggled', layer_type_cb, C1X, False)
323        self.pigment_radio_1_x.connect('toggled', layer_type_cb, C1X, True)
324        self.normal_radio_2_x.connect('toggled', layer_type_cb, C2X, False)
325        self.pigment_radio_2_x.connect('toggled', layer_type_cb, C2X, True)
326
327        def_compat_cb = ignore_detoggle(self.set_default_compat_mode_cb)
328        self.default_radio_1_x.connect('toggled', def_compat_cb, C1X)
329        self.default_radio_2_x.connect('toggled', def_compat_cb, C2X)
330
331        pigment_switch_cb = self.default_pigment_changed_cb
332        self.pigment_switch_1_x.connect('state-set', pigment_switch_cb, C1X)
333        self.pigment_switch_2_x.connect('state-set', pigment_switch_cb, C2X)
334
335    def setup_layer_type_strings(self):
336        """ Replace the placeholder labels and add tooltips
337        """
338        def string_setup(widget, label, tooltip):
339            widget.set_label(label)
340            widget.set_tooltip_text(tooltip)
341
342        normal_label, normal_tooltip = MODE_STRINGS[CombineNormal]
343        string_setup(self.normal_radio_1_x, normal_label, normal_tooltip)
344        string_setup(self.normal_radio_2_x, normal_label, normal_tooltip)
345        pigment_label, pigment_tooltip = MODE_STRINGS[CombineSpectralWGM]
346        string_setup(self.pigment_radio_1_x, pigment_label, pigment_tooltip)
347        string_setup(self.pigment_radio_2_x, pigment_label, pigment_tooltip)
348
349    def update_ui(self):
350        prefs = self.app.preferences
351        # File warnings update (can be changed from confirmation dialogs)
352        self.file_warning_mild.set_active(
353            prefs.get(FILE_WARNINGS[Compatibility.PARTIALLY], True))
354        self.file_warning_severe.set_active(
355            prefs.get(FILE_WARNINGS[Compatibility.INCOMPATIBLE], True))
356
357        # Even in a radio button group with 2 widgets, using set_active(False)
358        # will not toggle the other button on, hence this ugly pattern.
359        if prefs.get(DEFAULT_COMPAT, C2X) == C1X:
360            self.default_radio_1_x.set_active(True)
361        else:
362            self.default_radio_2_x.set_active(True)
363        mode_settings = prefs[COMPAT_SETTINGS]
364        # 1.x
365        self.pigment_switch_1_x.set_active(
366            mode_settings[C1X][config.PIGMENT_BY_DEFAULT])
367        if mode_settings[C1X][config.PIGMENT_LAYER_BY_DEFAULT]:
368            self.pigment_radio_1_x.set_active(True)
369        else:
370            self.normal_radio_1_x.set_active(True)
371        # 2.x
372        self.pigment_switch_2_x.set_active(
373            mode_settings[C2X][config.PIGMENT_BY_DEFAULT])
374        if mode_settings[C2X][config.PIGMENT_LAYER_BY_DEFAULT]:
375            self.pigment_radio_2_x.set_active(True)
376        else:
377            self.normal_radio_2_x.set_active(True)
378
379    def _update_prefs(self, mode, setting, value):
380        prefs = self.app.preferences
381        prefs[COMPAT_SETTINGS][mode].update({setting: value})
382
383    # Widget callbacks
384
385    def set_default_compat_mode_cb(self, radiobutton, compat_mode):
386        self.app.preferences[DEFAULT_COMPAT] = compat_mode
387
388    def set_compat_layer_type_cb(self, btn, mode, use_pigment):
389        self._update_prefs(mode, config.PIGMENT_LAYER_BY_DEFAULT, use_pigment)
390        update_default_layer_type(self.app)
391
392    def default_pigment_changed_cb(self, switch, use_pigment, mode):
393        self._update_prefs(mode, config.PIGMENT_BY_DEFAULT, use_pigment)
394        update_default_pigment_setting(self.app)
395
396
397def ora_compat_handler(app):
398    def handler(eotf_value, root_stack_elem):
399        default = app.preferences[DEFAULT_COMPAT]
400        if eotf_value is not None:
401            try:
402                eotf_value = float(eotf_value)
403                compat = C1X if eotf_value == 1.0 else C2X
404            except ValueError:
405                msg = "Invalid eotf: {eotf}, using default compat mode!"
406                logger.warning(msg.format(eotf=eotf_value))
407                eotf_value = None
408                compat = default
409        else:
410            logger.info("No eotf value specified in openraster file")
411            # Depending on user settings, decide whether to
412            # use the default value for the eotf, or the legacy value of 1.0
413            setting = app.preferences[CompatFileBehavior.SETTING]
414            compat = CompatFileBehavior.get_compat_mode(
415                setting, root_stack_elem, default)
416        set_compat_mode(app, compat, custom_eotf=eotf_value)
417    return handler
418
419
420def set_compat_mode(app, compat_mode, custom_eotf=None, update=True):
421    """Set compatibility mode
422
423    Set compatibility mode and update associated settings;
424    default pigment brush setting and default layer type.
425    If the "update" keyword is set to False, the settings
426    are not updated.
427
428    If the compatibility mode is changed, the scratchpad is
429    saved and reloaded under the new mode settings.
430    """
431    if compat_mode not in {C1X, C2X}:
432        compat_mode = C2X
433        msg = "Unknown compatibility mode: '{mode}'! Using 2.x instead."
434        logger.warning(msg.format(mode=compat_mode))
435    changed = compat_mode != app.compat_mode
436    app.compat_mode = compat_mode
437    # Save scratchpad (with current eotf)
438    if update and changed:
439        app.drawWindow.save_current_scratchpad_cb(None)
440    # Change eotf and set new compat mode
441    if compat_mode == C1X:
442        logger.info("Setting mode to 1.x (legacy)")
443        lib.eotf.set_eotf(1.0)
444    else:
445        logger.info("Setting mode to 2.x (standard)")
446        lib.eotf.set_eotf(custom_eotf or lib.eotf.base_eotf())
447    if update and changed:
448        # Reload scratchpad (with new eotf)
449        app.drawWindow.revert_current_scratchpad_cb(None)
450        for f in app.brush.observers:
451            f({'color_h', 'color_s', 'color_v'})
452        update_default_layer_type(app)
453        update_default_pigment_setting(app)
454
455
456def update_default_layer_type(app):
457    """Update default layer type from settings
458    """
459    prefs = app.preferences
460    mode_settings = prefs[COMPAT_SETTINGS][app.compat_mode]
461    if mode_settings[config.PIGMENT_LAYER_BY_DEFAULT]:
462        logger.info("Setting default layer type to Pigment")
463        set_default_mode(CombineSpectralWGM)
464    else:
465        logger.info("Setting default layer type to Normal")
466        set_default_mode(CombineNormal)
467
468
469def update_default_pigment_setting(app):
470    """Update default pigment brush setting value
471    """
472    prefs = app.preferences
473    mode_settings = prefs[COMPAT_SETTINGS][app.compat_mode]
474    app.brushmanager.set_pigment_by_default(
475        mode_settings[config.PIGMENT_BY_DEFAULT]
476    )
477
478
479class CompatSelector:
480    """ A dropdown menu with file loading compatibility options
481
482    If a file was accidentally set to use the wrong mode, these
483    options are used to force opening in a particular mode.
484    """
485
486    def __init__(self, app):
487        self.app = app
488        combo = Gtk.ComboBox()
489        store = Gtk.ListStore()
490        store.set_column_types((str, str))
491        for k, v in _FILE_OPEN_OPTIONS:
492            store.append((k, v))
493        combo.set_model(store)
494        combo.set_active(0)
495        cell = Gtk.CellRendererText()
496        combo.pack_start(cell, True)
497        combo.add_attribute(cell, 'text', 1)
498        combo_label = Gtk.Label(
499            # TRANSLATORS: This is a label for a dropdown menu in the
500            # TRANSLATORS: file chooser dialog when loading .ora files.
501            label=C_("File Load Compat Options", "Compatibility mode:")
502        )
503        hbox = Gtk.HBox()
504        hbox.set_spacing(6)
505        hbox.pack_start(combo_label, False, False, 0)
506        hbox.pack_start(combo, False, False, 0)
507        hbox.show_all()
508        hbox.set_visible(False)
509        self._compat_override = None
510        self._combo = combo
511        combo.connect('changed', self._combo_changed_cb)
512        self._widget = hbox
513
514    def _combo_changed_cb(self, combo):
515        idx = combo.get_active()
516        if idx >= 0:
517            self._compat_override = _FILE_OPEN_OPTIONS[idx][0]
518        else:
519            self._compat_override = None
520
521    def file_selection_changed_cb(self, chooser):
522        """ Show/hide widget and enable/disable override
523        """
524        fn = chooser.get_filename()
525        applicable = fn is not None and fn.endswith('.ora')
526        self.widget.set_visible(applicable)
527        if not applicable:
528            self._compat_override = None
529        else:
530            self._combo_changed_cb(self._combo)
531
532    @property
533    def widget(self):
534        return self._widget
535
536    @property
537    def compat_function(self):
538        """ Returns an overriding compatibility handler or None
539        """
540        if self._compat_override:
541            return lambda *a: set_compat_mode(self.app, self._compat_override)
542