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