Capstone: a drawing application#
This chapter assembles the guide into a program you run: a small drawing application. Click and drag to paint, pick a color, clear the canvas, and save the result to a PNG. It is built on Prima, the maintained self-contained toolkit from the GUI chapter, because it gives us a window, mouse events, and an offscreen surface that saves itself, all without an external toolkit to install.
The design is the one every paint program uses. There is an offscreen image that holds the actual painting, and a window that displays it. The mouse draws onto the offscreen image; the window’s paint handler just copies that image to the screen. Separating the two means the painting survives window redraws (resize, occlusion) for free, and saving is trivial because the real drawing already lives in an image object.
The model: an offscreen image#
The painting itself is a Prima::Image, drawn on with the same primitives you would use on a window canvas. We start it white:
use strict;
use warnings;
use Prima qw(Application Buttons);
my $W = 640;
my $H = 480;
# the real drawing lives here, offscreen
my $canvas = Prima::Image->new(width => $W, height => $H, type => im::RGB);
$canvas->begin_paint;
$canvas->color(cl::White);
$canvas->bar(0, 0, $W, $H); # bar is Prima's filled rectangle
$canvas->end_paint;
my $brush_color = cl::Black; # current paint color
my $last; # last mouse point while dragging
begin_paint and end_paint bracket any drawing onto the offscreen image. bar fills a rectangle (recall from the GUI chapter that Prima’s filled rectangle is bar, not rectangle). We hold the current brush color and the previous drag point as ordinary Perl variables.
The window: display and input#
The window’s job is to show $canvas and to translate mouse motion into paint strokes. Its onPaint handler copies the offscreen image to the screen with put_image; its mouse handlers draw onto the offscreen image and ask the window to repaint.
my $window = Prima::MainWindow->new(
text => 'pperl paint',
size => [$W, $H + 40], # extra height for the buttons
onPaint => sub {
my ($self, $canvas_widget) = @_;
$canvas_widget->put_image(0, 40, $canvas); # blit the drawing
},
onMouseDown => sub {
my ($self, $btn, $mod, $x, $y) = @_;
$last = [$x, $y - 40]; # translate for the button strip
},
onMouseMove => sub {
my ($self, $mod, $x, $y) = @_;
return unless $last; # only while a button is held
my @pt = ($x, $y - 40);
stroke($last, \@pt);
$last = \@pt;
$self->repaint;
},
onMouseUp => sub { $last = undef; },
);
The y - 40 offset keeps a strip at the bottom of the window for the buttons; the drawing area is everything above it. onMouseMove fires continuously, so we draw a short line segment from the previous point to the current one and remember the current point. Dragging fast still produces a connected stroke because we always join consecutive points rather than dotting individual pixels.
The brush: drawing onto the offscreen image#
stroke draws one segment of the brush onto the offscreen $canvas. Because the painting is a normal Prima::Image, we use the same drawing calls as anywhere else, bracketed by begin_paint and end_paint:
sub stroke {
my ($from, $to) = @_;
$canvas->begin_paint;
$canvas->color($brush_color);
$canvas->lineWidth(3);
$canvas->line($from->[0], $from->[1], $to->[0], $to->[1]);
# round the joints so fast strokes stay smooth
$canvas->fill_ellipse($to->[0], $to->[1], 3, 3);
$canvas->end_paint;
}
Setting lineWidth before the line gives a thicker stroke; the small fill_ellipse at the endpoint rounds off the joins so a fast zig-zag does not show gaps at the corners. This is the anti-aliasing-adjacent detail from the algorithms chapter showing up in practice: thick lines meeting at an angle leave a notch unless you round the joint.
Controls: color, clear, and save#
A row of buttons along the bottom gives the tool its verbs. Each is a Prima::Button with an onClick. Color buttons set $brush_color; clear repaints the offscreen image white; save writes it to disk.
my @palette = (
['Black', cl::Black], ['Red', cl::Red],
['Green', cl::Green], ['Blue', cl::Blue],
);
my $x = 5;
for my $entry (@palette) {
my ($label, $color) = @$entry;
$window->insert(Button =>
text => $label,
origin => [$x, 5],
size => [60, 30],
onClick => sub { $brush_color = $color },
);
$x += 65;
}
$window->insert(Button =>
text => 'Clear', origin => [$x, 5], size => [60, 30],
onClick => sub {
$canvas->begin_paint;
$canvas->color(cl::White);
$canvas->bar(0, 0, $W, $H);
$canvas->end_paint;
$window->repaint;
},
);
$x += 65;
$window->insert(Button =>
text => 'Save', origin => [$x, 5], size => [60, 30],
onClick => sub {
$canvas->save('painting.png')
and print "saved painting.png\n";
},
);
Prima->run;
insert(Button => ...) adds a child widget to the window. $canvas->save writes the offscreen image to a PNG, format inferred from the extension, returning true on success. Because the drawing has lived in an image object the whole time, saving is a single call with nothing to reconstruct.
Running it#
Assemble the pieces above into one file and run it with pperl. A window opens with a white canvas and a button strip; drag to paint, click a color to switch, Clear to reset, Save to write painting.png. Every piece traces back through the guide: the offscreen image and its primitives from the GUI chapter, the stroke-joining from the algorithms chapter, the color model from foundations, and the save path that is one method because the painting was always a real image.
Where the FFI thread would enter#
This capstone stays on the maintained CPAN stack, and deliberately so: it is meant to run today, unmodified. The FFI thread enters when you outgrow what the stack offers, for instance wanting a C brush-engine library with no Perl binding for pressure-sensitive strokes. That is reached exactly as the Cairo showcase showed: a curated binding presenting the C library as Perl objects. For a paint program, Prima’s own primitives are more than enough, and the honest engineering choice is to use them. The next capstone is where the FFI frontier genuinely comes into play, because realtime windowing and events push past what the current bindings reach.