24 min read

> "A user interface is like a joke. If you have to explain it, it is not that good."

Learning Objectives

  • Use common LCL controls: TButton, TEdit, TLabel, TComboBox, TMemo, TCheckBox, TRadioButton, TListBox
  • Apply layout techniques using anchors, alignment, panels, and splitters
  • Display tabular data with TStringGrid and configure its properties
  • Build data entry forms with validation, tab order, and focus control
  • Create multi-form applications with modal and modeless forms
  • Write shared event handlers using the Sender parameter and custom events

Chapter 28: Forms, Controls, and Events — Building User Interfaces

"A user interface is like a joke. If you have to explain it, it is not that good." — Martin LeBlanc


In Chapter 27, we built our first GUI application — a form with a button, a label, and a grid. It worked, but it was crude. The layout was fragile. The controls were minimal. The user experience was, to put it generously, utilitarian.

This chapter fixes that. We dive into the full catalog of common LCL controls, learn how to arrange them into professional layouts that adapt to different window sizes, build data entry forms with proper validation and navigation, create multi-form applications, and master event handling in depth. By the end, PennyWise will have a polished expense entry form that Rosa would not be embarrassed to show a client.


28.1 Common Controls

The LCL provides dozens of controls. You do not need to memorize all of them — you will learn them as you need them. But a solid understanding of the most common controls gives you the vocabulary to build almost any form.

TLabel

A label displays static text. Users cannot interact with it (no focus, no editing). Use labels for field names, instructions, and status messages.

lblName.Caption := 'Full Name:';
lblName.Font.Style := [fsBold];      { bold text }
lblName.Font.Color := clNavy;         { dark blue }
lblName.AutoSize := True;             { shrink-wrap to text }

Key properties:

Property Type Description
Caption string The displayed text
Font TFont Font face, size, style, color
AutoSize Boolean If True, label resizes to fit its text
WordWrap Boolean If True, text wraps to multiple lines
Alignment TAlignment taLeftJustify, taCenter, taRightJustify
FocusControl TWinControl The control that receives focus when the label's accelerator is pressed
Transparent Boolean If True, the parent's background shows through

The FocusControl property is important for accessibility: set it to the edit control that follows the label. When the label has an accelerator key (e.g., &Name displays as "Name" with an underline), pressing Alt+N moves focus to the linked control.

lblName.Caption := '&Name:';        { Alt+N accelerator }
lblName.FocusControl := edtName;     { Alt+N focuses edtName }

TEdit

A single-line text input field. This is the workhorse of data entry.

edtEmail.Text := '';                 { current text content }
edtEmail.MaxLength := 100;           { character limit }
edtEmail.CharCase := ecNormal;       { or ecUpperCase, ecLowerCase }
edtEmail.PasswordChar := '*';        { mask input for passwords }
edtEmail.ReadOnly := True;           { display-only }

Key properties and events:

Property/Event Type Description
Text string The current text content
MaxLength Integer Maximum characters allowed (0 = unlimited)
CharCase TEditCharCase Automatic case conversion
PasswordChar Char Mask character (#0 = no mask)
ReadOnly Boolean Prevents editing while allowing selection and copy
TextHint string Placeholder text shown when the field is empty
OnChange TNotifyEvent Fires after every modification
OnKeyPress TKeyPressEvent Fires before a character is inserted
OnExit TNotifyEvent Fires when focus leaves the control
OnEnter TNotifyEvent Fires when focus enters the control

The key events are OnChange (fires after every keystroke), OnKeyPress (fires before the character is inserted — you can modify or cancel it), and OnExit (fires when focus leaves the control — ideal for validation).

The TextHint property is especially useful for data entry forms. It displays grayed-out placeholder text like "Enter email address" that disappears when the user starts typing:

edtEmail.TextHint := 'user@example.com';

TMemo

A multi-line text area. Use it for notes, descriptions, log output, or any text that may span multiple lines.

mmoNotes.Lines.Clear;
mmoNotes.Lines.Add('First line');
mmoNotes.Lines.Add('Second line');
mmoNotes.ScrollBars := ssVertical;   { show vertical scrollbar }
mmoNotes.WordWrap := True;           { wrap long lines }
mmoNotes.MaxLength := 5000;          { character limit }

{ Access all text as a single string: }
AllText := mmoNotes.Text;

{ Or iterate lines: }
for I := 0 to mmoNotes.Lines.Count - 1 do
  ProcessLine(mmoNotes.Lines[I]);

The Lines property is a TStrings object — the same class you encountered with file handling. You can load and save memo content directly:

mmoNotes.Lines.LoadFromFile('notes.txt');
mmoNotes.Lines.SaveToFile('notes.txt');

TButton

The standard pushbutton. We covered its basics in Chapter 27. Additional useful properties:

btnSave.Default := True;   { activated by Enter key }
btnCancel.Cancel := True;   { activated by Escape key }
btnSave.Enabled := False;   { grayed out, unclickable }
btnSave.ModalResult := mrOK; { closes a modal form with this result }

The Default and Cancel properties are essential for dialog forms: they let the user press Enter to confirm or Escape to cancel without reaching for the mouse.

TComboBox

A dropdown list, optionally with free-text entry.

{ Dropdown list (no free text): }
cboCountry.Style := csDropDownList;
cboCountry.Items.Add('United States');
cboCountry.Items.Add('Canada');
cboCountry.Items.Add('Mexico');
cboCountry.ItemIndex := 0;  { select first item }

{ Dropdown with editable text (allows custom entries): }
cboCity.Style := csDropDown;
cboCity.Items.Add('New York');
cboCity.Items.Add('Los Angeles');
{ User can also type a city not in the list }

{ Reading the selection: }
SelectedCountry := cboCountry.Text;
SelectedIndex := cboCountry.ItemIndex;  { -1 if nothing selected }

The OnChange event fires when the selection changes — use it to update dependent controls (e.g., selecting a country updates a state/province dropdown).

The difference between csDropDown and csDropDownList is important. With csDropDown, the user can type any text, even values not in the list. With csDropDownList, the user is restricted to the items in the list. For category selection in PennyWise, we use csDropDownList because we want users to choose from predefined categories. For a city field where the user might enter an unlisted city, use csDropDown.

You can also associate data with each item using the Objects property:

cboCategory.Items.AddObject('Food & Dining', TObject(PtrInt(1)));
cboCategory.Items.AddObject('Transportation', TObject(PtrInt(2)));

{ Later, retrieve the associated ID: }
CategoryID := PtrInt(cboCategory.Items.Objects[cboCategory.ItemIndex]);

TCheckBox

A binary toggle: checked or unchecked. Use checkboxes for independent options where multiple can be active simultaneously.

chkTaxDeductible.Caption := 'Tax Deductible';
chkTaxDeductible.Checked := False;

{ In an event handler: }
if chkTaxDeductible.Checked then
  MarkAsTaxDeductible(CurrentExpense);

For three-state checkboxes (checked, unchecked, grayed), set AllowGrayed := True and use the State property (cbUnchecked, cbChecked, cbGrayed). The grayed state is useful for representing "mixed" or "indeterminate" — for example, when a selection contains some items that are tax deductible and some that are not.

TRadioButton

A mutual-exclusion toggle: selecting one radio button deselects all others in the same container. Use radio buttons for choices where exactly one option must be selected.

{ Place these inside a TGroupBox or TRadioGroup: }
rbWeekly.Caption := 'Weekly';
rbMonthly.Caption := 'Monthly';
rbYearly.Caption := 'Yearly';
rbMonthly.Checked := True;  { default selection }

The "same container" part is critical. Radio buttons inside a TGroupBox are mutually exclusive with each other but independent of radio buttons in a different TGroupBox. If you place all radio buttons directly on the form (without a group box), they are all mutually exclusive with each other — which may not be what you want if you have two separate groups of options.

💡 Intuition: Checkbox vs. Radio Button If the options are independent (the user can select any combination), use checkboxes. If the options are mutually exclusive (the user must pick exactly one), use radio buttons. "Toppings on a pizza" = checkboxes. "Size of the pizza" = radio buttons.

TListBox

A scrollable list of items. The user can select one or multiple items.

lstCategories.Items.Add('Food');
lstCategories.Items.Add('Transport');
lstCategories.Items.Add('Housing');

{ Single selection (default): }
SelectedCategory := lstCategories.Items[lstCategories.ItemIndex];

{ Multi-selection: }
lstCategories.MultiSelect := True;
for I := 0 to lstCategories.Items.Count - 1 do
  if lstCategories.Selected[I] then
    ProcessCategory(lstCategories.Items[I]);

The OnSelectionChange event fires when the selection changes. The Sorted property, if set to True, automatically sorts items alphabetically.

TRadioGroup

A convenient grouping of radio buttons in a single component. Instead of placing individual TRadioButton controls inside a TGroupBox, use TRadioGroup:

rgFrequency.Caption := 'Report Frequency';
rgFrequency.Items.Add('Daily');
rgFrequency.Items.Add('Weekly');
rgFrequency.Items.Add('Monthly');
rgFrequency.ItemIndex := 1;  { select "Weekly" }

{ Reading the selection: }
case rgFrequency.ItemIndex of
  0: GenerateDailyReport;
  1: GenerateWeeklyReport;
  2: GenerateMonthlyReport;
end;

TRadioGroup is more convenient than individual radio buttons because it manages the mutual exclusion automatically and gives you a simple ItemIndex for reading the selection. Its Columns property controls how many columns of radio buttons are displayed — useful when you have many options and want a more compact layout.

TDateTimePicker

A control for selecting dates and times with a dropdown calendar:

dtpDate.Date := Date;            { set to today }
dtpDate.MinDate := EncodeDate(2020, 1, 1);  { earliest allowed date }
dtpDate.MaxDate := Date;         { cannot select future dates }
dtpDate.DateFormat := dfShort;   { use system short date format }

The OnChange event fires when the user selects a new date. We used this control in PennyWise GUI v1 for expense dates.

TSpinEdit and TFloatSpinEdit

Numeric input controls with up/down buttons for incrementing and decrementing:

seQuantity.MinValue := 1;
seQuantity.MaxValue := 100;
seQuantity.Value := 1;
seQuantity.Increment := 1;

{ For decimal values, use TFloatSpinEdit: }
fseAmount.MinValue := 0.00;
fseAmount.MaxValue := 99999.99;
fseAmount.Value := 0.00;
fseAmount.Increment := 0.50;
fseAmount.DecimalPlaces := 2;

These are useful when you want to ensure the user enters a valid number within a specific range, without writing validation code.


28.2 Layout and Alignment

A well-designed form adapts to different window sizes, font settings, and DPI scales. Hardcoding pixel positions is fragile. Lazarus provides several mechanisms for flexible layout.

Anchors

Every control has an Anchors property — a set of akTop, akBottom, akLeft, akRight. An anchor binds the control's edge to the corresponding edge of its parent.

  • [akTop, akLeft] — the default. The control stays at its position relative to the top-left corner. If the form resizes, the control does not move.
  • [akTop, akLeft, akRight] — the control stretches horizontally when the form widens. Use this for text fields and grids that should fill the available width.
  • [akLeft, akTop, akBottom] — the control stretches vertically. Use this for side panels and navigation trees.
  • [akLeft, akTop, akRight, akBottom] — the control fills its parent, growing in all directions.
  • [akBottom, akRight] — the control stays in the bottom-right corner. Use this for OK/Cancel buttons.
{ Make the expense grid fill the center of the form: }
sgExpenses.Anchors := [akTop, akLeft, akRight, akBottom];

{ Keep the OK button in the bottom-right: }
btnOK.Anchors := [akBottom, akRight];

When you set anchors, the distance between the control's edge and the parent's edge is preserved. So if a button is 10 pixels from the right edge with akRight set, it stays 10 pixels from the right edge no matter how wide the form becomes.

Here is a practical example of anchor combinations for a typical form:

{ Title label — stays centered at the top }
lblTitle.Anchors := [akTop, akLeft, akRight];

{ Main text area — fills most of the form }
mmoContent.Anchors := [akTop, akLeft, akRight, akBottom];

{ OK button — bottom right }
btnOK.Anchors := [akBottom, akRight];

{ Cancel button — bottom right, to the left of OK }
btnCancel.Anchors := [akBottom, akRight];

{ Status label — bottom left }
lblStatus.Anchors := [akBottom, akLeft];

Align

The Align property is a more aggressive layout tool. It causes a control to fill an entire edge of its parent:

Value Behavior
alTop Fills the full width at the top
alBottom Fills the full width at the bottom
alLeft Fills the full height on the left
alRight Fills the full height on the right
alClient Fills all remaining space
alNone Manual positioning (default)

A common layout pattern uses multiple panels with Align:

Form
├── pnlToolbar     (Align = alTop)    ← toolbar at top
├── pnlNavigation  (Align = alLeft)   ← navigation sidebar
├── pnlStatus      (Align = alBottom) ← status bar at bottom
└── pnlContent     (Align = alClient) ← main content fills the rest

The order in which you set alignment matters. The LCL processes aligned controls in a specific order: first all alTop controls (in creation order), then alBottom, then alLeft, then alRight, and finally alClient. Within each alignment direction, controls are stacked in the order they were dropped on the form. If you want a toolbar above a second toolbar, make sure the first toolbar was created (dropped on the form) before the second one.

Panels and GroupBoxes

TPanel is a generic container that groups controls. It has no visual frame by default (set BevelOuter := bvNone for a flat panel or BevelOuter := bvRaised for a 3D effect).

TGroupBox is a labeled container with a visible frame and caption:

grpPaymentMethod.Caption := 'Payment Method';
{ Place radio buttons or other controls inside the group box }

Use panels for structural layout (toolbars, sidebars, content areas). Use group boxes for semantically related controls (a set of radio buttons, a cluster of related fields).

Splitters

A TSplitter lets the user resize adjacent panels by dragging. Place a splitter between two controls that share an edge:

{ In the form designer: }
{ 1. pnlLeft     — Align = alLeft, Width = 200 }
{ 2. Splitter1   — Align = alLeft (auto-placed between left and client) }
{ 3. pnlContent  — Align = alClient }

The splitter automatically handles the drag behavior. Set MinSize to prevent either panel from becoming too small. The ResizeStyle property controls the visual feedback during dragging: rsLine draws a line, rsPattern draws a hatched pattern, rsUpdate continuously resizes (smooth but CPU-intensive).

BorderSpacing

Every control has a BorderSpacing property with Top, Bottom, Left, Right, and Around sub-properties. These add margins between the control and its anchor points or alignment edges.

sgExpenses.BorderSpacing.Around := 8;  { 8-pixel margin on all sides }
pnlInput.BorderSpacing.Bottom := 4;    { 4-pixel gap below the panel }

This is especially important when using Align — without border spacing, aligned controls touch each other with no gap.

AutoSize and ChildSizing

Many controls have an AutoSize property that makes them shrink or grow to fit their content. Labels auto-size by default. Panels can auto-size vertically to fit their children.

For panels that contain multiple child controls, the ChildSizing property offers automatic layout:

pnlInput.AutoSize := True;
pnlInput.ChildSizing.ControlsPerLine := 5;
pnlInput.ChildSizing.Layout := cclLeftToRightThenTopToBottom;
pnlInput.ChildSizing.HorizontalSpacing := 8;
pnlInput.ChildSizing.VerticalSpacing := 4;

This arranges the panel's children in a flow layout — five controls per row, with 8-pixel horizontal gaps and 4-pixel vertical gaps. When the panel resizes, the children reflow automatically.

📊 Design Principle: Let the Layout System Work Resist the temptation to position every control manually with Left and Top coordinates. Use Align for major regions, Anchors for flexible positioning within those regions, and BorderSpacing for gaps. The result is a form that adapts gracefully to resizing, different DPI settings, and different platforms — without a single line of layout code in your event handlers.

TPageControl and TNotebook

When a form has too many controls for a single view, use TPageControl to organize them into tabbed pages:

{ TPageControl with three tabs }
PageControl1.ActivePageIndex := 0;  { show first tab }

{ Each tab is a TTabSheet }
tsGeneral.Caption := 'General';
tsDetails.Caption := 'Details';
tsNotes.Caption := 'Notes';

{ Place controls on each tab sheet }
{ Controls on tsGeneral: edtName, edtEmail }
{ Controls on tsDetails: edtPhone, cboCity }
{ Controls on tsNotes: mmoNotes }

The user clicks tabs to switch between pages. Only the controls on the active page are visible. TNotebook is a simpler alternative without visible tabs — you switch pages programmatically:

Notebook1.PageIndex := 2;  { show the third page }

Use TNotebook when you want to switch views based on a menu selection, a list selection, or a radio button — any situation where the tab headers would be redundant with another navigation control.

A Complete Layout Example

Let us design a realistic form layout for a contact editor — the kind of form you would build for a CRM application. This example demonstrates how Align, Anchors, panels, group boxes, and splitters work together:

{ Form setup }
frmContact.Width := 700;
frmContact.Height := 500;

{ Top panel — toolbar with buttons }
pnlToolbar.Align := alTop;
pnlToolbar.Height := 40;
pnlToolbar.BevelOuter := bvNone;
{ Buttons inside pnlToolbar: Save, Cancel, Delete }

{ Bottom panel — status information }
pnlStatus.Align := alBottom;
pnlStatus.Height := 30;
pnlStatus.BevelOuter := bvNone;
lblLastModified.Parent := pnlStatus;
lblLastModified.Anchors := [akLeft, akBottom];

{ Left panel — contact list }
pnlList.Align := alLeft;
pnlList.Width := 200;
lstContacts.Parent := pnlList;
lstContacts.Align := alClient;

{ Splitter between list and detail }
Splitter1.Align := alLeft;

{ Main panel — detail view, fills remaining space }
pnlDetail.Align := alClient;

{ Inside pnlDetail, use a group box for personal info }
grpPersonal.Parent := pnlDetail;
grpPersonal.Align := alTop;
grpPersonal.Height := 120;
grpPersonal.Caption := 'Personal Information';
{ Labels and edit boxes inside grpPersonal }

{ Another group box for address }
grpAddress.Parent := pnlDetail;
grpAddress.Align := alTop;
grpAddress.Height := 100;
grpAddress.Caption := 'Address';

{ Notes area fills remaining space }
mmoNotes.Parent := pnlDetail;
mmoNotes.Align := alClient;
mmoNotes.BorderSpacing.Around := 4;

This layout adapts gracefully: resizing the form grows the contact list and detail area proportionally. Dragging the splitter resizes the list and detail panels. The toolbar and status bar stay at their edges. The notes area expands to fill whatever space remains after the group boxes.


28.3 The TStringGrid and TDrawGrid

We used TStringGrid in Chapter 27 for the expense list. Let us explore it more thoroughly — it is one of the most useful controls in the LCL.

TStringGrid Basics

TStringGrid displays a two-dimensional grid of text cells. It has fixed rows (column headers) and fixed columns (row labels), plus data cells that can be edited by the user.

{ Setup }
sgData.ColCount := 5;
sgData.RowCount := 11;   { 1 header + 10 data rows }
sgData.FixedRows := 1;
sgData.FixedCols := 1;   { first column is a row number }

{ Column headers }
sgData.Cells[0, 0] := '#';
sgData.Cells[1, 0] := 'Name';
sgData.Cells[2, 0] := 'Email';
sgData.Cells[3, 0] := 'Phone';
sgData.Cells[4, 0] := 'City';

{ Row numbers }
for I := 1 to 10 do
  sgData.Cells[0, I] := IntToStr(I);

{ Data }
sgData.Cells[1, 1] := 'Rosa Martinelli';
sgData.Cells[2, 1] := 'rosa@example.com';

Useful Options

The Options property is a set of flags:

sgData.Options := sgData.Options
  + [goRowHighlight]     { highlight the entire selected row }
  + [goColSizing]        { allow resizing columns by dragging }
  + [goEditing]          { allow editing cells }
  + [goAlwaysShowEditor] { show the editor without double-clicking }
  - [goRangeSelect];     { disable range selection }

Here is a complete reference of the most useful options:

Option Effect
goRowHighlight Highlights the entire row of the selected cell
goColSizing Allows the user to resize columns by dragging the header border
goRowSizing Allows the user to resize rows by dragging the fixed column border
goEditing Allows editing cell contents
goAlwaysShowEditor Shows the editor immediately without double-clicking
goTabs Tab moves to the next cell (rather than the next control)
goRowSelect Selects entire rows, not individual cells
goRangeSelect Allows selecting a rectangular range of cells
goFixedVertLine Draws vertical lines in the fixed area
goFixedHorzLine Draws horizontal lines in the fixed area

Column Widths and Auto-Sizing

{ Manual column widths }
sgData.ColWidths[0] := 40;    { narrow row-number column }
sgData.ColWidths[1] := 180;
sgData.ColWidths[2] := 200;

{ Auto-size a column to fit its contents }
sgData.AutoSizeColumn(1);     { auto-size column 1 }

{ Auto-size all columns }
for I := 0 to sgData.ColCount - 1 do
  sgData.AutoSizeColumn(I);

Sorting

TStringGrid does not sort automatically, but you can sort its data by rearranging the rows:

procedure TfrmMain.SortGridByColumn(Grid: TStringGrid; Col: Integer);
var
  I, J: Integer;
begin
  { Simple bubble sort on grid rows }
  for I := 1 to Grid.RowCount - 2 do
    for J := 1 to Grid.RowCount - 1 - I do
      if CompareText(Grid.Cells[Col, J], Grid.Cells[Col, J + 1]) > 0 then
        Grid.ExchangeColRow(False, J, J + 1);
end;

You can hook this to a header click event to let the user sort by clicking column headers:

procedure TfrmMain.sgExpensesHeaderClick(Sender: TObject;
  IsColumn: Boolean; Index: Integer);
begin
  if IsColumn and (Index > 0) then
    SortGridByColumn(sgExpenses, Index);
end;

TDrawGrid

TDrawGrid is the parent of TStringGrid. It does not store text — instead, you draw each cell yourself in the OnDrawCell event. Use it for custom cell rendering (icons, progress bars, colored cells):

procedure TfrmMain.DrawGrid1DrawCell(Sender: TObject; aCol, aRow: Integer;
  aRect: TRect; aState: TGridDrawState);
begin
  with DrawGrid1.Canvas do
  begin
    if aRow = 0 then
      Brush.Color := clSilver   { header row }
    else if Odd(aRow) then
      Brush.Color := clWhite     { odd rows }
    else
      Brush.Color := $F0F0F0;    { even rows — light gray }
    FillRect(aRect);
    TextOut(aRect.Left + 4, aRect.Top + 2, GetCellText(aCol, aRow));
  end;
end;

You can also use OnDrawCell with a TStringGrid to customize how specific cells are rendered while still using the grid's built-in text storage. For example, to render negative amounts in red:

procedure TfrmMain.sgExpensesDrawCell(Sender: TObject;
  aCol, aRow: Integer; aRect: TRect; aState: TGridDrawState);
var
  Val: Double;
begin
  if (aCol = 3) and (aRow > 0) then  { amount column, data rows only }
  begin
    if TryStrToFloat(sgExpenses.Cells[aCol, aRow], Val) and (Val < 0) then
      sgExpenses.Canvas.Font.Color := clRed
    else
      sgExpenses.Canvas.Font.Color := clBlack;
  end;
end;

28.4 Data Entry Forms

A data entry form is the bread and butter of business applications. Getting it right means attention to three things: layout, validation, and navigation.

Tab Order

Users expect to move between fields by pressing the Tab key. The tab order is determined by the TabOrder property of each control. Set it sequentially:

edtName.TabOrder := 0;
edtEmail.TabOrder := 1;
edtPhone.TabOrder := 2;
cboCity.TabOrder := 3;
btnSave.TabOrder := 4;

In the Lazarus IDE, you can set tab order visually: go to Tab Order in the form's right-click menu. Lazarus shows a numbered overlay on each control that you can reorder by clicking.

Controls that should not participate in tab navigation can be excluded by setting TabStop := False. Labels and images have TabStop := False by default.

⚠️ Caution: Tab Order Pitfalls If you add controls in a non-sequential order (e.g., you add the Save button before the Phone field), the tab order will be wrong by default. Always verify tab order after designing a form. A form where Tab jumps randomly between fields feels broken to the user.

Validation Strategies

There are three common strategies for validating user input:

Strategy 1: Validate on Submit. Check all fields when the user clicks Save. Simple to implement, but the user does not know about errors until they try to submit.

procedure TfrmEntry.btnSaveClick(Sender: TObject);
begin
  if Trim(edtName.Text) = '' then
  begin
    ShowMessage('Name is required.');
    edtName.SetFocus;
    Exit;
  end;
  if not IsValidEmail(edtEmail.Text) then
  begin
    ShowMessage('Invalid email address.');
    edtEmail.SetFocus;
    Exit;
  end;
  { ... save the data ... }
end;

Strategy 2: Validate on Exit. Check each field when it loses focus (the OnExit event). The user gets immediate feedback.

procedure TfrmEntry.edtEmailExit(Sender: TObject);
begin
  if (edtEmail.Text <> '') and (not IsValidEmail(edtEmail.Text)) then
  begin
    edtEmail.Color := clYellow;  { highlight the problem }
    lblEmailError.Caption := 'Invalid email format';
    lblEmailError.Visible := True;
  end
  else
  begin
    edtEmail.Color := clDefault;
    lblEmailError.Visible := False;
  end;
end;

Strategy 3: Validate as You Type. Check the field on every keystroke (OnChange). Provides the most immediate feedback, but can be annoying for fields where partial input is always "invalid" (e.g., an email is invalid until the user types the @ sign).

We recommend Strategy 2 for most fields, with Strategy 1 as a final safety net before saving.

Input Filtering with OnKeyPress

Sometimes you want to prevent invalid characters from being entered at all. The OnKeyPress event lets you filter keystrokes:

procedure TfrmEntry.edtAmountKeyPress(Sender: TObject; var Key: Char);
begin
  { Allow digits, decimal point, backspace }
  if not (Key in ['0'..'9', '.', #8]) then
    Key := #0;  { cancel the keystroke }
  { Allow only one decimal point }
  if (Key = '.') and (Pos('.', edtAmount.Text) > 0) then
    Key := #0;
end;

Setting Key := #0 cancels the keystroke — the character is not inserted. This gives the user instant feedback: pressing a letter key in a numeric field does nothing.

Focus Control

SetFocus moves the cursor to a control. Always call it after displaying a validation error, so the user can immediately correct the problem:

if not Valid then
begin
  ShowMessage('Error in Amount field.');
  edtAmount.SetFocus;
  edtAmount.SelectAll;  { highlight the bad input }
end;

The SelectAll method selects all text in an edit control, making it easy for the user to type a replacement value without manually selecting and deleting.

Building a Reusable Validation Framework

Rather than scattering validation logic across individual event handlers, consider building a small validation framework that you can reuse across forms:

type
  TValidationResult = record
    Valid: Boolean;
    ErrorMessage: string;
    FailedControl: TWinControl;
  end;

function ValidateRequired(AEdit: TEdit; const FieldName: string): TValidationResult;
begin
  Result.Valid := Trim(AEdit.Text) <> '';
  if not Result.Valid then
  begin
    Result.ErrorMessage := FieldName + ' is required.';
    Result.FailedControl := AEdit;
  end;
end;

function ValidateNumeric(AEdit: TEdit; const FieldName: string;
  MinVal: Double = 0; MaxVal: Double = MaxDouble): TValidationResult;
var
  Value: Double;
begin
  Result.Valid := TryStrToFloat(AEdit.Text, Value);
  if Result.Valid then
  begin
    if (Value < MinVal) or (Value > MaxVal) then
    begin
      Result.Valid := False;
      Result.ErrorMessage := Format('%s must be between %.2f and %.2f.',
        [FieldName, MinVal, MaxVal]);
      Result.FailedControl := AEdit;
    end;
  end
  else
  begin
    Result.ErrorMessage := FieldName + ' must be a valid number.';
    Result.FailedControl := AEdit;
  end;
end;

function ValidateEmail(AEdit: TEdit): TValidationResult;
var
  S: string;
  AtPos, DotPos: Integer;
begin
  S := Trim(AEdit.Text);
  AtPos := Pos('@', S);
  DotPos := LastDelimiter('.', S);
  Result.Valid := (AtPos > 1) and (DotPos > AtPos + 1) and (DotPos < Length(S));
  if not Result.Valid then
  begin
    Result.ErrorMessage := 'Please enter a valid email address.';
    Result.FailedControl := AEdit;
  end;
end;

procedure ShowValidationError(const VR: TValidationResult);
begin
  if not VR.Valid then
  begin
    MessageDlg('Validation Error', VR.ErrorMessage, mtWarning, [mbOK], 0);
    if Assigned(VR.FailedControl) then
    begin
      VR.FailedControl.SetFocus;
      if VR.FailedControl is TCustomEdit then
        TCustomEdit(VR.FailedControl).SelectAll;
    end;
  end;
end;

Now your submit handler becomes clean and readable:

procedure TfrmEntry.btnSaveClick(Sender: TObject);
var
  VR: TValidationResult;
begin
  VR := ValidateRequired(edtName, 'Name');
  if not VR.Valid then begin ShowValidationError(VR); Exit; end;

  VR := ValidateEmail(edtEmail);
  if not VR.Valid then begin ShowValidationError(VR); Exit; end;

  VR := ValidateNumeric(edtAmount, 'Amount', 0.01, 99999.99);
  if not VR.Valid then begin ShowValidationError(VR); Exit; end;

  { All valid — save the data }
  SaveRecord;
end;

This pattern keeps validation logic testable (you can call the validation functions from unit tests) and consistent (every form uses the same validation style).

Visual Feedback for Validation

Beyond message dialogs, you can provide inline visual feedback. A common pattern uses a small label next to each field that shows or hides based on validity:

procedure TfrmEntry.HighlightError(AEdit: TEdit; ALabel: TLabel;
  const AMessage: string);
begin
  AEdit.Color := RGBToColor(255, 230, 230);  { light pink }
  ALabel.Caption := AMessage;
  ALabel.Font.Color := clRed;
  ALabel.Visible := True;
end;

procedure TfrmEntry.ClearError(AEdit: TEdit; ALabel: TLabel);
begin
  AEdit.Color := clDefault;
  ALabel.Visible := False;
end;

Use this in OnExit handlers to give the user feedback as they move between fields, without blocking their workflow with modal dialogs.

Accessibility Considerations

A well-designed form is usable without a mouse. This means:

  1. Every input field has a label with an accelerator. &Name: creates an Alt+N shortcut via the label's FocusControl property.
  2. Tab order is logical. Fields are visited in reading order (left to right, top to bottom).
  3. Default and Cancel buttons are set. The user can press Enter to submit or Escape to cancel.
  4. Error messages are specific. "Please enter a valid email address" is better than "Invalid input."
  5. Color is not the only indicator. If you use red text for errors, also use an icon or a text message for users who cannot distinguish colors.
  6. Font sizes respect system settings. Use ParentFont := True where possible so the form respects the user's accessibility settings.

Designing Forms for High-DPI Displays

Modern displays range from 96 DPI (standard) to 288 DPI (4K monitors). A form designed at 96 DPI may appear tiny on a high-DPI display. Lazarus provides several mechanisms to handle this:

First, ensure Application.Scaled := True in the project file (Lazarus sets this by default for new projects). This tells the LCL to scale all forms based on the system's DPI setting.

Second, design your forms at 96 DPI (the standard default). When the form is loaded on a high-DPI display, the LCL scales all positions, sizes, and font sizes proportionally. A button that is 75 pixels wide at 96 DPI becomes 150 pixels wide at 192 DPI (200% scaling).

Third, use anchors and alignment instead of hardcoded pixel positions. Anchored layouts adapt to scaling automatically. Hardcoded positions require careful scaling math.

Fourth, provide high-resolution icons for toolbars and image lists. A 16x16 icon at 96 DPI should have a 32x32 version for 200% scaling. Use TImageList's Scaled property to handle this.

Fifth, test on different DPI settings. You can simulate high DPI in Windows by changing the display scaling in Settings > Display > Scale and layout. Test at 100%, 125%, 150%, and 200% to catch layout issues.

⚠️ Caution: Scaling Gotchas When you scale a form, all controls scale proportionally — including their borders and spacing. But bitmap images do not scale automatically unless they are in an image list with Scaled := True. And custom drawing on TCanvas uses pixel coordinates, so if you hardcode pixel values in your OnPaint handler, they will not scale with the rest of the form. Use the form's Scale96ToFont method to convert design-time pixel values to runtime values.


28.5 Multiple Forms

Real applications have more than one form. A settings dialog, an "About" box, a detail view, a report window — these are all additional forms.

Creating a New Form

Go to File > New Form in Lazarus. This creates a new unit with a new form class. Name the form and unit appropriately (e.g., frmExpenseDetail in ExpenseDetailUnit.pas).

Showing a Modal Form

A modal form blocks the parent window until it is closed. The user must interact with the modal form before returning to the main form. Dialogs (Save, Open, Confirm) are typically modal.

uses ExpenseDetailUnit;  { add to the main form's uses clause }

procedure TfrmMain.btnEditClick(Sender: TObject);
var
  DetailForm: TfrmExpenseDetail;
begin
  DetailForm := TfrmExpenseDetail.Create(Self);
  try
    { Pass data to the detail form }
    DetailForm.ExpenseDate := GetSelectedDate;
    DetailForm.Description := GetSelectedDescription;
    DetailForm.Amount := GetSelectedAmount;

    { Show modal — execution pauses here until the form closes }
    if DetailForm.ShowModal = mrOK then
    begin
      { User clicked OK — retrieve updated data }
      SetSelectedDescription(DetailForm.Description);
      SetSelectedAmount(DetailForm.Amount);
      UpdateGrid;
    end;
    { If ShowModal returns mrCancel, do nothing }
  finally
    DetailForm.Free;  { always free the form when done }
  end;
end;

In the detail form, set the ModalResult property of the OK button to mrOK and the Cancel button to mrCancel. When the user clicks either button, the form closes and ShowModal returns the corresponding result.

The try..finally block is essential. If an exception occurs while the modal form is showing, the form is still freed. Without try..finally, a leaked form consumes memory and may cause strange behavior.

Showing a Modeless Form

A modeless form does not block the parent. The user can switch between the modeless form and the main form freely. Use modeless forms for tool windows, log displays, and auxiliary information panels.

procedure TfrmMain.btnShowLogClick(Sender: TObject);
begin
  if not Assigned(frmLog) then
    frmLog := TfrmLog.Create(Application);
  frmLog.Show;  { non-blocking — execution continues immediately }
end;

Note: with modeless forms, you typically create the form once and show/hide it rather than creating and destroying it repeatedly. Setting the owner to Application (instead of Self) means the form persists for the application's lifetime.

Passing Data Between Forms

There are several patterns:

Public properties: The cleanest approach. Expose data as properties on the form:

type
  TfrmExpenseDetail = class(TForm)
  private
    FAmount: Double;
  public
    property Amount: Double read FAmount write FAmount;
  end;

Constructor parameters: Pass data when creating the form (requires a custom constructor):

constructor TfrmExpenseDetail.CreateWithData(AOwner: TComponent;
  ADate: TDateTime; const ADesc: string; AAmount: Double);
begin
  inherited Create(AOwner);
  dtpDate.Date := ADate;
  edtDescription.Text := ADesc;
  edtAmount.Text := Format('%.2f', [AAmount]);
end;

Global variables: Works but is not recommended — it creates tight coupling and makes testing difficult.

💡 Intuition: Forms as Objects A form is just an object — an instance of a class that descends from TForm. Everything you learned about classes in Part III applies: encapsulation, properties, methods, inheritance. When you pass data to a form via properties, you are simply setting an object's fields. When you read data back, you are reading an object's fields. There is nothing magical about forms — they are classes with visual representations.


28.6 Event Handling in Depth

Shared Event Handlers Revisited

We saw in Chapter 27 that multiple components can share the same event handler, using Sender to distinguish them. This pattern becomes very powerful when combined with the Tag property:

{ Assign in the Object Inspector or in code: }
btnJanuary.Tag := 1;    btnJanuary.OnClick := @MonthButtonClick;
btnFebruary.Tag := 2;   btnFebruary.OnClick := @MonthButtonClick;
btnMarch.Tag := 3;       btnMarch.OnClick := @MonthButtonClick;
{ ... and so on for all 12 months }

procedure TfrmMain.MonthButtonClick(Sender: TObject);
var
  Month: Integer;
begin
  Month := (Sender as TButton).Tag;
  LoadExpensesForMonth(Month);
  lblMonth.Caption := FormatDateTime('MMMM yyyy',
    EncodeDate(CurrentYear, Month, 1));
end;

Twelve buttons, one handler, zero code duplication.

The Sender Parameter and Type Casting

The Sender parameter is typed as TObject — the base of the class hierarchy. To access control-specific properties, you must cast it:

{ Safe cast with 'as' — raises exception if Sender is not a TButton: }
ClickedButton := Sender as TButton;

{ Check with 'is' before casting: }
if Sender is TButton then
  (Sender as TButton).Caption := 'Clicked!'
else if Sender is TMenuItem then
  (Sender as TMenuItem).Checked := True;

Connecting Events in Code

While you usually connect events in the Object Inspector, you can also connect them programmatically:

procedure TfrmMain.FormCreate(Sender: TObject);
begin
  btnSave.OnClick := @HandleSave;
  edtAmount.OnExit := @ValidateAmount;
  sgExpenses.OnSelectCell := @HandleCellSelect;
end;

The @ operator is required in {$mode objfpc} to take the address of a method.

Creating Controls at Runtime

You can create controls dynamically, without using the form designer:

procedure TfrmMain.AddCategoryButton(const ACategory: string; AIndex: Integer);
var
  Btn: TButton;
begin
  Btn := TButton.Create(Self);  { Self (the form) owns it }
  Btn.Parent := pnlCategories;   { display inside this panel }
  Btn.Caption := ACategory;
  Btn.Tag := AIndex;
  Btn.Left := 10;
  Btn.Top := 10 + AIndex * 35;
  Btn.Width := 150;
  Btn.Height := 30;
  Btn.OnClick := @CategoryButtonClick;
end;

Key points: - Create(Self) — the form owns the button and will free it when the form is destroyed. - Parent := pnlCategories — the button appears inside this panel. Without setting Parent, the button exists in memory but is not visible. - You can create hundreds of controls this way, driven by data from a database or file.

A practical example: building a toolbar of category buttons from a list:

procedure TfrmMain.BuildCategoryButtons;
var
  I: Integer;
begin
  { Remove any existing buttons }
  while pnlCategories.ControlCount > 0 do
    pnlCategories.Controls[0].Free;

  { Create buttons from the category list }
  for I := 0 to CategoryList.Count - 1 do
    AddCategoryButton(CategoryList[I], I);
end;

Custom Events

You can define your own events on custom components or data classes:

type
  TExpenseEvent = procedure(Sender: TObject; const Amount: Double) of object;

  TExpenseTracker = class
  private
    FOnExpenseAdded: TExpenseEvent;
  public
    procedure AddExpense(const Amount: Double);
    property OnExpenseAdded: TExpenseEvent
      read FOnExpenseAdded write FOnExpenseAdded;
  end;

procedure TExpenseTracker.AddExpense(const Amount: Double);
begin
  { ... add the expense ... }
  if Assigned(FOnExpenseAdded) then
    FOnExpenseAdded(Self, Amount);
end;

Then in the form:

procedure TfrmMain.FormCreate(Sender: TObject);
begin
  FTracker := TExpenseTracker.Create;
  FTracker.OnExpenseAdded := @HandleExpenseAdded;
end;

procedure TfrmMain.HandleExpenseAdded(Sender: TObject; const Amount: Double);
begin
  lblTotal.Caption := Format('Total: $%.2f', [FTracker.Total]);
  sgExpenses.RowCount := sgExpenses.RowCount + 1;
end;

This is the Observer pattern in action. The TExpenseTracker notifies the form when data changes, without knowing anything about forms or UI. The business logic and the UI are cleanly separated.

The Difference Between OnClick and OnMouseDown

Beginners sometimes confuse OnClick with OnMouseDown. They are not the same:

  • OnMouseDown fires when the mouse button is pressed, before it is released. It provides the mouse position and button (left, right, middle).
  • OnClick fires when the mouse button is pressed and released on the same control. It is a higher-level event that represents a complete "click" gesture. If the user presses the button, drags the mouse away, and releases outside the control, OnClick does not fire.

For buttons and menu items, use OnClick. For drawing, dragging, and custom gestures, use OnMouseDown, OnMouseMove, and OnMouseUp.

Event Processing Order

When a user performs a simple action like clicking a button, multiple events fire in a specific order:

  1. OnMouseDown — mouse button pressed
  2. OnMouseUp — mouse button released
  3. OnClick — click recognized
  4. (If the button's parent form has focus management: OnEnter on the button, OnExit on the previously focused control)

Understanding this order helps when debugging unexpected behavior. If your OnExit validation prevents focus from leaving the current field, the button click may never complete.

Preventing Multiple Rapid Clicks

Users sometimes double-click a button that should only be clicked once (like "Submit Order"). This can cause duplicate submissions. Prevent it by disabling the button in the handler:

procedure TfrmOrder.btnSubmitClick(Sender: TObject);
begin
  btnSubmit.Enabled := False;  { prevent re-clicks }
  try
    SubmitOrder;
    ShowMessage('Order submitted successfully.');
    ModalResult := mrOK;
  except
    on E: Exception do
    begin
      ShowMessage('Failed to submit: ' + E.Message);
      btnSubmit.Enabled := True;  { re-enable on failure }
    end;
  end;
end;

28.7 Project Checkpoint: PennyWise Expense Entry Form

We now enhance PennyWise with a dedicated expense entry form — a modal dialog that appears when the user clicks "Add Expense" on the main form.

Design Goals

The expense detail form should: - Be a fixed-size dialog (non-resizable) that appears centered over the main form. - Contain fields for date, description, category, amount, notes, and a recurring option. - Validate all input before accepting it. - Return data to the main form via public getter methods. - Support keyboard-only operation: Tab between fields, Enter to submit, Escape to cancel.

The Expense Detail Form

unit ExpenseDetailUnit;

{$mode objfpc}{$H+}

interface

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

type

  { TfrmExpenseDetail }

  TfrmExpenseDetail = class(TForm)
    lblDate: TLabel;
    lblDescription: TLabel;
    lblCategory: TLabel;
    lblAmount: TLabel;
    lblNotes: TLabel;
    dtpDate: TDateTimePicker;
    edtDescription: TEdit;
    cboCategory: TComboBox;
    edtAmount: TEdit;
    mmoNotes: TMemo;
    chkRecurring: TCheckBox;
    pnlRecurring: TPanel;
    lblFrequency: TLabel;
    rgFrequency: TRadioGroup;
    btnOK: TButton;
    btnCancel: TButton;
    procedure btnOKClick(Sender: TObject);
    procedure chkRecurringChange(Sender: TObject);
    procedure edtAmountExit(Sender: TObject);
    procedure FormCreate(Sender: TObject);
  private
    FIsValid: Boolean;
    function Validate: Boolean;
  public
    { Public properties for data exchange with the main form }
    function GetDate: TDateTime;
    function GetDescription: string;
    function GetCategory: string;
    function GetAmount: Double;
    function GetNotes: string;
    function GetIsRecurring: Boolean;
    function GetFrequency: Integer;
    procedure SetDate(ADate: TDateTime);
    procedure SetDescription(const ADesc: string);
    procedure SetCategory(const ACat: string);
    procedure SetAmount(AAmount: Double);
  end;

var
  frmExpenseDetail: TfrmExpenseDetail;

implementation

{$R *.lfm}

{ TfrmExpenseDetail }

procedure TfrmExpenseDetail.FormCreate(Sender: TObject);
begin
  Caption := 'Add Expense';
  Position := poMainFormCenter;
  BorderStyle := bsDialog;  { non-resizable dialog }

  { Set up labels with accelerators for keyboard navigation }
  lblDate.Caption := '&Date:';
  lblDate.FocusControl := dtpDate;
  lblDescription.Caption := 'D&escription:';
  lblDescription.FocusControl := edtDescription;
  lblCategory.Caption := '&Category:';
  lblCategory.FocusControl := cboCategory;
  lblAmount.Caption := '&Amount:';
  lblAmount.FocusControl := edtAmount;
  lblNotes.Caption := '&Notes:';
  lblNotes.FocusControl := mmoNotes;

  { Set up categories }
  cboCategory.Style := csDropDownList;
  cboCategory.Items.Add('Food & Dining');
  cboCategory.Items.Add('Transportation');
  cboCategory.Items.Add('Housing');
  cboCategory.Items.Add('Utilities');
  cboCategory.Items.Add('Entertainment');
  cboCategory.Items.Add('Health');
  cboCategory.Items.Add('Education');
  cboCategory.Items.Add('Shopping');
  cboCategory.Items.Add('Personal');
  cboCategory.Items.Add('Other');
  cboCategory.ItemIndex := 0;

  { Set up amount field with placeholder }
  edtAmount.TextHint := '0.00';

  { Set up recurring frequency options }
  rgFrequency.Items.Add('Weekly');
  rgFrequency.Items.Add('Bi-weekly');
  rgFrequency.Items.Add('Monthly');
  rgFrequency.Items.Add('Yearly');
  rgFrequency.ItemIndex := 2;  { default to Monthly }
  pnlRecurring.Visible := False;  { hidden until checkbox is checked }

  { Default date to today }
  dtpDate.Date := Date;

  { Button setup }
  btnOK.Caption := 'OK';
  btnOK.Default := True;
  btnCancel.Caption := 'Cancel';
  btnCancel.Cancel := True;
  btnCancel.ModalResult := mrCancel;

  { Notes setup }
  mmoNotes.Lines.Clear;
  mmoNotes.ScrollBars := ssVertical;

  { Tab order }
  dtpDate.TabOrder := 0;
  edtDescription.TabOrder := 1;
  cboCategory.TabOrder := 2;
  edtAmount.TabOrder := 3;
  mmoNotes.TabOrder := 4;
  chkRecurring.TabOrder := 5;
  btnOK.TabOrder := 6;
  btnCancel.TabOrder := 7;
end;

procedure TfrmExpenseDetail.chkRecurringChange(Sender: TObject);
begin
  pnlRecurring.Visible := chkRecurring.Checked;
end;

procedure TfrmExpenseDetail.edtAmountExit(Sender: TObject);
var
  Amount: Double;
begin
  if edtAmount.Text = '' then
    Exit;
  if not TryStrToFloat(edtAmount.Text, Amount) then
  begin
    edtAmount.Color := $CCCCFF;  { light red tint }
    edtAmount.Hint := 'Please enter a valid number';
    edtAmount.ShowHint := True;
  end
  else
  begin
    edtAmount.Color := clDefault;
    edtAmount.ShowHint := False;
    { Format to two decimal places }
    edtAmount.Text := Format('%.2f', [Amount]);
  end;
end;

function TfrmExpenseDetail.Validate: Boolean;
var
  Amount: Double;
begin
  Result := False;

  if Trim(edtDescription.Text) = '' then
  begin
    MessageDlg('Validation Error',
      'Please enter a description.', mtWarning, [mbOK], 0);
    edtDescription.SetFocus;
    Exit;
  end;

  if not TryStrToFloat(edtAmount.Text, Amount) then
  begin
    MessageDlg('Validation Error',
      'Please enter a valid amount.', mtWarning, [mbOK], 0);
    edtAmount.SetFocus;
    edtAmount.SelectAll;
    Exit;
  end;

  if Amount <= 0 then
  begin
    MessageDlg('Validation Error',
      'Amount must be greater than zero.', mtWarning, [mbOK], 0);
    edtAmount.SetFocus;
    edtAmount.SelectAll;
    Exit;
  end;

  if cboCategory.ItemIndex < 0 then
  begin
    MessageDlg('Validation Error',
      'Please select a category.', mtWarning, [mbOK], 0);
    cboCategory.SetFocus;
    Exit;
  end;

  Result := True;
end;

procedure TfrmExpenseDetail.btnOKClick(Sender: TObject);
begin
  if Validate then
    ModalResult := mrOK;
  { If validation fails, the form stays open }
end;

function TfrmExpenseDetail.GetDate: TDateTime;
begin
  Result := dtpDate.Date;
end;

function TfrmExpenseDetail.GetDescription: string;
begin
  Result := Trim(edtDescription.Text);
end;

function TfrmExpenseDetail.GetCategory: string;
begin
  Result := cboCategory.Text;
end;

function TfrmExpenseDetail.GetAmount: Double;
begin
  Result := StrToFloatDef(edtAmount.Text, 0);
end;

function TfrmExpenseDetail.GetNotes: string;
begin
  Result := mmoNotes.Text;
end;

function TfrmExpenseDetail.GetIsRecurring: Boolean;
begin
  Result := chkRecurring.Checked;
end;

function TfrmExpenseDetail.GetFrequency: Integer;
begin
  Result := rgFrequency.ItemIndex;
end;

procedure TfrmExpenseDetail.SetDate(ADate: TDateTime);
begin
  dtpDate.Date := ADate;
end;

procedure TfrmExpenseDetail.SetDescription(const ADesc: string);
begin
  edtDescription.Text := ADesc;
end;

procedure TfrmExpenseDetail.SetCategory(const ACat: string);
var
  Idx: Integer;
begin
  Idx := cboCategory.Items.IndexOf(ACat);
  if Idx >= 0 then
    cboCategory.ItemIndex := Idx;
end;

procedure TfrmExpenseDetail.SetAmount(AAmount: Double);
begin
  edtAmount.Text := Format('%.2f', [AAmount]);
end;

end.

Calling the Detail Form from the Main Form

procedure TfrmMain.btnAddClick(Sender: TObject);
var
  DetailForm: TfrmExpenseDetail;
begin
  DetailForm := TfrmExpenseDetail.Create(Self);
  try
    if DetailForm.ShowModal = mrOK then
    begin
      AddExpenseToGrid(
        DetailForm.GetDate,
        DetailForm.GetDescription,
        DetailForm.GetCategory,
        DetailForm.GetAmount
      );
      UpdateTotal;
    end;
  finally
    DetailForm.Free;
  end;
end;

Editing an Existing Expense

The same detail form can serve double duty — both adding new expenses and editing existing ones. When editing, pre-populate the fields:

procedure TfrmMain.EditSelectedExpense;
var
  DetailForm: TfrmExpenseDetail;
  Row: Integer;
begin
  Row := sgExpenses.Row;
  if Row < 1 then Exit;  { no selection }

  DetailForm := TfrmExpenseDetail.Create(Self);
  try
    DetailForm.Caption := 'Edit Expense';  { change the title }
    DetailForm.SetDate(StrToDateDef(sgExpenses.Cells[0, Row], Date));
    DetailForm.SetDescription(sgExpenses.Cells[1, Row]);
    DetailForm.SetCategory(sgExpenses.Cells[2, Row]);
    DetailForm.SetAmount(StrToFloatDef(sgExpenses.Cells[3, Row], 0));

    if DetailForm.ShowModal = mrOK then
    begin
      sgExpenses.Cells[0, Row] := FormatDateTime('yyyy-mm-dd',
        DetailForm.GetDate);
      sgExpenses.Cells[1, Row] := DetailForm.GetDescription;
      sgExpenses.Cells[2, Row] := DetailForm.GetCategory;
      sgExpenses.Cells[3, Row] := Format('%.2f', [DetailForm.GetAmount]);
      UpdateTotal;
    end;
  finally
    DetailForm.Free;
  end;
end;

This is the power of treating forms as objects: the same class serves both creation and editing, with different initial data.

Checkpoint Checklist - [ ] Main form has "Add Expense" button that opens a modal detail form - [ ] Detail form has date picker, description, category dropdown, amount, notes, and recurring checkbox - [ ] Labels have accelerator keys (Alt+D for Date, Alt+E for Description, etc.) - [ ] Recurring checkbox shows/hides frequency radio group - [ ] Amount field validates on exit (highlights invalid input) - [ ] OK button validates all fields before closing - [ ] Cancel button closes without saving - [ ] Data flows from detail form back to main form via public methods - [ ] Tab order is logical: date, description, category, amount, notes, recurring, OK, Cancel - [ ] The same form can be used for both adding and editing expenses

Debugging Form Interactions

When forms interact (the main form opens a detail form, the detail form returns data), bugs can be subtle. Here are debugging strategies specific to multi-form applications:

Problem: The detail form appears behind the main form. This happens when you call Show instead of ShowModal for a dialog form, or when the owner is set incorrectly. For modal dialogs, always use ShowModal. For the form's Position property, use poMainFormCenter or poOwnerFormCenter to ensure the dialog appears centered over the parent.

Problem: Data does not transfer back from the detail form. Check that you are reading the detail form's properties before calling Free. The try..finally block must read the data inside the try section, before the finally section frees the form.

Problem: Changes appear in the detail form but not in the main form. Check that you are actually calling UpdateGrid or UpdateTotal after receiving data from the detail form. The main form does not automatically know that its grid needs updating.

Problem: The form flashes briefly and disappears. This usually means the ModalResult is being set before you intend. Check that no button has its ModalResult property set in the Object Inspector when you planned to set it conditionally in code (like our btnOK, which only sets ModalResult after validation passes).

Problem: Closing the application does not prompt to save. Ensure that OnCloseQuery is connected and that FHasUnsavedChanges is being set correctly. Add a breakpoint in FormCloseQuery to verify it is being called.

These debugging scenarios are the most common issues students encounter when building multi-form applications. Each one has a specific, identifiable cause and a straightforward fix.


28.8 Summary

This chapter equipped you with the full vocabulary of user interface construction in Lazarus.

What we covered:

  • Common controls — TLabel, TEdit, TMemo, TButton, TComboBox, TCheckBox, TRadioButton, TListBox, TRadioGroup, TDateTimePicker, TSpinEdit — each with its key properties and events, presented with property reference tables.
  • Layout and alignment — Anchors bind controls to parent edges for flexible resizing. Align fills entire edges. Panels and group boxes provide structure. Splitters enable user-resizable regions. BorderSpacing adds margins. ChildSizing provides automatic flow layout.
  • TStringGrid displays tabular data with fixed headers, editable cells, sortable columns via header clicks, and custom drawing via OnDrawCell.
  • Data entry forms require careful attention to tab order, validation (on-submit, on-exit, or as-you-type), input filtering via OnKeyPress, focus management (SetFocus, SelectAll), and accessibility (accelerator keys, keyboard navigation, color-independent error indicators).
  • Multiple forms — modal forms block the parent window; modeless forms coexist. Data passes between forms via public properties or methods. The same form class can serve both creation and editing with different initial data.
  • Event handling in depth — shared handlers with Sender and Tag, programmatic event connection with @, runtime control creation, and custom events using method pointers (the Observer pattern).
  • PennyWise now has a modal expense detail form with comprehensive validation, accelerator keys, a recurring expense option, and clean data flow between forms.

In Chapter 29, we add menus, toolbars, standard dialogs, and action lists — the structural elements that make PennyWise feel like a professional desktop application.