new layer merging, convert layer to normal mode
[mypaint:mypaint.git] / gui / document.py
1 # -*- coding: utf-8 -*-
2 #
3 # This file is part of MyPaint.
4 # Copyright (C) 2007-2010 by Martin Renold <martinxyz@gmx.ch>
5 #
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
10
11 import os, math
12
13 import pygtkcompat
14 import gobject
15 import gtk
16 from gtk import gdk
17 from gettext import gettext as _
18
19 import lib.document
20 from lib import backgroundsurface, command, helpers, layer
21 import tileddrawwidget, stategroup
22 from brushmanager import ManagedBrush
23 import dialogs
24 import canvasevent
25 import linemode
26
27
28 class CanvasController (object):
29     """Minimal canvas controller using a stack of modes.
30
31     Basic CanvasController objects can be set up to handle scroll events like
32     zooming or rotation only, pointer events like drawing only, or both.
33
34     The actual interpretation of each event is delegated to the top item on the
35     controller's modes stack: see `gui.canvasevent.CanvasInteractionMode` for
36     details. Simpler modes may assume the basic CanvasController interface,
37     more complex ones 
38
39     """
40
41     # NOTE: if muliple views of a single model are required, this interface
42     # will have to be revised.
43
44
45     def __init__(self, tdw):
46         """Initialize.
47
48         :param tdw: The view widget to attach handlers onto.
49         :type tdw: gui.tileddrawwidget.TiledDrawWidget
50
51         """
52         object.__init__(self)
53         self.tdw = tdw     #: the TiledDrawWidget being controlled.
54         self.modes = canvasevent.ModeStack(self)  #: stack of delegates
55
56
57     def init_pointer_events(self):
58         """Establish TDW event listeners for pointer button presses & drags.
59         """
60         self.tdw.connect("button-press-event", self.button_press_cb)
61         self.tdw.connect("motion-notify-event", self.motion_notify_cb)
62         self.tdw.connect("button-release-event", self.button_release_cb)
63
64
65     def init_scroll_events(self):
66         """Establish TDW event listeners for scroll-wheel actions.
67         """
68         self.tdw.connect("scroll-event", self.scroll_cb)
69
70
71     def button_press_cb(self, tdw, event):
72         """Delegates a ``button-press-event`` to the top mode in the stack.
73         """
74         result = self.modes.top.button_press_cb(tdw, event)
75         self.__update_last_event_info(tdw, event)
76         return result
77
78
79     def button_release_cb(self, tdw, event):
80         """Delegates a ``button-release-event`` to the top mode in the stack.
81         """
82         result = self.modes.top.button_release_cb(tdw, event)
83         self.__update_last_event_info(tdw, event)
84         return result
85
86
87     def motion_notify_cb(self, tdw, event):
88         """Delegates a ``motion-notify-event`` to the top mode in the stack.
89         """
90         result = self.modes.top.motion_notify_cb(tdw, event)
91         self.__update_last_event_info(tdw, event)
92         return result
93
94
95     def scroll_cb(self, tdw, event):
96         """Delegates a ``scroll-event`` to the top mode in the stack.
97         """
98         result = self.modes.top.scroll_cb(tdw, event)
99         self.__update_last_event_info(tdw, event)
100         return result
101
102
103     def __update_last_event_info(self, tdw, event):
104         # Update the stored details of the last event delegated.
105         tdw.__last_event_x = event.x
106         tdw.__last_event_y = event.y
107         tdw.__last_event_time = event.time
108
109
110     def get_last_event_info(self, tdw):
111         """Get details of the last event delegated to a mode in the stack.
112
113         :rtype tuple: ``(time, x, y)``
114
115         """
116         t, x, y = 0, None, None
117         try:
118             t = tdw.__last_event_time
119             x = tdw.__last_event_x
120             y = tdw.__last_event_y
121         except AttributeError:
122             pass
123         return (t, x, y)
124
125
126
127 class Document (CanvasController):
128     """Manipulation of a loaded document via the the GUI.
129
130     A `gui.Document` is something like a Controller in the MVC sense: it
131     translates GtkAction activations and keypresses for changing the view into
132     View (`gui.tileddrawwidget`) manipulations. It is also responsible for
133     directly manipulating the Model (`lib.document`) in response to actions
134     and keypresses, for example manipulating the layer stack.
135
136     Some per-application state can be manipulated through this object too: for
137     example the drawing brush which is owned by the main application
138     singleton.
139
140     """
141
142     # Layers have this attr set temporarily if they don't have a name yet
143     _NONAME_LAYER_REFNUM_ATTR = "_document_noname_ref_number"
144
145
146     def __init__(self, app, leader=None):
147         self.app = app
148         self.model = lib.document.Document(self.app.brush)
149         tdw = tileddrawwidget.TiledDrawWidget(self.app, self.model)
150         CanvasController.__init__(self, tdw)
151         self.modes.observers.append(self.mode_stack_changed_cb)
152
153         # Pass on certain actions to other gui.documents.
154         self.followers = []
155
156         self.model.frame_observers.append(self.frame_changed_cb)
157         self.model.symmetry_observers.append(self.update_symmetry_toolitem)
158
159         # Deferred until after the app starts (runs in the first idle-
160         # processing phase) as a workaround for https://gna.org/bugs/?14372
161         # ([Windows] crash when moving the pen during startup)
162         gobject.idle_add(self.init_pointer_events)
163         gobject.idle_add(self.init_scroll_events)
164
165         # Input stroke observers
166         self.input_stroke_ended_observers = []
167         """Callbacks interested in the end of an input stroke.
168
169         Observers are called with the GTK event as their only argument. This
170         is a good place to listen for "just painted something" events in some
171         cases; app.brush will contain everything needed about the input stroke
172         which is ending.
173
174         An input stroke is a single button-down, move, button-up
175         action. This sort of stroke is not the same as a brush engine
176         stroke (see ``lib.document``). It is possible that the visible
177         stroke starts earlier and ends later, depending on how the
178         operating system maps pressure to button up/down events.
179         """
180
181         self.input_stroke_started_observers = []
182         """See `self.input_stroke_ended_observers`"""
183
184         # FIXME: hack, to be removed
185         fname = os.path.join(self.app.datapath, 'backgrounds', '03_check1.png')
186         pixbuf = gdk.pixbuf_new_from_file(fname)
187         self.tdw.neutral_background_pixbuf = backgroundsurface.Background(pixbuf)
188
189         self.zoomlevel_values = [1.0/16, 1.0/8, 2.0/11, 0.25, 1.0/3, 0.50, 2.0/3,  # micro
190                                  1.0, 1.5, 2.0, 3.0, 4.0, 5.5, 8.0,        # normal
191                                  11.0, 16.0, 23.0, 32.0, 45.0, 64.0]       # macro
192
193         default_zoom = self.app.preferences['view.default_zoom']
194         self.tdw.scale = default_zoom
195         self.tdw.zoom_min = min(self.zoomlevel_values)
196         self.tdw.zoom_max = max(self.zoomlevel_values)
197
198         # Device-specific brushes: save at end of stroke
199         self.input_stroke_ended_observers.append(self.input_stroke_ended_cb)
200
201         self.init_stategroups()
202         if leader is not None:
203             # This is a side conteoller (e.g. the scratchpad) which plays
204             # follow-the- leader for some events.
205             assert isinstance(leader, Document)
206             leader.followers.append(self)
207             self.action_group = leader.action_group # hack, but needed by tdw
208         else:
209             # This doc owns the Actions which are (sometimes) passed on to
210             # followers to perform. It's model is also the main 'document'
211             # being worked on by the user.
212             self.init_actions()
213             self.init_context_actions()
214             for action in self.action_group.list_actions():
215                 self.app.kbm.takeover_action(action)
216             for action in self.modes_action_group.list_actions():
217                 self.app.kbm.takeover_action(action)
218             self.init_extra_keys()
219
220             toggle_action = self.app.builder.get_object('ContextRestoreColor')
221             toggle_action.set_active(self.app.preferences['misc.context_restores_color'])
222
223     def init_actions(self):
224         # Actions are defined in mypaint.xml, just grab a ref to the groups
225         self.action_group = self.app.builder.get_object('DocumentActions')
226         self.modes_action_group = self.app.builder.get_object("ModeStackActions")
227
228         # Set up certain actions to reflect model state changes
229         self.model.command_stack_observers.append(
230                 self.update_command_stack_toolitems)
231         self.update_command_stack_toolitems(self.model.command_stack)
232         self.model.doc_observers.append(self.model_structure_changed_cb)
233         self.model_structure_changed_cb(self.model)
234
235
236     def init_context_actions(self):
237         ag = self.action_group
238         context_actions = []
239         for x in range(10):
240             r = ('Context0%d' % x, None, _('Restore Brush %d') % x,
241                  '%d' % x, None, self.context_cb)
242             s = ('Context0%ds' % x, None, _('Save to Brush %d') % x,
243                  '<control>%d' % x, None, self.context_cb)
244             context_actions.append(s)
245             context_actions.append(r)
246         ag.add_actions(context_actions)
247
248
249     def init_stategroups(self):
250         sg = stategroup.StateGroup()
251         self.layerblink_state = sg.create_state(self.layerblink_state_enter,
252                                                 self.layerblink_state_leave)
253         sg = stategroup.StateGroup()
254         self.strokeblink_state = sg.create_state(self.strokeblink_state_enter,
255                                                  self.strokeblink_state_leave)
256         self.strokeblink_state.autoleave_timeout = 0.3
257
258         # separate stategroup...
259         sg2 = stategroup.StateGroup()
260         self.layersolo_state = sg2.create_state(self.layersolo_state_enter,
261                                                 self.layersolo_state_leave)
262         self.layersolo_state.autoleave_timeout = None
263
264
265     def init_extra_keys(self):
266         # The keyboard shortcuts below are not visible in the menu.
267         # Shortcuts assigned through the menu will take precedence.
268         # If we assign the same key twice, the last one will work.
269         k = self.app.kbm.add_extra_key
270
271         k('bracketleft', 'Smaller') # GIMP, Photoshop, Painter
272         k('bracketright', 'Bigger') # GIMP, Photoshop, Painter
273         k('<control>bracketleft', 'RotateLeft') # Krita
274         k('<control>bracketright', 'RotateRight') # Krita
275         k('less', 'LessOpaque') # GIMP
276         k('greater', 'MoreOpaque') # GIMP
277         k('equal', 'ZoomIn') # (on US keyboard next to minus)
278         k('comma', 'Smaller') # Krita
279         k('period', 'Bigger') # Krita
280
281         k('BackSpace', 'ClearLayer')
282
283         k('<control>z', 'Undo')
284         k('<control>y', 'Redo')
285         k('<control><shift>z', 'Redo')
286         k('<control>w', lambda(action): self.app.drawWindow.quit_cb())
287         k('KP_Add', 'ZoomIn')
288         k('KP_Subtract', 'ZoomOut')
289         k('KP_4', 'RotateLeft') # Blender
290         k('KP_6', 'RotateRight') # Blender
291         k('KP_5', 'ResetRotation')
292         k('plus', 'ZoomIn')
293         k('minus', 'ZoomOut')
294         k('<control>plus', 'ZoomIn') # Krita
295         k('<control>minus', 'ZoomOut') # Krita
296         k('bar', 'Symmetry')
297
298         k('Left', lambda(action): self.pan('PanLeft'))
299         k('Right', lambda(action): self.pan('PanRight'))
300         k('Down', lambda(action): self.pan('PanDown'))
301         k('Up', lambda(action): self.pan('PanUp'))
302
303         k('<control>Left', 'RotateLeft')
304         k('<control>Right', 'RotateRight')
305
306
307     # GENERIC
308     def undo_cb(self, action):
309         cmd = self.model.undo()
310         if isinstance(cmd, command.MergeLayer):
311             # show otherwise invisible change (hack...)
312             self.layerblink_state.activate()
313
314
315     def redo_cb(self, action):
316         cmd = self.model.redo()
317         if isinstance(cmd, command.MergeLayer):
318             # show otherwise invisible change (hack...)
319             self.layerblink_state.activate()
320
321
322     def copy_cb(self, action):
323         # use the full document bbox, so we can paste layers back to the correct position
324         bbox = self.model.get_bbox()
325         if bbox.w == 0 or bbox.h == 0:
326             print "WARNING: empty document, nothing copied"
327             return
328         else:
329             pixbuf = self.model.layer.render_as_pixbuf(*bbox)
330         cb = gtk.Clipboard()
331         cb.set_image(pixbuf)
332
333
334     def paste_cb(self, action):
335         cb = gtk.Clipboard()
336         def callback(clipboard, pixbuf, junk):
337             if not pixbuf:
338                 print 'The clipboard doeas not contain any image to paste!'
339                 return
340             # paste to the upper left of our doc bbox (see above)
341             x, y, w, h = self.model.get_bbox()
342             self.model.load_layer_from_pixbuf(pixbuf, x, y)
343         cb.request_image(callback)
344
345
346     def pick_context_cb(self, action):
347         active_tdw = self.tdw.__class__.get_active_tdw()
348         if not self.tdw is active_tdw:
349             for follower in self.followers:
350                 if follower.tdw is active_tdw:
351                     print "passing %s action to %s" % (action.get_name(), follower)
352                     follower.pick_context_cb(action)
353                     return
354             return
355         x, y = self.tdw.get_cursor_in_model_coordinates()
356         for idx, layer in reversed(list(enumerate(self.model.layers))):
357             if layer.locked:
358                 continue
359             if not layer.visible:
360                 continue
361             alpha = layer.get_alpha (x, y, 5) * layer.effective_opacity
362             if alpha > 0.1:
363                 old_layer = self.model.layer
364                 self.model.select_layer(idx)
365                 if self.model.layer != old_layer:
366                     self.layerblink_state.activate()
367
368                 # find the most recent (last) stroke that touches our picking point
369                 si = self.model.layer.get_stroke_info_at(x, y)
370                 if si:
371                     self.restore_brush_from_stroke_info(si)
372                     self.si = si # FIXME: should be a method parameter?
373                     self.strokeblink_state.activate(action)
374                 return
375
376
377     def restore_brush_from_stroke_info(self, strokeinfo):
378         mb = ManagedBrush(self.app.brushmanager)
379         mb.brushinfo.load_from_string(strokeinfo.brush_string)
380         self.app.brushmanager.select_brush(mb)
381         self.app.brushmodifier.restore_context_of_selected_brush()
382
383
384     # LAYER
385     def clear_layer_cb(self, action):
386         self.model.clear_layer()
387
388
389     def remove_layer_cb(self, action):
390         self.model.remove_layer()
391
392     def convert_layer_to_normal_mode_cb(self, action):
393         self.model.convert_layer_to_normal_mode()
394
395     def layer_bg_cb(self, action):
396         idx = self.model.layer_idx - 1
397         if idx < 0:
398             return
399         self.model.select_layer(idx)
400         self.layerblink_state.activate(action)
401
402
403     def layer_fg_cb(self, action):
404         idx = self.model.layer_idx + 1
405         if idx >= len(self.model.layers):
406             return
407         self.model.select_layer(idx)
408         self.layerblink_state.activate(action)
409
410
411     def layer_increase_opacity(self, action):
412         opa = helpers.clamp(self.model.layer.opacity + 0.08, 0.0, 1.0)
413         self.model.set_layer_opacity(opa)
414
415
416     def layer_decrease_opacity(self, action):
417         opa = helpers.clamp(self.model.layer.opacity - 0.08, 0.0, 1.0)
418         self.model.set_layer_opacity(opa)
419
420
421     def solo_layer_cb(self, action):
422         self.layersolo_state.toggle(action)
423
424
425     def new_layer_cb(self, action):
426         insert_idx = self.model.layer_idx
427         if action.get_name() == 'NewLayerFG':
428             insert_idx += 1
429         self.model.add_layer(insert_idx)
430         self.layerblink_state.activate(action)
431
432
433 #     @with_wait_cursor
434     def merge_layer_cb(self, action):
435         if self.model.merge_layer_down():
436             self.layerblink_state.activate(action)
437
438     def toggle_layers_above_cb(self, action):
439         self.tdw.toggle_show_layers_above()
440
441
442     def pick_layer_cb(self, action):
443         x, y = self.tdw.get_cursor_in_model_coordinates()
444         for idx, layer in reversed(list(enumerate(self.model.layers))):
445             if layer.locked:
446                 continue
447             if not layer.visible:
448                 continue
449             alpha = layer.get_alpha (x, y, 5) * layer.effective_opacity
450             if alpha > 0.1:
451                 self.model.select_layer(idx)
452                 self.layerblink_state.activate(action)
453                 return
454         self.model.select_layer(0)
455         self.layerblink_state.activate(action)
456
457
458     def move_layer_in_stack_cb(self, action):
459         """Moves the current layer up or down one slot (action callback)
460
461         The direction the layer moves depends on the action name:
462         "RaiseLayerInStack" or "LowerLayerInStack".
463
464         """
465         current_layer_pos = self.model.layer_idx
466         if action.get_name() == 'RaiseLayerInStack':
467             new_layer_pos = current_layer_pos + 1
468         elif action.get_name() == 'LowerLayerInStack':
469             new_layer_pos = current_layer_pos - 1
470         else:
471             return
472         if new_layer_pos < len(self.model.layers) and new_layer_pos >= 0:
473             self.model.move_layer(current_layer_pos, new_layer_pos,
474                                   select_new=True)
475
476
477     def duplicate_layer_cb(self, action):
478         """Duplicates the current layer (action callback)"""
479         layer = self.model.get_current_layer()
480         name = layer.name
481         if name:
482             name = _("Copy of %s") % name
483         else:
484             layer_num = self.get_number_for_nameless_layer(layer)
485             name = _("Copy of Untitled layer #%d") % layer_num
486         self.model.duplicate_layer(self.model.layer_idx, name)
487
488
489     def rename_layer_cb(self, action):
490         """Prompts for a new name for the current layer (action callback)"""
491         layer = self.model.get_current_layer()
492         new_name = dialogs.ask_for_name(self.app.drawWindow, _("Layer Name"), layer.name)
493         if new_name:
494             self.model.rename_layer(layer, new_name)
495
496
497     def layer_lock_toggle_cb(self, action):
498         layer = self.model.layer
499         if bool(layer.locked) != bool(action.get_active()):
500             self.model.set_layer_locked(action.get_active(), layer)
501
502
503     def layer_visible_toggle_cb(self, action):
504         layer = self.model.layer
505         if bool(layer.visible) != bool(action.get_active()):
506             self.model.set_layer_visibility(action.get_active(), layer)
507
508
509     # BRUSH
510     def brush_bigger_cb(self, action):
511         adj = self.app.brush_adjustment['radius_logarithmic']
512         adj.set_value(adj.get_value() + 0.3)
513
514
515     def brush_smaller_cb(self, action):
516         adj = self.app.brush_adjustment['radius_logarithmic']
517         adj.set_value(adj.get_value() - 0.3)
518
519
520     def more_opaque_cb(self, action):
521         # FIXME: hm, looks this slider should be logarithmic?
522         adj = self.app.brush_adjustment['opaque']
523         adj.set_value(adj.get_value() * 1.8)
524
525
526     def less_opaque_cb(self, action):
527         adj = self.app.brush_adjustment['opaque']
528         adj.set_value(adj.get_value() / 1.8)
529
530
531     def brighter_cb(self, action):
532         h, s, v = self.app.brush.get_color_hsv()
533         v += 0.08
534         if v > 1.0: v = 1.0
535         self.app.brush.set_color_hsv((h, s, v))
536
537
538     def darker_cb(self, action):
539         h, s, v = self.app.brush.get_color_hsv()
540         v -= 0.08
541         # stop a little higher than 0.0, to avoid resetting hue to 0
542         if v < 0.005: v = 0.005
543         self.app.brush.set_color_hsv((h, s, v))
544
545
546     def increase_hue_cb(self,action):
547         h, s, v = self.app.brush.get_color_hsv()
548         e = 0.015
549         h = (h + e) % 1.0
550         self.app.brush.set_color_hsv((h, s, v))
551
552
553     def decrease_hue_cb(self,action):
554         h, s, v = self.app.brush.get_color_hsv()
555         e = 0.015
556         h = (h - e) % 1.0
557         self.app.brush.set_color_hsv((h, s, v))
558
559
560     def purer_cb(self,action):
561         h, s, v = self.app.brush.get_color_hsv()
562         s += 0.08
563         if s > 1.0: s = 1.0
564         self.app.brush.set_color_hsv((h, s, v))
565
566
567     def grayer_cb(self,action):
568         h, s, v = self.app.brush.get_color_hsv()
569         s -= 0.08
570         # stop a little higher than 0.0, to avoid resetting hue to 0
571         if s < 0.005: s = 0.005
572         self.app.brush.set_color_hsv((h, s, v))
573
574
575     def context_cb(self, action):
576         name = action.get_name()
577         store = False
578         bm = self.app.brushmanager
579         if name == 'ContextStore':
580             context = bm.selected_context
581             if not context:
582                 print 'No context was selected, ignoring store command.'
583                 return
584             store = True
585         else:
586             if name.endswith('s'):
587                 store = True
588                 name = name[:-1]
589             i = int(name[-2:])
590             context = bm.contexts[i]
591         bm.selected_context = context
592         if store:
593             context.brushinfo = self.app.brush.clone()
594             context.preview = bm.selected_brush.preview
595             context.save()
596         else:
597             if self.app.preferences['misc.context_restores_color']:
598                 bm.select_brush(context) # restore brush
599                 self.app.brushmodifier.restore_context_of_selected_brush() # restore color
600             else:
601                 bm.select_brush(context)
602
603     def context_toggle_color_cb(self, action):
604         self.app.preferences['misc.context_restores_color'] = bool(action.get_active())
605
606     def strokeblink_state_enter(self):
607         l = layer.Layer()
608         self.si.render_overlay(l)
609         self.tdw.overlay_layer = l
610         self.tdw.queue_draw() # OPTIMIZE: excess
611
612     def strokeblink_state_leave(self, reason):
613         self.tdw.overlay_layer = None
614         self.tdw.queue_draw() # OPTIMIZE: excess
615
616
617     def layerblink_state_enter(self):
618         self.tdw.current_layer_solo = True
619         self.tdw.queue_draw()
620
621     def layerblink_state_leave(self, reason):
622         if self.layersolo_state.active:
623             # FIXME: use state machine concept, maybe?
624             return
625         self.tdw.current_layer_solo = False
626         self.tdw.queue_draw()
627
628
629     def layersolo_state_enter(self):
630         s = self.layerblink_state
631         if s.active:
632             s.leave()
633         self.tdw.current_layer_solo = True
634         self.tdw.queue_draw()
635
636     def layersolo_state_leave(self, reason):
637         self.tdw.current_layer_solo = False
638         self.tdw.queue_draw()
639
640
641     #def blink_layer_cb(self, action):
642     #    self.layerblink_state.activate(action)
643
644
645     def pan(self, command):
646         self.model.split_stroke()
647         allocation = self.tdw.get_allocation()
648         step = min((allocation.width, allocation.height)) / 5
649         if   command == 'PanLeft' : self.tdw.scroll(-step, 0)
650         elif command == 'PanRight': self.tdw.scroll(+step, 0)
651         elif command == 'PanUp'   : self.tdw.scroll(0, -step)
652         elif command == 'PanDown' : self.tdw.scroll(0, +step)
653         else: assert 0
654
655
656     def zoom(self, command):
657         try:
658             zoom_index = self.zoomlevel_values.index(self.tdw.scale)
659         except ValueError:
660             zoom_levels = self.zoomlevel_values[:]
661             zoom_levels.append(self.tdw.scale)
662             zoom_levels.sort()
663             zoom_index = zoom_levels.index(self.tdw.scale)
664
665         if   command == 'ZoomIn' : zoom_index += 1
666         elif command == 'ZoomOut': zoom_index -= 1
667         else: assert 0
668         if zoom_index < 0: zoom_index = 0
669         if zoom_index >= len(self.zoomlevel_values):
670             zoom_index = len(self.zoomlevel_values) - 1
671
672         z = self.zoomlevel_values[zoom_index]
673         self._set_zoom(z)
674
675
676     def _set_zoom(self, z, at_pointer=True):
677         if at_pointer:
678             etime, ex, ey = self.get_last_event_info(self.tdw)
679             self.tdw.set_zoom(z, (ex, ey))
680         else:
681             self.tdw.set_zoom(z)
682
683
684     def rotate(self, command):
685         # Allows easy and quick rotation to 45/90/180 degrees
686         rotation_step = 2*math.pi/16
687         etime, ex, ey = self.get_last_event_info(self.tdw)
688         center = ex, ey
689         if command == 'RotateRight':
690             self.tdw.rotate(+rotation_step, center)
691         else:   # command == 'RotateLeft'
692             self.tdw.rotate(-rotation_step, center)
693
694
695     def zoom_cb(self, action):
696         self.zoom(action.get_name())
697
698
699     def rotate_cb(self, action):
700         self.rotate(action.get_name())
701
702
703     def symmetry_action_toggled_cb(self, action):
704         """Change the model's symmetry state in response to UI events.
705         """
706         alloc = self.tdw.get_allocation()
707         if action.get_active():
708             xmid_d, ymid_d = alloc.width/2.0, alloc.height/2.0
709             xmid_m, ymid_m = self.tdw.display_to_model(xmid_d, ymid_d)
710             if self.model.get_symmetry_axis() != xmid_m:
711                 self.model.set_symmetry_axis(xmid_m)
712         else:
713             if self.model.get_symmetry_axis() is not None:
714                 self.model.set_symmetry_axis(None)
715
716
717     def update_symmetry_toolitem(self):
718         """Updates the UI to reflect changes to the model's symmetry state.
719         """
720         ag = self.action_group
721         action = ag.get_action("Symmetry")
722         new_xmid = self.model.get_symmetry_axis()
723         if new_xmid is None and action.get_active():
724             action.set_active(False)
725         elif (new_xmid is not None) and (not action.get_active()):
726             action.set_active(True)
727
728
729     def mirror_horizontal_cb(self, action):
730         self.tdw.mirror()
731
732
733     def mirror_vertical_cb(self, action):
734         self.tdw.rotate(math.pi)
735         self.tdw.mirror()
736
737
738     def reset_view_cb(self, command):
739         if command is None:
740             command_name = None
741             reset_all = True
742         else:
743             command_name = command.get_name()
744             reset_all = (command_name is None) or ('View' in command_name)
745         if reset_all or ('Rotation' in command_name):
746             self.tdw.set_rotation(0.0)
747         if reset_all or ('Zoom' in command_name):
748             default_zoom = self.app.preferences['view.default_zoom']
749             self._set_zoom(default_zoom)
750         if reset_all or ('Mirror' in command_name):
751             self.tdw.set_mirrored(False)
752         if reset_all:
753             self.tdw.recenter_document()
754         elif 'Fit' in command_name:
755             # View>Fit: fits image within window's borders.
756             junk, junk, w, h = self.tdw.doc.get_effective_bbox()
757             if w == 0:
758                 # When there is nothing on the canvas reset zoom to default.
759                 self.reset_view_cb(None)
760             else:
761                 allocation = self.tdw.get_allocation()
762                 w1, h1 = allocation.width, allocation.height
763                 # Store radians and reset rotation to zero.
764                 radians = self.tdw.rotation
765                 self.tdw.set_rotation(0.0)
766                 # Store mirror and temporarily it turn off mirror.
767                 mirror = self.tdw.mirrored
768                 self.tdw.set_mirrored(False)
769                 # Using w h as the unrotated bbox, calculate the bbox of the
770                 # rotated doc.
771                 cos = math.cos(radians)
772                 sin = math.sin(radians)
773                 wcos = w * cos
774                 hsin = h * sin
775                 wsin = w * sin
776                 hcos = h * cos
777                 # We only need to calculate the positions of two corners of the
778                 # bbox since it is centered and symetrical, but take the max
779                 # value since during rotation one corner's distance along the
780                 # x axis shortens while the other lengthens. Same for the y axis.
781                 x = max(abs(wcos - hsin), abs(wcos + hsin))
782                 y = max(abs(wsin + hcos), abs(wsin - hcos))
783                 # Compare the doc and window dimensions and take the best fit
784                 zoom = min((w1-20)/x, (h1-20)/y)
785                 # Reapply all transformations
786                 self.tdw.recenter_document() # Center image
787                 self.tdw.set_rotation(radians) # reapply canvas rotation
788                 self.tdw.set_mirrored(mirror) #reapply mirror
789                 self._set_zoom(zoom, at_pointer=False) # Set new zoom level
790
791
792     # DEBUGGING
793
794     def print_inputs_cb(self, action):
795         self.model.brush.set_print_inputs(action.get_active())
796
797
798     def visualize_rendering_cb(self, action):
799         self.tdw.renderer.visualize_rendering = action.get_active()
800
801
802     def no_double_buffering_cb(self, action):
803         self.tdw.renderer.set_double_buffered(not action.get_active())
804
805
806     # LAST-USED BRUSH STATE
807
808     def input_stroke_ended_cb(self, event):
809         # Store device-specific brush settings at the end of the stroke, not
810         # when the device changes because the user can change brush radii etc.
811         # in the middle of a stroke, and because device_changed_cb won't
812         # respond when the user fiddles with colours, opacity and sizes via the
813         # dialogs.
814         device_name = self.app.preferences.get('devbrush.last_used', None)
815         if device_name is None:
816             return
817         bm = self.app.brushmanager
818         selected_brush = bm.clone_selected_brush(name=None) # for saving
819         bm.store_brush_for_device(device_name, selected_brush)
820         # However it may be better to reflect any brush settings change into
821         # the last-used devbrush immediately. The UI idea here is that the
822         # pointer (when you're holding the pen) is special, it's the point of a
823         # real-world tool that you're dipping into a palette, or modifying
824         # using the sliders.
825
826
827     # MODEL STATE REFLECTION
828
829     def update_command_stack_toolitems(self, stack):
830         # Undo and Redo are shown and hidden, and have their labels updated
831         # in response to user commands.
832         ag = self.action_group
833         undo_action = ag.get_action("Undo")
834         undo_action.set_sensitive(len(stack.undo_stack) > 0)
835         if len(stack.undo_stack) > 0:
836             cmd = stack.undo_stack[-1]
837             desc = _("Undo %s") % cmd.display_name
838         else:
839             desc = _("Undo")  # Used when initializing the prefs dialog
840         undo_action.set_label(desc)
841         undo_action.set_tooltip(desc)
842         redo_action = ag.get_action("Redo")
843         redo_action.set_sensitive(len(stack.redo_stack) > 0)
844         if len(stack.redo_stack) > 0:
845             cmd = stack.redo_stack[-1]
846             desc = _("Redo %s") % cmd.display_name
847         else:
848             desc = _("Redo")  # Used when initializing the prefs dialog
849         redo_action.set_label(desc)
850         redo_action.set_tooltip(desc)
851
852
853     def model_structure_changed_cb(self, doc):
854         # Handle model structural changes.
855         ag = self.action_group
856
857         # Reflect position of current layer in the list.
858         sel_is_top = sel_is_bottom = False
859         sel_is_bottom = doc.layer_idx == 0
860         sel_is_top = doc.layer_idx == len(doc.layers)-1
861         ag.get_action("RaiseLayerInStack").set_sensitive(not sel_is_top)
862         ag.get_action("LowerLayerInStack").set_sensitive(not sel_is_bottom)
863         ag.get_action("LayerFG").set_sensitive(not sel_is_top)
864         ag.get_action("LayerBG").set_sensitive(not sel_is_bottom)
865         ag.get_action("MergeLayer").set_sensitive(not sel_is_bottom)
866         ag.get_action("PickLayer").set_sensitive(len(doc.layers) > 1)
867
868         # The current layer's status
869         layer = doc.layer
870         action = ag.get_action("LayerLockedToggle")
871         if bool(action.get_active()) != bool(layer.locked):
872             action.set_active(bool(layer.locked))
873         action = ag.get_action("LayerVisibleToggle")
874         if bool(action.get_active()) != bool(layer.visible):
875             action.set_active(bool(layer.visible))
876
877         # Active modes.
878         self.modes.top.model_structure_changed_cb(doc)
879
880
881     def frame_changed_cb(self):
882         self.tdw.queue_draw()
883
884
885     def get_number_for_nameless_layer(self, layer):
886         """Assigns a unique integer for otherwise nameless layers
887
888         For use by the layers window, mainly: when presenting the layers stack
889         we need a unique number to make it distinguishable from other layers.
890
891         """
892         assert not layer.name
893         num = getattr(layer, self._NONAME_LAYER_REFNUM_ATTR, None)
894         if num is None:
895             seen_nums = set([0])
896             for l in self.model.layers:
897                 if l.name:
898                     continue
899                 n = getattr(l, self._NONAME_LAYER_REFNUM_ATTR, None)
900                 if n is not None:
901                     seen_nums.add(n)
902             # Hmm. Which method is best?
903             if True:
904                 # High water mark
905                 num = max(seen_nums) + 1
906             else:
907                 # Reuse former IDs
908                 num = len(self.model.layers)
909                 for i in xrange(1, num):
910                     if i not in seen_nums:
911                         num = i
912                         break
913             setattr(layer, self._NONAME_LAYER_REFNUM_ATTR, num)
914         return num
915
916
917     def mode_flip_action_activated_cb(self, action):
918         flip_action_name = action.get_name()
919         assert flip_action_name.startswith("Flip")
920         radio_action_name = flip_action_name.replace("Flip", "", 1)
921         radio_action = self.app.find_action(radio_action_name)
922         if radio_action.get_active():
923             self.modes.pop()
924         else:
925             radio_action.set_active(True)
926
927
928     def mode_radioaction_changed_cb(self, action, current_action):
929         """Callback: GtkRadioAction controlling the modes stack activated.
930         """
931         # Update the mode stack so that its top element matches the newly
932         # chosen action.
933         action_name = current_action.get_name()
934         mode_class = canvasevent.ModeRegistry.get_mode_class(action_name)
935         assert mode_class is not None
936
937         if self.modes.top.__class__ is not mode_class:
938             mode = mode_class()
939             self.modes.context_push(mode)
940
941     def mode_stack_changed_cb(self, mode):
942         """Callback: mode stack has changed structure.
943         """
944         # Activate the action corresponding to the current top mode.
945         print "DEBUG: mode stack updated:", self.modes
946         action_name = getattr(mode, '__action_name__', None)
947         if action_name is None:
948             return None
949         action = self.app.builder.get_object(action_name)
950         if action is not None:
951             # Not every mode has a corresponding action
952             if not action.get_active():
953                 action.set_active(True)
954