gui: Use new-style classes everywhere
[mypaint:mypaint.git] / gui / colors / adjbases.py
1 # This file is part of MyPaint.
2 # Copyright (C) 2012-2013 by Andrew Chadwick <a.t.chadwick@gmail.com>
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 """Manager+adjuster bases for tweaking a single colour via many widgets.
11 """
12
13 ## Imports
14 import gui.gtk2compat as gtk2compat
15
16 import math
17 from copy import deepcopy, copy
18 from warnings import warn
19 import weakref
20 import os
21 import logging
22 logger = logging.getLogger(__name__)
23
24 import gtk
25 from gtk import gdk
26 import gobject
27 import cairo
28 from gettext import gettext as _
29
30 from util import *
31 from uicolor import *
32 from bases import CachedBgDrawingArea
33 from bases import IconRenderable
34 from uimisc import *
35 from palette import Palette
36 from lib.observable import event
37
38
39 ## Module constants
40
41
42 PREFS_KEY_CURRENT_COLOR = 'colors.current'
43 PREFS_KEY_COLOR_HISTORY = 'colors.history'
44 PREFS_KEY_WHEEL_TYPE = 'colors.wheels.type'
45 PREFS_PALETTE_DICT_KEY = "colors.palette"
46 DATAPATH_PALETTES_SUBDIR = 'palettes'
47 DEFAULT_PALETTE_FILE = 'MyPaint_Default.gpl'
48
49
50 ## API deprecation support
51
52 class DeprecatedAPIWarning (UserWarning):
53     pass
54
55
56 def deprecated(replacement=None):
57     """Decorator for deprecated calls, with an optional suggested replacement.
58     """
59     def owrapper(func):
60         if replacement is not None:
61             def iwrapper(*a, **kw):
62                 msg = "%s is deprecated: use %s() instead" % \
63                                     (func.__name__, replacement.__name__)
64                 warn(msg, DeprecatedAPIWarning, stacklevel=2)
65                 return replacement(*a, **kw)
66         else:
67             def iwrapper(*a, **kw):
68                 msg = "%s is deprecated" % (func.__name__,)
69                 warn(msg, DeprecatedAPIWarning, stacklevel=2)
70                 return func(*a, **kw)
71         iwrapper.__name__ = func.__name__
72         iwrapper.__doc__ = func.__doc__
73         return iwrapper
74     return owrapper
75
76
77 ## Class definitions
78
79
80 class ColorManager (gobject.GObject):
81     """Manages the data common to several attached `ColorAdjuster`s.
82
83     This data is basically everything that one or more adjusters might want to
84     display. It includes a working color (used by the main app as the active
85     brush color), a short history of previously-painted-with colors, tables of
86     angular distortions for color wheels etc.
87
88     """
89
90     ## GObject integration (type name)
91
92     __gtype_name__ = "ColorManager" #: GObject integration
93
94
95     ## Behavioural constants
96
97     _DEFAULT_HIST = ['#ee3333', '#336699', '#44aa66', '#aa6633', '#292929']
98     _HIST_LEN = 5
99     _HUE_DISTORTION_TABLES = {
100         # {"PREFS_KEY_WHEEL_TYPE-name": table-of-ranges}
101         "rgb": None,
102         "ryb": [
103                 ((0.,   1/6.),  (0.,    1/3.)),  # red -> yellow
104                 ((1/6., 1/3.),  (1/3.,  1/2.)),  # yellow -> green
105                 ((1/3., 2/3.),  (1/2.,  2/3.)),  # green -> blue
106             ],
107         "rygb": [
108                 ((0.,   1/6.),  (0., 0.25)),   # red -> yellow
109                 ((1/6., 1/3.),  (0.25, 0.5)),  # yellow -> green
110                 ((1/3., 2/3.),  (0.5, 0.75)),  # green -> blue
111                 ((2/3., 1.  ),  (0.75, 1.)),   # blue -> red
112             ],
113         }
114     _DEFAULT_WHEEL_TYPE = "rgb"
115
116
117     ## Construction
118
119
120     def __init__(self, prefs=None, datapath=u'.'):
121         """Initialises with default colours and an empty adjuster list.
122
123         :param prefs: Prefs dict for saving settings.
124         :param datapath: Base path for saving palettes and masks.
125
126         """
127         gobject.GObject.__init__(self)
128         if prefs is None:
129             prefs = {}
130
131         # Defaults
132         self._color = None  #: Currently edited color, a UIColor object
133         self._hist = []  #: List of previous colors, most recent last
134         self._palette = None  #: Current working palette
135         self._adjusters = weakref.WeakSet() #: The set of registered adjusters
136         self._picker_cursor = gdk.Cursor(gdk.CROSSHAIR) #: Cursor for pickers
137         self._datapath = datapath #: Base path for saving palettes and masks
138         self._hue_distorts = None #: Hue-remapping table for color wheels
139         self._prefs = prefs #: Shared preferences dictionary
140
141         # Build the history. Last item is most recent.
142         hist_hex = list(prefs.get(PREFS_KEY_COLOR_HISTORY, []))
143         hist_hex = self._DEFAULT_HIST + hist_hex
144         self._hist = [RGBColor.new_from_hex_str(s) for s in hist_hex]
145         self._trim_hist()
146
147         # Restore current colour, or use the most recent colour.
148         col_hex = prefs.get(PREFS_KEY_CURRENT_COLOR, None)
149         if col_hex is None:
150             col_hex = hist_hex[-1]
151         self._color = RGBColor.new_from_hex_str(col_hex)
152
153         # Initialize angle distort table
154         wheel_type = prefs.get(PREFS_KEY_WHEEL_TYPE, self._DEFAULT_WHEEL_TYPE)
155         distorts_table = self._HUE_DISTORTION_TABLES[wheel_type]
156         self._hue_distorts = distorts_table
157
158         # Initialize working palette
159         palette_dict = prefs.get(PREFS_PALETTE_DICT_KEY, None)
160         if palette_dict is not None:
161             palette = Palette.new_from_simple_dict(palette_dict)
162         else:
163             datapath = self.get_data_path()
164             palettes_dir = os.path.join(datapath, DATAPATH_PALETTES_SUBDIR)
165             default = os.path.join(palettes_dir, DEFAULT_PALETTE_FILE)
166             palette = Palette(filename=default)
167         self._palette = palette
168
169         # Capture updates to the working palette
170         palette.info_changed += self._palette_changed_cb
171         palette.match_changed += self._palette_changed_cb
172         palette.sequence_changed += self._palette_changed_cb
173         palette.color_changed += self._palette_changed_cb
174
175
176     ## Picker cursor
177
178
179     def set_picker_cursor(self, cursor):
180         """Sets the color picker cursor.
181         """
182         self._picker_cursor = cursor
183
184
185     def get_picker_cursor(self):
186         """Return the color picker cursor.
187
188         This shared cursor is for use by adjusters connected to this manager
189         which have a screen color picker. The default is a crosshair.
190
191         """
192         return self._picker_cursor
193
194
195     # TODO: if the color picker function needs to be made partly app-aware,
196     # move it here and let BrushColorManager override/extend it.
197
198
199     ## Template/read-only data path for palettes, masks etc.
200
201
202     def set_data_path(self, datapath):
203         """Sets the template/read-only data path for palettes, masks etc.
204         """
205         self._datapath = datapath
206
207     def get_data_path(self):
208         """Returns the template/read-only data path for palettes, masks etc.
209
210         This is for use by adjusters connected to this manager which need to
211         load template resources, e.g. palette selectors.
212
213         """
214         return self._datapath
215
216     ## REMOVING. Is this property used anywhere?
217     #datapath = property(get_data_path, set_data_path)
218
219
220     ## Attached ColorAdjusters
221
222     
223     def add_adjuster(self, adjuster):
224         """Adds an adjuster to the internal set of adjusters."""
225         self._adjusters.add(adjuster)
226
227
228     @deprecated(add_adjuster)
229     def _add_adjuster(self, adjuster):
230         pass
231
232
233     def remove_adjuster(self, adjuster):
234         """Removes an adjuster."""
235         self._adjusters.remove(adjuster)
236
237
238     @deprecated(remove_adjuster)
239     def _remove_adjuster(self, adjuster):
240         pass
241
242
243     def get_adjusters(self):
244         """Returns an iterator over the set of registered adjusters."""
245         return iter(self._adjusters)
246
247
248     ## Main shared UIColor object
249
250
251     def set_color(self, color):
252         """Sets the shared `UIColor`, and notifies all registered adjusters.
253
254         Calling this invokes the `color_updated()` method on each registered
255         color adjuster after the color has been updated.
256
257         """
258         if color == self._color:
259             return
260         self._color = copy(color)
261         self._prefs[PREFS_KEY_CURRENT_COLOR] = color.to_hex_str()
262         for adj in self._adjusters:
263             adj.color_updated()
264         self._palette.match_color(color)
265
266
267     def get_color(self):
268         """Gets a copy of the shared `UIColor`.
269         """
270         return copy(self._color)
271
272
273     color = property(get_color, set_color)
274
275
276     ## History of colors used for painting
277
278
279     def _trim_hist(self):
280         self._hist = self._hist[-self._HIST_LEN:]
281
282
283     def push_history(self, color):
284         """Pushes a colour to the user history list.
285
286         Calling this invokes the `color_history_updated()` method on each
287         registered color adjuster after the history has been updated.
288
289         """
290         while color in self._hist:
291             self._hist.remove(color)
292         self._hist.append(color)
293         self._trim_hist()
294         key = PREFS_KEY_COLOR_HISTORY
295         val = []
296         for c in self._hist:
297             s = c.to_hex_str()
298             val.append(s)
299         self._prefs[key] = val
300         for adj in self._adjusters:
301             adj.color_history_updated()
302
303
304     def get_history(self):
305         """Returns a copy of the color history.
306         """
307         return deepcopy(self._hist)
308
309
310     def get_previous_color(self):
311         """Returns the most recently used color from the user history list.
312         """
313         return deepcopy(self._hist[-1])
314
315
316     ## Prefs access
317
318
319     def get_prefs(self):
320         """Returns the current preferences hash.
321         """
322         return self._prefs
323
324
325     @deprecated(get_prefs)
326     def _get_prefs(self):
327         pass
328
329
330     ## Color wheel distortion table (support for RYGB/RGB/RYB-wheels)
331
332
333     def set_wheel_type(self, typename):
334         """Sets the type of attached colour wheels by name.
335
336         :param typename: Wheel type name: "rgb", "ryb", or "rygb".
337         :type typename: str
338
339         This corresponds to a hue-angle remapping, which will be adopted by
340         all wheel-style adjusters attached to this ColorManager.
341
342         """
343         old_typename = self.get_wheel_type()
344         if typename not in self._HUE_DISTORTION_TABLES:
345             typename = self._DEFAULT_WHEEL_TYPE
346         if typename == old_typename:
347             return
348         self._hue_distorts = self._HUE_DISTORTION_TABLES[typename]
349         self._prefs[PREFS_KEY_WHEEL_TYPE] = typename
350         for adj in self._adjusters:
351             if isinstance(adj, HueSaturationWheelAdjuster):
352                 adj.clear_background()
353
354
355     def get_wheel_type(self):
356         """Returns the current colour wheel type name.
357         """
358         default = self._DEFAULT_WHEEL_TYPE
359         return self._prefs.get(PREFS_KEY_WHEEL_TYPE, default)
360
361
362     def distort_hue(self, h):
363         """Distorts a hue from RGB-wheel angles to the current wheel type's.
364         """
365         if self._hue_distorts is None:
366             return h
367         h %= 1.0
368         for rgb_wheel_range, distorted_wheel_range in self._hue_distorts:
369             in0, in1 = rgb_wheel_range
370             out0, out1 = distorted_wheel_range
371             if h > in0 and h <= in1:
372                 h -= in0
373                 h *= (out1-out0) / (in1-in0)
374                 h += out0
375                 break
376         return h
377
378
379     def undistort_hue(self, h):
380         """Reverses the mapping imposed by ``distort_hue()``.
381         """
382         if self._hue_distorts is None:
383             return h
384         h %= 1.0
385         for rgb_wheel_range, distorted_wheel_range in self._hue_distorts:
386             out0, out1 = rgb_wheel_range
387             in0, in1 = distorted_wheel_range
388             if h > in0 and h <= in1:
389                 h -= in0
390                 h *= (out1-out0) / (in1-in0)
391                 h += out0
392                 break
393         return h
394
395
396     ## Palette access
397
398
399     @property
400     def palette(self):
401         """Gets the shared Palette instance."""
402         return self._palette
403
404
405     def _palette_changed_cb(self, palette, *args, **kwargs):
406         """Stores changes made to the palette to the prefs"""
407         prefs = self.get_prefs()
408         prefs[PREFS_PALETTE_DICT_KEY] = palette.to_simple_dict()
409
410
411
412 class ColorAdjuster(object):
413     """Base class for any object which can manipulate a shared `UIColor`.
414
415     Color adjusters are used for changing one or more elements of a colour.
416     Several are bound to a central `ColorManager`, and broadcast
417     changes to it.
418
419     """
420
421     ## Constants
422
423     _DEFAULT_COLOR = RGBColor(0.55, 0.55, 0.55)
424
425
426     ## Central ColorManager instance (accessors)
427
428
429     def set_color_manager(self, manager):
430         """Sets the shared colour adjustment manager this adjuster points to.
431         """
432         if manager is not None:
433             if self in manager.get_adjusters():
434                 return
435         existing = self.get_color_manager()
436         if existing is not None:
437             existing.remove_adjuster(self)
438         self.__manager = manager
439         if self.__manager is not None:
440             self.__manager.add_adjuster(self)
441
442
443     def get_color_manager(self):
444         """Gets the shared colour adjustment manager.
445         """
446         try:
447             return self.__manager
448         except AttributeError:
449             self.__manager = None
450             return None
451
452
453     color_manager = property(get_color_manager, set_color_manager)
454
455
456     ## Access to the central managed UIColor (convenience methods)
457
458
459     def get_managed_color(self):
460         """Gets the managed color. Convenience method for use by subclasses.
461         """
462         if self.color_manager is None:
463             return RGBColor(color=self._DEFAULT_COLOR)
464         return self.color_manager.get_color()
465
466
467     def set_managed_color(self, color):
468         """Sets the managed color. Convenience method for use by subclasses.
469         """
470         if self.color_manager is None:
471             return
472         if color is not None:
473             self.color_manager.set_color(color)
474
475
476     managed_color = property(get_managed_color, set_managed_color)
477
478
479     ## Central shared prefs access (convenience methods)
480
481
482     def get_prefs(self):
483         if self.color_manager is not None:
484             return self.color_manager.get_prefs()
485         return {}
486
487
488     @deprecated(get_prefs)
489     def _get_prefs(self):
490         pass
491
492
493     ## Update notification
494
495
496     def color_updated(self):
497         """Called by the manager when the shared `UIColor` changes.
498         """
499         pass
500
501
502     def color_history_updated(self):
503         """Called by the manager when the color usage history changes.
504         """
505         pass
506         
507
508
509 class ColorAdjusterWidget (CachedBgDrawingArea, ColorAdjuster):
510     """Base class for sliders, wheels, picker areas etc.
511
512     Provides access to the central colour manager via the gobject property
513     ``color-manager``, and click/drag event handlers for picking colours.
514     Derived classes should draw a colourful background by overriding
515     `CachedBgWidgetMixin.render_background_cb()`, and keep handlers registered
516     here happy by implementing `get_color_at_position()`.
517
518     Colour adjusters can operate as sources for dragging colours: subclasses
519     should set `IS_DRAG_SOURCE` to `True` before the object is realized to
520     enable this.
521
522     """
523
524     ## Behavioural and stylistic class constants
525
526     SCROLL_DELTA = 0.015   #: Delta for a scroll event
527     IS_DRAG_SOURCE = False  #: Set to True to make press+move do a select+drag
528     DRAG_THRESHOLD = 10  #: Drag threshold, in pixels
529     _DRAG_COLOR_ID = 1
530     _DRAG_TARGETS = [("application/x-color", 0, _DRAG_COLOR_ID)]
531     HAS_DETAILS_DIALOG = False  #: Set true for a double-click details dialog
532     BORDER_WIDTH = 2    #: Size of the border around the widget.
533     STATIC_TOOLTIP_TEXT = None #: Static tooltip, used during constructor
534     OUTLINE_WIDTH = 3  #: Dark outline around shapes: size
535     OUTLINE_RGBA = (0, 0, 0, 0.4)  #: Dark shape outline: color
536     EDGE_HIGHLIGHT_WIDTH = 1.0  #: Light Tango-ish border for shapes: size
537     EDGE_HIGHLIGHT_RGBA = (1, 1, 1, 0.25) #: Light Tango-ish border: xolor
538
539
540     ## Deprecated property names
541
542     @property
543     def border(self):
544         warn("Use BORDER_WIDTH instead", DeprecatedAPIWarning, 2)
545         return self.BORDER_WIDTH
546
547     @property
548     def outline_width(self):
549         warn("Use OUTLINE_WIDTH instead", DeprecatedAPIWarning, 2)
550         return self.OUTLINE_WIDTH
551
552     @property
553     def outline_rgba(self):
554         warn("Use OUTLINE_RGBA instead", DeprecatedAPIWarning, 2)
555         return self.OUTLINE_RGBA
556
557     @property
558     def edge_highlight_rgba(self):
559         warn("Use EDGE_HIGHLIGHT_RGBA instead", DeprecatedAPIWarning, 2)
560         return self.EDGE_HIGHLIGHT_RGBA
561
562     @property
563     def edge_highlight_width(self):
564         warn("Use EDGE_HIGHLIGHT_WIDTH instead", DeprecatedAPIWarning, 2)
565         return self.EDGE_HIGHLIGHT_WIDTH
566
567     @property
568     def tooltip_text(self):
569         warn("Use STATIC_TOOLTIP_TEXT instead", DeprecatedAPIWarning, 2)
570         return self.STATIC_TOOLTIP_TEXT
571
572
573     ## GObject integration (type name, properties)
574
575     __gtype_name__ = "ColorAdjusterWidget"
576     __gproperties__ = {
577         'color-manager': (ColorManager,
578                           "Color manager",
579                           "The ColorManager owning the color to be adjusted",
580                           gobject.PARAM_READWRITE),
581         }
582
583     ## Construction (TODO: rename internals at some point)
584
585     def __init__(self):
586         """Initializes, and registers click and drag handlers.
587         """
588         CachedBgDrawingArea.__init__(self)
589         self.__button_down = None
590         self.__drag_start_pos = None
591         self.__drag_start_color = None
592         self.connect("button-press-event", self.__button_press_cb)
593         self.connect("motion-notify-event", self.__motion_notify_cb)
594         self.connect("button-release-event", self.__button_release_cb)
595         self.add_events(gdk.BUTTON_PRESS_MASK|gdk.BUTTON_RELEASE_MASK)
596         self.add_events(gdk.BUTTON_MOTION_MASK)
597         self._init_color_drag()
598         if self.STATIC_TOOLTIP_TEXT is not None:
599             self.set_tooltip_text(self.STATIC_TOOLTIP_TEXT)
600
601
602     ## Color drag and drop
603
604
605     def _init_color_drag(self, *_junk):
606         # Drag init
607         self._drag_dest_set()
608         self.connect("drag-motion", self.drag_motion_cb)
609         self.connect('drag-leave', self.drag_leave_cb)
610         self.connect('drag-begin', self.drag_begin_cb)
611         self.connect('drag-end', self.drag_end_cb)
612         if self.IS_DRAG_SOURCE:
613             self.connect("drag-data-get", self.drag_data_get_cb)
614         self.connect("drag-data-received", self.drag_data_received_cb)
615         settings = self.get_settings()
616         settings.set_property("gtk-dnd-drag-threshold", self.DRAG_THRESHOLD)
617
618     def _drag_source_set(self):
619         targets = [gtk.TargetEntry.new(*e) for e in self._DRAG_TARGETS]
620         start_button_mask = gdk.BUTTON1_MASK
621         actions = gdk.ACTION_MOVE | gdk.ACTION_COPY
622         self.drag_source_set(start_button_mask, targets, actions)
623
624     def _drag_dest_set(self):
625         targets = [gtk.TargetEntry.new(*e) for e in self._DRAG_TARGETS]
626         flags = gtk.DEST_DEFAULT_MOTION | gtk.DEST_DEFAULT_DROP
627         actions = gdk.ACTION_MOVE | gdk.ACTION_COPY
628         self.drag_dest_set(flags, targets, actions)
629
630     def drag_motion_cb(self, widget, context, x, y, t):
631         pass
632
633     def drag_leave_cb(self, widget, context, time):
634         pass
635
636     def drag_begin_cb(self, widget, context):
637         color = self.get_managed_color()
638         preview = gtk2compat.gdk.pixbuf.new(gdk.COLORSPACE_RGB,
639                                              has_alpha=False, bps=8,
640                                              width=32, height=32)
641         pixel = color.to_fill_pixel()
642         preview.fill(pixel)
643         self.drag_source_set_icon_pixbuf(preview)
644
645     def drag_end_cb(self, widget, context):
646         pass
647
648     def drag_data_get_cb(self, widget, context, selection, target_type,
649                          time):
650         """Gets the current color when a drop happens somewhere"""
651         if "application/x-color" not in map(str, context.list_targets()):
652             return False
653         color = self.get_managed_color()
654         data = color.to_drag_data()
655         selection.set(gdk.atom_intern("application/x-color", False),
656                       16, data)
657         logger.debug("drag-data-get: sending type=%r", selection.get_data_type())
658         logger.debug("drag-data-get: sending fmt=%r", selection.get_format())
659         logger.debug("drag-data-get: sending data=%r len=%r",
660                      selection.get_data(), len(selection.get_data()))
661         return True
662
663     def drag_data_received_cb(self, widget, context, x, y, selection,
664                               info, time):
665         """Gets the current color when a drop happens on the widget"""
666         if "application/x-color" not in map(str, context.list_targets()):
667             return False
668         data = selection.get_data()
669         data_type = selection.get_data_type()
670         fmt = selection.get_format()
671         logger.debug("drag-data-received: got type=%r", data_type)
672         logger.debug("drag-data-received: got fmt=%r", fmt)
673         logger.debug("drag-data-received: got data=%r len=%r", data, len(data))
674         color = RGBColor.new_from_drag_data(data)
675         context.finish(True, True, time)
676         self.set_managed_color(color)
677         return True
678
679
680     ## GObject properties (TODO: use decorator syntax instead)
681
682
683     def do_set_property(self, prop, value):
684         if prop.name == 'color-manager':
685             self.set_color_manager(value)
686         else:
687             raise AttributeError, 'unknown property %s' % prop.name
688
689
690     def do_get_property(self, prop):
691         if prop.name == 'color-manager':
692             return self.get_color_manager()
693         else:
694             raise AttributeError, 'unknown property %s' % prop.name
695
696
697     ## Color-at-position interface (for subclasses, primarily)
698
699
700     def get_color_at_position(self, x, y):
701         """Get the color a position represents. Subclasses must override.
702
703         Can be legitimately used by drawing routines, but at this level
704         it's used only by the private handlers for button presses etc.
705
706         """
707         raise NotImplementedError
708
709
710     def set_color_at_position(self, x, y, color):
711         """Handles colours set by the double-click color selection dialog.
712
713         Certain subclasses which are sensitive to the `x` and `y` position of
714         the double click that launches the dialog override this. At this level
715         these parameters are ignored.
716
717         """
718         self.set_managed_color(color)
719
720
721     ## CachedBgDrawingArea implementation: bg validity determined by color
722
723
724     def get_background_validity(self):
725         """Returns a validity token for the displayed background.
726
727         This implementation of `CachedBgWidgetMixin.get_background_validity()`
728         uses the full string representation of the managed colour, but can be
729         overriden to return a smaller subset of its channels or quantize it
730         for fewer redraws.
731
732         """
733         return repr(self.get_managed_color())
734
735
736     ## Pointer event handling
737
738
739     def __button_press_cb(self, widget, event):
740         """Button press handler.
741         """
742         self.__button_down = event.button
743         color = self.get_color_at_position(event.x, event.y)
744         self.set_managed_color(color)
745
746         # Double-click shows the details adjuster
747         if event.type == gdk._2BUTTON_PRESS \
748                     and self.HAS_DETAILS_DIALOG:
749             self.__button_down = None
750             if self.IS_DRAG_SOURCE:
751                 self.drag_source_unset()
752             prev_color = self.get_color_manager().get_previous_color()
753             color = RGBColor.new_from_dialog(
754               title=_("Color details"),
755               color=color,
756               previous_color=prev_color,
757               parent=self.get_toplevel())
758             if color is not None:
759                 self.set_color_at_position(event.x, event.y, color)
760             return
761
762         # Button2 and drag tweaks the current luma
763         if event.button != 1:
764             pos = event.x, event.y
765             self.__drag_start_pos = pos
766             self.__drag_start_color = color
767
768         # Button1 starts DnD drags
769         if event.button == 1 and self.IS_DRAG_SOURCE:
770             if color is None:
771                 self.drag_source_unset()
772             else:
773                 self._drag_source_set()
774
775
776     def __motion_notify_cb(self, widget, event):
777         """Button1 motion handler.
778         """
779         if self.__button_down == 1:
780             # Non-drag-source widgets update the colour continuously while
781             # the mouse button is held down and the pointer moved.
782             if self.IS_DRAG_SOURCE:
783                 return
784             color = self.get_color_at_position(event.x, event.y)
785             self.set_managed_color(color)
786         else:
787             # Relative chroma/luma/hue bending
788             if self.__drag_start_color is None:
789                 return
790             col = HCYColor(color=self.__drag_start_color)
791             alloc = self.get_allocation()
792             w, h = alloc.width, alloc.height
793             size = max(w, h)
794             ex, ey = event.x, event.y
795             sx, sy = self.__drag_start_pos
796             dx, dy = sx-ex, sy-ey
797
798             # Pick a dimension to tweak
799             if event.state & gdk.SHIFT_MASK:
800                 bend = "chroma"
801                 dy = -dy
802             elif event.state & gdk.CONTROL_MASK:
803                 bend = "hue"
804             else:
805                 bend = "luma"
806                 dy = -dy
807
808             # Interpretation of dx depends on text direction
809             if widget.get_direction() == gtk.TEXT_DIR_RTL:
810                 dx = -dx
811
812             # Use the delta with the largest absolute value
813             # FIXME: this has some jarring discontinuities
814             dd = dx if abs(dx) > abs(dy) else dy
815
816             if bend == "chroma":
817                 c0 = clamp(col.c, 0., 1.)
818                 p = (c0 * size) - dd
819                 col.c = clamp(p / size, 0., 1.)
820             elif bend == "hue":
821                 h0 = clamp(col.h, 0., 1.)
822                 p = (h0 * size) - dd
823                 h = p / size
824                 while h < 0:
825                     h += 1.0
826                 col.h = h % 1.0
827             else:   # luma
828                 y0 = clamp(col.y, 0., 1.)
829                 p = (y0 * size) - dd
830                 col.y = clamp(p / size, 0., 1.)
831             self.set_managed_color(col)
832
833
834     def __button_release_cb(self, widget, event):
835         """Button release handler.
836         """
837         manager = self.get_color_manager()
838         self.__button_down = None
839         self.__drag_start_pos = None
840         self.__drag_start_color = None
841
842
843     ## Update notification
844
845     def color_updated(self):
846         """Called in response to the managed colour changing: queues a redraw.
847         """
848         self.queue_draw()
849
850
851
852 class IconRenderableColorAdjusterWidget (ColorAdjusterWidget, IconRenderable):
853     """Base class for ajuster widgets whose background can be used for icons.
854
855     Typically the background of something like a wheel adjuster is the most
856     useful part for the purposes of icon making.
857
858     """
859
860
861     ## Rendering
862
863     def render_as_icon(self, cr, size):
864         """Renders the background into an icon.
865
866         This implementation requires a `render_background_cb()` method which
867         supports an extra argument named ``icon_border``, the pixel size of a
868         suggested small outer border.
869
870         """
871         b = max(2, int(size/16))
872         self.render_background_cb(cr, wd=size, ht=size, icon_border=b)
873
874
875
876 class PreviousCurrentColorAdjuster (ColorAdjusterWidget):
877     """Shows the current and previous colour side by side for comparison.
878     """
879
880     ## Constants (behavioural specialization)
881
882     # Class specialisation
883     IS_DRAG_SOURCE = True
884     HAS_DETAILS_DIALOG = True
885     STATIC_TOOLTIP_TEXT = _("Newly chosen color, and the color "
886                             "most recently used for painting")
887
888     ## Construction
889
890     def __init__(self):
891         ColorAdjusterWidget.__init__(self)
892         s = self.BORDER_WIDTH*2 + 4
893         self.set_size_request(s, s)
894
895
896     ## Rendering
897
898     def render_background_cb(self, cr, wd, ht):
899         mgr = self.get_color_manager()
900         if mgr is None:
901             return
902         curr = mgr.get_color()
903         prev = mgr.get_previous_color()
904         b = self.BORDER_WIDTH
905
906         eff_wd = wd-b-b
907         eff_ht = ht-b-b
908
909         cr.rectangle(b+0.5, b+0.5, eff_wd-1, eff_ht-1)
910         cr.set_line_join(cairo.LINE_JOIN_ROUND)
911         cr.set_source_rgba(*self.OUTLINE_RGBA)
912         cr.set_line_width(self.OUTLINE_WIDTH)
913         cr.stroke()
914
915         cr.rectangle(b, b, int(eff_wd/2), eff_ht)
916         cr.set_source_rgb(*curr.get_rgb())
917         cr.fill()
918         cr.rectangle(wd/2, b, eff_wd - int(eff_wd/2), eff_ht)
919         cr.set_source_rgb(*prev.get_rgb())
920         cr.fill()
921
922         cr.rectangle(b+0.5, b+0.5, eff_wd-1, eff_ht-1)
923         cr.set_source_rgba(*self.EDGE_HIGHLIGHT_RGBA)
924         cr.set_line_width(self.EDGE_HIGHLIGHT_WIDTH)
925         cr.set_line_join(cairo.LINE_JOIN_ROUND)
926         cr.stroke()
927
928     def get_background_validity(self):
929         mgr = self.get_color_manager()
930         if mgr is None:
931             return
932         curr = mgr.get_color()
933         prev = mgr.get_previous_color()
934         return (curr.get_rgb(), prev.get_rgb())
935
936     def paint_foreground_cb(self, cr, wd, ht):
937         pass
938
939
940     ## Color-at-position
941
942     def get_color_at_position(self, x, y):
943         alloc = self.get_allocation()
944         mgr = self.get_color_manager()
945         if x < alloc.width / 2:
946             color = mgr.get_color()
947         else:
948             color = mgr.get_previous_color()
949         return deepcopy(color)
950
951
952     ## Update notifications
953
954     def color_history_updated(self):
955         self.queue_draw()
956
957
958
959 class SliderColorAdjuster (ColorAdjusterWidget):
960     """Base class for slider controls with a coloured background.
961
962     Supports both simple and complex gradients. A simple gradient is a
963     continuous linear interpolation between the two endpoints; complex
964     gradients are sampled many times along their length and then interpolated
965     between linearly.
966
967     """
968
969     # GObject integration
970     __gtype_name__ = "SliderColorAdjuster"
971
972     vertical = False  #: Bar orientation.
973     samples = 0       #: How many extra samples to use along the bar length.
974
975     def __init__(self):
976         """Initialise; state variables can be set here.
977
978         The state variables, `vertical`, `border`, and `samples`
979         can be set here, but not after the widget has been realized.
980
981         """
982         ColorAdjusterWidget.__init__(self)
983         self.connect("realize", self.__realize_cb)
984         self.connect("scroll-event", self.__scroll_cb)
985         self.add_events(gdk.SCROLL_MASK)
986
987
988     def __realize_cb(self, widget):
989         """Realize handler; establishes sizes based on `vertical` etc.
990         """
991         b = self.BORDER_WIDTH
992         bw = SLIDER_MIN_WIDTH
993         bl = SLIDER_MIN_LENGTH
994         if self.vertical:
995             self.set_size_request(bw, bl)
996         else:
997             self.set_size_request(bl, bw)
998
999
1000     def render_background_cb(self, cr, wd, ht):
1001         ref_col = self.get_managed_color()
1002         b = self.BORDER_WIDTH
1003         bar_length = (self.vertical and ht or wd) - b - b
1004         b_x = b+0.5
1005         b_y = b+0.5
1006         b_w = wd-b-b-1
1007         b_h = ht-b-b-1
1008
1009         # Build the gradient
1010         if self.vertical:
1011             bar_gradient = cairo.LinearGradient(0, b, 0, b+bar_length)
1012         else:
1013             bar_gradient = cairo.LinearGradient( b, 0, b+bar_length, 0)
1014         samples = self.samples + 2
1015         for s in xrange(samples+1):
1016             p = float(s)/samples
1017             col = self.get_color_for_bar_amount(p)
1018             r, g, b = col.get_rgb()
1019             if self.vertical:
1020                 p = 1 - p
1021             bar_gradient.add_color_stop_rgb(p, r, g, b)
1022
1023         # Paint bar with Tango-like edges
1024         cr.set_line_join(cairo.LINE_JOIN_ROUND)
1025         cr.set_source_rgba(*self.OUTLINE_RGBA)
1026         cr.set_line_width(self.OUTLINE_WIDTH)
1027         cr.rectangle(b_x, b_y, b_w, b_h)
1028         cr.stroke()
1029
1030         ## Paint bar
1031         cr.set_source(bar_gradient)
1032         cr.rectangle(b_x-0.5, b_y-0.5, b_w+1, b_h+1)
1033         cr.fill()
1034
1035         ## Highlighted edge
1036         if b_w > 5 and b_h > 5:
1037             cr.set_line_width(self.EDGE_HIGHLIGHT_WIDTH)
1038             cr.set_source_rgba(*self.EDGE_HIGHLIGHT_RGBA)
1039             cr.rectangle(b_x, b_y, b_w, b_h)
1040             cr.stroke()
1041
1042
1043     def get_bar_amount_for_color(self, color):
1044         """Bar amount for a given `UIColor`; subclasses must implement.
1045         """
1046         raise NotImplementedError
1047
1048
1049     def get_color_for_bar_amount(self, amt):
1050         """The `UIColor` for a given bar amount; subclasses must implement.
1051         """
1052         raise NotImplementedError
1053
1054
1055     def get_color_at_position(self, x, y):
1056         """Colour for a particular position using ``bar_amount`` methods.
1057         """
1058         amt = self.point_to_amount(x, y)
1059         return self.get_color_for_bar_amount(amt)
1060
1061
1062     def paint_foreground_cb(self, cr, wd, ht):
1063         b = int(self.BORDER_WIDTH)
1064         col = self.get_managed_color()
1065         amt = self.get_bar_amount_for_color(col)
1066         amt = float(clamp(amt, 0, 1))
1067         bar_size = int((self.vertical and ht or wd) - 1 - 2*b)
1068         if self.vertical:
1069             amt = 1.0 - amt
1070             x1 = b + 0.5
1071             x2 = wd - x1
1072             y1 = y2 = int(amt * bar_size) + b + 0.5
1073         else:
1074             x1 = x2 = int(amt * bar_size) + b + 0.5
1075             y1 = b + 0.5
1076             y2 = ht - y1
1077
1078         cr.set_line_cap(cairo.LINE_CAP_ROUND)
1079         cr.set_line_width(5)
1080         cr.move_to(x1, y1)
1081         cr.line_to(x2, y2)
1082         cr.set_source_rgb(0,0,0)
1083         cr.stroke_preserve()
1084
1085         cr.set_source_rgb(1,1,1)
1086         cr.set_line_width(3.5)
1087         cr.stroke_preserve()
1088
1089         cr.set_source_rgb(*col.get_rgb())
1090         cr.set_line_width(0.25)
1091         cr.stroke()
1092
1093
1094     def point_to_amount(self, x, y):
1095         alloc = self.get_allocation()
1096         if self.vertical:
1097             len = alloc.height - 2*self.BORDER_WIDTH
1098             p = y
1099         else:
1100             len = alloc.width - 2*self.BORDER_WIDTH
1101             p = x
1102         p = clamp(p - self.BORDER_WIDTH, 0, len)
1103         amt = float(p)/len
1104         if self.vertical:
1105             amt = 1 - amt
1106         return amt
1107
1108
1109     def __scroll_cb(self, widget, event):
1110         d = self.SCROLL_DELTA
1111         if not self.vertical:
1112             d *= -1
1113         if event.direction in (gdk.SCROLL_DOWN, gdk.SCROLL_LEFT):
1114             d *= -1
1115         col = self.get_managed_color()
1116         amt = self.get_bar_amount_for_color(col)
1117         amt = clamp(amt+d, 0.0, 1.0)
1118         col = self.get_color_for_bar_amount(amt)
1119         self.set_managed_color(col)
1120         return True
1121
1122
1123
1124 class HueSaturationWheelMixin(object):
1125     """Mixin for wheel-style hue/saturation adjusters, indep. of colour space
1126
1127     Implementing most of the wheel-drawing machinery as a mixin allows the
1128     methods to be reused independently of the usual base classes for
1129     Adjusters, which might be inconvenient if sub-widgets are required.
1130
1131     This base class is independent of the colour space, but assumes a
1132     cylindrical shape with the central axis representing lightness and angle
1133     representing hue.
1134
1135     Desaturated colours reside at the centre of the wheel. This makes them
1136     somewhat harder to pick ordinarily, but desaturated colours are handy for
1137     artists. Therefore, we apply a subtle gamma curve when drawing, and when
1138     interpreting clicked values at this level. The internal API presented here
1139     for use by subclasses already has this compensation applied.
1140
1141     """
1142
1143     # Class configuration vars, for overriding
1144
1145     #: How many slices to render
1146     HUE_SLICES = 64
1147
1148     #: How many divisions of grey to use for gamma interp.
1149     SAT_SLICES = 5
1150
1151     #: Greyscale gamma
1152     SAT_GAMMA = 1.50
1153
1154
1155     def get_radius(self, wd=None, ht=None, border=None, alloc=None):
1156         """Returns the radius, suitable for a pixel-edge-aligned centre.
1157         """
1158         if wd is None or ht is None:
1159             if alloc is None:
1160                 alloc = self.get_allocation()
1161             wd = alloc.width
1162             ht = alloc.height
1163         if border is None:
1164             border = self.BORDER_WIDTH
1165         return int((min(wd, ht) / 2.0)) - int(border) + 0.5
1166
1167
1168     def get_center(self, wd=None, ht=None, alloc=None):
1169         """Returns the wheel centre, suitable for an N+0.5 radius.
1170         """
1171         if wd is None or ht is None:
1172             if alloc is None:
1173                 alloc = self.get_allocation()
1174             wd = alloc.width
1175             ht = alloc.height
1176         cx = int(wd/2)
1177         cy = int(ht/2)
1178         return cx, cy
1179
1180
1181     def get_background_validity(self):
1182         """Gets the bg validity token, for `CachedBgWidgetMixin` impls.
1183         """
1184         # The wheel's background is valid if the central grey hasn't changed.
1185         grey = self.color_at_normalized_polar_pos(0, 0)
1186         rgb = grey.get_rgb()
1187         k = max(rgb)
1188         assert k == min(rgb)
1189         # Quantize a bit to reduce redraws due to conversion noise.
1190         return int(k * 1000)
1191
1192
1193     def get_color_at_position(self, x, y):
1194         """Gets the colour at a position, for `ColorAdjusterWidget` impls.
1195         """
1196         alloc = self.get_allocation()
1197         cx, cy = self.get_center(alloc=alloc)
1198         # Normalized radius
1199         r = math.sqrt((x-cx)**2 + (y-cy)**2)
1200         radius = float(self.get_radius(alloc=alloc))
1201         if r > radius:
1202             r = radius
1203         r /= radius
1204         r **= self.SAT_GAMMA
1205         # Normalized polar angle
1206         theta = 1.25 - (math.atan2(x-cx, y-cy) / (2*math.pi))
1207         while theta <= 0:
1208             theta += 1.0
1209         theta %= 1.0
1210         mgr = self.get_color_manager()
1211         if mgr:
1212             theta = mgr.undistort_hue(theta)
1213         return self.color_at_normalized_polar_pos(r, theta)
1214
1215
1216     def render_background_cb(self, cr, wd, ht, icon_border=None):
1217         """Renders the offscreen bg, for `ColorAdjusterWidget` impls.
1218         """
1219         cr.save()
1220
1221         ref_col = self.get_managed_color()
1222         ref_grey = self.color_at_normalized_polar_pos(0, 0)
1223
1224         border = icon_border
1225         if border is None:
1226             border = self.BORDER_WIDTH
1227         radius = self.get_radius(wd, ht, border)
1228
1229         steps = self.HUE_SLICES
1230         sat_slices = self.SAT_SLICES
1231         sat_gamma = self.SAT_GAMMA
1232
1233         # Move to the centre
1234         cx, cy = self.get_center(wd, ht)
1235         cr.translate(cx, cy)
1236
1237         # Clip, for a slight speedup
1238         cr.arc(0, 0, radius+border, 0, 2*math.pi)
1239         cr.clip()
1240
1241         # Tangoesque outer border
1242         cr.set_line_width(self.OUTLINE_WIDTH)
1243         cr.arc(0, 0, radius, 0, 2*math.pi)
1244         cr.set_source_rgba(*self.OUTLINE_RGBA)
1245         cr.stroke()
1246
1247         # Each slice in turn
1248         cr.save()
1249         cr.set_line_width(1.0)
1250         cr.set_line_join(cairo.LINE_JOIN_ROUND)
1251         step_angle = 2.0*math.pi/steps
1252         mgr = self.get_color_manager()
1253         for ih in xrange(steps+1): # overshoot by 1, no solid bit for final
1254             h = float(ih)/steps
1255             if mgr:
1256                 h = mgr.undistort_hue(h)
1257             edge_col = self.color_at_normalized_polar_pos(1.0, h)
1258             rgb = edge_col.get_rgb()
1259             if ih > 0:
1260                 # Backwards gradient
1261                 cr.arc_negative(0, 0, radius, 0, -step_angle)
1262                 x, y = cr.get_current_point()
1263                 cr.line_to(0, 0)
1264                 cr.close_path()
1265                 lg = cairo.LinearGradient(radius, 0, float(x+radius)/2, y)
1266                 lg.add_color_stop_rgba(0, rgb[0], rgb[1], rgb[2], 1.0)
1267                 lg.add_color_stop_rgba(1, rgb[0], rgb[1], rgb[2], 0.0)
1268                 cr.set_source(lg)
1269                 cr.fill()
1270             if ih < steps:
1271                 # Forward solid
1272                 cr.arc(0, 0, radius, 0, step_angle)
1273                 x, y = cr.get_current_point()
1274                 cr.line_to(0, 0)
1275                 cr.close_path()
1276                 cr.set_source_rgb(*rgb)
1277                 cr.stroke_preserve()
1278                 cr.fill()
1279             cr.rotate(step_angle)
1280         cr.restore()
1281
1282         # Cheeky approximation of the right desaturation gradients
1283         rg = cairo.RadialGradient(0,0, 0,  0,0,  radius)
1284         add_distance_fade_stops(rg, ref_grey.get_rgb(),
1285                                 nstops=sat_slices,
1286                                 gamma=1.0/sat_gamma)
1287         cr.set_source(rg)
1288         cr.arc(0, 0, radius, 0, 2*math.pi)
1289         cr.fill()
1290
1291         # Tangoesque inner border 
1292         cr.set_source_rgba(*self.EDGE_HIGHLIGHT_RGBA)
1293         cr.set_line_width(self.EDGE_HIGHLIGHT_WIDTH)
1294         cr.arc(0, 0, radius, 0, 2*math.pi)
1295         cr.stroke()
1296
1297         # Some small notches on the disc edge for pure colors
1298         if wd > 75 or ht > 75:
1299             cr.save()
1300             cr.arc(0, 0, radius+self.EDGE_HIGHLIGHT_WIDTH, 0, 2*math.pi)
1301             cr.clip()
1302             pure_cols = [RGBColor(1,0,0), RGBColor(1,1,0), RGBColor(0,1,0),
1303                          RGBColor(0,1,1), RGBColor(0,0,1), RGBColor(1,0,1),]
1304             for col in pure_cols:
1305                 x, y = self.get_pos_for_color(col)
1306                 x = int(x)-cx
1307                 y = int(y)-cy
1308                 cr.set_source_rgba(*self.EDGE_HIGHLIGHT_RGBA)
1309                 cr.arc(x+0.5, y+0.5, 1.0+self.EDGE_HIGHLIGHT_WIDTH, 0, 2*math.pi)
1310                 cr.fill()
1311                 cr.set_source_rgba(*self.OUTLINE_RGBA)
1312                 cr.arc(x+0.5, y+0.5, self.EDGE_HIGHLIGHT_WIDTH, 0, 2*math.pi)
1313                 cr.fill()
1314             cr.restore()
1315
1316         cr.restore()
1317
1318
1319     def color_at_normalized_polar_pos(self, r, theta):
1320         """Get the colour represented by a polar position.
1321     
1322         The terms `r` and `theta` are normalised to the range 0...1 and refer
1323         to the undistorted colour space.
1324
1325         """
1326         raise NotImplementedError
1327
1328
1329     def get_normalized_polar_pos_for_color(self, col):
1330         """Inverse of `color_at_normalized_polar_pos`.
1331         """
1332         # FIXME: make the names consistent
1333         raise NotImplementedError
1334
1335
1336     def get_pos_for_color(self, col):
1337         nr, ntheta = self.get_normalized_polar_pos_for_color(col)
1338         mgr = self.get_color_manager()
1339         if mgr:
1340             ntheta = mgr.distort_hue(ntheta)
1341         nr **= 1.0/self.SAT_GAMMA
1342         alloc = self.get_allocation()
1343         wd, ht = alloc.width, alloc.height
1344         radius = self.get_radius(wd, ht, self.BORDER_WIDTH)
1345         cx, cy = self.get_center(wd, ht)
1346         r = radius * clamp(nr, 0, 1)
1347         t = clamp(ntheta, 0, 1) * 2 * math.pi
1348         x = int(cx + r*math.cos(t)) + 0.5
1349         y = int(cy + r*math.sin(t)) + 0.5
1350         return x, y
1351
1352
1353     def paint_foreground_cb(self, cr, wd, ht):
1354         """Fg marker painting, for `ColorAdjusterWidget` impls.
1355         """
1356         col = self.get_managed_color()
1357         radius = self.get_radius(wd, ht, self.BORDER_WIDTH)
1358         cx = int(wd/2)
1359         cy = int(ht/2)
1360         cr.arc(cx, cy, radius+0.5, 0, 2*math.pi)
1361         cr.clip()
1362         x, y = self.get_pos_for_color(col)
1363         draw_marker_circle(cr, x, y, size=2)
1364
1365
1366 class HueSaturationWheelAdjuster (HueSaturationWheelMixin,
1367                                   IconRenderableColorAdjusterWidget):
1368     """Concrete base class for hue/saturation wheels, indep. of colour space.
1369     """
1370
1371     def __init__(self):
1372         IconRenderableColorAdjusterWidget.__init__(self)
1373         w = PRIMARY_ADJUSTERS_MIN_WIDTH
1374         h = PRIMARY_ADJUSTERS_MIN_HEIGHT
1375         self.set_size_request(w, h)
1376
1377