1 # This file is part of MyPaint.
2 # Copyright (C) 2011 by Martin Renold <martinxyz@gmx.ch>
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.
15 from gettext import gettext as _
16 from lib.helpers import rgb_to_hsv, hsv_to_rgb
19 """Applies changed brush settings to the active brush, with overrides.
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.::
25 BrushManager ---select_brush---> BrushModifier --> TiledDrawWidget
27 The `BrushManager` provides the brush settings as stored on disk, and
28 the BrushModifier passes them via `TiledDrawWidget` to the brush engine.
31 MODE_FORCED_ON_SETTINGS = [1.0, {}]
32 MODE_FORCED_OFF_SETTINGS = [0.0, {}]
35 def __init__(self, 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
47 def _init_actions(self):
48 self.action_group = gtk.ActionGroup('BrushModifierActions')
49 ag = self.action_group
50 self.app.add_action_group(ag)
52 # name, stock id, label,
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),
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")
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
75 for action in self.action_group.list_actions():
76 action.set_draw_as_radio(True)
77 self.app.kbm.takeover_action(action)
79 # Faking radio items, but ours can be disabled by re-activating them
80 # and backtrack along their history.
84 def _push_hist(self, justentered):
85 if justentered in self._hist:
86 self._hist.remove(justentered)
87 self._hist.append(justentered)
90 def _pop_hist(self, justleft):
91 for mode in self.action_group.list_actions():
94 while len(self._hist) > 0:
95 mode = self._hist.pop()
96 if mode is not justleft:
99 self.normal_mode.set_active(True)
102 def set_override_setting(self, setting_name, override):
103 """Overrides a boolean setting currently in effect.
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.
113 unmod_b = self.unmodified_brushinfo
114 modif_b = self.app.brush
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)
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)
129 def _cancel_other_modes(self, action):
130 for other_action in self.action_group.list_actions():
131 if action is other_action:
133 setting_name = other_action.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()
142 def blend_mode_normal_cb(self, action):
143 """Callback for the ``BlendModeNormal`` action.
145 normal_wanted = action.get_active()
147 self._cancel_other_modes(action)
148 self._push_hist(action)
150 # Disallow cancelling Normal mode unless something else
152 other_active = self.eraser_mode.get_active()
153 other_active |= self.lock_alpha_mode.get_active()
155 self.normal_mode.set_active(True)
158 def blend_mode_eraser_cb(self, action):
159 """Callback for the ``BlendModeEraser`` action.
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.
166 unmod_b = self.unmodified_brushinfo
167 modif_b = self.app.brush
168 eraser_wanted = action.get_active()
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
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',
184 self._set_radius_internal(r + dr)
185 self._push_hist(action)
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()
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
199 r0 = self._store_eraser_mode_radius_change()
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)
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
213 def blend_mode_lock_alpha_cb(self, action):
214 """Callback for the ``BlendModeLockAlpha`` action.
216 lock_alpha_wanted = action.get_active()
217 if lock_alpha_wanted:
218 self._cancel_other_modes(action)
219 self._push_hist(action)
221 self._pop_hist(action)
222 self.set_override_setting("lock_alpha", lock_alpha_wanted)
225 def restore_context_of_selected_brush(self):
226 """Restores color from the unmodified base brush.
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.
232 c = self.unmodified_brushinfo.get_color_hsv()
233 self.app.brush.set_color_hsv(c)
236 def brush_selected_cb(self, managed_brush, brushinfo):
237 """Responds to the user changing their brush.
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.
244 self._in_brush_selected_cb = True
246 prev_lock_alpha = b.is_alpha_locked()
248 # Changing the effective brush
251 color = b.get_color_hsv()
253 mix_old = b.get_base_value('restore_color')
254 b.load_from_brushinfo(brushinfo)
255 self.unmodified_brushinfo = b.clone()
257 mix = b.get_base_value('restore_color')
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)
264 # switching from a brush with fixed color back to a normal one
265 color = self._last_selected_color
267 b.set_color_hsv(color)
268 b.set_string_property("parent_brush_name", managed_brush.name)
270 # User picked a dedicated eraser brush
271 # Unset any lock_alpha state (necessary?)
272 self.set_override_setting("lock_alpha", False)
274 # Preserve the old lock_alpha state
275 self.set_override_setting("lock_alpha", prev_lock_alpha)
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
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)
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()
299 self._in_brush_selected_cb = False
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:
307 r0 = self._eraser_mode_original_radius
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.
318 def _brush_is_dedicated_eraser(self):
319 if self.unmodified_brushinfo is None:
321 return self.unmodified_brushinfo.is_eraser()
324 def brush_modified_cb(self, changed_settings):
325 """Responds to changes of the brush settings.
327 if self._brush_is_dedicated_eraser():
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()
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)
343 if not self._in_brush_selected_cb:
344 self._last_selected_color = self.app.brush.get_color_hsv()