July 2nd, 2007 @ 01:36

After yesterday’s rant, I wrote my very own Hue Picker in pyGTK. It is meant to be very simple and straight forward : it just exposes a “changed” signal and two useful functions (get_hue / set_hue).

The usual preview :
Hue Picker Preview
(the “Source hue : [23 ^]” is just a placeholder and isn’t part of the widget)

Full source included below.
The ring drawing code is a mostly direct python port of C GtkHSV’s one.

"""
huepicker
Author : Guillaume "iXce" Seguin
Email  : guillaume@segu.in
 
 # Simple visual color picker based exclusively on hue #
 
Copyright (C) 2007 Guillaume Seguin
 
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
 
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.
 
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
"""
 
import gtk
import gobject
import cairo
from array import array
from colorsys import hsv_to_rgb
from math import cos, sin, atan2, pi
 
class HuePicker (gtk.DrawingArea):
    '''Simple visual color picker based exclusively on hue'''
 
    __gsignals__ = {"changed" : (gobject.SIGNAL_RUN_FIRST,
                                 gobject.TYPE_NONE,
                                 [gobject.TYPE_INT])}
 
    ring_width = 20
    _surface = None
    _ring_surface = None
    _anti_aliasing = True
    _hue = 0
    _grabbed = False
    rings = {}
 
    def __init__ (self, side = 200, anti_aliasing = True):
        '''Prepare widget'''
        super (HuePicker, self).__init__ ()
        self._anti_aliasing = anti_aliasing
        self.add_events (gtk.gdk.BUTTON_PRESS_MASK |
                         gtk.gdk.BUTTON_RELEASE_MASK |
                         gtk.gdk.POINTER_MOTION_MASK)
        self.connect ("expose_event", self.expose)
        self.connect ("button_press_event", self.button_press)
        self.connect ("motion_notify_event", self.motion_notify)
        self.set_size_request (side, side)
 
    def set_cross_cursor (self, time):
        '''Grab pointer and set it to use the Cross cursor'''
        cursor = gtk.gdk.Cursor (gtk.gdk.CROSSHAIR)
        mask = gtk.gdk.BUTTON_RELEASE_MASK | gtk.gdk.POINTER_MOTION_MASK \
             | gtk.gdk.BUTTON_MOTION_MASK
        return gtk.gdk.pointer_grab (window = self.window, owner_events = False,
                                     event_mask = mask, cursor = cursor)
 
    def button_press (self, widget, event):
        '''Grab pointer for selection if it is currently inside the ring'''
        if self.in_ring (event.x, event.y):
            if self.set_cross_cursor (event.time) == gtk.gdk.GRAB_SUCCESS:
                self._grabbed = True
                self.connect ("button_release_event", self.button_release)
 
    def button_release (self, widget, event):
        '''Release pointer if grabbed'''
        if self._grabbed:
            gtk.gdk.pointer_ungrab ()
            self._grabbed = False
            self.process_motion (event)
        return True
 
    def motion_notify (self, widget, event):
        '''Update hue if grabbed'''
        if self._grabbed:
            self.process_motion (event)
        return False
 
    def process_motion (self, event):
        '''Update hue according to pointer position'''
        side, center, outer, inner, radius = self.get_dists ()
        dx = event.x - center
        dy = center - event.y
        h = atan2 (dy, dx) / (2.0 * pi)
        if h < 0:
            h += 1.0
        h *= 360
        # Hue changed, let's update everything
        if h != self._hue:
            self.hue = h
            self.emit ("changed", self._hue)
 
    # Original C code from GtkHSV by Simon Budig | Federico Mena-Quintero |
    # Jonathan Blandford (Copyright (C) 1999 The Free Software Foundation)
    def draw_ring (self):
        '''Draw the ring on a cairo surface'''
        # Useful dists
        side, center, outer, inner, radius = self.get_dists ()
        if side in HuePicker.rings:
            self._ring_surface = HuePicker.rings [side]
            return
        i2 = (inner - 1) * (inner - 1)
        o2 = (outer + 1) * (outer + 1)
        # Python array that will hold the ring image pixel by pixel
        data = array('B', [0] * side * side * 4)
        i = 0
        for y in range (side):
            dy = center - y
            for x in range (side):
                dx = x - center
                dist = dx * dx + dy * dy;
                if dist < i2 or dist > o2:
                    i += 4
                    continue
                h = atan2 (dy, dx) / (2.0 * pi)
                if h < 0:
                    h += 1.0
                r, g, b = hsv_to_rgb (h, 1, 1)
                data[i+0] = int (b * 255)
                data[i+1] = int (g * 255)
                data[i+2] = int (r * 255)
                data[i+3] = 255
                i += 4
        stride = side * 4
        # Load ring into a cairo surface
        s = cairo.ImageSurface.create_for_data (data, cairo.FORMAT_ARGB32,
                                                side, side, stride)
        # Trim ring if required
        if not self._anti_aliasing:
            self._ring_surface = s
            HuePicker.rings [side] = s
            return
        surface = cairo.ImageSurface (cairo.FORMAT_ARGB32, side, side)
        cr = cairo.Context (surface)
        cr.set_source_surface (s)
        cr.set_line_width (self.ring_width)
        cr.new_path ()
        cr.arc (center, center, radius, 0, 2.0 * pi)
        cr.stroke ()
        self._ring_surface = surface
        HuePicker.rings [side] = surface
 
    def redraw (self):
        '''Draw the ring and the value marker'''
        # Get the useful dists
        side, center, outer, inner, radius = self.get_dists ()
        # Draw ring
        if not self._ring_surface:
            self.draw_ring ()
        # Prepare final surface
        self._surface = cairo.ImageSurface (cairo.FORMAT_ARGB32, side, side)
        cr = cairo.Context (self._surface)
        # Paste the ring on the final surface
        cr.set_source_surface (self._ring_surface)
        cr.paint ()
        # Draw the marker
        r, g, b = hsv_to_rgb (float (self._hue) / 360, 1, 1)
        # Use an appropriate marker color according to 
        if self.intensity (r, g, b) > 0.5:
            cr.set_source_rgb (0, 0, 0)
        else:
            cr.set_source_rgb (1, 1, 1)
        cr.new_path ()
        angle = - float (self.hue) * pi / 180.0
        x = cos (angle)
        y = sin (angle)
        cr.move_to (center + x * inner, center + y * inner)
        cr.line_to (center + x * outer, center + y * outer)
        cr.set_line_width (2)
        cr.stroke ()
 
    def intensity (self, r, g, b):
        '''Intensity of a given RGB color'''
        return 0.30 * r + 0.59 * g + 0.11 * b
 
    def get_dists (self):
        '''Helper function returning useful distances such as radiuses'''
        alloc = self.get_allocation ()
        side = min (alloc.width, alloc.height)
        center = side / 2
        outer = center - 5
        inner = outer - self.ring_width
        radius = outer - self.ring_width / 2
        return side, center, outer, inner, radius
 
    def in_ring (self, x, y):
        '''Helper function to check if a point is inside the picker ring'''
        side, center, outer, inner, radius = self.get_dists ()
        dx = x - center
        dy = center - y
        dist = dx * dx + dy * dy
        i2 = (inner - 1) * (inner - 1)
        o2 = (outer + 1) * (outer + 1)
        if dist < i2 or dist > o2:
            return False
        else:
            return True
 
    def get_hue (self):
        '''Returns currently selected hue in the range 0 - 360'''
        return self._hue
 
    def set_hue (self, hue):
        '''Update ring according to the new hue'''
        self._hue = max (0, min (360, hue))
        self.redraw ()
        self.queue_draw ()
    hue = property (get_hue, set_hue)
 
    def expose (self, widget = None, event = None):
        '''Expose event handler'''
        cr = self.window.cairo_create ()
        if not self._surface:
            self.redraw ()
        # Just copy the saved surface into the widget cairo context
        cr.set_source_surface (self._surface)
        # Clip if possible
        if event:
            cr.rectangle (event.area.x, event.area.y,
                          event.area.width, event.area.height)
            cr.clip ()
        cr.paint ()
        return False