1# -*- coding: utf-8 -*- 2# 3# This file is part of MyPaint. 4# Copyright (C) 2007-2019 by the MyPaint Development Team. 5# Copyright (C) 2007-2014 by Martin Renold <martinxyz@gmx.ch> 6# 7# This program is free software; you can redistribute it and/or modify 8# it under the terms of the GNU General Public License as published by 9# the Free Software Foundation; either version 2 of the License, or 10# (at your option) any later version. 11 12"""Main drawing window. 13 14Painting is done in tileddrawwidget.py. 15""" 16 17## Imports 18 19from __future__ import division, print_function 20 21import os 22import os.path 23import webbrowser 24from warnings import warn 25import logging 26import math 27import xml.etree.ElementTree as ET 28 29from lib.gibindings import Gtk 30from lib.gibindings import Gdk 31 32from . import compatibility 33from . import historypopup 34from . import stategroup 35from . import colorpicker # noqa: F401 (registration of GObject classes) 36from . import windowing # noqa: F401 (registration of GObject classes) 37from . import toolbar 38from . import dialogs 39from . import layermodes # noqa: F401 (registration of GObject classes) 40from . import quickchoice 41import gui.viewmanip # noqa: F401 (registration of GObject classes) 42import gui.layermanip # noqa: F401 (registration of GObject classes) 43from lib.color import HSVColor 44from . import uicolor 45import gui.picker 46import gui.footer 47from . import brushselectionwindow # noqa: F401 (registration) 48from .overlays import LastPaintPosOverlay 49from .overlays import ScaleOverlay 50from .framewindow import FrameOverlay 51from .symmetry import SymmetryOverlay 52import gui.tileddrawwidget 53import gui.displayfilter 54import gui.meta 55import lib.xml 56import lib.glib 57from lib.gettext import gettext as _ 58from lib.gettext import C_ 59 60logger = logging.getLogger(__name__) 61 62 63## Module constants 64 65BRUSHPACK_URI = 'https://github.com/mypaint/mypaint/wiki/Brush-Packages' 66 67 68## Class definitions 69 70class DrawWindow (Gtk.Window): 71 """Main drawing window""" 72 73 ## Class configuration 74 75 __gtype_name__ = 'MyPaintDrawWindow' 76 77 _MODE_ICON_TEMPLATE = "<b>{name}</b>\n{description}" 78 79 #: Constructor callables and canned args for named quick chooser 80 #: instances. Used by _get_quick_chooser(). 81 _QUICK_CHOOSER_CONSTRUCT_INFO = { 82 "BrushChooserPopup": ( 83 quickchoice.BrushChooserPopup, [], 84 ), 85 "ColorChooserPopup": ( 86 quickchoice.ColorChooserPopup, [], 87 ), 88 "ColorChooserPopupFastSubset": ( 89 quickchoice.ColorChooserPopup, ["fast_subset", True], 90 ), 91 } 92 93 ## Initialization and lifecycle 94 95 def __init__(self): 96 super(DrawWindow, self).__init__() 97 98 import gui.application 99 app = gui.application.get_app() 100 self.app = app 101 self.app.kbm.add_window(self) 102 103 # Window handling 104 self._updating_toggled_item = False 105 self.is_fullscreen = False 106 107 # Enable drag & drop 108 drag_targets = [ 109 Gtk.TargetEntry.new("text/uri-list", 0, 1), 110 Gtk.TargetEntry.new("application/x-color", 0, 2) 111 ] 112 drag_flags = ( 113 Gtk.DestDefaults.MOTION | 114 Gtk.DestDefaults.HIGHLIGHT | 115 Gtk.DestDefaults.DROP) 116 drag_actions = Gdk.DragAction.DEFAULT | Gdk.DragAction.COPY 117 self.drag_dest_set(drag_flags, drag_targets, drag_actions) 118 119 # Connect events 120 self.connect('delete-event', self.quit_cb) 121 self.connect("drag-data-received", self._drag_data_received_cb) 122 self.connect("window-state-event", self.window_state_event_cb) 123 124 # Deferred setup 125 self._done_realize = False 126 self.connect("realize", self._realize_cb) 127 128 self.app.filehandler.current_file_observers.append(self.update_title) 129 130 # Named quick chooser instances 131 self._quick_choosers = {} 132 133 # Park the focus on the main tdw rather than on the toolbar. Default 134 # activation doesn't really mean much for MyPaint's main window, so 135 # it's safe to do this and it looks better. 136 # self.main_widget.set_can_default(True) 137 # self.main_widget.set_can_focus(True) 138 # self.main_widget.grab_focus() 139 140 def _realize_cb(self, drawwindow): 141 # Deferred setup: anything that needs to be done when self.app is fully 142 # initialized. 143 if self._done_realize: 144 return 145 self._done_realize = True 146 147 doc = self.app.doc 148 tdw = doc.tdw 149 assert tdw is self.app.builder.get_object("app_canvas") 150 tdw.display_overlays.append(FrameOverlay(doc)) 151 tdw.display_overlays.append(SymmetryOverlay(doc)) 152 self.update_overlays() 153 self._init_actions() 154 kbm = self.app.kbm 155 kbm.add_extra_key('Menu', 'ShowPopupMenu') 156 kbm.add_extra_key('Tab', 'FullscreenAutohide') 157 self._init_stategroups() 158 159 self._init_menubar() 160 self._init_toolbars() 161 topbar = self.app.builder.get_object("app_topbar") 162 topbar.menubar = self.menubar 163 topbar.toolbar1 = self._toolbar1 164 topbar.toolbar2 = self._toolbar2 165 166 # Workspace setup 167 ws = self.app.workspace 168 ws.tool_widget_added += self.app_workspace_tool_widget_added_cb 169 ws.tool_widget_removed += self.app_workspace_tool_widget_removed_cb 170 171 # Footer bar updates 172 self.app.brush.observers.append(self._update_footer_color_widgets) 173 tdw.transformation_updated += self._update_footer_scale_label 174 doc.modes.changed += self._modestack_changed_cb 175 context_id = self.app.statusbar.get_context_id("active-mode") 176 self._active_mode_context_id = context_id 177 self._update_status_bar_mode_widgets(doc.modes.top) 178 mode_img = self.app.builder.get_object("app_current_mode_icon") 179 mode_img.connect("query-tooltip", self._mode_icon_query_tooltip_cb) 180 mode_img.set_has_tooltip(True) 181 182 # Update picker action sensitivity 183 layerstack = doc.model.layer_stack 184 layerstack.layer_inserted += self._update_layer_pick_action 185 layerstack.layer_deleted += self._update_layer_pick_action 186 187 def _init_actions(self): 188 # Actions are defined in resources.xml. 189 # all we need to do here is connect some extra state management. 190 191 ag = self.action_group = self.app.builder.get_object("WindowActions") 192 self.update_fullscreen_action() 193 194 # Set initial state from user prefs 195 ag.get_action("ToggleScaleFeedback").set_active( 196 self.app.preferences.get("ui.feedback.scale", False)) 197 ag.get_action("ToggleLastPosFeedback").set_active( 198 self.app.preferences.get("ui.feedback.last_pos", False)) 199 200 # Keyboard handling 201 for action in self.action_group.list_actions(): 202 self.app.kbm.takeover_action(action) 203 204 def _init_stategroups(self): 205 sg = stategroup.StateGroup() 206 p2s = sg.create_popup_state 207 hist = p2s(historypopup.HistoryPopup(self.app, self.app.doc.model)) 208 209 self.popup_states = { 210 'ColorHistoryPopup': hist, 211 } 212 213 hist.autoleave_timeout = 0.600 214 self.history_popup_state = hist 215 216 for action_name, popup_state in self.popup_states.items(): 217 label = self.app.find_action(action_name).get_label() 218 popup_state.label = label 219 220 def _init_menubar(self): 221 # Load Menubar, duplicate into self.popupmenu 222 ui_dir = os.path.dirname(os.path.abspath(__file__)) 223 menupath = os.path.join(ui_dir, 'menu.xml') 224 with open(menupath) as fp: 225 menubar_xml = fp.read() 226 self.app.ui_manager.add_ui_from_string(menubar_xml) 227 self.popupmenu = self._clone_menu( 228 menubar_xml, 229 'PopupMenu', 230 self.app.doc.tdw, 231 ) 232 self.menubar = self.app.ui_manager.get_widget('/Menubar') 233 234 def _init_toolbars(self): 235 self._toolbar_manager = toolbar.ToolbarManager(self) 236 self._toolbar1 = self._toolbar_manager.toolbar1 237 self._toolbar2 = self._toolbar_manager.toolbar2 238 239 def _clone_menu(self, xml, name, owner=None): 240 """Menu duplicator 241 242 Hopefully temporary hack for converting UIManager XML describing the 243 main menubar into a rebindable popup menu. UIManager by itself doesn't 244 let you do this, by design, but we need a bigger menu than the little 245 things it allows you to build. 246 """ 247 ui_elt = ET.fromstring(xml) 248 rootmenu_elt = ui_elt.find("menubar") 249 rootmenu_elt.attrib["name"] = name 250 xml = ET.tostring(ui_elt) 251 xml = xml.decode("utf-8") 252 self.app.ui_manager.add_ui_from_string(xml) 253 tmp_menubar = self.app.ui_manager.get_widget('/' + name) 254 popupmenu = Gtk.Menu() 255 for item in tmp_menubar.get_children(): 256 tmp_menubar.remove(item) 257 popupmenu.append(item) 258 if owner is not None: 259 popupmenu.attach_to_widget(owner, None) 260 popupmenu.set_title("MyPaint") 261 popupmenu.connect("selection-done", self.popupmenu_done_cb) 262 popupmenu.connect("deactivate", self.popupmenu_done_cb) 263 popupmenu.connect("cancel", self.popupmenu_done_cb) 264 self.popupmenu_last_active = None 265 return popupmenu 266 267 def update_title(self, filename): 268 if filename: 269 # TRANSLATORS: window title for use with a filename 270 title_base = _("%s - MyPaint") % os.path.basename(filename) 271 else: 272 # TRANSLATORS: window title for use without a filename 273 title_base = _("MyPaint") 274 # Show whether legacy 1.x compatibility mode is active 275 if self.app.compat_mode == compatibility.C1X: 276 compat_str = " (%s)" % C_("Prefs Dialog|Compatibility", "1.x") 277 else: 278 compat_str = "" 279 self.set_title(title_base + compat_str) 280 281 def _drag_data_received_cb(self, widget, context, x, y, data, info, time): 282 """Handles data being received""" 283 rawdata = data.get_data() 284 if not rawdata: 285 return 286 if info == 1: # file uris 287 # Perhaps these should be handled as layers instead now? 288 # Though .ORA files should probably still replace the entire 289 # working file. 290 uri = rawdata.split("\r\n")[0] 291 file_path, _h = lib.glib.filename_from_uri(uri) 292 if os.path.exists(file_path): 293 ok_to_open = self.app.filehandler.confirm_destructive_action( 294 title = C_( 295 u'Open dragged file confirm dialog: title', 296 u"Open Dragged File?", 297 ), 298 confirm = C_( 299 u'Open dragged file confirm dialog: continue button', 300 u"_Open", 301 ), 302 ) 303 if ok_to_open: 304 self.app.filehandler.open_file(file_path) 305 elif info == 2: # color 306 color = uicolor.from_drag_data(rawdata) 307 self.app.brush_color_manager.set_color(color) 308 self.app.brush_color_manager.push_history(color) 309 310 ## Window and dockpanel handling 311 312 def reveal_dockpanel_cb(self, action): 313 """Action callback: reveal a dockpanel in its current location. 314 315 This adds the related dockpanel if it has not yet been added to 316 the workspace. In fullscreen mode, the action also acts to show 317 the sidebar or floating window which contains the dockpanel. 318 It also brings its tab to the fore. 319 320 The panel's name is parsed from the action name. An action name 321 of 'RevealFooPanel' relates to a panel whose GType-system class 322 name is "MyPaintFooPanel". Old-style "Tool" suffixes are 323 supported too, but are deprecated. 324 325 """ 326 action_name = action.get_name() 327 if not action_name.startswith("Reveal"): 328 raise ValueError("Action's name must start with 'Reveal'") 329 type_name = action_name.replace("Reveal", "", 1) 330 if not (type_name.endswith("Tool") or type_name.endswith("Panel")): 331 raise ValueError("Action's name must end with 'Panel' or 'Tool'") 332 gtype_name = "MyPaint" + type_name 333 workspace = self.app.workspace 334 workspace.reveal_tool_widget(gtype_name, []) 335 336 def toggle_dockpanel_cb(self, action): 337 """Action callback: add or remove a dockpanel from the UI.""" 338 action_name = action.get_name() 339 type_name = action_name 340 for prefix in ["Toggle"]: 341 if type_name.startswith(prefix): 342 type_name = type_name.replace(prefix, "", 1) 343 break 344 if not (type_name.endswith("Tool") or type_name.endswith("Panel")): 345 raise ValueError("Action's name must end with 'Panel' or 'Tool'") 346 gtype_name = "MyPaint" + type_name 347 workspace = self.app.workspace 348 added = workspace.get_tool_widget_added(gtype_name, []) 349 active = action.get_active() 350 if active and not added: 351 workspace.add_tool_widget(gtype_name, []) 352 elif added and not active: 353 workspace.remove_tool_widget(gtype_name, []) 354 355 def toggle_window_cb(self, action): 356 """Handles a variety of window-toggling GtkActions. 357 358 Handled here: 359 360 * Workspace-managed dockpanels which require no constructor args. 361 * Regular app subwindows, exposed via its get_subwindow() method. 362 363 """ 364 action_name = action.get_name() 365 if action_name.endswith("Tool") or action_name.endswith("Panel"): 366 self.toggle_dockpanel_cb(action) 367 elif self.app.has_subwindow(action_name): 368 window = self.app.get_subwindow(action_name) 369 active = action.get_active() 370 visible = window.get_visible() 371 if active: 372 if not visible: 373 window.show_all() 374 window.present() 375 elif visible: 376 if not active: 377 window.hide() 378 else: 379 logger.warning("unknown window or tool %r" % (action_name,)) 380 381 def app_workspace_tool_widget_added_cb(self, ws, widget): 382 gtype_name = widget.__gtype_name__ 383 self._set_tool_widget_related_toggleaction_active(gtype_name, True) 384 385 def app_workspace_tool_widget_removed_cb(self, ws, widget): 386 gtype_name = widget.__gtype_name__ 387 self._set_tool_widget_related_toggleaction_active(gtype_name, False) 388 389 def _set_tool_widget_related_toggleaction_active(self, gtype_name, active): 390 active = bool(active) 391 assert gtype_name.startswith("MyPaint") 392 for prefix in ("Toggle", ""): 393 action_name = gtype_name.replace("MyPaint", prefix, 1) 394 action = self.app.builder.get_object(action_name) 395 if action and isinstance(action, Gtk.ToggleAction): 396 if bool(action.get_active()) != active: 397 action.set_active(active) 398 break 399 400 ## Feedback and overlays 401 402 # It's not intended that all categories of feedback will use 403 # overlays, but they currently all do. This may change now we have a 404 # conventional statusbar for textual types of feedback. 405 406 def toggle_scale_feedback_cb(self, action): 407 self.app.preferences['ui.feedback.scale'] = action.get_active() 408 self.update_overlays() 409 410 def toggle_last_pos_feedback_cb(self, action): 411 self.app.preferences['ui.feedback.last_pos'] = action.get_active() 412 self.update_overlays() 413 414 def update_overlays(self): 415 # Updates the list of overlays on the main doc's TDW to match the prefs 416 doc = self.app.doc 417 disp_overlays = [ 418 ('ui.feedback.scale', ScaleOverlay), 419 ('ui.feedback.last_pos', LastPaintPosOverlay), 420 ] 421 overlays_changed = False 422 for key, class_ in disp_overlays: 423 current_instance = None 424 for ov in doc.tdw.display_overlays: 425 if isinstance(ov, class_): 426 current_instance = ov 427 active = self.app.preferences.get(key, False) 428 if active and not current_instance: 429 doc.tdw.display_overlays.append(class_(doc)) 430 overlays_changed = True 431 elif current_instance and not active: 432 doc.tdw.display_overlays.remove(current_instance) 433 overlays_changed = True 434 if overlays_changed: 435 doc.tdw.queue_draw() 436 437 ## Popup windows and dialogs 438 439 def popup_cb(self, action): 440 """Action callback: show a popup window (old mechanism)""" 441 warn( 442 "The old UI states mechanism is scheduled for replacement. " 443 "Don't use this in new code.", 444 PendingDeprecationWarning, 445 stacklevel=2, 446 ) 447 state = self.popup_states[action.get_name()] 448 state.activate(action) 449 450 def _get_quick_chooser(self, name): 451 """Get a named quick chooser instance (factory method)""" 452 chooser = self._quick_choosers.get(name) 453 if not chooser: 454 ctor_info = self._QUICK_CHOOSER_CONSTRUCT_INFO.get(name) 455 ctor, extra_args = ctor_info 456 args = [self.app] + list(extra_args) 457 chooser = ctor(*args) 458 self._quick_choosers[name] = chooser 459 return chooser 460 461 def _popup_quick_chooser(self, name): 462 """Pops up a named quick chooser instance, hides the others""" 463 chooser = self._get_quick_chooser(name) 464 if chooser.get_visible(): 465 chooser.advance() 466 return 467 for other_name in self._QUICK_CHOOSER_CONSTRUCT_INFO: 468 if other_name == name: 469 continue 470 other_chooser = self._quick_choosers.get(other_name) 471 if other_chooser and other_chooser.get_visible(): 472 other_chooser.hide() 473 chooser.popup() 474 475 def quick_chooser_popup_cb(self, action): 476 """Action callback: show the named quick chooser (new system)""" 477 chooser_name = action.get_name() 478 self._popup_quick_chooser(chooser_name) 479 480 @property 481 def brush_chooser(self): 482 """Property: the brush chooser""" 483 return self._get_quick_chooser("BrushChooserPopup") 484 485 @property 486 def color_chooser(self): 487 """Property: the primary color chooser""" 488 return self._get_quick_chooser("ColorChooserPopup") 489 490 def color_details_dialog_cb(self, action): 491 mgr = self.app.brush_color_manager 492 new_col = dialogs.ask_for_color( 493 title=_("Set current color"), 494 color=mgr.get_color(), 495 previous_color=mgr.get_previous_color(), 496 parent=self, 497 ) 498 if new_col is not None: 499 mgr.set_color(new_col) 500 501 ## Subwindows 502 503 def fullscreen_autohide_toggled_cb(self, action): 504 workspace = self.app.workspace 505 workspace.autohide_enabled = action.get_active() 506 507 # Fullscreen mode 508 # This implementation requires an ICCCM and EWMH-compliant window manager 509 # which supports the _NET_WM_STATE_FULLSCREEN hint. There are several 510 # available. 511 512 def fullscreen_cb(self, *junk): 513 if not self.is_fullscreen: 514 self.fullscreen() 515 else: 516 self.unfullscreen() 517 518 def window_state_event_cb(self, widget, event): 519 # Respond to changes of the fullscreen state only 520 if not event.changed_mask & Gdk.WindowState.FULLSCREEN: 521 return 522 self.is_fullscreen = ( 523 event.new_window_state & Gdk.WindowState.FULLSCREEN 524 ) 525 self.update_fullscreen_action() 526 # Reset all state for the top mode on the stack. Mainly for 527 # freehand modes: https://github.com/mypaint/mypaint/issues/39 528 mode = self.app.doc.modes.top 529 mode.leave() 530 mode.enter(doc=self.app.doc) 531 # The alternative is to use checkpoint(), but if freehand were 532 # to reinit its workarounds, that might cause glitches. 533 534 def update_fullscreen_action(self): 535 action = self.action_group.get_action("Fullscreen") 536 if self.is_fullscreen: 537 action.set_icon_name("mypaint-unfullscreen-symbolic") 538 action.set_tooltip(_("Leave Fullscreen Mode")) 539 action.set_label(_("Leave Fullscreen")) 540 else: 541 action.set_icon_name("mypaint-fullscreen-symbolic") 542 action.set_tooltip(_("Enter Fullscreen Mode")) 543 action.set_label(_("Fullscreen")) 544 545 def popupmenu_show_cb(self, action): 546 self.show_popupmenu() 547 548 def show_popupmenu(self, event=None): 549 self.menubar.set_sensitive(False) # excessive feedback? 550 button = 1 551 time = 0 552 if event is not None: 553 if event.type == Gdk.EventType.BUTTON_PRESS: 554 button = event.button 555 time = event.time 556 # GTK3: arguments have a different order, and "data" is required. 557 # GTK3: Use keyword arguments for max compatibility. 558 self.popupmenu.popup(parent_menu_shell=None, parent_menu_item=None, 559 func=None, button=button, activate_time=time, 560 data=None) 561 if event is None: 562 # We're responding to an Action, most probably the menu key. 563 # Open out the last highlighted menu to speed key navigation up. 564 if self.popupmenu_last_active is None: 565 self.popupmenu.select_first(True) # one less keypress 566 else: 567 self.popupmenu.select_item(self.popupmenu_last_active) 568 569 def popupmenu_done_cb(self, *a, **kw): 570 # Not sure if we need to bother with this level of feedback, 571 # but it actually looks quite nice to see one menu taking over 572 # the other. Makes it clear that the popups are the same thing as 573 # the full menu, maybe. 574 self.menubar.set_sensitive(True) 575 self.popupmenu_last_active = self.popupmenu.get_active() 576 577 ## Scratchpad menu options 578 579 def save_scratchpad_as_default_cb(self, action): 580 self.app.filehandler.save_scratchpad( 581 self.app.filehandler.get_scratchpad_default(), 582 export=True, 583 ) 584 585 def clear_default_scratchpad_cb(self, action): 586 self.app.filehandler.delete_default_scratchpad() 587 588 def new_scratchpad_cb(self, action): 589 app = self.app 590 default_scratchpad_path = app.filehandler.get_scratchpad_default() 591 if os.path.isfile(default_scratchpad_path): 592 app.filehandler.open_scratchpad(default_scratchpad_path) 593 else: 594 scratchpad_model = app.scratchpad_doc.model 595 scratchpad_model.clear() 596 self._copy_main_background_to_scratchpad() 597 scratchpad_path = app.filehandler.get_scratchpad_autosave() 598 app.scratchpad_filename = scratchpad_path 599 app.preferences['scratchpad.last_opened'] = scratchpad_path 600 601 def load_scratchpad_cb(self, action): 602 if self.app.scratchpad_filename: 603 self.save_current_scratchpad_cb(action) 604 current_pad = self.app.scratchpad_filename 605 else: 606 current_pad = self.app.filehandler.get_scratchpad_autosave() 607 self.app.filehandler.open_scratchpad_dialog() 608 609 # Check to see if a file has been opened 610 # outside of the scratchpad directory 611 path_abs = os.path.abspath(self.app.scratchpad_filename) 612 pfx_abs = os.path.abspath(self.app.filehandler.get_scratchpad_prefix()) 613 if not path_abs.startswith(pfx_abs): 614 # file is NOT within the scratchpad directory - 615 # load copy as current scratchpad 616 self.app.preferences['scratchpad.last_opened'] = current_pad 617 self.app.scratchpad_filename = current_pad 618 619 def save_as_scratchpad_cb(self, action): 620 self.app.filehandler.save_scratchpad_as_dialog() 621 622 def revert_current_scratchpad_cb(self, action): 623 filename = self.app.scratchpad_filename 624 if os.path.isfile(filename): 625 self.app.filehandler.open_scratchpad(filename) 626 logger.info("Reverted scratchpad to %s" % (filename,)) 627 else: 628 logger.warning("No file to revert to yet.") 629 630 def save_current_scratchpad_cb(self, action): 631 self.app.filehandler.save_scratchpad(self.app.scratchpad_filename) 632 633 def scratchpad_copy_background_cb(self, action): 634 self._copy_main_background_to_scratchpad() 635 636 def _copy_main_background_to_scratchpad(self): 637 app = self.app 638 if not app.scratchpad_doc: 639 return 640 main_model = app.doc.model 641 main_bg_layer = main_model.layer_stack.background_layer 642 scratchpad_model = app.scratchpad_doc.model 643 scratchpad_model.layer_stack.set_background(main_bg_layer) 644 645 ## Palette actions 646 647 def palette_next_cb(self, action): 648 mgr = self.app.brush_color_manager 649 newcolor = mgr.palette.move_match_position(1, mgr.get_color()) 650 if newcolor: 651 mgr.set_color(newcolor) 652 # Show the palette panel if hidden 653 workspace = self.app.workspace 654 workspace.reveal_tool_widget("MyPaintPaletteTool", []) 655 656 def palette_prev_cb(self, action): 657 mgr = self.app.brush_color_manager 658 newcolor = mgr.palette.move_match_position(-1, mgr.get_color()) 659 if newcolor: 660 mgr.set_color(newcolor) 661 # Show the palette panel if hidden 662 workspace = self.app.workspace 663 workspace.reveal_tool_widget("MyPaintPaletteTool", []) 664 665 def palette_add_current_color_cb(self, *args, **kwargs): 666 """Append the current color to the palette (action or clicked cb)""" 667 mgr = self.app.brush_color_manager 668 color = mgr.get_color() 669 mgr.palette.append(color, name=None, unique=True, match=True) 670 # Show the palette panel if hidden 671 workspace = self.app.workspace 672 workspace.reveal_tool_widget("MyPaintPaletteTool", []) 673 674 ## Miscellaneous actions 675 676 def quit_cb(self, *junk): 677 self.app.doc.model.sync_pending_changes() 678 self.app.save_gui_config() # FIXME: should do this periodically 679 ok_to_quit = self.app.filehandler.confirm_destructive_action( 680 title = C_( 681 "Quit confirm dialog: title", 682 u"Really Quit?", 683 ), 684 confirm = C_( 685 "Quit confirm dialog: continue button", 686 u"_Quit", 687 ), 688 ) 689 if not ok_to_quit: 690 return True 691 692 self.app.doc.model.cleanup() 693 self.app.profiler.cleanup() 694 Gtk.main_quit() 695 return False 696 697 def download_brush_pack_cb(self, *junk): 698 uri = BRUSHPACK_URI 699 logger.info('Opening URI %r in web browser', uri) 700 webbrowser.open(uri) 701 702 def import_brush_pack_cb(self, *junk): 703 format_id, filename = dialogs.open_dialog( 704 _(u"Import brush package…"), self, 705 [(_("MyPaint brush package (*.zip)"), "*.zip")] 706 ) 707 if not filename: 708 return 709 imported = self.app.brushmanager.import_brushpack(filename, self) 710 logger.info("Imported brush groups %r", imported) 711 workspace = self.app.workspace 712 for groupname in imported: 713 workspace.reveal_tool_widget("MyPaintBrushGroupTool", (groupname,)) 714 715 ## Information dialogs 716 717 # TODO: Move into dialogs.py? 718 719 def about_cb(self, action): 720 gui.meta.run_about_dialog(self, self.app) 721 722 def show_online_help_cb(self, action): 723 # The online help texts are migrating to the wiki for v1.2.x. 724 wiki_base = "https://github.com/mypaint/mypaint/wiki/" 725 action_name = action.get_name() 726 # TODO: these page names should be localized. 727 help_page = { 728 "OnlineHelpIndex": "v1.2-User-Manual", 729 "OnlineHelpBrushShortcutKeys": "v1.2-Brush-Shortcut-Keys", 730 }.get(action_name) 731 if help_page: 732 help_uri = wiki_base + help_page 733 logger.info('Opening URI %r in web browser', help_uri) 734 webbrowser.open(help_uri) 735 else: 736 raise RuntimeError("Unknown online help %r" % action_name) 737 738 ## Footer bar stuff 739 740 def _update_footer_color_widgets(self, settings): 741 """Updates the footer bar color info when the brush color changes.""" 742 if not settings.intersection(('color_h', 'color_s', 'color_v')): 743 return 744 bm_btn_name = "footer_bookmark_current_color_button" 745 bm_btn = self.app.builder.get_object(bm_btn_name) 746 brush_color = HSVColor(*self.app.brush.get_color_hsv()) 747 palette = self.app.brush_color_manager.palette 748 bm_btn.set_sensitive(brush_color not in palette) 749 750 def _update_footer_scale_label(self, renderer): 751 """Updates the footer's scale label when the transformation changes""" 752 label = self.app.builder.get_object("app_canvas_scale_label") 753 scale = renderer.scale * 100.0 754 rotation = (renderer.rotation / (2*math.pi)) % 1.0 755 if rotation > 0.5: 756 rotation -= 1.0 757 rotation *= 360.0 758 try: 759 template = label.__template 760 except AttributeError: 761 template = label.get_label() 762 label.__template = template 763 params = { 764 "scale": scale, 765 "rotation": rotation 766 } 767 label.set_text(template.format(**params)) 768 769 def _modestack_changed_cb(self, modestack, old, new): 770 self._update_status_bar_mode_widgets(new) 771 772 def _update_status_bar_mode_widgets(self, mode): 773 """Updates widgets on the status bar that reflect the current mode""" 774 # Update the status bar 775 statusbar = self.app.statusbar 776 context_id = self._active_mode_context_id 777 statusbar.pop(context_id) 778 statusbar_msg = u"{usage!s}".format(name=mode.get_name(), 779 usage=mode.get_usage()) 780 statusbar.push(context_id, statusbar_msg) 781 # Icon 782 icon_name = mode.get_icon_name() 783 icon_size = Gtk.IconSize.SMALL_TOOLBAR 784 mode_img = self.app.builder.get_object("app_current_mode_icon") 785 if not icon_name: 786 icon_name = "missing-image" 787 mode_img.set_from_icon_name(icon_name, icon_size) 788 789 def _mode_icon_query_tooltip_cb(self, widget, x, y, kbmode, tooltip): 790 mode = self.app.doc.modes.top 791 icon_name = mode.get_icon_name() 792 if not icon_name: 793 icon_name = "missing-image" 794 icon_size = Gtk.IconSize.DIALOG 795 tooltip.set_icon_from_icon_name(icon_name, icon_size) 796 description = None 797 action = mode.get_action() 798 if action: 799 description = action.get_tooltip() 800 if not description: 801 description = mode.get_usage() 802 params = { 803 "name": lib.xml.escape(mode.get_name()), 804 "description": lib.xml.escape(description) 805 } 806 markup = self._MODE_ICON_TEMPLATE.format(**params) 807 tooltip.set_markup(markup) 808 return True 809 810 def _footer_color_details_button_realize_cb(self, button): 811 action = self.app.find_action("ColorDetailsDialog") 812 button.set_related_action(action) 813 814 ## Footer picker buttons 815 816 def _footer_context_picker_button_realize_cb(self, button): 817 presenter = gui.picker.ButtonPresenter() 818 presenter.set_button(button) 819 presenter.set_picking_grab(self.app.context_grab) 820 self._footer_context_picker_button_presenter = presenter 821 822 def _footer_color_picker_button_realize_cb(self, button): 823 presenter = gui.picker.ButtonPresenter() 824 presenter.set_button(button) 825 presenter.set_picking_grab(self.app.color_grab) 826 self._footer_color_picker_button_presenter = presenter 827 828 ## Footer indicator widgets 829 830 def _footer_brush_indicator_drawingarea_realize_cb(self, drawarea): 831 presenter = gui.footer.BrushIndicatorPresenter() 832 presenter.set_drawing_area(drawarea) 833 presenter.set_brush_manager(self.app.brushmanager) 834 presenter.set_chooser(self.brush_chooser) 835 self._footer_brush_indicator_presenter = presenter 836 837 ## Picker actions (PickLayer, PickContext) 838 839 # App-wide really, but they can be handled here sensibly while 840 # there's only one window. 841 842 def pick_context_cb(self, action): 843 """Pick Context action: select layer and brush from stroke""" 844 # Get the controller owning most recently moved painted to or 845 # moved over view widget as its primary tdw. 846 # That controller points at the doc we want to pick from. 847 doc = self.app.doc.get_active_instance() 848 if not doc: 849 return 850 x, y = doc.tdw.get_pointer_in_model_coordinates() 851 doc.pick_context(x, y, action) 852 853 def pick_layer_cb(self, action): 854 """Pick Layer action: select the layer under the pointer""" 855 doc = self.app.doc.get_active_instance() 856 if not doc: 857 return 858 x, y = doc.tdw.get_pointer_in_model_coordinates() 859 doc.pick_layer(x, y, action) 860 861 def _update_layer_pick_action(self, layerstack, *_ignored): 862 """Updates the Layer Picking action's sensitivity""" 863 # PickContext is always sensitive, however 864 pickable = len(layerstack) > 1 865 self.app.find_action("PickLayer").set_sensitive(pickable) 866 867 ## Display filter choice 868 869 def _display_filter_radioaction_changed_cb(self, action, newaction): 870 """Handle changes to the Display Filter radioaction set.""" 871 newaction_name = newaction.get_name() 872 newfilter = { 873 "DisplayFilterNone": None, 874 "DisplayFilterLumaOnly": gui.displayfilter.luma_only, 875 "DisplayFilterInvertColors": gui.displayfilter.invert_colors, 876 "DisplayFilterSimDeuteranopia": gui.displayfilter.sim_deuteranopia, 877 "DisplayFilterSimProtanopia": gui.displayfilter.sim_protanopia, 878 "DisplayFilterSimTritanopia": gui.displayfilter.sim_tritanopia, 879 }.get(newaction_name) 880 for tdw in gui.tileddrawwidget.TiledDrawWidget.get_visible_tdws(): 881 if tdw.renderer.display_filter is newfilter: 882 continue 883 logger.debug("Updating display_filter on %r to %r", tdw, newfilter) 884 tdw.renderer.display_filter = newfilter 885 tdw.queue_draw() 886