Case Study 1: Building a Text Editor

Overview

A text editor is the quintessential desktop application — it exercises every concept in this chapter: menus with shortcuts, file dialogs, a toolbar, a status bar, an action list, and proper application lifecycle management. We build "PascalPad," a simple but functional text editor in under 300 lines.


Problem Statement

Build a text editor with the following features:

  1. A TMemo filling the window for text editing.
  2. File menu: New, Open, Save, Save As, Exit — with standard keyboard shortcuts.
  3. Edit menu: Undo, Cut, Copy, Paste, Select All.
  4. Format menu: Word Wrap toggle, Font selection.
  5. A toolbar with buttons for New, Open, Save, Cut, Copy, Paste.
  6. A status bar showing line count, character count, and modified status.
  7. Unsaved changes detection with prompt on close.
  8. Window title shows filename and modification indicator.

Architecture: TActionList-Centered Design

We put TActionList at the center of our architecture. Every command is an action. Menu items and toolbar buttons are views of those actions.

unit PascalPadMain;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, Forms, Controls, Graphics, Dialogs, Menus, ComCtrls,
  ActnList, StdActns, StdCtrls;

type

  { TfrmPascalPad }

  TfrmPascalPad = class(TForm)
    ActionList1: TActionList;
    MainMenu1: TMainMenu;
    ToolBar1: TToolBar;
    StatusBar1: TStatusBar;
    Memo1: TMemo;
    OpenDialog1: TOpenDialog;
    SaveDialog1: TSaveDialog;
    FontDialog1: TFontDialog;
    ImageList1: TImageList;

    { Actions }
    actNew: TAction;
    actOpen: TAction;
    actSave: TAction;
    actSaveAs: TAction;
    actExit: TAction;
    actUndo: TAction;
    actCut: TAction;
    actCopy: TAction;
    actPaste: TAction;
    actSelectAll: TAction;
    actWordWrap: TAction;
    actFont: TAction;

    { Menu items — all linked to actions }
    mnuFile: TMenuItem;
    mnuEdit: TMenuItem;
    mnuFormat: TMenuItem;
    miNew: TMenuItem;
    miOpen: TMenuItem;
    miSave: TMenuItem;
    miSaveAs: TMenuItem;
    miExit: TMenuItem;
    miUndo: TMenuItem;
    miCut: TMenuItem;
    miCopy: TMenuItem;
    miPaste: TMenuItem;
    miSelectAll: TMenuItem;
    miWordWrap: TMenuItem;
    miFont: TMenuItem;

    procedure actNewExecute(Sender: TObject);
    procedure actOpenExecute(Sender: TObject);
    procedure actSaveExecute(Sender: TObject);
    procedure actSaveAsExecute(Sender: TObject);
    procedure actExitExecute(Sender: TObject);
    procedure actUndoExecute(Sender: TObject);
    procedure actCutExecute(Sender: TObject);
    procedure actCopyExecute(Sender: TObject);
    procedure actPasteExecute(Sender: TObject);
    procedure actSelectAllExecute(Sender: TObject);
    procedure actWordWrapExecute(Sender: TObject);
    procedure actFontExecute(Sender: TObject);
    procedure actSaveUpdate(Sender: TObject);
    procedure actCopyUpdate(Sender: TObject);
    procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean);
    procedure FormCreate(Sender: TObject);
    procedure Memo1Change(Sender: TObject);
  private
    FCurrentFile: string;
    FModified: Boolean;
    procedure UpdateTitle;
    procedure UpdateStatus;
    function ConfirmSave: Boolean;
    procedure NewFile;
  public
  end;

implementation

{$R *.lfm}

procedure TfrmPascalPad.FormCreate(Sender: TObject);
begin
  { Action setup }
  actNew.ShortCut := ShortCut(Ord('N'), [ssCtrl]);
  actOpen.ShortCut := ShortCut(Ord('O'), [ssCtrl]);
  actSave.ShortCut := ShortCut(Ord('S'), [ssCtrl]);
  actSaveAs.ShortCut := ShortCut(Ord('S'), [ssCtrl, ssShift]);
  actUndo.ShortCut := ShortCut(Ord('Z'), [ssCtrl]);
  actCut.ShortCut := ShortCut(Ord('X'), [ssCtrl]);
  actCopy.ShortCut := ShortCut(Ord('C'), [ssCtrl]);
  actPaste.ShortCut := ShortCut(Ord('V'), [ssCtrl]);
  actSelectAll.ShortCut := ShortCut(Ord('A'), [ssCtrl]);

  { Dialog setup }
  OpenDialog1.Filter := 'Text Files (*.txt)|*.txt|Pascal Files (*.pas)|*.pas|All Files (*.*)|*.*';
  SaveDialog1.Filter := OpenDialog1.Filter;
  SaveDialog1.DefaultExt := 'txt';

  { Memo setup }
  Memo1.Align := alClient;
  Memo1.ScrollBars := ssBoth;
  Memo1.Font.Name := 'Consolas';
  Memo1.Font.Size := 11;

  { Status bar }
  StatusBar1.Panels.Clear;
  with StatusBar1.Panels.Add do begin Width := 150; Text := 'Lines: 1'; end;
  with StatusBar1.Panels.Add do begin Width := 150; Text := 'Characters: 0'; end;
  with StatusBar1.Panels.Add do begin Width := 100; Text := 'Ready'; end;

  NewFile;
end;

procedure TfrmPascalPad.NewFile;
begin
  Memo1.Lines.Clear;
  FCurrentFile := '';
  FModified := False;
  UpdateTitle;
  UpdateStatus;
end;

procedure TfrmPascalPad.actNewExecute(Sender: TObject);
begin
  if FModified and not ConfirmSave then Exit;
  NewFile;
end;

procedure TfrmPascalPad.actOpenExecute(Sender: TObject);
begin
  if FModified and not ConfirmSave then Exit;
  if OpenDialog1.Execute then
  begin
    Memo1.Lines.LoadFromFile(OpenDialog1.FileName);
    FCurrentFile := OpenDialog1.FileName;
    FModified := False;
    UpdateTitle;
    UpdateStatus;
  end;
end;

procedure TfrmPascalPad.actSaveExecute(Sender: TObject);
begin
  if FCurrentFile = '' then
    actSaveAsExecute(Sender)
  else
  begin
    Memo1.Lines.SaveToFile(FCurrentFile);
    FModified := False;
    UpdateTitle;
    StatusBar1.Panels[2].Text := 'Saved';
  end;
end;

procedure TfrmPascalPad.actSaveAsExecute(Sender: TObject);
begin
  SaveDialog1.FileName := FCurrentFile;
  if SaveDialog1.Execute then
  begin
    Memo1.Lines.SaveToFile(SaveDialog1.FileName);
    FCurrentFile := SaveDialog1.FileName;
    FModified := False;
    UpdateTitle;
    StatusBar1.Panels[2].Text := 'Saved';
  end;
end;

procedure TfrmPascalPad.actExitExecute(Sender: TObject);
begin
  Close;
end;

procedure TfrmPascalPad.actUndoExecute(Sender: TObject);
begin
  Memo1.Undo;
end;

procedure TfrmPascalPad.actCutExecute(Sender: TObject);
begin
  Memo1.CutToClipboard;
end;

procedure TfrmPascalPad.actCopyExecute(Sender: TObject);
begin
  Memo1.CopyToClipboard;
end;

procedure TfrmPascalPad.actPasteExecute(Sender: TObject);
begin
  Memo1.PasteFromClipboard;
end;

procedure TfrmPascalPad.actSelectAllExecute(Sender: TObject);
begin
  Memo1.SelectAll;
end;

procedure TfrmPascalPad.actWordWrapExecute(Sender: TObject);
begin
  Memo1.WordWrap := not Memo1.WordWrap;
  actWordWrap.Checked := Memo1.WordWrap;
  if Memo1.WordWrap then
    Memo1.ScrollBars := ssVertical
  else
    Memo1.ScrollBars := ssBoth;
end;

procedure TfrmPascalPad.actFontExecute(Sender: TObject);
begin
  FontDialog1.Font := Memo1.Font;
  if FontDialog1.Execute then
    Memo1.Font := FontDialog1.Font;
end;

{ OnUpdate handlers — called automatically }
procedure TfrmPascalPad.actSaveUpdate(Sender: TObject);
begin
  actSave.Enabled := FModified;
end;

procedure TfrmPascalPad.actCopyUpdate(Sender: TObject);
begin
  actCopy.Enabled := Memo1.SelLength > 0;
  actCut.Enabled := Memo1.SelLength > 0;
end;

procedure TfrmPascalPad.Memo1Change(Sender: TObject);
begin
  FModified := True;
  UpdateTitle;
  UpdateStatus;
end;

procedure TfrmPascalPad.FormCloseQuery(Sender: TObject; var CanClose: Boolean);
begin
  if FModified then
    CanClose := ConfirmSave
  else
    CanClose := True;
end;

function TfrmPascalPad.ConfirmSave: Boolean;
begin
  case MessageDlg('Unsaved Changes',
    'Save changes before continuing?',
    mtConfirmation, [mbYes, mbNo, mbCancel], 0) of
    mrYes:
    begin
      actSaveExecute(nil);
      Result := not FModified;  { True if save succeeded }
    end;
    mrNo: Result := True;
    else  Result := False;  { Cancel }
  end;
end;

procedure TfrmPascalPad.UpdateTitle;
var
  FileName: string;
begin
  if FCurrentFile <> '' then
    FileName := ExtractFileName(FCurrentFile)
  else
    FileName := 'Untitled';
  if FModified then
    Caption := '* ' + FileName + ' — PascalPad'
  else
    Caption := FileName + ' — PascalPad';
end;

procedure TfrmPascalPad.UpdateStatus;
begin
  StatusBar1.Panels[0].Text := Format('Lines: %d', [Memo1.Lines.Count]);
  StatusBar1.Panels[1].Text := Format('Characters: %d', [Length(Memo1.Text)]);
end;

end.

Key Design Observations

  1. Every command is an action. There are no orphan event handlers — every operation flows through the action list. Adding a toolbar button for any action requires only setting its Action property.

  2. OnUpdate keeps UI synchronized. The Save action is grayed out when there are no changes. Cut and Copy are grayed out when nothing is selected. This happens automatically.

  3. ConfirmSave is reusable. The same confirmation logic serves New, Open, Close, and Exit — four different workflows that all need to check for unsaved changes.

  4. The editor is functional in ~200 lines. This is the power of RAD: the framework handles window management, clipboard operations, file dialogs, and widget rendering. You write only the business logic.


Extensions to Try

  1. Find and Replace: Add a modeless Find/Replace dialog (Edit > Find, Ctrl+F).
  2. Line numbers: Replace TMemo with TSynEdit (a syntax-highlighting code editor component).
  3. Recent files: Track the last 5 opened files in a submenu.
  4. Print: Add File > Print using the Printers/PrintersDlgs units.
  5. Multiple documents: Use TPageControl with one tab per document (MDI-style).