Capstone: a drawing application#

A small paint program you can run: drag to paint, pick a colour, and save a PNG. It is built entirely on the native SDL2 binding pperl ships, which supplies the window, the input, an accelerated 2D renderer and image output.

The design is the one every paint program uses. An offscreen canvas holds the painting; the window merely displays it. The mouse draws onto the canvas, and the frame loop copies the canvas to the window. Keeping the two separate means the painting survives resizes and occlusion for free, and saving is reading back something that already exists.

The canvas is a target texture#

SDL renders into a window by default. Give it a texture created with SDL_TEXTUREACCESS_TARGET and it will render into that instead, which is this program’s offscreen image.

use SDL2 qw(:all);

my ($W, $H) = (320, 240);
my $STRIP   = 40;                       # button strip along the bottom

SDL_Init(SDL_INIT_VIDEO) == 0 or die "SDL_Init: ", SDL_GetError(), "\n";

my $win = SDL2::Window->new(title => 'pperl paint', w => $W, h => $H + $STRIP);
my $ren = $win->renderer or die "renderer: ", SDL_GetError(), "\n";

my $canvas = SDL2::Texture->new($ren, w => $W, h => $H,
                                access => SDL_TEXTUREACCESS_TARGET);

sub with_canvas {
    my ($code) = @_;
    $ren->set_target($canvas);
    $code->();
    $ren->set_target(undef);            # undef means the window again
}

with_canvas(sub {                       # start from a white sheet
    $ren->set_draw_color(255, 255, 255);
    $ren->clear;
});

A new target texture holds transparent black, so the canvas is cleared to white once, before anything is painted on it. Everything drawn inside with_canvas lands on the painting; everything outside it lands on the window and is replaced next frame. Getting that backwards is the one structural mistake available here, and its symptom is a painting that vanishes the moment the window redraws.

The brush#

A stroke is a line from the previous mouse position to the current one, so a fast drag stays connected rather than dotting.

my @palette = ([0,0,0], [220,40,40], [40,160,60], [40,90,220]);
my $brush   = $palette[0];

sub stroke {
    my ($from, $to) = @_;
    with_canvas(sub {
        $ren->set_draw_color(@$brush);
        # A 3px nib: draw the segment three times, offset. Cheaper than
        # a real thick-line routine and adequate for a brush.
        for my $d (-1, 0, 1) {
            $ren->draw_line($from->[0] + $d, $from->[1],
                            $to->[0]   + $d, $to->[1]);
            $ren->draw_line($from->[0], $from->[1] + $d,
                            $to->[0],   $to->[1]   + $d);
        }
    });
}

SDL’s draw_line is one pixel wide with no thickness parameter, hence the offset draw passes. A proper brush would rasterise a disc along the segment, which is the algorithms chapter circle routine applied per step.

Controls, without widgets#

SDL2 has no widget toolkit. There is no button object, no layout, no event routing by widget. A control is a rectangle you draw and a coordinate test you write. For four swatches that is less code than configuring a widget would be, and for a real application it is the point at which you would want a toolkit instead.

sub button_rect { my ($i) = @_; return [ 8 + $i * 44, $H + 6, 36, 28 ] }

sub draw_frame {
    $ren->set_draw_color(60, 60, 60);
    $ren->clear;
    $ren->copy($canvas, undef, [0, 0, $W, $H]);
    for my $i (0 .. $#palette) {
        $ren->set_draw_color(@{ $palette[$i] });
        $ren->fill_rect(button_rect($i));
    }
    $ren->set_draw_color(200, 200, 200);
    $ren->draw_rect(button_rect($_)) for 0 .. $#palette;
    $ren->present;
}

copy blits the canvas texture into the top of the window, the swatches are filled rectangles below it, and present shows the frame.

Input#

Events arrive as plain hash references. A press below the canvas picks a colour; a press above it starts a stroke.

my $last;
sub handle {
    my ($ev) = @_;
    if ($ev->{type} == SDL_MOUSEBUTTONDOWN) {
        if ($ev->{y} >= $H) {
            for my $i (0 .. $#palette) {
                my $r = button_rect($i);
                $brush = $palette[$i]
                    if $ev->{x} >= $r->[0] && $ev->{x} < $r->[0] + $r->[2]
                    && $ev->{y} >= $r->[1] && $ev->{y} < $r->[1] + $r->[3];
            }
        } else { $last = [ $ev->{x}, $ev->{y} ] }
    }
    elsif ($ev->{type} == SDL_MOUSEMOTION && $last) {
        my $p = [ $ev->{x}, $ev->{y} ];
        stroke($last, $p);
        $last = $p;
    }
    elsif ($ev->{type} == SDL_MOUSEBUTTONUP) { $last = undef }
}

my $running = 1;
while ($running) {
    while (my $ev = SDL_PollEvent()) {
        $running = 0 if $ev->{type} == SDL_QUIT;
        handle($ev);
    }
    draw_frame();
    SDL_Delay(16);
}

SDL_MOUSEMOTION fires only while the pointer moves, and $last is what makes it a drag rather than a paint-on-hover: it is set on press and cleared on release.

Saving#

The renderer writes its current target straight out, so saving is pointing it at the canvas and asking. The file takes the target’s size: pointed at the canvas it is the 320 x 240 painting, without the button strip, whatever size the window has.

sub save_png {
    my ($path) = @_;
    with_canvas(sub { $ren->save_png($path) });
}

save_png croaks on failure and returns the renderer on success. Errors therefore arrive as exceptions, so call it plainly and wrap it in eval only if you want to catch the failure. The habitual or die "save failed" does no harm, but it can never fire.

save_jpg and save_bmp sit beside it with the same contract.

Running the drawing app#

Assemble the pieces into one file and run it with pperl. Drag to paint, click a swatch to change colour, and call save_png to write the result.

The whole program has been checked with a click and a drag pushed onto SDL’s event queue, both in a window and headless (SDL_VIDEODRIVER=dummy): the canvas starts white, a swatch click selects red, the drag paints red at the midpoint of the stroke and leaves a pixel 50 rows below untouched. In both cases save_png writes a 320 x 240 RGBA PNG holding exactly the painting: a white sheet crossed by the red stroke, with no button strip.

Where the FFI thread would enter#

This capstone needs no raw FFI: one shipped binding covers all of it. The FFI thread enters when you outgrow it: a pressure-sensitive tablet, or a C brush engine with no Perl binding. That is reached exactly as the Cairo showcase described, and the next capstone shows the raw layer in use where it earns its place.