Cairo Easter Eggs

#!/usr/bin/env python
#
# [SNIPPET_NAME: Cairo Easter Eggs]
# [SNIPPET_CATEGORIES: Cairo, PyGTK]
# [SNIPPET_DESCRIPTION: Simple Cairo exmple that displays colourful Easter eggs]
# [SNIPPET_AUTHOR: Bruno Girin <[email protected]>]
# [SNIPPET_LICENSE: GPL]
#
# This snippet is derived from laszlok's example introduced during the
# Opportunistic Developer Week on 3rd March 2010. IRC logs are available here:
# http://irclogs.ubuntu.com/2010/03/03/%23ubuntu-classroom.html
# It demonstrates how to create a simple drawing using Cairo and how to display
# it in a GTK window.
# Although quite simple, this drawing demonstrates the following Cairo features:
#   * Drawing lines with different colours and widths
#   * Filling an area with a simple RGB colour
#   * Using a path for clipping
#   * Positioning objects in a window based on the window's geometry
#
# NOTE: Creating a QT version of this snippet would require changing the
# implementation of the DrawingInterface class and replacing the use of the
# gtk.gdk.Rectangle class throughout so should be fairly straightfoward.

import gtk
import cairo
from datetime import datetime

class DrawingInterface:
    """
    The GTK interface to the drawing.
    
    This class creates a GTK window, adds a drawing area to it and handles
    GTK's destroy and expose events in order to close the application and
    re-draw the area using Cairo.
    """
    def __init__(self, area):
        self.main_window = gtk.Window()
        self.draw_area = gtk.DrawingArea()
        
        self.main_window.connect("destroy", self.on_destroy)
        self.draw_area.connect("expose-event", self.on_expose)
        
        self.main_window.add(self.draw_area)
        self.main_window.set_size_request(area.width, area.height)
        
        self.drawing = Drawing()
        
        self.main_window.show_all()
        
    def on_destroy(self, widget):
        gtk.main_quit()
        
    def on_expose(self, widget, event):
        ctx = widget.window.cairo_create()
        self.drawing.draw(ctx, widget.get_allocation())
    
class Drawing:
    """
    Top-level drawing class that contains a list of all the objects to draw.
    """
    def __init__(self):
        """Initialise the class by creating an instance of each object."""
        self.objects = [
            Background(),
            Egg(),
            SmallEgg()
        ]
        
    def draw(self, ctx, area):
        """Draw the complete drawing by drawing each object in turn."""
        for o in self.objects:
            o.draw(ctx, area)

class Background:
    """
    A simple background that draws a white rectangle the size of the area.
    """
    def draw(self, ctx, area):
        ctx.rectangle(area.x, area.y,
                      area.width, area.height)
        ctx.set_source_rgb(1, 1, 1) # white
        ctx.fill()

class Egg(object):
    """
    An object that draws an egg with colourful stripes in the center of the area.
    
    Note that this class extends object, so that sub-classes can call the
    super() method.
    All attributes of the egg are generated pseudo-randomly by using the
    current local date and time:
      * Hour, minute and second are used to calculate the hue of the wobbly
        stripes and egg background: the egg will be red at midnight, yellow
        at 4am, green at 8am, cyan at midday, blue at 4pm, magenta at 8pm.
      * The day of the month deicides on how wobbly the stripes are: nearly
        straight on the 1st up to very wobbly on the 31st.
      * The month in the year drives the direction of the wobbliness:
        negative for even numbered months, positive for odd numbered months.
      * The year drives the number of wobbles in each stripe: 2 for even
        numbered years, 3 for odd numbered years.
    """
    def __init__(self):
        n = datetime.now()
        self.padding = 0.1 # 10% padding on all sides
        self.wobbly_sign = (n.month % 2) * 2 - 1
        self.wobbliness = n.day / 31.0
        self.wobbly_lines = 3
        self.wobbles = n.year % 2 + 2
        hue = ((((n.second / 60.0) + n.minute) / 60.0) + n.hour) / 24.0
        self.rgb = self.hsv_to_rgb((hue, 1.0, 1.0))
        self.whratio = 2.0 / 3.0 # the egg has a constant ratio
        self.outline_width_ratio = 0.01

    def hsv_to_rgb(self, hsv):
        """
        Transforms an HSV triple into an RGB triple.
        
        This method uses the algorithm detailed on Wikipedia:
        http://en.wikipedia.org/wiki/HSL_and_HSV#From_HSV
        with the difference that the hue is a number between 0.0 and 1.0,
        not between 0 and 360.
        """
        (h, s, v) = hsv
        # Calculate the chroma
        c = v * s
        # Adjust the hue to a value between 0.0 and 6.0
        hp = (h % 1.0) * 6
        x = c * (1 - abs(hp % 2 - 1))
        m = v - c
        if(hp < 1.0):
            return (c+m, x+m, m)
        elif(hp < 2.0):
            return (x+m, c+m, m)
        elif(hp < 3.0):
            return (m, c+m, x+m)
        elif(hp < 4.0):
            return (m, x+m, c+m)
        elif(hp < 5.0):
            return (x+m, m, c+m)
        else:
            return (c+m, m, x+m)
    
    def draw(self, ctx, area):
        """
        Draw the egg.
        
        This method first calculates the area in which the egg will be drawn.
        It then draws colourful wobbly lines and the egg's outline.
        """
        egg_area = self.calculate_egg_area(area)
        self.draw_wobbly_lines(ctx, egg_area)
        self.draw_egg_outline(ctx, egg_area)
    
    def calculate_egg_area(self, area):
        """
        Calculate the egg's area.
        
        The area is calculated based on the fact that the egg should keep a
        constant ratio, be in the centre of the drawing area and have some
        padding around it.
        """
        if(float(area.width) / float(area.height) > self.whratio):
            # If the area's width / height ratio is higher than the egg's
            # width / height ratio, the size of the egg is constrained by the
            # height of the area. So calculate the height of the egg first by
            # shaving the padding off the area's height and derive the other
            # values from it.
            h = area.height * (1 - 2 * self.padding)
            y = area.y + area.height * self.padding
            w = h * self.whratio
            x = area.x + (area.width - w) / 2
        else:
            # Otherwise, the size of the egg is constrained by the width of the
            # area. So calculate the width of the egg first by shaving the
            # padding off the area's width and derive the other values from it.
            w = area.width * (1 - 2 * self.padding)
            x = area.x + area.width * self.padding
            h = w / self.whratio
            y = area.y + (area.height - h) / 2
        return gtk.gdk.Rectangle(int(x), int(y), int(w), int(h))
    
    def draw_wobbly_lines(self, ctx, area):
        """
        Draw colourful wobbly lines going through the egg.
        
        This method first sets a clip area that has the shape of the egg and
        draws colourful wobbly lines using cubic bezier curves that are
        constrained by the clip area.
        """
        line_y_interval = area.height / (self.wobbly_lines + 1)
        line_y = line_y_interval
        wobble_x_interval = area.width / self.wobbles
        wobble_x_offset = wobble_x_interval / 2
        wobble_y_offset = wobble_x_offset * self.wobbliness * self.wobbly_sign
        # Save the context so that we can restore it
        ctx.save()
        # Set the clip area for all subsequent drawing primitives
        self.draw_egg_path(ctx, area)
        ctx.clip()
        # Draw a background to the egg in a colour that is an off-white tint
        # of the stripes' colour
        ctx.rectangle(area.x, area.y,
                      area.width, area.height)
        ctx.set_source_rgb(*[(v + 15.0)/16.0 for v in self.rgb])
        ctx.fill()
        # Start the wobbly line path
        ctx.new_path()
        for l in range(self.wobbly_lines):
            ctx.move_to(area.x, area.y + line_y)
            for w in range(self.wobbles):
                ctx.rel_curve_to(
                    wobble_x_offset, wobble_y_offset,                      # P1
                    wobble_x_interval - wobble_x_offset, -wobble_y_offset, # P2
                    wobble_x_interval, 0                                   # P3
                )
            line_y += line_y_interval
        # Draw the created path
        ctx.set_source_rgb(*self.rgb)
        ctx.set_line_width(area.height / self.wobbly_lines / 5.0)
        # Set the line cap to square so that the lines extend slightly beyond
        # the boundary of the clip area; this prevents white space from
        # appearing at the end of the stripes when wobbliness is high
        ctx.set_line_cap(cairo.LINE_CAP_SQUARE)
        ctx.stroke()
        # Restore the context to the state it had when save() was called, in
        # effect removing the clip
        ctx.restore()
    
    def draw_egg_outline(self, ctx, area):
        """
        Draw the egg's outline in black.
        """
        self.draw_egg_path(ctx, area)
        ctx.set_source_rgb(0.0, 0.0, 0.0)
        ctx.set_line_width(area.height * self.outline_width_ratio)
        ctx.stroke()
    
    def draw_egg_path(self, ctx, area):
        """
        Draw the path for the egg outline.
        
        This is in its own separate function because this path is used both
        for clipping the wobbly lines and for drawing the egg's outline.
        This path is composed of four cubic bezier curves laid out on a 4x6
        grid. In the diagram below, curve end points are represented by E and
        curve control points are represented by C.
        
        .---C---E---C---.
        |   |   |   |   |
        .---.---.---.---.
        |   |   |   |   |
        C---.---.---.---C
        |   |   |   |   |
        .---.---.---.---.
        |   |   |   |   |
        E---.---.---.---E
        |   |   |   |   |
        C---.---.---.---C
        |   |   |   |   |
        .---C---E---C---.
        
        This layout results in a roughly egg-shaped curve. It could be improved
        but I reckon that's close enough.
        """
        ctx.move_to(area.x + area.width / 2, area.y)
        ctx.curve_to(
            area.x + 3 * area.width / 4, area.y,
            area.x + area.width, area.y + area.height / 3,
            area.x + area.width, area.y + 2 * area.height / 3
        )
        ctx.curve_to(
            area.x + area.width, area.y + 5 * area.height / 6,
            area.x + 3 * area.width / 4, area.y + area.height,
            area.x + area.width / 2, area.y + area.height
        )
        ctx.curve_to(
            area.x + area.width / 4, area.y + area.height,
            area.x, area.y + 5 * area.height / 6,
            area.x, area.y + 2 * area.height / 3
        )
        ctx.curve_to(
            area.x, area.y + area.height / 3,
            area.x + area.width / 4, area.y,
            area.x + area.width / 2, area.y
        )

class SmallEgg(Egg):
    """
    A small version of the egg that will be positioned in front.
    """
    def __init__(self):
        """
        Create a small egg.
        
        This constructor calls the super class's constructor to set most
        attributes. It adds a size ratio and updates the outline width ratio
        correspondingly to ensure that the width of the stroke is the same for
        the small egg as it is for the large egg, despite the difference in
        overall size. It also swaps round the components of the RGB colour used
        to draw the egg so that the colour of the small egg is always different
        from the colour of the big egg.
        """
        super(SmallEgg, self).__init__()
        self.size_ratio = 0.25
        self.outline_width_ratio /= self.size_ratio
        self.rgb = (self.rgb[1], self.rgb[2], self.rgb[0])
        
    def calculate_egg_area(self, area):
        """
        Calculate the egg's area.
        
        This method calls the sper-class's method and modifies the resulting
        area so that the size of the small egg is adjusted based on the ratio
        and it's horizontal and vertical position is adjusted so that it lays
        in the bottom left corner of the window. Because of the way this
        position is calculated, the position of the small egg relative to the
        big egg changes as the width / height ratio of the window changes.
        The closer the window's ratio is to the egg's ratio (2/3), the more
        overlap between the eggs.
        """
        super_area = super(SmallEgg, self).calculate_egg_area(area)
        w = super_area.width * self.size_ratio
        h = super_area.height * self.size_ratio
        x = self.padding * area.width
        y = (1 - 0.5 * self.padding) * area.height - h
        return gtk.gdk.Rectangle(int(x), int(y), int(w), int(h))
        
if __name__ == "__main__":
    area = gtk.gdk.Rectangle(0, 0, 300, 400)
    DrawingInterface(area)
    gtk.main()