Monthly Archives: January 2009

Button Graphic Generator in VisualWorks/Cairo

On one of our projects, our customer wanted us to use some pretty buttons instead of the standard browser widgets. He’s not a graphic designer, but he’s got a good eye and he mocked something up in Apple’s Pages program. Then I implemented his design in VisualWorks using the Cairo vector graphics library and Pango text layout library.

I’ll assume a basic familiarity with Cairo in this post. If you need a quick primer, have a look at the Cairo Tutorial.

The VisualWorks binding for Cairo is a is written by Travis Griggs and available in the Cincom Public Store Repository. It’s a fairly thin layer, but it is very clean and provides some nice mappings from VisualWorks graphics objects (Point, Rectangle, etc) to the underlying Cairo functions. The only tricky part about it is that you have to find some Cairo binaries somewhere. On my Mac, I use MacPorts and install the “cairo” port.

Back to the button. Here’s a screenshot from the Pages document that our customer sent:

buttons-screenshot-pages

Icons are from the free FamFamFam Silk Icons set, which is full of useful little 16×16 PNGs. Otherwise, the rest of the button is just a rounded rectangle, a gradient fill, label text, and a drop shadow.

To start, let’s make a class for these guys. Each button will have its own image and text, and I’ve imported the CairoGraphics namespace here so that I don’t have to scope all of my Cairo class references:

Smalltalk defineClass: #CairoButton
  superclass: #{Core.Object}
  indexedType: #none
  private: false
  instanceVariableNames: 'image text '
  classInstanceVariableNames: ''
  imports: '
      private CairoGraphics.*
      '
  category: ''

Then, our basic API. We need to be able to hand it an image (preferably by filename), a string for its text, and tell it to write itself to a PNG file (again by filename). We will use Cairo’s built-in PNG functions for load/save, and somewhere in the middle we’ll do some drawing:

text: anObject
  text := anObject

imageFile: aFilePath
  image := ImageSurface pngPath: aFilePath

writeToPngNamed: aFilePath
  | surface cr |
  surface := ImageSurface format: CairoFormat argb32 extent: 100 @ 40.
  self drawOn: surface context.
  surface writeToPng: aFilePath

drawOn: cr
  cr
    source: ColorValue white;
    paint.

We can invoke this now, and regardless of our input it writes a boring, 100×40 file, but at least we’re running end-to-end.

(CairoButton new)
  imageFile: 'famfamfam_silk_icons_v013/icons/add.png';
  text: 'Add note';
  writeToFile: 'addNote.png'

Next, let’s draw the rounded shape of the button. We’ll make it just a little smaller than the image itself to allow room for the drop shadow, and we offset by 0.5 to stroke just one pixel of border. Travis built a handy rounded-rectangle path helper #rectangle:fillet: into the CairoGraphics package, so we use it here:

drawOn: cr
  cr
    source: ColorValue white;
    paint.
  self drawShapeOn: cr

drawShapeOn: cr
  | extent |
  extent := cr surface extent - self shadowRadius.
  cr lineWidth: 1.
  cr rectangle: (0.5 asPoint corner: extent + 0.5) fillet: self cornerRadius.
  cr
    source: self borderColor;
    stroke

shadowRadius
  ^4

cornerRadius
  ^8

borderColor
  ^ColorValue brightness: 0.749

addnote1This gives us a decent-looking rounded rectangle. We’d like to fill it with a gradient, which is easy enough to add. We’ll simply re-use the same path and tell Cairo to fill it with a custom gradient based on the one in the Pages file. Cairo’s linear gradient exists in 2D, with two endpoints and color “stops” at proportional distances between the endpoints. Since we’re trying to match what Pages did, we’ll build a gradient that starts at (0@0) and goes to (0@height).

drawShapeOn: cr
  | extent |
  extent := cr surface extent - self shadowRadius.
  cr lineWidth: 1.
  cr rectangle: (0.5 asPoint corner: extent + 0.5) fillet: self cornerRadius.
  cr source: (self backgroundGradientFrom: 0 @ 0 to: 0 @ extent y).
  cr fillPreserve.
  cr
    source: self borderColor;
    stroke

backgroundGradientFrom: aStartPoint to: aStopPoint
  ^(LinearGradient from: aStartPoint to: aStopPoint)
    addStopAt: 0 colorValue: ColorValue white;
    addStopAt: 0.43 colorValue: (ColorValue brightness: 0.9);
    addStopAt: 0.5 colorValue: (ColorValue brightness: 0.82);
    addStopAt: 1 colorValue: (ColorValue brightness: 0.95);
    yourself

addnote2Now we have a shaded button, but with nothing on it. The image and the text will be inset from the edge of the button by 9 pixels horizontally and 8 pixels vertically; and there will be a spacing of 5 pixels between them.

drawOn: cr
  cr
    source: ColorValue white;
    paint.
  self drawShapeOn: cr.
  self drawImageOn: cr.
  self drawTextOn: cr

padding
  ^9 @ 8

spacing
  ^5

drawImageOn: cr
  cr saveWhile:
    [cr translate: self padding.
    cr
      source: image;
      paint]

drawTextOn: cr
  cr source: self textColor.
  cr moveTo: self padding + ((image width + self spacing) @ 0).
  (cr newLayout)
    text: text;
    fontDescriptionString: self font;
    showOn: cr

font
  ^'Arial Bold 14px'

addnote3We’re getting closer. The button has its image and text, but its size is still fixed at 100×40. We really ought to measure the text and make our image size match. Fortunately, the Pango library (which we used to draw the text via the #newLayout method) can give us measurements for our text, so we gather that information before we create our initial PNG surface. Our image size will be derived from the text size, padding on all sides, the image size, spacing between the image and text, and the radius of the drop shadow:

writePngFileNamed: aFilePath 
  | surface |
  surface := ImageSurface format: CairoFormat argb32
        extent: self textExtent ceiling + (self padding * 2) 
            + ((self spacing + image width) @ 0) + self shadowRadius.
  self drawOn: surface context.
  surface writeToPng: aFilePath

textExtent
  | surface |
  surface := CairoGraphics.ImageSurface format: CairoFormat argb32
        extent: 1 @ 1.
  ^(surface context newLayout)
    text: text;
    fontDescriptionString: self font;
    extent

addnote4In #textExtent, I had to create a Cairo surface to use as a basis for Pango’s measurements. I could have used my “image” instance variable, but that didn’t seem like a healthy dependence to me. Better to create a scratch surface and throw it away.

Finally, we want a drop shadow. This is where we have to fake things a little. Cairo doesn’t have a “blur” operation, so we’ll take the shape the button we’ve drawn and smear it around. To avoid having to draw our shape several times, we use Cairo’s built in layering capability to draw our shape on a separate layer, then repeatedly place this layer down in the drop shadow region (thanks to Travis Griggs for the help on this):

drawOn: cr
  | button |
  cr
    source: ColorValue white;
    paint.
  cr pushGroup.
  self drawShapeOn: cr.
  self drawImageOn: cr.
  self drawTextOn: cr.
  button := cr popGroup.
  self drawShadowOf: button on: cr.
  cr
    source: button;
    paint

drawShadowOf: button on: cr
  cr saveWhile: 
      [cr translate: (Point r: self shadowRadius / 2 theta: 45 degreesToRadians).
      cr source: (ColorValue black alpha: 0.08).
      0 to: 359
        by: 45
        do: 
          [:n | 
          cr saveWhile: 
              [cr translate: (Point r: 1 theta: n degreesToRadians).
              cr mask: button]]]

addnote5Now it looks just like the customer wanted, and all we have left to do is take out the white background that we forced into place by removing the first two lines of #drawOn:.

Then, put your free icons and creative imagination to work:

closegitmo

deletetelevision

leavemeeting

shivertimbers

stopcomplaining

trysmalltalk

Advertisements

3 Comments

Filed under Smalltalk