gui: Use new-style classes everywhere
[mypaint:mypaint.git] / gui / colors / hcywheel.py
1 # This file is part of MyPaint.
2 # Copyright (C) 2012 by Andrew Chadwick <andrewc-git@piffle.org>
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 """Hue/Relative chroma/Luma adjuster widgets, with an editable gamut mask.
11 """
12
13 import math
14 from copy import deepcopy
15 from random import random
16 import re
17 import os.path
18
19 import gui.gtk2compat as gtk2compat
20 import gtk
21 from gtk import gdk
22 import cairo
23 from gettext import gettext as _
24
25 from bases import CachedBgDrawingArea
26 from adjbases import ColorManager
27 from adjbases import ColorAdjuster
28 from adjbases import ColorAdjusterWidget
29 from adjbases import HueSaturationWheelMixin
30 from adjbases import HueSaturationWheelAdjuster
31 from sliders import HCYLumaSlider
32 from combined import CombinedAdjusterPage
33 from uicolor import *
34 from util import *
35 from palette import Palette
36 import geom
37 from paletteview import palette_load_via_dialog
38 from paletteview import palette_save_via_dialog
39
40
41 PREFS_MASK_KEY = "colors.hcywheel.mask.gamuts"
42 PREFS_ACTIVE_KEY = "colors.hcywheel.mask.active"
43 MASK_EDITOR_HELP=_("""<b>Gamut mask editor</b>
44
45 Edit the gamut mask here, or turn it off or on. Gamut masks are like a piece of
46 tracing paper with cut-out holes, placed over the color wheel to limit the
47 range of colors you can select. This allows you to plan your color schemes in
48 advance, which is useful for color scripting or to create specific moods. The
49 theory is that the corners of each mask shape represent a <i>subjective</i>
50 primary color, and that each shape contains all the colors which can be mixed
51 using those corner primaries. Subjective secondary colors lie at the midpoints
52 of the shape edges, and the center of the shape is the subjective neutral tone
53 for the shape.
54
55 Click to add shapes if the wheel is blank. Shapes can be dragged around and
56 their outlines can be adjusted by adding or moving the control points. Make a
57 shape too small to be useful to remove it: dragging a shape to the edge of the
58 disc is a quick way of doing this. You can delete shapes by dragging them
59 inside other shapes too. The entire mask can be rotated by turning the edge of
60 the disc to generate new and unexpected color schemes.
61
62 Gamut masks can be saved to GIMP-format palette files, and loaded from them.
63 The New button lets you choose one of several templates as a starting point.
64 """)
65
66
67 class MaskableWheelMixin(object):
68     """Provides wheel widgets with maskable areas.
69
70     For use with implementations of `HueSaturationWheelAdjusterMixin`.
71     Concrete implementations can be masked so that they ignore clicks outside
72     certain colour areas. If the mask is active, clicks inside the mask
73     shapes are treated as normal, but clicks outside them are remapped to a
74     point on the nearest edge of the nearest shape. This can be useful for
75     artists who wish to plan the colour gamut of their artwork in advance.
76
77     http://gurneyjourney.blogspot.com/2011/09/part-1-gamut-masking-method.html
78     http://gurneyjourney.blogspot.com/2008/01/color-wheel-masking-part-1.html
79
80     """
81
82     # Class-level variables: drawing constants etc.
83     min_shape_size = 0.15 #: Smallest useful shape: fraction of radius
84
85     # Instance variables (defaults / documentation)
86     __mask = None
87     mask_toggle = None #: gtk.ToggleAction controling whether the mask is used
88     mask_observers = None #: List of no-argument mask change observer callbacks
89
90
91     def __init__(self):
92         """Instantiate instance vars and bind actions.
93         """
94         self.__mask = []
95         self.mask_observers = []
96         action_name = "wheel%s_masked" % (id(self),)
97         self.mask_toggle = gtk.ToggleAction(action_name,
98           _("Gamut mask active"),
99           _("Limit your palette for specific moods using a gamut mask"),
100           None)
101         self.mask_toggle.connect("toggled", self.__mask_toggled_cb)
102
103
104     def __mask_toggled_cb(self, action):
105         active = action.get_active()
106         prefs = self.get_prefs()
107         prefs[PREFS_ACTIVE_KEY] = active
108         self.queue_draw()
109
110
111     def set_color_manager(self, manager):
112         """Sets the color manager, and reads an initial mask from prefs.
113
114         Extends `ColorAdjuster`'s implementation.
115
116         """
117         ColorAdjuster.set_color_manager(self, manager)
118         prefs = self.get_prefs()
119         mask_flat = prefs.get(PREFS_MASK_KEY, None)
120         mask_active = prefs.get(PREFS_ACTIVE_KEY, False)
121         if mask_flat is not None:
122             self.set_mask(self._unflatten_mask(mask_flat))
123             self.mask_toggle.set_active(mask_active)
124
125     @staticmethod
126     def _flatten_mask(mask):
127         flat_mask = []
128         for shape_colors in mask:
129             shape_flat = [c.to_hex_str() for c in shape_colors]
130             flat_mask.append(shape_flat)
131         return flat_mask
132
133     @staticmethod
134     def _unflatten_mask(flat_mask):
135         mask = []
136         for shape_flat in flat_mask:
137             shape_colors = [RGBColor.new_from_hex_str(s) for s in shape_flat]
138             mask.append(shape_colors)
139         return mask
140
141
142     def set_mask_from_palette(self, pal):
143         """Sets the mask from a palette.
144
145         Any `palette.Palette` can be loaded into the wheel widget, and colour
146         names are used for distinguishing mask shapes. If a colour name
147         matches the pattern "``mask #<decimal-int>``", it will be associated
148         with the shape having the ID ``<decimal-int>``.
149
150         """
151         if pal is None:
152             return
153         mask_id_re = re.compile(r'\bmask\s*#?\s*(\d+)\b')
154         mask_shapes = {}
155         for i in xrange(len(pal)):
156             color = pal.get_color(i)
157             if color is None:
158                 continue
159             shape_id = 0
160             color_name = pal.get_color_name(i)
161             if color_name is not None:
162                 mask_id_match = mask_id_re.search(color_name)
163                 if mask_id_match:
164                     shape_id = int(mask_id_match.group(1))
165             if shape_id not in mask_shapes:
166                 mask_shapes[shape_id] = []
167             mask_shapes[shape_id].append(color)
168         mask_list = []
169         shape_ids = mask_shapes.keys()
170         shape_ids.sort()
171         for shape_id in shape_ids:
172             mask_list.append(mask_shapes[shape_id])
173         self.set_mask(mask_list)
174
175
176     def set_mask(self, mask):
177         """Sets the mask (a list of lists of `UIColor`s).
178         """
179         mgr = self.get_color_manager()
180         prefs = self.get_prefs()
181         if mask is None:
182             self.__mask = None
183             self.mask_toggle.set_active(False)
184             prefs[PREFS_MASK_KEY] = None
185         else:
186             self.mask_toggle.set_active(True)
187             self.__mask = mask
188             prefs[PREFS_MASK_KEY] = self._flatten_mask(mask)
189         for func in self.mask_observers:
190             func()
191         self.queue_draw()
192
193
194     def get_mask(self):
195         """Returns the current mask.
196         """
197         return self.__mask
198
199
200     def get_mask_voids(self):
201         """Returns the current mask as a list of lists of (x, y) pairs.
202         """
203         voids = []
204         if not self.__mask:
205             return voids
206         for shape in self.__mask:
207             if len(shape) >= 3:
208                 void = self.colors_to_mask_void(shape)
209                 voids.append(void)
210         return voids
211
212
213     def colors_to_mask_void(self, colors):
214         """Converts a set of colours to a mask void (convex hull).
215
216         Mask voids are the convex hulls of the (x, y) positions for the
217         colours making up the mask, so mask shapes with fewer than 3 colours
218         are returned as the empty list.
219
220         """
221         points = []
222         if len(colors) < 3:
223             return points
224         for col in colors:
225             points.append(self.get_pos_for_color(col))
226         return geom.convex_hull(points)
227
228
229     def get_color_at_position(self, x, y, ignore_mask=False):
230         """Converts an `x`, `y` position to a colour.
231
232         Ordinarily, this implmentation uses any active mask to limit the
233         colours which can be clicked on. Set `ignore_mask` to disable this
234         added behaviour.
235
236         """
237         sup = HueSaturationWheelMixin
238         if ignore_mask or not self.mask_toggle.get_active():
239             return sup.get_color_at_position(self, x, y)
240         voids = self.get_mask_voids()
241         if not voids:
242             return sup.get_color_at_position(self, x, y)
243         isects = []
244         for vi, void in enumerate(voids):
245             # If we're inside a void, use the unchanged value
246             if geom.point_in_convex_poly((x, y), void):
247                 return sup.get_color_at_position(self, x, y)
248             # If outside, find the nearest point on the nearest void's edge
249             for p1, p2 in geom.pairwise(void):
250                 isect = geom.nearest_point_in_segment(p1,p2, (x,y))
251                 if isect is not None:
252                     d = math.sqrt((isect[0]-x)**2 + (isect[1]-y)**2)
253                     isects.append((d, isect))
254                 # Above doesn't include segment ends, so add those
255                 d = math.sqrt((p1[0]-x)**2 + (p1[1]-y)**2)
256                 isects.append((d, p1))
257         # Determine the closest point.
258         if isects:
259             isects.sort()
260             x, y = isects[0][1]
261         return sup.get_color_at_position(self, x, y)
262
263
264     @staticmethod
265     def _get_void_size(void):
266         """Size metric for a mask void (list of x,y points; convex hull)
267         """
268         area = geom.poly_area(void)
269         return math.sqrt(area)
270
271
272     def _get_mask_fg(self):
273         """Returns the mask edge drawing colour as an rgb triple.
274         """
275         if gtk2compat.USE_GTK3:
276             state = self.get_state_flags()
277             style = self.get_style_context()
278             c = style.get_color(state)
279             return RGBColor.new_from_gdk_rgba(c).get_rgb()
280         else:
281             state = self.get_state()
282             style = self.get_style() # using get_style_context for GTK3
283             c = style.fg[state]
284             return RGBColor.new_from_gdk_color(c).get_rgb()
285
286
287     def _get_mask_bg(self):
288         """Returns the mask area drawing colour as an rgb triple.
289         """
290         if gtk2compat.USE_GTK3:
291             state = self.get_state_flags()
292             style = self.get_style_context()
293             c = style.get_background_color(state)
294             return RGBColor.new_from_gdk_rgba(c).get_rgb()
295         else:
296             state = self.get_state()
297             style = self.get_style() # using get_style_context for GTK3
298             c = style.bg[state]
299             return RGBColor.new_from_gdk_color(c).get_rgb()
300
301
302     def draw_mask(self, cr, wd, ht):
303         """Draws the mask, if enabled and if it has any usable voids.
304
305         For the sake of the editor subclass, this doesn't draw any voids
306         which are smaller than `self.min_shape_size` times the wheel radius.
307
308         """
309
310         if not self.mask_toggle.get_active():
311             return
312         if self.__mask is None or self.__mask == []:
313             return
314
315         cr.save()
316
317         radius = self.get_radius(wd=wd, ht=ht)
318         cx, cy = self.get_center(wd=wd, ht=ht)
319         cr.arc(cx, cy, radius+self.BORDER_WIDTH, 0, 2*math.pi)
320         cr.clip()
321
322         bg_rgb = self._get_mask_bg()
323         fg_rgb = self._get_mask_fg()
324
325         cr.push_group()
326         cr.set_operator(cairo.OPERATOR_OVER)
327         cr.set_source_rgb(*bg_rgb)
328         cr.rectangle(0, 0, wd, ht)
329         cr.fill()
330         voids = []
331         min_size = radius * self.min_shape_size
332         for void in self.get_mask_voids():
333             if len(void) < 3:
334                 continue
335             size = self._get_void_size(void)
336             if size >= min_size:
337                 voids.append(void)
338         cr.set_source_rgb(*fg_rgb)
339         for void in voids:
340             cr.new_sub_path()
341             cr.move_to(*void[0])
342             for x, y in void[1:]:
343                 cr.line_to(x, y)
344             cr.close_path()
345         cr.set_line_width(2.0)
346         cr.stroke_preserve()
347         cr.set_operator(cairo.OPERATOR_SOURCE)
348         cr.set_source_rgba(1,1,1,0)
349         cr.fill()
350         cr.set_operator(cairo.OPERATOR_OVER)
351         cr.pop_group_to_source()
352
353         cr.paint_with_alpha(0.666)
354         cr.restore()
355
356
357     def paint_foreground_cb(self, cr, wd, ht):
358         """Paints the foreground items: mask, then marker.
359         """
360         self.draw_mask(cr, wd, ht)
361         HueSaturationWheelMixin.paint_foreground_cb(self, cr, wd, ht)
362
363
364 class HCYHueChromaWheelMixin(object):
365     """Mixin for wheel-style adjusters to display the H+C from the HCY model.
366
367     For use with implementations of `HueSaturationWheelAdjusterMixin`; make
368     sure this mixin comes before it in the MRO.
369
370     """
371
372     def get_normalized_polar_pos_for_color(self, col):
373         col = HCYColor(color=col)
374         return col.c, col.h
375
376     def color_at_normalized_polar_pos(self, r, theta):
377         col = HCYColor(color=self.get_managed_color())
378         col.h = theta
379         col.c = r
380         return col
381
382
383 class HCYHueChromaWheel (MaskableWheelMixin,
384                          HCYHueChromaWheelMixin,
385                          HueSaturationWheelAdjuster):
386     """Circular mapping of the H and C terms of the HCY model.
387     """
388
389     STATIC_TOOLTIP_TEXT = _("HCY Hue and Chroma")
390
391
392     def __init__(self):
393         """Instantiate, binding events.
394         """
395         MaskableWheelMixin.__init__(self)
396         HueSaturationWheelAdjuster.__init__(self)
397         self.connect("scroll-event", self.__scroll_cb)
398         self.add_events(gdk.SCROLL_MASK)
399
400
401     def __scroll_cb(self, widget, event):
402         # Scrolling controls luma.
403         d = self.SCROLL_DELTA
404         if event.direction in (gdk.SCROLL_DOWN, gdk.SCROLL_LEFT):
405             d *= -1
406         col = HCYColor(color=self.get_managed_color())
407         y = clamp(col.y+d, 0.0, 1.0)
408         if col.y != y:
409             col.y = y
410             self.set_managed_color(col)
411         return True
412
413
414
415 class HCYMaskEditorWheel (HCYHueChromaWheel):
416     """HCY wheel specialized for mask editing.
417     """
418
419     ## Instance vars
420     __last_cursor = None   # previously set cursor (determines some actions)
421     # Objects which are active or being manipulated
422     __tmp_new_ctrlpoint = None   # new control-point colour
423     __active_ctrlpoint = None   # active point in active_void
424     __active_shape = None  # list of colours or None
425     # Drag state
426     __drag_func = None
427     __drag_start_pos = None
428
429     ## Class-level constants and variables
430     # Specialized cursors for different actions
431     __add_cursor = gdk.Cursor(gdk.PLUS)
432     __move_cursor = gdk.Cursor(gdk.FLEUR)
433     __move_point_cursor = gdk.Cursor(gdk.CROSSHAIR)
434     __rotate_cursor = gdk.Cursor(gdk.EXCHANGE)
435     # Constrain the range of allowable lumas
436     __MAX_LUMA = 0.75
437     __MIN_LUMA = 0.25
438
439     # Drawing constraints and activity proximities
440     __ctrlpoint_radius = 2.5
441     __ctrlpoint_grab_radius = 10
442     __max_num_shapes = 6   # how many shapes are allowed
443
444     # Tooltip text. Is here a better way of explaining this? It obscures the
445     # editor quite a lot.
446     STATIC_TOOLTIP_TEXT = _("Gamut mask editor. Click in the middle to create "
447                             "or manipulate shapes, or rotate the mask using "
448                             "the edges of the disc.")
449
450
451     def __init__(self):
452         """Instantiate, and connect the editor events.
453         """
454         HCYHueChromaWheel.__init__(self)
455         self.connect("button-press-event", self.__button_press_cb)
456         self.connect("button-release-event", self.__button_release_cb)
457         self.connect("motion-notify-event", self.__motion_cb)
458         self.connect("leave-notify-event", self.__leave_cb)
459         self.add_events(gdk.POINTER_MOTION_MASK|gdk.LEAVE_NOTIFY_MASK)
460
461
462     def __leave_cb(self, widget, event):
463         # Reset the active objects when the pointer leaves.
464         if self.__drag_func is not None:
465             return
466         self.__active_shape = None
467         self.__active_ctrlpoint = None
468         self.__tmp_new_ctrlpoint = None
469         self.queue_draw()
470         self.__set_cursor(None)
471
472
473     def __set_cursor(self, cursor):
474         # Sets the window cursor, retaining a record.
475         if cursor != self.__last_cursor:
476             self.get_window().set_cursor(cursor)
477             self.__last_cursor = cursor
478
479
480     def __update_active_objects(self, x, y):
481         # Decides what a click or a drag at (x, y) would do, and updates the
482         # mouse cursor and draw state to match.
483
484         assert self.__drag_func is None
485         self.__active_shape = None
486         self.__active_ctrlpoint = None
487         self.__tmp_new_ctrlpoint = None
488         self.queue_draw()  # yes, always
489
490         # Possible mask void manipulations
491         mask = self.get_mask()
492         for mask_idx in xrange(len(mask)):
493             colors = mask[mask_idx]
494             if len(colors) < 3:
495                 continue
496
497             # If the pointer is near an existing control point, clicking and
498             # dragging will move it.
499             void = []
500             for col_idx in xrange(len(colors)):
501                 col = colors[col_idx]
502                 px, py = self.get_pos_for_color(col)
503                 dp = math.sqrt((x-px)**2 + (y-py)**2)
504                 if dp <= self.__ctrlpoint_grab_radius:
505                     mask.remove(colors)
506                     mask.insert(0, colors)
507                     self.__active_shape = colors
508                     self.__active_ctrlpoint = col_idx
509                     self.__set_cursor(None)
510                     return
511                 void.append((px, py))
512
513             # If within a certain distance of an edge, dragging will create and
514             # then move a new control point.
515             void = geom.convex_hull(void)
516             for p1, p2 in geom.pairwise(void):
517                 isect = geom.nearest_point_in_segment(p1, p2, (x, y))
518                 if isect is not None:
519                     ix, iy = isect
520                     di = math.sqrt((ix-x)**2 + (iy-y)**2)
521                     if di <= self.__ctrlpoint_grab_radius:
522                         newcol = self.get_color_at_position(ix, iy)
523                         self.__tmp_new_ctrlpoint = newcol
524                         mask.remove(colors)
525                         mask.insert(0, colors)
526                         self.__active_shape = colors
527                         self.__set_cursor(None)
528                         return
529
530             # If the mouse is within a mask void, then dragging would move that
531             # shape around within the mask.
532             if geom.point_in_convex_poly((x, y), void):
533                 mask.remove(colors)
534                 mask.insert(0, colors)
535                 self.__active_shape = colors
536                 self.__set_cursor(None)
537                 return
538
539         # Away from shapes, clicks and drags manipulate the entire mask: adding
540         # cutout voids to it, or rotating the whole mask around its central
541         # axis.
542         alloc = self.get_allocation()
543         cx, cy = self.get_center(alloc=alloc)
544         radius = self.get_radius(alloc=alloc)
545         dx, dy = x-cx, y-cy
546         r = math.sqrt(dx**2 + dy**2)
547         if r < radius*(1.0-self.min_shape_size):
548             if len(mask) < self.__max_num_shapes:
549                 d = self.__dist_to_nearest_shape(x, y)
550                 minsize = radius * self.min_shape_size
551                 if d is None or d > minsize:
552                     # Clicking will result in a new void
553                     self.__set_cursor(self.__add_cursor)
554         else:
555             # Click-drag to rotate the entire mask
556             self.__set_cursor(self.__rotate_cursor)
557
558
559     def __drag_active_shape(self, px, py):
560         # Updates the position of the active shape during drags.
561         sup = HCYHueChromaWheel
562         x0, y0 = self.__drag_start_pos
563         dx = px - x0
564         dy = py - y0
565         self.__active_shape[:] = []
566         for col in self.__active_shape_predrag:
567             cx, cy = self.get_pos_for_color(col)
568             cx += dx
569             cy += dy
570             col2 = sup.get_color_at_position(self, cx, cy, ignore_mask=True)
571             self.__active_shape.append(col2)
572
573
574     def __drag_active_ctrlpoint(self, px, py):
575         # Moves the highlighted control point during drags.
576         sup = HCYHueChromaWheel
577         x0, y0 = self.__drag_start_pos
578         dx = px - x0
579         dy = py - y0
580         col = self.__active_ctrlpoint_predrag
581         cx, cy = self.get_pos_for_color(col)
582         cx += dx
583         cy += dy
584         col = sup.get_color_at_position(self, cx, cy, ignore_mask=True)
585         self.__active_shape[self.__active_ctrlpoint] = col
586
587
588     def __rotate_mask(self, px, py):
589         # Rotates the entire mask around the grey axis during drags.
590         cx, cy = self.get_center()
591         x0, y0 = self.__drag_start_pos
592         theta0 = math.atan2(x0-cx, y0-cy)
593         theta = math.atan2(px-cx, py-cy)
594         dntheta = (theta0 - theta) / (2*math.pi)
595         while dntheta <= 0:
596             dntheta += 1.0
597         if self.__mask_predrag is None:
598             self.__mask_predrag = []
599             for shape in self.get_mask():
600                 shape_hcy = [HCYColor(color=c) for c in shape]
601                 self.__mask_predrag.append(shape_hcy)
602         mgr = self.get_color_manager()
603         newmask = []
604         for shape in self.__mask_predrag:
605             shape_rot = []
606             for col in shape:
607                 col_r = HCYColor(color=col)
608                 h = mgr.distort_hue(col_r.h)
609                 h += dntheta
610                 h %= 1.0
611                 col_r.h = mgr.undistort_hue(h)
612                 shape_rot.append(col_r)
613             newmask.append(shape_rot)
614         self.set_mask(newmask)
615
616
617     def __button_press_cb(self, widget, event):
618         # Begins drags.
619         if self.__drag_func is None:
620             self.__update_active_objects(event.x, event.y)
621             self.__drag_start_pos = event.x, event.y
622             if self.__tmp_new_ctrlpoint is not None:
623                 self.__active_ctrlpoint = len(self.__active_shape)
624                 self.__active_shape.append(self.__tmp_new_ctrlpoint)
625                 self.__tmp_new_ctrlpoint = None
626             if self.__active_ctrlpoint is not None:
627                 self.__active_shape_predrag = self.__active_shape[:]
628                 ctrlpt = self.__active_shape[self.__active_ctrlpoint]
629                 self.__active_ctrlpoint_predrag = ctrlpt
630                 self.__drag_func = self.__drag_active_ctrlpoint
631                 self.__set_cursor(self.__move_point_cursor)
632             elif self.__active_shape is not None:
633                 self.__active_shape_predrag = self.__active_shape[:]
634                 self.__drag_func = self.__drag_active_shape
635                 self.__set_cursor(self.__move_cursor)
636             elif self.__last_cursor is self.__rotate_cursor:
637                 self.__mask_predrag = None
638                 self.__drag_func = self.__rotate_mask
639
640
641     def __button_release_cb(self, widget, event):
642         # Ends the current drag & cleans up, or handle other clicks.
643         if self.__drag_func is None:
644             # Clicking when not in a drag adds a new shape
645             if self.__last_cursor is self.__add_cursor:
646                 self.__add_void(event.x, event.y)
647         else:
648             # Cleanup when dragging ends
649             self.__drag_func = None
650             self.__drag_start_pos = None
651             self.__cleanup_mask()
652         self.__update_active_objects(event.x, event.y)
653
654
655     def __motion_cb(self, widget, event):
656         # Fire the current drag function if one's active.
657         if self.__drag_func is not None:
658             self.__drag_func(event.x, event.y)
659             self.queue_draw()
660         else:
661             self.__update_active_objects(event.x, event.y)
662
663
664     def __cleanup_mask(self):
665         mask = self.get_mask()
666
667         # Drop points from all shapes which are not part of the convex hulls.
668         for shape in mask:
669             if len(shape) <= 3:
670                 continue
671             points = [self.get_pos_for_color(c) for c in shape]
672             edge_points = geom.convex_hull(points)
673             for col, point in zip(shape, points):
674                 if point in edge_points:
675                     continue
676                 shape.remove(col)
677
678         # Drop shapes smaller than the minimum size.
679         newmask = []
680         min_size = self.get_radius() * self.min_shape_size
681         for shape in mask:
682             points = [self.get_pos_for_color(c) for c in shape]
683             void = geom.convex_hull(points)
684             size = self._get_void_size(void)
685             if size >= min_size:
686                 newmask.append(shape)
687         mask = newmask
688
689         # Drop shapes whose points entirely lie within other shapes
690         newmask = []
691         maskvoids = [(shape, geom.convex_hull([self.get_pos_for_color(c)
692                                                for c in shape]))
693                      for shape in mask]
694         for shape1, void1 in maskvoids:
695             shape1_subsumed = True
696             for p1 in void1:
697                 p1_subsumed = False
698                 for shape2, void2 in maskvoids:
699                     if shape1 is shape2:
700                         continue
701                     if geom.point_in_convex_poly(p1, void2):
702                         p1_subsumed = True
703                         break
704                 if not p1_subsumed:
705                     shape1_subsumed = False
706                     break
707             if not shape1_subsumed:
708                 newmask.append(shape1)
709         mask = newmask
710
711         self.set_mask(mask)
712         self.queue_draw()
713
714
715     def __dist_to_nearest_shape(self, x, y):
716         # Distance from `x`, `y` to the nearest edge or vertex of any shape.
717         dists = []
718         for hull in self.get_mask_voids():
719             # cx, cy = geom.poly_centroid(hull)
720             for p1, p2 in geom.pairwise(hull):
721                 np = geom.nearest_point_in_segment(p1,p2, (x,y))
722                 if np is not None:
723                     nx, ny = np
724                     d = math.sqrt((x-nx)**2 + (y-ny)**2)
725                     dists.append(d)
726             # Segment end too
727             d = math.sqrt((p1[0]-x)**2 + (p1[1]-y)**2)
728             dists.append(d)
729         if not dists:
730             return None
731         dists.sort()
732         return dists[0]
733
734
735     def __add_void(self, x, y):
736         # Adds a new shape into the empty space centred at `x`, `y`.
737         self.queue_draw()
738         # Pick a nice size for the new shape, taking care not to
739         # overlap any other shapes, at least initially.
740         alloc = self.get_allocation()
741         cx, cy = self.get_center(alloc=alloc)
742         radius = self.get_radius(alloc=alloc)
743         dx, dy = x-cx, y-cy
744         r = math.sqrt(dx**2 + dy**2)
745         d = self.__dist_to_nearest_shape(x, y)
746         if d is None:
747             d = radius
748         size = min((radius - r), d) * 0.95
749         minsize = radius * self.min_shape_size
750         if size < minsize:
751             return
752         # Create a regular polygon with one of its edges facing the
753         # middle of the wheel.
754         shape = []
755         nsides = 3 + len(self.get_mask())
756         psi = math.atan2(dy, dx) + (math.pi/nsides)
757         psi += math.pi
758         for i in xrange(nsides):
759             theta = 2.0 * math.pi * float(i)/nsides
760             theta += psi
761             px = int(x + size*math.cos(theta))
762             py = int(y + size*math.sin(theta))
763             col = self.get_color_at_position(px, py, ignore_mask=True)
764             shape.append(col)
765         mask = self.get_mask()
766         mask.append(shape)
767         self.set_mask(mask)
768
769
770     def draw_mask_control_points(self, cr, wd, ht):
771         # Draw active and inactive control points on the active shape.
772
773         if self.__active_shape is None:
774             return
775
776         cr.save()
777         active_rgb = 1, 1, 1
778         normal_rgb = 0, 0, 0
779         delete_rgb = 1, 0, 0
780         cr.set_line_width(1.0)
781         void = self.colors_to_mask_void(self.__active_shape)
782
783         # Highlight the objects that would be directly or indirectly affected
784         # if the shape were dragged, and how.
785         min_size = self.get_radius(wd=wd, ht=ht) * self.min_shape_size
786         void_rgb = normal_rgb
787         if self._get_void_size(void) < min_size:
788             # Shape will be deleted
789             void_rgb = delete_rgb
790         elif (  (self.__active_ctrlpoint is None) \
791           and (self.__tmp_new_ctrlpoint is None) ):
792             # The entire shape would be moved
793             void_rgb = active_rgb
794         # Outline the current shape
795         cr.set_source_rgb(*void_rgb)
796         for p_idx, p in enumerate(void):
797             if p_idx == 0:
798                 cr.move_to(*p)
799             else:
800                 cr.line_to(*p)
801         cr.close_path()
802         cr.stroke()
803
804         # Control points
805         colors = self.__active_shape
806         for col_idx, col in enumerate(colors):
807             px, py = self.get_pos_for_color(col)
808             if (px, py) not in void:
809                 # not in convex hull (is it worth doing this fragile test?)
810                 continue
811             point_rgb = void_rgb
812             if col_idx == self.__active_ctrlpoint:
813                 point_rgb = active_rgb
814             cr.set_source_rgb(*point_rgb)
815             cr.arc(px, py, self.__ctrlpoint_radius, 0, 2*math.pi)
816             cr.fill()
817         if self.__tmp_new_ctrlpoint:
818             px, py = self.get_pos_for_color(self.__tmp_new_ctrlpoint)
819             cr.set_source_rgb(*active_rgb)
820             cr.arc(px, py, self.__ctrlpoint_radius, 0, 2*math.pi)
821             cr.fill()
822
823         # Centroid
824         cr.set_source_rgb(*void_rgb)
825         cx, cy = geom.poly_centroid(void)
826         cr.save()
827         cr.set_line_cap(cairo.LINE_CAP_SQUARE)
828         cr.set_line_width(0.5)
829         cr.translate(int(cx)+0.5, int(cy)+0.5)
830         cr.move_to(-2, 0)
831         cr.line_to(2, 0)
832         cr.stroke()
833         cr.move_to(0, -2)
834         cr.line_to(0, 2)
835         cr.stroke()
836
837         cr.restore()
838
839
840     def paint_foreground_cb(self, cr, wd, ht):
841         """Foreground drawing override.
842         """
843         self.draw_mask(cr, wd, ht)
844         self.draw_mask_control_points(cr, wd, ht)
845
846
847     def get_managed_color(self):
848         """Override, with a limited range or returned luma.
849         """
850         col = super(HCYMaskEditorWheel, self).get_managed_color()
851         col = HCYColor(color=col)
852         col.y = clamp(col.y, self.__MIN_LUMA, self.__MAX_LUMA)
853         return col
854
855
856     def set_managed_color(self, color):
857         """Override, limiting the luma range.
858         """
859         col = HCYColor(color=color)
860         col.y = clamp(col.y, self.__MIN_LUMA, self.__MAX_LUMA)
861         super(HCYMaskEditorWheel, self).set_managed_color(col)
862
863
864
865 class HCYMaskPreview (MaskableWheelMixin,
866                       HCYHueChromaWheelMixin,
867                       HueSaturationWheelAdjuster):
868     """Mask preview widget; not scrollable.
869
870     These widgets can be used with `paletteview.palette_load_via_dialog()` as
871     preview widgets during mask selection.
872
873     """
874
875     def __init__(self, mask=None):
876         MaskableWheelMixin.__init__(self)
877         HueSaturationWheelAdjuster.__init__(self)
878         self.set_app_paintable(True)
879         self.set_has_window(False)
880         self.set_mask(mask)
881         self.mask_toggle.set_active(True)
882         self.set_size_request(64, 64)
883
884     def render_background_cb(self, cr, wd, ht):
885         sup = HueSaturationWheelAdjuster
886         sup.render_background_cb(self, cr, wd=wd, ht=ht)
887         self.draw_mask(cr, wd=wd, ht=ht)
888
889     def paint_foreground_cb(self, cr, wd, ht):
890         pass
891
892     def get_background_validity(self):
893         return deepcopy(self.get_mask())
894
895     def set_palette(self, palette):
896         # Compatibility with palette_load_via_dialog()
897         self.set_mask_from_palette(palette)
898
899
900 class HCYMaskTemplateDialog (gtk.Dialog):
901     """Dialog for choosing a mask from a small set of templates.
902
903     http://gurneyjourney.blogspot.co.uk/2008/02/shapes-of-color-schemes.html
904
905     """
906
907     @property
908     def __templates(self):
909         Y = 0.5
910         H = 1-0.05
911         # Reusable shapes...
912         atmos_triad = [( H, 0.95, Y),
913                        ((  H+0.275)%1, 0.55, Y),
914                        ((1+H-0.275)%1, 0.55, Y)]
915         def __coffin(h):
916             # Hexagonal coffin shape with the foot end at the centre
917             # of the wheel.
918             shape = []
919             shape.append(((h     + 0.25)%1, 0.03, Y))
920             shape.append(((h + 1 - 0.25)%1, 0.03, Y))
921             shape.append(((h     + 0.01)%1, 0.95, Y))
922             shape.append(((h + 1 - 0.01)%1, 0.95, Y))
923             shape.append(((h     + 0.04)%1, 0.70, Y))
924             shape.append(((h + 1 - 0.04)%1, 0.70, Y))
925             return shape
926         def __complement_blob(h):
927             # Small pentagonal blob at the given hue, used for an organic-
928             # looking dab of a complementary hue.
929             shape = []
930             shape.append(((h+0.015)%1, 0.94, Y))
931             shape.append(((h+0.985)%1, 0.94, Y))
932             shape.append(((h+0.035)%1, 0.71, Y))
933             shape.append(((h+0.965)%1, 0.71, Y))
934             shape.append(((h      )%1, 0.54, Y))
935             return shape
936         templates = []
937         templates.append((_("Atmospheric Triad"),
938           _("Moody and subjective, defined by one dominant primary and two "
939             "primaries which are less intense."),
940           [ deepcopy(atmos_triad) ]))
941         templates.append((_("Shifted Triad"),
942           _("Weighted more strongly towards the dominant colour."),
943           [[( H, 0.95, Y),
944             ((  H+0.35)%1, 0.4, Y),
945             ((1+H-0.35)%1, 0.4, Y) ]] ))
946         templates.append((_("Complementary"),
947           _("Contrasting opposites, balanced by having central neutrals "
948             "between them on the colour wheel."),
949           [[((H+0.005)%1,  0.9, Y),
950             ((H+0.995)%1,  0.9, Y),
951             ((H+0.25 )%1,  0.1, Y),
952             ((H+0.75 )%1,  0.1, Y),
953             ((H+0.505)%1,  0.9, Y),
954             ((H+0.495)%1,  0.9, Y),
955             ]] ))
956         templates.append((_("Mood and Accent"),
957           _("One main range of colors, with a complementary accent for "
958             "variation and highlights."),
959           [ deepcopy(atmos_triad),
960             __complement_blob(H+0.5) ] ))
961             #[((H+0.483)%1, 0.95, Y),
962             # ((H+0.517)%1, 0.95, Y),
963             # ((H+0.52)%1, 0.725, Y),
964             # ((H+0.48)%1, 0.725, Y) ]] ))
965         templates.append((_("Split Complementary"),
966           _("Two analogous colours and a complement to them, with no "
967             "secondary colours between them."),
968           [ __coffin(H+0.5), __coffin(1+H-0.1), __coffin(H+0.1) ] ))
969         return templates
970
971
972     def __init__(self, parent, target):
973         gtk.Dialog.__init__(self, _("New gamut mask from template"), parent,
974                             gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
975                             (gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT))
976         self.set_position(gtk.WIN_POS_MOUSE)
977         target_mgr = target.get_color_manager()
978         mgr = ColorManager()
979         mgr.set_wheel_type(target_mgr.get_wheel_type())
980         self.target = target
981         size = 64
982         for name, desc, mask_shapes_float in self.__templates:
983             mask = []
984             for mask_shape_float in mask_shapes_float:
985                 shape = []
986                 for h, c, y in mask_shape_float:
987                     h = mgr.undistort_hue(h)
988                     shape.append(HCYColor(h, c, y))
989                 mask.append(shape)
990             label = gtk.Label()
991             label.set_markup("<b>%s</b>\n\n%s" % (name, desc))
992             label.set_size_request(375, -1)
993             label.set_line_wrap(True)
994             label.set_alignment(0, 0.5)
995             preview = HCYMaskPreview(mask)
996             preview.set_color_manager(mgr)
997             preview_frame = gtk.AspectFrame(obey_child=True)
998             preview_frame.add(preview)
999             preview_frame.set_shadow_type(gtk.SHADOW_NONE)
1000             hbox = gtk.HBox()
1001             hbox.set_spacing(6)
1002             hbox.pack_start(preview_frame, False, False)
1003             hbox.pack_start(label, True, True)
1004             button = gtk.Button()
1005             button.add(hbox)
1006             button.set_relief(gtk.RELIEF_NONE)
1007             button.connect("clicked", self.__button_clicked_cb, mask)
1008             self.vbox.pack_start(button, True, True)
1009         self.connect("response", self.__response_cb)
1010         self.connect("show", self.__show_cb)
1011         for w in self.vbox:
1012             w.show_all()
1013         ref_color = target.get_managed_color()
1014         mgr.set_color(ref_color)
1015
1016
1017     def __button_clicked_cb(self, widget, mask):
1018         self.target.set_mask(mask)
1019         self.hide()
1020
1021
1022     def __show_cb(self, widget, *a):
1023         self.vbox.show_all()
1024
1025
1026     def __response_cb(self, widget, response_id):
1027         self.hide()
1028         return True
1029
1030
1031 class HCYMaskPropertiesDialog (gtk.Dialog):
1032     """Dialog for choosing, editing, or enabling/disabling masks.
1033     """
1034
1035     def __init__(self, parent, target):
1036         gtk.Dialog.__init__(self, _("Gamut mask editor"), parent,
1037                             gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
1038                             (gtk.STOCK_HELP, gtk.RESPONSE_HELP,
1039                              gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT,
1040                              gtk.STOCK_OK, gtk.RESPONSE_ACCEPT))
1041         self.set_position(gtk.WIN_POS_MOUSE)
1042         self.target = target
1043         ed = HCYMaskEditorWheel()
1044         ed_mgr = ColorManager()
1045         ed.set_color_manager(ed_mgr)
1046         self.editor = ed
1047         ed.set_size_request(300, 300)
1048         ed.mask_toggle.set_active(True)
1049         self.mask_toggle_ctrl = gtk.CheckButton(_("Active"), use_underline=False)
1050         self.mask_toggle_ctrl.set_tooltip_text(ed.mask_toggle.get_tooltip())
1051         ed.mask_observers.append(self.__mask_changed_cb)
1052
1053         hbox = gtk.HBox()
1054         hbox.set_spacing(3)
1055
1056         # Sidebar buttonbox
1057         # On the right and packed to the top. This places its secondary
1058         # control, a mask toggle button, next to the "OK" button so it's less
1059         # likely to be missed.
1060         bbox = gtk.VButtonBox()
1061         new_btn = self.__new_button = gtk.Button(stock=gtk.STOCK_NEW)
1062         load_btn = self.__load_button = gtk.Button(stock=gtk.STOCK_OPEN)
1063         save_btn = self.__save_button = gtk.Button(stock=gtk.STOCK_SAVE)
1064         clear_btn = self.__clear_button = gtk.Button(stock=gtk.STOCK_CLEAR)
1065
1066         new_btn.set_tooltip_text(_("Create mask from template"))
1067         load_btn.set_tooltip_text(_("Load mask from a GIMP palette file"))
1068         save_btn.set_tooltip_text(_("Save mask to a GIMP palette file"))
1069         clear_btn.set_tooltip_text(_("Erase the mask"))
1070
1071         new_btn.connect("clicked", self.__new_clicked)
1072         save_btn.connect("clicked", self.__save_clicked)
1073         load_btn.connect("clicked", self.__load_clicked)
1074         clear_btn.connect("clicked", self.__clear_clicked)
1075
1076         bbox.pack_start(new_btn)
1077         bbox.pack_start(load_btn)
1078         bbox.pack_start(save_btn)
1079         bbox.pack_start(clear_btn)
1080         bbox.pack_start(self.mask_toggle_ctrl)
1081         bbox.set_child_secondary(self.mask_toggle_ctrl, True)
1082         bbox.set_layout(gtk.BUTTONBOX_START)
1083
1084         hbox.pack_start(ed, True, True)
1085         hbox.pack_start(bbox, False, False)
1086         hbox.set_border_width(9)
1087
1088         self.vbox.pack_start(hbox, True, True)
1089
1090         self.connect("response", self.__response_cb)
1091         self.connect("show", self.__show_cb)
1092         for w in self.vbox:
1093             w.show_all()
1094
1095
1096     def __mask_changed_cb(self):
1097         mask = self.editor.get_mask()
1098         empty = mask == []
1099         self.__save_button.set_sensitive(not empty)
1100         self.__clear_button.set_sensitive(not empty)
1101
1102     def __new_clicked(self, widget):
1103         mask = self.editor.get_mask()
1104         dialog = HCYMaskTemplateDialog(self, self.editor)
1105         dialog.run()
1106
1107
1108     def __save_clicked(self, button):
1109         pal = Palette()
1110         mask = self.editor.get_mask()
1111         for i, shape in enumerate(mask):
1112             for j, col in enumerate(shape):
1113                 col_name = "mask#%d primary#%d" % (i, j)  #NOT localised
1114                 pal.append(col, col_name)
1115         preview = HCYMaskPreview()
1116         preview.set_size_request(128, 128)
1117         mgr = ColorManager()
1118         preview.set_color_manager(mgr)
1119         preview.set_managed_color(self.editor.get_managed_color())
1120         palette_save_via_dialog(pal, title=_("Save mask as a Gimp palette"),
1121                                 parent=self, preview=preview)
1122
1123
1124     def __load_clicked(self, button):
1125         preview = HCYMaskPreview()
1126         preview.set_size_request(128, 128)
1127         mgr = ColorManager()
1128         preview.set_color_manager(mgr)
1129         preview.set_managed_color(self.editor.get_managed_color())
1130         dialog_title = _("Load mask from a Gimp palette")
1131         pal = palette_load_via_dialog(title=dialog_title, parent=self,
1132                                       preview=preview)
1133         if pal is None:
1134             return
1135         self.editor.set_mask_from_palette(pal)
1136
1137
1138     def __clear_clicked(self, widget):
1139         self.editor.set_mask([])
1140
1141
1142     def __show_cb(self, widget, *a):
1143         # When the dialog is shown, clone the target adjuster's mask for
1144         # editing. Assume the user wants to turn on the mask if there
1145         # is no mask on the target already (reduce the number of mouse clicks)
1146         active = True
1147         if self.target.get_mask():
1148             active = self.target.mask_toggle.get_active()
1149         self.mask_toggle_ctrl.set_active(active)
1150         mask = deepcopy(self.target.get_mask())
1151         self.editor.set_mask(mask)
1152
1153         # The wheel type may have changed elsewhere
1154         editor_mgr = self.editor.get_color_manager()
1155         wheel_type = self.target.get_color_manager().get_wheel_type()
1156         editor_mgr.set_wheel_type(wheel_type)
1157
1158         # Clone the target's luma too,
1159         # but not too bright, not too dark
1160         col = HCYColor(color=self.target.get_managed_color())
1161         self.editor.set_managed_color(col)
1162
1163         # Necessary for the content to be displayed
1164         self.vbox.show_all()
1165
1166
1167     def __response_cb(self, widget, response_id):
1168         if response_id == gtk.RESPONSE_ACCEPT:
1169             self.target.set_mask(self.editor.get_mask())
1170             mask_active = self.mask_toggle_ctrl.get_active()
1171             self.target.mask_toggle.set_active(mask_active)
1172         if response_id == gtk.RESPONSE_HELP:
1173             # Sub-sub-sub dialog. Ugh. Still, we have a lot to say.
1174             dialog = gtk.MessageDialog(
1175               parent=self,
1176               flags=gtk.DIALOG_MODAL|gtk.DIALOG_DESTROY_WITH_PARENT,
1177               buttons=gtk.BUTTONS_CLOSE,  )
1178             markup_paras = re.split(r'\n[\040\t]*\n', MASK_EDITOR_HELP)
1179             markup = "\n\n".join([s.replace("\n", " ") for s in markup_paras])
1180             dialog.set_markup(markup)
1181             dialog.set_title(_("Gamut mask editor help"))
1182             dialog.connect("response", lambda *a: dialog.destroy())
1183             dialog.run()
1184         else:
1185             self.hide()
1186         return True
1187
1188
1189 class HCYAdjusterPage (CombinedAdjusterPage):
1190     """Combined HCY adjuster.
1191     """
1192
1193     def __init__(self):
1194         y_adj = HCYLumaSlider()
1195         y_adj.vertical = True
1196         hc_adj = HCYHueChromaWheel()
1197
1198         table = gtk.Table(rows=2, columns=2)
1199         xopts = gtk.FILL|gtk.EXPAND
1200         yopts = gtk.FILL|gtk.EXPAND
1201         table.attach(y_adj, 0,1,  0,1,  gtk.FILL, yopts,  3, 3)
1202         table.attach(hc_adj, 1,2,  0,2,  xopts, yopts,  3, 3)
1203
1204         self.__y_adj = y_adj
1205         self.__hc_adj = hc_adj
1206         self.__table = table
1207         self.__mask_dialog = None
1208
1209
1210     @classmethod
1211     def get_properties_description(cls):
1212         return _("Set gamut mask")
1213
1214     def show_properties(self):
1215         if self.__mask_dialog is None:
1216             toplevel = self.__hc_adj.get_toplevel()
1217             dia = HCYMaskPropertiesDialog(toplevel, self.__hc_adj)
1218             self.__mask_dialog = dia
1219         self.__mask_dialog.run()
1220
1221     @classmethod
1222     def get_page_icon_name(cls):
1223         return 'mypaint-tool-hcywheel'
1224
1225     @classmethod
1226     def get_page_title(cls):
1227         return _('HCY Wheel')
1228
1229     @classmethod
1230     def get_page_description(cls):
1231         return _("Set the color using cylindrical hue/chroma/luma space. "
1232                  "The circular slices are equiluminant.")
1233
1234     def get_page_widget(self):
1235         frame = gtk.AspectFrame(obey_child=True)
1236         frame.set_shadow_type(gtk.SHADOW_NONE)
1237         frame.add(self.__table)
1238         return frame
1239
1240     def set_color_manager(self, manager):
1241         ColorAdjuster.set_color_manager(self, manager)
1242         self.__y_adj.set_property("color-manager", manager)
1243         self.__hc_adj.set_property("color-manager", manager)
1244
1245
1246 if __name__ == '__main__':
1247     import os, sys
1248     from adjbases import ColorManager
1249     mgr = ColorManager()
1250     mgr.set_color(HSVColor(0.0, 0.0, 0.55))
1251     if len(sys.argv) > 1:
1252         # Generate icons
1253         wheel = HCYHueChromaWheel()
1254         wheel.set_color_manager(mgr)
1255         icon_name = HCYAdjusterPage.get_page_icon_name()
1256         for dir_name in sys.argv[1:]:
1257             wheel.save_icon_tree(dir_name, icon_name)
1258     else:
1259         # Interactive test
1260         page = HCYAdjusterPage()
1261         page.set_color_manager(mgr)
1262         window = gtk.Window()
1263         window.add(page.get_page_widget())
1264         window.set_title(os.path.basename(sys.argv[0]))
1265         window.set_border_width(6)
1266         window.connect("destroy", lambda *a: gtk.main_quit())
1267         window.show_all()
1268         gtk.main()
1269