Case Study 1: Building a Simple Paint Program

Overview

A paint program is the perfect exercise for canvas drawing. It combines mouse event handling, persistent drawing state, color management, and the OnPaint event loop. We build "PascalPaint," a freehand drawing application with tool selection, color palette, line width control, and undo support.


Problem Statement

Build a paint application with:

  1. Freehand drawing (click and drag to draw).
  2. A color palette (at least 8 colors).
  3. A line width selector (TTrackBar from 1 to 20 pixels).
  4. A "Clear" button that clears the canvas.
  5. Undo support (Ctrl+Z removes the last stroke).
  6. Drawings persist across repaints (the OnPaint handler replays all strokes).

Architecture: Stroke-Based Drawing

The key insight is that we cannot simply draw on the canvas — we must store every stroke so we can replay them in OnPaint. A stroke is defined by:

type
  TStroke = record
    Points: array of TPoint;
    PointCount: Integer;
    Color: TColor;
    Width: Integer;
  end;

We maintain a dynamic array of strokes. When the user draws, we append points to the current stroke. In OnPaint, we replay all strokes.


Implementation

unit PascalPaintMain;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, Forms, Controls, Graphics, Dialogs, ExtCtrls,
  StdCtrls, ComCtrls;

type
  TStroke = record
    Points: array of TPoint;
    PointCount: Integer;
    Color: TColor;
    Width: Integer;
  end;

  { TfrmPaint }

  TfrmPaint = class(TForm)
    pnlTools: TPanel;
    pnlPalette: TPanel;
    pbCanvas: TPaintBox;
    trkWidth: TTrackBar;
    lblWidth: TLabel;
    btnClear: TButton;
    btnUndo: TButton;
    procedure FormCreate(Sender: TObject);
    procedure pbCanvasPaint(Sender: TObject);
    procedure pbCanvasMouseDown(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure pbCanvasMouseMove(Sender: TObject; Shift: TShiftState;
      X, Y: Integer);
    procedure pbCanvasMouseUp(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure btnClearClick(Sender: TObject);
    procedure btnUndoClick(Sender: TObject);
    procedure trkWidthChange(Sender: TObject);
    procedure PaletteColorClick(Sender: TObject);
    procedure FormKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
  private
    FStrokes: array of TStroke;
    FStrokeCount: Integer;
    FDrawing: Boolean;
    FCurrentColor: TColor;
    FCurrentWidth: Integer;
    FPaletteColors: array[0..11] of TColor;
    procedure AddPointToCurrentStroke(X, Y: Integer);
    procedure DrawStroke(Canvas: TCanvas; const Stroke: TStroke);
    procedure CreatePalette;
  public
  end;

var
  frmPaint: TfrmPaint;

implementation

{$R *.lfm}

procedure TfrmPaint.FormCreate(Sender: TObject);
begin
  Caption := 'PascalPaint';
  Width := 900;
  Height := 600;
  KeyPreview := True;  { form receives key events before controls }

  FCurrentColor := clBlack;
  FCurrentWidth := 3;
  FStrokeCount := 0;
  FDrawing := False;

  { Tool panel }
  pnlTools.Align := alLeft;
  pnlTools.Width := 120;
  pnlTools.BevelOuter := bvNone;

  { Line width }
  trkWidth.Min := 1;
  trkWidth.Max := 20;
  trkWidth.Position := 3;
  lblWidth.Caption := 'Width: 3';

  { Canvas }
  pbCanvas.Align := alClient;
  pbCanvas.Cursor := crCross;

  CreatePalette;
end;

procedure TfrmPaint.CreatePalette;
var
  I: Integer;
  Panel: TPanel;
begin
  FPaletteColors[0] := clBlack;
  FPaletteColors[1] := clWhite;
  FPaletteColors[2] := clRed;
  FPaletteColors[3] := clGreen;
  FPaletteColors[4] := clBlue;
  FPaletteColors[5] := clYellow;
  FPaletteColors[6] := clFuchsia;
  FPaletteColors[7] := clAqua;
  FPaletteColors[8] := clMaroon;
  FPaletteColors[9] := clNavy;
  FPaletteColors[10] := clOlive;
  FPaletteColors[11] := clGray;

  for I := 0 to 11 do
  begin
    Panel := TPanel.Create(Self);
    Panel.Parent := pnlPalette;
    Panel.Width := 30;
    Panel.Height := 30;
    Panel.Left := 10 + (I mod 3) * 34;
    Panel.Top := 10 + (I div 3) * 34;
    Panel.Color := FPaletteColors[I];
    Panel.Tag := I;
    Panel.BevelWidth := 2;
    Panel.Cursor := crHandPoint;
    Panel.OnClick := @PaletteColorClick;
  end;
end;

procedure TfrmPaint.PaletteColorClick(Sender: TObject);
begin
  FCurrentColor := FPaletteColors[(Sender as TPanel).Tag];
end;

procedure TfrmPaint.trkWidthChange(Sender: TObject);
begin
  FCurrentWidth := trkWidth.Position;
  lblWidth.Caption := Format('Width: %d', [FCurrentWidth]);
end;

procedure TfrmPaint.pbCanvasMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
begin
  if Button = mbLeft then
  begin
    FDrawing := True;

    { Start a new stroke }
    Inc(FStrokeCount);
    SetLength(FStrokes, FStrokeCount);
    with FStrokes[FStrokeCount - 1] do
    begin
      Color := FCurrentColor;
      Width := FCurrentWidth;
      PointCount := 0;
      SetLength(Points, 0);
    end;

    AddPointToCurrentStroke(X, Y);
  end;
end;

procedure TfrmPaint.pbCanvasMouseMove(Sender: TObject; Shift: TShiftState;
  X, Y: Integer);
begin
  if FDrawing and (ssLeft in Shift) then
  begin
    AddPointToCurrentStroke(X, Y);
    pbCanvas.Invalidate;
  end;
end;

procedure TfrmPaint.pbCanvasMouseUp(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
begin
  if Button = mbLeft then
    FDrawing := False;
end;

procedure TfrmPaint.AddPointToCurrentStroke(X, Y: Integer);
begin
  if FStrokeCount = 0 then Exit;
  with FStrokes[FStrokeCount - 1] do
  begin
    Inc(PointCount);
    SetLength(Points, PointCount);
    Points[PointCount - 1] := Point(X, Y);
  end;
end;

procedure TfrmPaint.DrawStroke(Canvas: TCanvas; const Stroke: TStroke);
var
  I: Integer;
begin
  if Stroke.PointCount < 1 then Exit;

  Canvas.Pen.Color := Stroke.Color;
  Canvas.Pen.Width := Stroke.Width;
  Canvas.MoveTo(Stroke.Points[0].X, Stroke.Points[0].Y);

  for I := 1 to Stroke.PointCount - 1 do
    Canvas.LineTo(Stroke.Points[I].X, Stroke.Points[I].Y);

  { If it is a single point (click without drag), draw a dot }
  if Stroke.PointCount = 1 then
  begin
    Canvas.Brush.Color := Stroke.Color;
    Canvas.Ellipse(
      Stroke.Points[0].X - Stroke.Width div 2,
      Stroke.Points[0].Y - Stroke.Width div 2,
      Stroke.Points[0].X + Stroke.Width div 2,
      Stroke.Points[0].Y + Stroke.Width div 2);
  end;
end;

procedure TfrmPaint.pbCanvasPaint(Sender: TObject);
var
  I: Integer;
begin
  { Clear background }
  pbCanvas.Canvas.Brush.Color := clWhite;
  pbCanvas.Canvas.FillRect(0, 0, pbCanvas.Width, pbCanvas.Height);

  { Replay all strokes }
  for I := 0 to FStrokeCount - 1 do
    DrawStroke(pbCanvas.Canvas, FStrokes[I]);
end;

procedure TfrmPaint.btnClearClick(Sender: TObject);
begin
  if MessageDlg('Clear Canvas', 'Erase everything?',
    mtConfirmation, [mbYes, mbNo], 0) = mrYes then
  begin
    FStrokeCount := 0;
    SetLength(FStrokes, 0);
    pbCanvas.Invalidate;
  end;
end;

procedure TfrmPaint.btnUndoClick(Sender: TObject);
begin
  if FStrokeCount > 0 then
  begin
    Dec(FStrokeCount);
    SetLength(FStrokes, FStrokeCount);
    pbCanvas.Invalidate;
  end;
end;

procedure TfrmPaint.FormKeyDown(Sender: TObject; var Key: Word;
  Shift: TShiftState);
begin
  if (Key = Ord('Z')) and (ssCtrl in Shift) then
    btnUndoClick(Sender);
end;

end.

Key Design Observations

  1. Strokes, not pixels. We store drawing operations (strokes), not pixel data. This makes undo trivial — just remove the last stroke and repaint.

  2. OnPaint replays everything. The paint handler clears the canvas and redraws all strokes from scratch. This is correct and necessary: the OS can request a repaint at any time.

  3. Mouse events drive state changes. MouseDown starts a stroke, MouseMove extends it, MouseUp ends it. The actual drawing happens in OnPaint.

  4. Ctrl+Z undo uses KeyPreview := True on the form so the form receives keyboard events before any control.


Extensions to Try

  1. Shape tools: Add rectangle, ellipse, and line tools (click start point, drag to end point).
  2. Fill tool: Use a flood-fill algorithm to fill enclosed areas.
  3. Save/Load: Save the stroke list to a file. Load it back.
  4. Export to PNG: Create a TBitmap, replay all strokes on it, and save it as PNG.
  5. Eraser tool: A white-colored stroke that paints over other strokes.