> "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
In This Chapter
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:
- Every input field has a label with an accelerator.
&Name:creates an Alt+N shortcut via the label'sFocusControlproperty. - Tab order is logical. Fields are visited in reading order (left to right, top to bottom).
- Default and Cancel buttons are set. The user can press Enter to submit or Escape to cancel.
- Error messages are specific. "Please enter a valid email address" is better than "Invalid input."
- 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.
- Font sizes respect system settings. Use
ParentFont := Truewhere 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:
OnMouseDown— mouse button pressedOnMouseUp— mouse button releasedOnClick— click recognized- (If the button's parent form has focus management:
OnEnteron the button,OnExiton 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
SenderandTag, 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.