OSDN Git Service

ee5ce11346489c24dacf0066b30adefcf0a948f0
[mypaint-anime/master.git] / gui / brushmodifier.py
1 # This file is part of MyPaint.
2 # Copyright (C) 2011 by Martin Renold <martinxyz@gmx.ch>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8
9
10 #import stategroup
11 import stock
12 import gtk
13 from gtk import gdk
14 import gobject
15 from gettext import gettext as _
16 from lib.helpers import rgb_to_hsv, hsv_to_rgb
17
18 class BrushModifier:
19     """Applies changed brush settings to the active brush, with overrides.
20
21     A single instance of this lives within the main `application.Application`
22     instance. The BrushModifier tracks brush settings like color, eraser and
23     lock alpha mode that can be overridden by the GUI.::
24
25       BrushManager ---select_brush---> BrushModifier --> TiledDrawWidget
26
27     The `BrushManager` provides the brush settings as stored on disk, and
28     the BrushModifier passes them via `TiledDrawWidget` to the brush engine.
29     """
30
31     MODE_FORCED_ON_SETTINGS = [1.0, {}]
32     MODE_FORCED_OFF_SETTINGS = [0.0, {}]
33
34
35     def __init__(self, app):
36         self.app = app
37         app.brushmanager.selected_brush_observers.append(self.brush_selected_cb)
38         app.brush.observers.append(self.brush_modified_cb)
39         self.unmodified_brushinfo = None
40         self._in_brush_selected_cb = False
41         self._in_internal_radius_change = False
42         self._eraser_mode_original_radius = None
43         self._last_selected_color = None
44         self._init_actions()
45
46
47     def _init_actions(self):
48         self.action_group = gtk.ActionGroup('BrushModifierActions')
49         ag = self.action_group
50         self.app.add_action_group(ag)
51         toggle_actions = [
52             # name, stock id, label,
53             #   accel, tooltip,
54             #   callback, default state
55             ('BlendModeNormal', stock.BRUSH_BLEND_MODE_NORMAL, None,
56                 None, _("Paint normally"),
57                 self.blend_mode_normal_cb, True),
58             ('BlendModeEraser', stock.BRUSH_BLEND_MODE_ERASER, None,
59                 None, _("Eraser Mode: remove strokes using the current brush"),
60                 self.blend_mode_eraser_cb),
61             ('BlendModeLockAlpha', stock.BRUSH_BLEND_MODE_ALPHA_LOCK, None,
62                 None, _("Lock Alpha: paint over existing strokes only, using the current brush"),
63                 self.blend_mode_lock_alpha_cb),
64             ]
65         ag.add_toggle_actions(toggle_actions)
66         self.eraser_mode = ag.get_action("BlendModeEraser")
67         self.lock_alpha_mode = ag.get_action("BlendModeLockAlpha")
68         self.normal_mode = ag.get_action("BlendModeNormal")
69
70         # Each mode ToggleAction has a corresponding setting
71         self.eraser_mode.setting_name = "eraser"
72         self.lock_alpha_mode.setting_name = "lock_alpha"
73         self.normal_mode.setting_name = None
74
75         for action in self.action_group.list_actions():
76             action.set_draw_as_radio(True)
77             self.app.kbm.takeover_action(action)
78
79         # Faking radio items, but ours can be disabled by re-activating them
80         # and backtrack along their history.
81         self._hist = []
82
83
84     def _push_hist(self, justentered):
85         if justentered in self._hist:
86             self._hist.remove(justentered)
87         self._hist.append(justentered)
88
89
90     def _pop_hist(self, justleft):
91         for mode in self.action_group.list_actions():
92             if mode.get_active():
93                 return
94         while len(self._hist) > 0:
95             mode = self._hist.pop()
96             if mode is not justleft:
97                 mode.set_active(True)
98                 return
99         self.normal_mode.set_active(True)
100
101
102     def set_override_setting(self, setting_name, override):
103         """Overrides a boolean setting currently in effect.
104
105         If `override` is true, the named setting will be forced to a base value
106         greater than 0.9, and if it is false a base value less than 0.1 will be
107         applied. Where possible, values from the base brush will be used. The
108         setting from `unmodified_brushinfo`, including any nonlinear
109         components, will be used if its base value is suitably large (or
110         small). If not, a base value of either 1 or 0 and no nonlinear
111         components will be applied.
112         """
113         unmod_b = self.unmodified_brushinfo
114         modif_b = self.app.brush
115         if override:
116             if not modif_b.has_large_base_value(setting_name):
117                 settings = self.MODE_FORCED_ON_SETTINGS
118                 if unmod_b.has_large_base_value(setting_name):
119                     settings = unmod_b.get_setting(setting_name)
120                 modif_b.set_setting(setting_name, settings)
121         else:
122             if not modif_b.has_small_base_value(setting_name):
123                 settings = self.MODE_FORCED_OFF_SETTINGS
124                 if unmod_b.has_small_base_value(setting_name):
125                     settings = unmod_b.get_setting(setting_name)
126                 modif_b.set_setting(setting_name, settings)
127
128
129     def _cancel_other_modes(self, action):
130         for other_action in self.action_group.list_actions():
131             if action is other_action:
132                 continue
133             setting_name = other_action.setting_name
134             if setting_name:
135                 self.set_override_setting(setting_name, False)
136             if other_action.get_active():
137                 other_action.block_activate()
138                 other_action.set_active(False)
139                 other_action.unblock_activate()
140
141
142     def blend_mode_normal_cb(self, action):
143         """Callback for the ``BlendModeNormal`` action.
144         """
145         normal_wanted = action.get_active()
146         if normal_wanted:
147             self._cancel_other_modes(action)
148             self._push_hist(action)
149         else:
150             # Disallow cancelling Normal mode unless something else
151             # has become active.
152             other_active = self.eraser_mode.get_active()
153             other_active |= self.lock_alpha_mode.get_active()
154             if not other_active:
155                 self.normal_mode.set_active(True)
156
157
158     def blend_mode_eraser_cb(self, action):
159         """Callback for the ``BlendModeEraser`` action.
160
161         This manages the size difference between the eraser-mode version of a
162         normal brush and its normal state. Initially the eraser-mode version is
163         three steps bigger, but that's configurable by the user through simply
164         changing the brush radius as normal while in eraser mode.
165         """
166         unmod_b = self.unmodified_brushinfo
167         modif_b = self.app.brush
168         eraser_wanted = action.get_active()
169         if eraser_wanted:
170             self._cancel_other_modes(action)
171             if not self._in_brush_selected_cb:
172                 # We're entering eraser mode because the user activated the
173                 # toggleaction, not because the brush changed.
174                 if not self._brush_is_dedicated_eraser():
175                     # change brush radius
176                     r = modif_b.get_base_value('radius_logarithmic')
177                     self._eraser_mode_original_radius = r
178                     default = 3*(0.3)
179                     # this value allows the user to go back to the exact
180                     # original size with brush_smaller_cb()
181                     dr = self.app.preferences.get(
182                         'document.eraser_mode_radius_change',
183                         default)
184                     self._set_radius_internal(r + dr)
185             self._push_hist(action)
186         else:
187             if self._brush_is_dedicated_eraser():
188                 # No, you may not leave eraser mode with one of these.
189                 action.block_activate()
190                 action.set_active(True)
191                 action.unblock_activate()
192                 return
193             if not self._in_brush_selected_cb:
194                 # We're leaving eraser mode because the user deactivated the
195                 # ToggleAction, not because the brush changed. Might have to
196                 # restore the effective brush radius to what it was before.
197                 # Also store any changes the user made to the relative radius
198                 # change.
199                 r0 = self._store_eraser_mode_radius_change()
200                 if r0 is not None:
201                     self._set_radius_internal(r0)
202             self._eraser_mode_original_radius = None
203             self._pop_hist(action)
204         self.set_override_setting("eraser", eraser_wanted)
205
206
207     def _set_radius_internal(self, r):
208         self._in_internal_radius_change = True
209         self.app.brush.set_base_value('radius_logarithmic', r)
210         self._in_internal_radius_change = False
211
212
213     def blend_mode_lock_alpha_cb(self, action):
214         """Callback for the ``BlendModeLockAlpha`` action.
215         """
216         lock_alpha_wanted = action.get_active()
217         if lock_alpha_wanted:
218             self._cancel_other_modes(action)
219             self._push_hist(action)
220         else:
221             self._pop_hist(action)
222         self.set_override_setting("lock_alpha", lock_alpha_wanted)
223
224
225     def restore_context_of_selected_brush(self):
226         """Restores color from the unmodified base brush.
227
228         After a brush has been selected, restore additional brush settings -
229         currently just color - from `unmodified_brushinfo`. This is called
230         after selecting a brush by picking a stroke from the canvas.
231         """
232         c = self.unmodified_brushinfo.get_color_hsv()
233         self.app.brush.set_color_hsv(c)
234
235
236     def brush_selected_cb(self, managed_brush, brushinfo):
237         """Responds to the user changing their brush.
238
239         This observer callback is responsible for allocating the current brush
240         settings to the current brush singleton in `self.app`. The Brush
241         Selector, the Pick Context action, and the Brushkeys and
242         Device-specific brush associations all cause this to be invoked.
243         """
244         self._in_brush_selected_cb = True
245         b = self.app.brush
246         prev_lock_alpha = b.is_alpha_locked()
247
248         # Changing the effective brush
249         # Preserve colour
250         b.begin_atomic()
251         color = b.get_color_hsv()
252
253         mix_old = b.get_base_value('restore_color')
254         b.load_from_brushinfo(brushinfo)
255         self.unmodified_brushinfo = b.clone()
256
257         mix = b.get_base_value('restore_color')
258         if mix:
259             c1 = hsv_to_rgb(*color)
260             c2 = hsv_to_rgb(*b.get_color_hsv())
261             c3 = [(1.0-mix)*v1 + mix*v2 for v1, v2 in zip(c1, c2)]
262             color = rgb_to_hsv(*c3)
263         elif mix_old:
264             # switching from a brush with fixed color back to a normal one
265             color = self._last_selected_color
266
267         b.set_color_hsv(color)
268         b.set_string_property("parent_brush_name", managed_brush.name)
269         if b.is_eraser():
270             # User picked a dedicated eraser brush
271             # Unset any lock_alpha state (necessary?)
272             self.set_override_setting("lock_alpha", False)
273         else:
274             # Preserve the old lock_alpha state
275             self.set_override_setting("lock_alpha", prev_lock_alpha)
276         b.end_atomic()
277
278         # Updates the blend mode buttons to match the new settings.
279         # First decide which blend mode is active and which aren't.
280         active_blend_mode = self.normal_mode
281         blend_modes = []
282         for mode_action in self.action_group.list_actions():
283             setting_name = mode_action.setting_name
284             if setting_name is not None:
285                 if b.has_large_base_value(setting_name):
286                     active_blend_mode = mode_action
287             blend_modes.append(mode_action)
288         blend_modes.remove(active_blend_mode)
289
290         # Twiddle the UI to match without emitting "activate" signals.
291         active_blend_mode.block_activate()
292         active_blend_mode.set_active(True)
293         active_blend_mode.unblock_activate()
294         for other in blend_modes:
295             other.block_activate()
296             other.set_active(False)
297             other.unblock_activate()
298
299         self._in_brush_selected_cb = False
300
301
302     def _store_eraser_mode_radius_change(self):
303         # Store any changes to the radius when a normal brush is in eraser mode.
304         if self._brush_is_dedicated_eraser() \
305                 or self._in_internal_radius_change:
306             return
307         r0 = self._eraser_mode_original_radius
308         if r0 is None:
309             return
310         modif_b = self.app.brush
311         new_dr = modif_b.get_base_value('radius_logarithmic') - r0
312         self.app.preferences['document.eraser_mode_radius_change'] = new_dr
313         # Return what the radius should be reset to on the ordinary version
314         # of the brush if eraser mode were cancelled right now.
315         return r0
316
317
318     def _brush_is_dedicated_eraser(self):
319         if self.unmodified_brushinfo is None:
320             return False
321         return self.unmodified_brushinfo.is_eraser()
322
323
324     def brush_modified_cb(self, changed_settings):
325         """Responds to changes of the brush settings.
326         """
327         if self._brush_is_dedicated_eraser():
328             return
329
330         # If we're in eraser mode at the moment the user could be
331         # changing the brush radius: remember the new base-relative
332         # size change associated with an eraser if they are.
333         if self.app.brush.is_eraser() \
334                 and "radius_logarithmic" in changed_settings \
335                 and not self._in_internal_radius_change:
336             self._store_eraser_mode_radius_change()
337
338         if changed_settings.intersection(('color_h', 'color_s', 'color_v')):
339             # Cancel eraser mode on ordinary brushes
340             if self.eraser_mode.get_active() and 'eraser_mode' not in changed_settings:
341                 self.eraser_mode.set_active(False)
342
343             if not self._in_brush_selected_cb:
344                 self._last_selected_color = self.app.brush.get_color_hsv()