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:
- Freehand drawing (click and drag to draw).
- A color palette (at least 8 colors).
- A line width selector (TTrackBar from 1 to 20 pixels).
- A "Clear" button that clears the canvas.
- Undo support (Ctrl+Z removes the last stroke).
- 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
-
Strokes, not pixels. We store drawing operations (strokes), not pixel data. This makes undo trivial — just remove the last stroke and repaint.
-
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.
-
Mouse events drive state changes. MouseDown starts a stroke, MouseMove extends it, MouseUp ends it. The actual drawing happens in OnPaint.
-
Ctrl+Z undo uses
KeyPreview := Trueon the form so the form receives keyboard events before any control.
Extensions to Try
- Shape tools: Add rectangle, ellipse, and line tools (click start point, drag to end point).
- Fill tool: Use a flood-fill algorithm to fill enclosed areas.
- Save/Load: Save the stroke list to a file. Load it back.
- Export to PNG: Create a TBitmap, replay all strokes on it, and save it as PNG.
- Eraser tool: A white-colored stroke that paints over other strokes.