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