> "Good tools make tasks easier. Great tools make tasks invisible."
Learning Objectives
- Create main menus with keyboard shortcuts using TMainMenu
- Implement context menus with TPopupMenu for right-click actions
- Use standard dialogs: TOpenDialog, TSaveDialog, TColorDialog, TFontDialog
- Display message dialogs with MessageDlg and ShowMessage
- Structure an application with proper startup, shutdown, and global state management
- Build toolbars with TToolBar and status bars with TStatusBar
- Separate actions from UI elements using TActionList
In This Chapter
Chapter 29: Menus, Dialogs, and Application Structure
"Good tools make tasks easier. Great tools make tasks invisible." — Anonymous
A graphical application without menus is like a book without a table of contents — the reader can find things eventually, but not efficiently. Menus, toolbars, and standard dialogs are the structural skeleton of a desktop application. They give the user a predictable, discoverable interface for every operation your software supports.
Consider the applications you use daily — a web browser, a word processor, a file manager. They all share common structural elements: a menu bar across the top, a toolbar below it, a status bar at the bottom, standard keyboard shortcuts (Ctrl+S to save, Ctrl+Z to undo), and native dialogs for opening and saving files. These conventions are not arbitrary; they are the product of decades of user interface research. Users who learn one application can transfer their knowledge to another because the structural patterns are consistent.
In this chapter, we add that skeleton to PennyWise. By the end, our finance tracker will have a proper menu bar (File, Edit, View, Help), a toolbar with icons, context menus for right-click actions, standard dialogs for opening and saving files, and an About box. More importantly, we will learn to separate actions from the controls that trigger them, using TActionList — a powerful design pattern that eliminates duplication between menu items, toolbar buttons, and keyboard shortcuts.
The concepts in this chapter are not specific to Lazarus. Every desktop GUI framework — Delphi VCL, .NET WinForms, Qt, GTK, Java Swing — provides equivalent components for menus, toolbars, dialogs, and action management. The specific class names differ, but the patterns transfer directly.
29.1 Main Menus
Every professional desktop application has a main menu bar. It is the user's map of available operations, organized by category. The LCL provides TMainMenu for this purpose.
Creating a Menu in the Form Designer
- Drop a
TMainMenucomponent onto your form. It appears as a non-visual component (an icon in the component tray below the form, not on the form surface — menus are not positioned manually). - Double-click the TMainMenu icon to open the Menu Editor.
- Type the top-level menu captions:
&File,&Edit,&View,&Help. The&before a letter creates a keyboard accelerator (Alt+F opens File, Alt+E opens Edit, etc.). - Under each top-level item, add sub-items. Under File:
&New,&Open...,&Save,Save &As..., a separator (-),E&xit.
The Menu Editor is straightforward: click on the blank slot to the right of an existing item to add a sibling, or click below to add a child. The Name property is set automatically (e.g., miFile, miFileNew, miFileSave) but you should rename them to be descriptive: miNew, miOpen, miSave, miSaveAs, miExit.
The convention of ending File with "Exit" and putting Help at the rightmost position is universal across desktop applications. On macOS, the LCL automatically moves the About item to the application menu and the Quit item to the standard location, respecting platform conventions. You write one menu structure, and the LCL adapts it to each platform.
The ellipsis (...) after "Open" and "Save As" is a UI convention meaning "this action will open a dialog before completing." It tells the user that clicking "Open..." will not immediately do something — it will first ask for a file name. Items without an ellipsis (like "Save") execute immediately. Follow this convention consistently.
Menu Item Properties
Each menu item (TMenuItem) has properties you should know:
miSave.Caption := '&Save'; { text with accelerator }
miSave.ShortCut := ShortCut(Ord('S'), [ssCtrl]); { Ctrl+S }
miSave.Enabled := FHasChanges; { gray out when nothing to save }
miSave.Checked := False; { check mark (for toggle items) }
miSave.OnClick := @HandleSave; { event handler }
Key properties in detail:
| Property | Type | Description |
|---|---|---|
Caption |
string | Menu text; & marks the accelerator letter |
ShortCut |
TShortCut | Keyboard shortcut (displayed automatically next to the caption) |
Enabled |
Boolean | If False, the item is grayed out and unclickable |
Checked |
Boolean | Shows a check mark next to the item (for toggle options) |
Visible |
Boolean | If False, the item is hidden entirely |
ImageIndex |
Integer | Index into the menu's image list for an icon |
RadioItem |
Boolean | If True, checking this item unchecks others in its group |
GroupIndex |
Integer | Groups radio items together |
The ShortCut function constructs a keyboard shortcut from a key and modifier set. Common shortcuts:
| Shortcut | Code |
|---|---|
| Ctrl+N | ShortCut(Ord('N'), [ssCtrl]) |
| Ctrl+O | ShortCut(Ord('O'), [ssCtrl]) |
| Ctrl+S | ShortCut(Ord('S'), [ssCtrl]) |
| Ctrl+Shift+S | ShortCut(Ord('S'), [ssCtrl, ssShift]) |
| Ctrl+Z | ShortCut(Ord('Z'), [ssCtrl]) |
| Ctrl+Y | ShortCut(Ord('Y'), [ssCtrl]) |
| F1 | ShortCut(VK_F1, []) |
| F5 | ShortCut(VK_F5, []) |
| Delete | ShortCut(VK_DELETE, []) |
| Ctrl+Delete | ShortCut(VK_DELETE, [ssCtrl]) |
You can also set shortcuts in the Object Inspector by clicking the ShortCut property and pressing the desired key combination.
Separators
A separator is a horizontal line between groups of related menu items. Create one by adding a menu item with its Caption set to - (a single hyphen). Separators organize the menu visually:
File
├── New Ctrl+N
├── Open... Ctrl+O
├── ────────────────────
├── Save Ctrl+S
├── Save As... Ctrl+Shift+S
├── ────────────────────
├── Export to CSV...
├── ────────────────────
└── Exit Alt+F4
The grouping communicates meaning. "New" and "Open" are in the same group (creating/opening files). "Save" and "Save As" are in another (persisting changes). "Export" is a separate concern (format conversion). "Exit" is always last and separated from everything else.
Submenus
A menu item can have its own sub-items, creating a hierarchical menu. Right-click a menu item in the Menu Editor and choose "Create Submenu." Use submenus sparingly — more than two levels deep is confusing.
A common use for submenus is the "Recent Files" list:
procedure TfrmMain.BuildRecentFilesMenu;
var
I: Integer;
MenuItem: TMenuItem;
begin
miRecentFiles.Clear; { remove old items }
for I := 0 to FRecentFiles.Count - 1 do
begin
MenuItem := TMenuItem.Create(miRecentFiles);
MenuItem.Caption := Format('&%d %s', [I + 1, FRecentFiles[I]]);
MenuItem.Tag := I;
MenuItem.OnClick := @RecentFileClick;
miRecentFiles.Add(MenuItem);
end;
{ Disable the submenu if no recent files }
miRecentFiles.Enabled := FRecentFiles.Count > 0;
end;
procedure TfrmMain.RecentFileClick(Sender: TObject);
var
Index: Integer;
begin
Index := (Sender as TMenuItem).Tag;
if (Index >= 0) and (Index < FRecentFiles.Count) then
LoadFromFile(FRecentFiles[Index]);
end;
Handling Menu Clicks
Menu item click handlers are identical to button click handlers:
procedure TfrmMain.miNewClick(Sender: TObject);
begin
if FHasUnsavedChanges then
if not ConfirmSaveChanges then
Exit;
ClearAllExpenses;
FCurrentFile := '';
UpdateTitleBar;
end;
procedure TfrmMain.miExitClick(Sender: TObject);
begin
Close; { triggers the OnCloseQuery event }
end;
Checked and Radio Menu Items
Some menu items act as toggles. The View menu commonly has items like "Show Toolbar" and "Show Status Bar" that can be checked or unchecked:
procedure TfrmMain.miShowToolbarClick(Sender: TObject);
begin
miShowToolbar.Checked := not miShowToolbar.Checked;
pnlToolbar.Visible := miShowToolbar.Checked;
end;
For mutually exclusive options (like a view mode), use RadioItem and GroupIndex:
miViewList.RadioItem := True;
miViewList.GroupIndex := 1;
miViewDetails.RadioItem := True;
miViewDetails.GroupIndex := 1;
miViewIcons.RadioItem := True;
miViewIcons.GroupIndex := 1;
When the user clicks one radio menu item, the others in the same group are automatically unchecked. This behavior is identical to radio buttons in a group box — the difference is that radio menu items appear in a menu instead of on a form. Use radio menu items when the options are infrequently changed (like a view mode) and checkboxes when the user toggles options independently (like showing or hiding the toolbar).
Dynamically Enabling and Disabling Menu Items
Menu items should be grayed out when their operation is not available. For example, "Save" should be grayed out when there are no unsaved changes. "Delete" should be grayed out when no item is selected. "Paste" should be grayed out when the clipboard is empty. This feedback communicates to the user what actions are currently possible and prevents them from attempting operations that would fail.
You can update menu item states in the OnClick event of the parent menu. The File menu's OnClick fires when the user opens the File menu, before any item is displayed:
procedure TfrmMain.miFileClick(Sender: TObject);
begin
miSave.Enabled := FHasUnsavedChanges;
miSaveAs.Enabled := FCurrentFile <> '';
end;
However, with TActionList (section 29.7), this manual synchronization is unnecessary — the OnUpdate handler handles it automatically.
💡 Intuition: Menus as Discovery Not every user reads documentation. Menus serve as discoverable documentation — the user can browse the menu structure to learn what operations are available, see keyboard shortcuts, and understand the application's capabilities. A well-organized menu is a map of your application's features.
29.2 Context Menus
A context menu (also called a popup menu or right-click menu) appears when the user right-clicks a component. It shows actions relevant to the clicked item.
Creating a Context Menu
Drop a TPopupMenu onto the form. Double-click it to open the Menu Editor and add items. Then assign it to a component's PopupMenu property:
sgExpenses.PopupMenu := PopupMenu1;
Now right-clicking the expense grid shows the context menu.
Designing a Good Context Menu
A context menu should contain only actions that are relevant to the right-clicked item. For an expense grid, typical items include:
Edit Expense... Enter
Delete Expense Delete
────────────────────
Copy Amount Ctrl+C
────────────────────
Sort by Date
Sort by Amount
Sort by Category
Keep context menus short — no more than 8-10 items. If you need more, consider using submenus or rethinking your menu structure.
Context-Sensitive Items
The items in a context menu should be relevant to what was right-clicked. Use the OnPopup event of the TPopupMenu to enable or disable items based on context:
procedure TfrmMain.PopupMenu1Popup(Sender: TObject);
begin
miEditExpense.Enabled := sgExpenses.Row > 0;
miDeleteExpense.Enabled := sgExpenses.Row > 0;
miCopyAmount.Enabled := sgExpenses.Row > 0;
end;
Knowing What Was Right-Clicked
When a context menu opens on a TStringGrid, you need to know which row the user right-clicked. Use the grid's MouseToCell method in the OnPopup event:
procedure TfrmMain.PopupMenu1Popup(Sender: TObject);
var
P: TPoint;
Col, Row: Integer;
begin
P := sgExpenses.ScreenToClient(Mouse.CursorPos);
sgExpenses.MouseToCell(P.X, P.Y, Col, Row);
if Row > 0 then
sgExpenses.Row := Row; { select the right-clicked row }
miEditExpense.Enabled := Row > 0;
miDeleteExpense.Enabled := Row > 0;
end;
This ensures that the context menu operates on the row the user right-clicked, not whatever row happened to be selected before.
Programmatic Context Menus
You can also show a context menu programmatically, without the PopupMenu property. This is useful when the menu content depends entirely on runtime context:
procedure TfrmMain.sgExpensesMouseDown(Sender: TObject;
Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
var
Col, Row: Integer;
Pt: TPoint;
begin
if Button = mbRight then
begin
sgExpenses.MouseToCell(X, Y, Col, Row);
if Row > 0 then
begin
sgExpenses.Row := Row;
Pt := sgExpenses.ClientToScreen(Point(X, Y));
PopupMenu1.PopUp(Pt.X, Pt.Y);
end;
end;
end;
The PopUp(X, Y) method shows the menu at the specified screen coordinates. This gives you full control over when and where the context menu appears.
Multiple Context Menus
Different areas of your form may need different context menus. The expense grid might have Edit/Delete options, while a chart area might have Export/Print options. Create separate TPopupMenu components for each context:
sgExpenses.PopupMenu := pmExpenseGrid; { Edit, Delete, Copy }
pbPieChart.PopupMenu := pmChart; { Export as PNG, Print }
pnlNavigation.PopupMenu := pmNavigation; { Expand All, Collapse All }
Each popup menu has its own items and its own OnPopup handler for context-sensitive behavior.
Sharing Items Between Main and Context Menus
Often the same action appears in both the main menu and the context menu. Rather than duplicating event handlers, point both menu items at the same handler:
miContextEdit.OnClick := @miEditExpenseClick;
miContextDelete.OnClick := @miDeleteExpenseClick;
Or better yet, use TActionList (section 29.7) to share the action across both menus.
29.3 Standard Dialogs
The LCL provides standard dialog components that wrap the operating system's native file, color, and font dialogs. These dialogs look and behave exactly as the user expects on their platform.
TOpenDialog
Opens a file selection dialog:
var
OpenDialog: TOpenDialog;
begin
OpenDialog := TOpenDialog.Create(Self);
try
OpenDialog.Title := 'Open PennyWise File';
OpenDialog.Filter := 'PennyWise Files (*.pw)|*.pw|CSV Files (*.csv)|*.csv|All Files (*.*)|*.*';
OpenDialog.FilterIndex := 1; { default to .pw files }
OpenDialog.InitialDir := GetUserDir;
if OpenDialog.Execute then
begin
LoadFromFile(OpenDialog.FileName);
FCurrentFile := OpenDialog.FileName;
UpdateTitleBar;
end;
finally
OpenDialog.Free;
end;
end;
The Filter property uses a specific format: Description|Pattern|Description|Pattern.... The pipe character separates the display text from the file pattern. FilterIndex selects which filter is active by default (1-based).
Key TOpenDialog properties:
| Property | Type | Description |
|---|---|---|
Title |
string | Dialog window title |
Filter |
string | File type filter string |
FilterIndex |
Integer | Default filter (1-based) |
FileName |
string | Selected file path (after Execute returns True) |
InitialDir |
string | Starting directory |
DefaultExt |
string | Auto-appended extension if user omits one |
Options |
set | Flags: ofFileMustExist, ofOverwritePrompt, ofAllowMultiSelect |
Alternatively, drop a TOpenDialog component on your form at design time and configure its properties in the Object Inspector. Then just call if OpenDialog1.Execute then ....
For multi-file selection, include ofAllowMultiSelect in the Options set and read the results from the Files property:
OpenDialog1.Options := OpenDialog1.Options + [ofAllowMultiSelect];
if OpenDialog1.Execute then
begin
for I := 0 to OpenDialog1.Files.Count - 1 do
ImportFile(OpenDialog1.Files[I]);
end;
TSaveDialog
Nearly identical to TOpenDialog, but for saving:
procedure TfrmMain.HandleSaveAs;
begin
SaveDialog1.Title := 'Save PennyWise File';
SaveDialog1.Filter := 'PennyWise Files (*.pw)|*.pw|CSV Files (*.csv)|*.csv';
SaveDialog1.DefaultExt := 'pw';
SaveDialog1.FileName := FCurrentFile;
SaveDialog1.Options := SaveDialog1.Options + [ofOverwritePrompt];
if SaveDialog1.Execute then
begin
SaveToFile(SaveDialog1.FileName);
FCurrentFile := SaveDialog1.FileName;
FHasUnsavedChanges := False;
UpdateTitleBar;
end;
end;
The DefaultExt property automatically appends the extension if the user does not type one. The ofOverwritePrompt option shows a confirmation dialog if the selected file already exists.
TColorDialog
Opens the platform's color picker:
procedure TfrmMain.miChangeColorClick(Sender: TObject);
begin
ColorDialog1.Color := sgExpenses.Color; { start with current color }
if ColorDialog1.Execute then
sgExpenses.Color := ColorDialog1.Color;
end;
The color dialog is useful for preferences: letting the user choose highlight colors, chart colors, or background colors. On Windows, it shows the standard Windows color picker with basic and custom color slots. On Linux and macOS, the native color chooser appears.
TFontDialog
Opens the platform's font picker:
procedure TfrmMain.miFontClick(Sender: TObject);
begin
FontDialog1.Font := sgExpenses.Font;
if FontDialog1.Execute then
sgExpenses.Font := FontDialog1.Font;
end;
The font dialog lets the user choose the font face, size, style (bold, italic, underline, strikeout), and color. The Font property is both the input (the initially selected font) and the output (the user's selection).
TFindDialog and TReplaceDialog
For applications with text editing capabilities, TFindDialog provides a standard Find dialog:
procedure TfrmMain.miFindClick(Sender: TObject);
begin
FindDialog1.Execute; { shows the dialog — non-modal }
end;
procedure TfrmMain.FindDialog1Find(Sender: TObject);
var
SearchText: string;
Options: TFindOptions;
begin
SearchText := FindDialog1.FindText;
Options := FindDialog1.Options;
{ frDown in Options means search forward }
{ frMatchCase in Options means case-sensitive }
{ frWholeWord in Options means whole words only }
SearchInGrid(SearchText, frMatchCase in Options);
end;
TReplaceDialog extends TFindDialog with a "Replace" field and "Replace" / "Replace All" buttons.
TSelectDirectoryDialog
For selecting a directory (rather than a file), use TSelectDirectoryDialog:
procedure TfrmMain.miSelectBackupDirClick(Sender: TObject);
var
DirDialog: TSelectDirectoryDialog;
begin
DirDialog := TSelectDirectoryDialog.Create(Self);
try
DirDialog.Title := 'Select Backup Directory';
DirDialog.InitialDir := GetUserDir;
if DirDialog.Execute then
FBackupDir := DirDialog.FileName;
finally
DirDialog.Free;
end;
end;
A Dialog Usage Checklist
When using any standard dialog, follow these best practices:
- Set a descriptive Title that tells the user what the dialog is for ("Open PennyWise File", not just "Open").
- Set appropriate Filters for file dialogs. Put the most common format first. Always include "All Files (.)" as the last option.
- Set DefaultExt for save dialogs so users do not need to type the extension.
- Set InitialDir to a sensible default — the user's documents directory, the last-used directory, or the directory of the current file.
- Check the return value of
Execute. If it returns False, the user cancelled — do nothing. - Handle errors after the dialog returns. The file might not exist (for Open) or the directory might not be writable (for Save).
⚠️ Caution: Dialog Ownership
If you create dialogs programmatically (with TOpenDialog.Create(Self)), always free them in a finally block. If you drop them on the form at design time, the form owns them and frees them automatically. The design-time approach is simpler and is what we recommend.
29.4 Message Dialogs
Message dialogs are simple popup windows that inform the user, ask a question, or request a decision.
ShowMessage
The simplest dialog — a message with an OK button:
ShowMessage('File saved successfully.');
MessageDlg
A more flexible dialog with configurable buttons, icons, and return values:
Result := MessageDlg('Confirm Delete',
'Are you sure you want to delete this expense?',
mtConfirmation, { icon type }
[mbYes, mbNo], { button set }
0); { help context (0 = none) }
if Result = mrYes then
DeleteExpense(SelectedIndex);
Available icon types:
| Constant | Icon | When to Use |
|---|---|---|
mtInformation |
Information (blue circle with "i") | Confirming successful operations |
mtWarning |
Warning (yellow triangle with "!") | Actions with consequences |
mtError |
Error (red circle with "X") | Operation failures |
mtConfirmation |
Question (blue circle with "?") | Asking for a decision |
mtCustom |
No icon | Generic messages |
Available button sets: mbYes, mbNo, mbOK, mbCancel, mbAbort, mbRetry, mbIgnore, mbAll, mbNoToAll, mbYesToAll. Combine them in a set: [mbYes, mbNo, mbCancel].
Return values: mrYes, mrNo, mrOK, mrCancel, mrAbort, mrRetry, mrIgnore.
The default button (which is focused when the dialog appears and activated by pressing Enter) is the first button in the set. If you want a specific button to be the default, list it first. For destructive operations, consider making "No" or "Cancel" the default rather than "Yes" — this prevents accidental data loss from a reflexive Enter keypress.
A common pattern for retry-on-failure uses mbRetry and mbIgnore:
function TfrmMain.TrySaveWithRetry(const FileName: string): Boolean;
begin
Result := False;
repeat
try
SaveToFile(FileName);
Result := True;
except
on E: Exception do
begin
case MessageDlg('Save Error',
'Could not save to ' + FileName + ':' + LineEnding + E.Message,
mtError, [mbRetry, mbAbort], 0) of
mrRetry: Continue;
mrAbort: Exit;
end;
end;
end;
until Result;
end;
This gives the user the chance to fix the problem (close another program that has the file locked, free up disk space) and retry, rather than simply failing.
A common pattern for the "save before closing" prompt uses three buttons:
case MessageDlg('Unsaved Changes',
'Do you want to save your changes before closing?',
mtConfirmation, [mbYes, mbNo, mbCancel], 0) of
mrYes:
begin
HandleSave;
{ proceed with closing }
end;
mrNo:
{ proceed with closing without saving };
mrCancel:
{ abort the close operation };
end;
QuestionDlg
An alternative to MessageDlg that provides more control over button captions:
Result := QuestionDlg('Clear All Data',
'This will permanently delete all expenses. Continue?',
mtWarning,
[mrYes, 'Delete Everything', mrNo, 'Keep Data', 'IsDefault'],
0);
This creates a dialog with custom button labels — "Delete Everything" and "Keep Data" instead of the generic "Yes" and "No." The 'IsDefault' tag after "Keep Data" makes it the default (focused) button.
InputBox and InputQuery
Prompt the user for a text value:
{ InputBox — returns the entered string (or a default) }
NewCategory := InputBox('New Category', 'Enter category name:', 'Other');
{ InputQuery — returns True if the user clicked OK }
var
CategoryName: string;
begin
CategoryName := '';
if InputQuery('New Category', 'Enter category name:', CategoryName) then
AddCategory(CategoryName);
end;
InputQuery is generally preferred because it tells you whether the user clicked OK or Cancel. With InputBox, you cannot distinguish between the user clicking Cancel and the user clicking OK with the default value unchanged.
📊 Guideline: When to Use Which Dialog - ShowMessage for simple notifications: "Saved," "Done," "No results found." - MessageDlg with mtInformation for important information the user should acknowledge. - MessageDlg with mtConfirmation for yes/no decisions: "Delete?", "Save changes?" - MessageDlg with mtWarning for actions that may have consequences: "This cannot be undone." - MessageDlg with mtError for error reports: "Could not open file." - InputBox/InputQuery for single-value input that does not warrant a full form.
29.5 Application Structure
As applications grow, their startup, shutdown, and state management become important architectural concerns.
Application Startup
The .lpr project file handles basic initialization:
begin
Application.Initialize;
Application.CreateForm(TfrmMain, frmMain);
Application.CreateForm(TfrmAbout, frmAbout);
Application.Run;
end.
The forms are created in order. The first form created with CreateForm becomes the main form — closing it terminates the application.
For more complex initialization (loading settings, checking for updates, applying themes), use the main form's OnCreate event:
procedure TfrmMain.FormCreate(Sender: TObject);
begin
LoadSettings;
ApplyUserPreferences;
InitializeDatabase;
BuildRecentFilesMenu;
if ParamCount > 0 then
LoadFromFile(ParamStr(1)); { open file passed as command-line argument }
UpdateTitleBar;
end;
The ParamStr(1) check lets the user open a file by double-clicking it in the file manager (if the .pw extension is associated with PennyWise) or by passing a file path on the command line.
Application Shutdown
The OnCloseQuery event fires when the user tries to close the main form. Use it to prompt for unsaved changes:
procedure TfrmMain.FormCloseQuery(Sender: TObject; var CanClose: Boolean);
begin
if FHasUnsavedChanges then
begin
case MessageDlg('Unsaved Changes',
'You have unsaved changes. Save before closing?',
mtConfirmation, [mbYes, mbNo, mbCancel], 0) of
mrYes:
begin
HandleSave;
CanClose := True;
end;
mrNo:
CanClose := True;
mrCancel:
CanClose := False;
end;
end;
end;
The OnClose event fires after OnCloseQuery allows closing. Use it for final cleanup:
procedure TfrmMain.FormClose(Sender: TObject; var CloseAction: TCloseAction);
begin
SaveSettings;
CloseAction := caFree; { free the form (default for the main form) }
end;
The CloseAction parameter controls what happens when the form closes:
| Value | Effect |
|---|---|
caFree |
Destroy the form (default for the main form) |
caHide |
Hide the form but keep it in memory (useful for modeless forms) |
caMinimize |
Minimize the form instead of closing |
caNone |
Do nothing (prevents closing) |
Global State
Applications need to track state that persists across event handlers: the current file path, whether there are unsaved changes, the user's preferences. Store this as private fields of the main form:
private
FCurrentFile: string;
FHasUnsavedChanges: Boolean;
FSettings: TAppSettings;
FRecentFiles: TStringList;
Persistent Settings with TIniFile
For settings that should persist between sessions, save them to a configuration file:
procedure TfrmMain.SaveSettings;
var
Ini: TIniFile;
begin
Ini := TIniFile.Create(GetAppConfigDir(False) + 'pennywise.ini');
try
Ini.WriteInteger('Window', 'Left', Left);
Ini.WriteInteger('Window', 'Top', Top);
Ini.WriteInteger('Window', 'Width', Width);
Ini.WriteInteger('Window', 'Height', Height);
Ini.WriteString('Files', 'LastFile', FCurrentFile);
Ini.WriteBool('View', 'ShowToolbar', pnlToolbar.Visible);
Ini.WriteBool('View', 'ShowStatusBar', StatusBar1.Visible);
{ Save recent files list }
Ini.WriteInteger('RecentFiles', 'Count', FRecentFiles.Count);
for I := 0 to FRecentFiles.Count - 1 do
Ini.WriteString('RecentFiles', 'File' + IntToStr(I), FRecentFiles[I]);
finally
Ini.Free;
end;
end;
procedure TfrmMain.LoadSettings;
var
Ini: TIniFile;
ConfigPath: string;
I, Count: Integer;
begin
ConfigPath := GetAppConfigDir(False) + 'pennywise.ini';
if not FileExists(ConfigPath) then
Exit;
Ini := TIniFile.Create(ConfigPath);
try
Left := Ini.ReadInteger('Window', 'Left', Left);
Top := Ini.ReadInteger('Window', 'Top', Top);
Width := Ini.ReadInteger('Window', 'Width', Width);
Height := Ini.ReadInteger('Window', 'Height', Height);
FCurrentFile := Ini.ReadString('Files', 'LastFile', '');
pnlToolbar.Visible := Ini.ReadBool('View', 'ShowToolbar', True);
StatusBar1.Visible := Ini.ReadBool('View', 'ShowStatusBar', True);
{ Load recent files list }
FRecentFiles.Clear;
Count := Ini.ReadInteger('RecentFiles', 'Count', 0);
for I := 0 to Count - 1 do
FRecentFiles.Add(Ini.ReadString('RecentFiles', 'File' + IntToStr(I), ''));
finally
Ini.Free;
end;
end;
Add IniFiles to your uses clause for TIniFile.
GetAppConfigDir(False) returns the user-specific configuration directory: C:\Users\Username\AppData\Local\ on Windows, ~/.config/ on Linux, ~/Library/Application Support/ on macOS. The False parameter means user-specific (not global/system-wide).
Updating the Title Bar
A standard convention: show the file name and modification status in the title bar:
procedure TfrmMain.UpdateTitleBar;
var
FileName: string;
begin
if FCurrentFile <> '' then
FileName := ExtractFileName(FCurrentFile)
else
FileName := 'Untitled';
if FHasUnsavedChanges then
Caption := '* ' + FileName + ' — PennyWise'
else
Caption := FileName + ' — PennyWise';
end;
The asterisk before the filename is a universal convention that signals unsaved changes. Users have been trained by decades of text editors and word processors to recognize this signal.
Tracking Unsaved Changes
Mark changes whenever the data is modified:
procedure TfrmMain.MarkModified;
begin
if not FHasUnsavedChanges then
begin
FHasUnsavedChanges := True;
UpdateTitleBar;
end;
end;
procedure TfrmMain.MarkSaved;
begin
FHasUnsavedChanges := False;
UpdateTitleBar;
end;
Call MarkModified from every operation that changes data (adding, editing, deleting expenses). Call MarkSaved after a successful save operation.
SDI vs. MDI Application Architecture
Desktop applications follow one of two structural patterns:
SDI (Single Document Interface) — Each document opens in its own independent window. Each window has its own menu bar and toolbar. This is the modern standard: think Notepad, Visual Studio Code, or most web browsers. SDI applications are simpler to implement and more consistent with modern OS conventions.
MDI (Multiple Document Interface) — A single parent window contains child windows, each representing a document. The parent has the menu bar and toolbar; the children are confined within the parent's boundaries. Think of classic Microsoft Word (pre-2000) or the Lazarus IDE itself.
For PennyWise, we use SDI — one main form with child panels, not MDI. If PennyWise needed to open multiple budget files simultaneously, we would create separate windows (SDI) rather than embedding them inside a parent (MDI).
In Lazarus, MDI is supported by setting the form's FormStyle property:
{ Parent form }
frmMain.FormStyle := fsMDIForm;
{ Child forms }
frmDocument.FormStyle := fsMDIChild;
However, MDI support varies by platform (it works well on Windows but is less natural on Linux and macOS). For new applications, SDI is almost always the better choice.
Command-Line Integration
A professional application responds to command-line arguments. PennyWise should open a file when launched from the command line or when the user double-clicks a .pw file:
procedure TfrmMain.FormCreate(Sender: TObject);
begin
{ ... other initialization ... }
{ Check for command-line file argument }
if ParamCount > 0 then
begin
if FileExists(ParamStr(1)) then
LoadFromFile(ParamStr(1))
else
MessageDlg('File Not Found',
'Could not find file: ' + ParamStr(1),
mtError, [mbOK], 0);
end;
end;
On Windows, you can associate the .pw extension with PennyWise through the system registry. On Linux, you create a .desktop file with a MimeType entry. On macOS, you configure the Info.plist file. We will cover platform-specific distribution in Chapter 32.
29.6 Toolbars and Status Bars
TToolBar
A toolbar provides quick access to frequently used operations. Drop a TToolBar onto the form — it automatically aligns to the top.
Add buttons to the toolbar using the toolbar editor (right-click the toolbar, choose "New Button") or by dropping TToolButton components onto it. Set each button's properties:
tbtnNew.Caption := 'New';
tbtnNew.ImageIndex := 0; { index into the toolbar's Images list }
tbtnNew.OnClick := @miNewClick; { same handler as the menu item }
tbtnOpen.Caption := 'Open';
tbtnOpen.ImageIndex := 1;
tbtnOpen.OnClick := @miOpenClick;
Setting Up an Image List
Toolbars typically use a TImageList component for icons:
- Drop a
TImageListon the form. Set itsWidthandHeight(typically 16x16 or 24x24). - Double-click it to open the image list editor. Add PNG or BMP icons.
- Set the toolbar's
Imagesproperty to the image list. - Set each button's
ImageIndexto the corresponding icon index.
You can use the same image list for both the toolbar and the main menu by setting MainMenu1.Images := ImageList1. This displays icons next to menu items — a nice professional touch.
Toolbar Button Styles
Toolbar buttons come in several styles:
| Style | Behavior |
|---|---|
tbsButton |
Standard push button (default) |
tbsSeparator |
Vertical separator line between button groups |
tbsDivider |
Space between button groups (no visible line) |
tbsCheck |
Toggle button (stays pressed when clicked) |
tbsDropDown |
Button with a dropdown arrow for a popup menu |
A toolbar with separators:
{ Buttons are arranged left to right in creation order }
tbtnNew.Style := tbsButton;
tbtnOpen.Style := tbsButton;
tbtnSave.Style := tbsButton;
tbtnSep1.Style := tbsSeparator; { visual divider }
tbtnAddExpense.Style := tbsButton;
tbtnDeleteExpense.Style := tbsButton;
tbtnSep2.Style := tbsSeparator;
tbtnCharts.Style := tbsCheck; { toggles chart panel visibility }
Showing Button Captions
By default, toolbar buttons show only icons. To show text below or beside the icons:
ToolBar1.ShowCaptions := True;
ToolBar1.List := True; { text beside icon instead of below }
Dropdown Toolbar Buttons
A dropdown toolbar button combines a regular button with a dropdown menu. The user can click the button for the default action or click the dropdown arrow for a menu of related actions:
tbtnNew.Style := tbsDropDown;
tbtnNew.DropdownMenu := PopupMenuNew; { a TPopupMenu with options }
tbtnNew.OnClick := @actNewClick; { default action when button clicked }
{ The popup menu might contain: }
{ New Empty File }
{ New from Template... }
{ New from Recent... }
This pattern is common in office applications. The "New" button creates a blank document by default, but the dropdown offers templates and other options.
Tooltip Hints on Toolbar Buttons
Toolbar buttons should display tooltips (hints) when the user hovers over them:
tbtnSave.Hint := 'Save (Ctrl+S)';
tbtnSave.ShowHint := True;
{ Or enable hints globally: }
Application.ShowHint := True;
Application.HintPause := 500; { delay before hint appears (ms) }
Application.HintHidePause := 5000; { how long hint stays visible (ms) }
Including the keyboard shortcut in the hint teaches users the shortcut without them having to explore the menus.
TStatusBar
A status bar displays information at the bottom of the window. Drop a TStatusBar onto the form — it automatically aligns to the bottom.
Status bars have panels — segments that display different information:
procedure TfrmMain.FormCreate(Sender: TObject);
begin
StatusBar1.Panels.Clear;
with StatusBar1.Panels.Add do
begin
Width := 200;
Text := 'Ready';
end;
with StatusBar1.Panels.Add do
begin
Width := 150;
Text := 'Expenses: 0';
end;
with StatusBar1.Panels.Add do
begin
Width := 100;
Alignment := taRightJustify;
Text := '$0.00';
end;
end;
procedure TfrmMain.UpdateStatusBar;
begin
StatusBar1.Panels[0].Text := 'Ready';
StatusBar1.Panels[1].Text := Format('Expenses: %d', [ExpenseCount]);
StatusBar1.Panels[2].Text := Format('$%.2f', [TotalExpenses]);
end;
Status bar panels have a Style property: psText for displaying text (default) and psOwnerDraw for custom drawing (using the OnDrawPanel event). You can draw progress bars, icons, or any custom content in an owner-drawn panel.
Use the status bar to display context-sensitive information: the current operation ("Saving..."), the number of selected items, file size, or the current date. Update it in the same event handlers that update the rest of the UI.
A well-designed status bar provides at-a-glance information without the user needing to open menus or dialogs. For PennyWise, the three panels tell Rosa everything she needs to know: the current state of the application (Ready, Saving, Exporting), how many expenses are loaded, and the total amount. She can glance at the bottom of the window and immediately know the scope of her data.
Toolbar and Status Bar Visibility Toggles
The View menu typically includes toggles for toolbar and status bar visibility. This is a small detail that makes PennyWise feel professional:
procedure TfrmMain.actShowToolbarExecute(Sender: TObject);
begin
pnlToolbar.Visible := not pnlToolbar.Visible;
end;
procedure TfrmMain.actShowToolbarUpdate(Sender: TObject);
begin
actShowToolbar.Checked := pnlToolbar.Visible;
end;
procedure TfrmMain.actShowStatusBarExecute(Sender: TObject);
begin
StatusBar1.Visible := not StatusBar1.Visible;
end;
procedure TfrmMain.actShowStatusBarUpdate(Sender: TObject);
begin
actShowStatusBar.Checked := StatusBar1.Visible;
end;
The current visibility state is saved to the INI file in SaveSettings and restored in LoadSettings, so the user's preference persists between sessions.
29.7 Action Lists
Here is a common problem: you have a "Save" operation accessible from three places — the File > Save menu item, the toolbar Save button, and the Ctrl+S keyboard shortcut. All three should call the same code, be enabled/disabled together, and show the same icon. Without some organizing principle, you end up duplicating the handler assignment, the Enabled logic, and the icon setting in three places.
TActionList solves this problem by separating actions from the UI elements that trigger them.
How It Works
- Drop a
TActionListon the form. - Double-click it to open the Action List Editor.
- Create actions:
actNew,actOpen,actSave,actSaveAs,actExit,actDelete, etc. - Set each action's properties:
Caption,ShortCut,ImageIndex,Hint. - Write the action's
OnExecutehandler (the code that runs when the action is triggered). - Write the action's
OnUpdatehandler (code that enables/disables the action based on state). - Assign the action to multiple UI elements: set the menu item's
Actionproperty to the action, set the toolbar button'sActionproperty to the same action.
procedure TfrmMain.actSaveExecute(Sender: TObject);
begin
if FCurrentFile = '' then
HandleSaveAs
else
begin
SaveToFile(FCurrentFile);
FHasUnsavedChanges := False;
UpdateTitleBar;
StatusBar1.Panels[0].Text := 'Saved';
end;
end;
procedure TfrmMain.actSaveUpdate(Sender: TObject);
begin
actSave.Enabled := FHasUnsavedChanges;
end;
The OnUpdate handler is called automatically before the action's UI elements are displayed. This means the menu item, toolbar button, and keyboard shortcut are all enabled or disabled together, without you writing any synchronization code.
Defining Action Categories
Actions can be organized into categories using the Category property. In the Action List Editor, actions with the same category are grouped together:
actNew.Category := 'File';
actOpen.Category := 'File';
actSave.Category := 'File';
actAddExpense.Category := 'Edit';
actDeleteExpense.Category := 'Edit';
actShowCharts.Category := 'View';
actAbout.Category := 'Help';
Categories are just for organization in the IDE — they do not affect runtime behavior.
Standard Actions
The LCL provides pre-built standard actions for common operations:
| Action Class | Purpose |
|---|---|
TEditCut, TEditCopy, TEditPaste |
Clipboard operations |
TEditSelectAll |
Select all text |
TEditUndo |
Undo |
TFileOpen, TFileSaveAs |
File dialogs |
These actions automatically work with the focused control. For example, TEditCopy copies the selected text from whatever TEdit or TMemo currently has focus, without you writing any code.
To use a standard action, click "New Standard Action" in the Action List Editor, select the desired action class, and it is ready to use.
Complete PennyWise Action List
Here is the full set of actions for PennyWise:
procedure TfrmMain.SetupActions;
begin
{ File actions }
actNew.Caption := '&New';
actNew.ShortCut := ShortCut(Ord('N'), [ssCtrl]);
actNew.ImageIndex := 0;
actNew.Hint := 'Create a new expense file';
actOpen.Caption := '&Open...';
actOpen.ShortCut := ShortCut(Ord('O'), [ssCtrl]);
actOpen.ImageIndex := 1;
actOpen.Hint := 'Open an existing expense file';
actSave.Caption := '&Save';
actSave.ShortCut := ShortCut(Ord('S'), [ssCtrl]);
actSave.ImageIndex := 2;
actSave.Hint := 'Save the current file';
actSaveAs.Caption := 'Save &As...';
actSaveAs.ShortCut := ShortCut(Ord('S'), [ssCtrl, ssShift]);
actSaveAs.Hint := 'Save the current file with a new name';
actExportCSV.Caption := 'E&xport to CSV...';
actExportCSV.Hint := 'Export expenses to a CSV file';
actExit.Caption := 'E&xit';
actExit.ShortCut := ShortCut(VK_F4, [ssAlt]);
actExit.Hint := 'Close PennyWise';
{ Edit actions }
actAddExpense.Caption := '&Add Expense...';
actAddExpense.ShortCut := ShortCut(VK_INSERT, []);
actAddExpense.ImageIndex := 3;
actAddExpense.Hint := 'Add a new expense';
actEditExpense.Caption := '&Edit Expense...';
actEditExpense.ShortCut := ShortCut(VK_RETURN, []);
actEditExpense.ImageIndex := 5;
actEditExpense.Hint := 'Edit the selected expense';
actDeleteExpense.Caption := '&Delete Expense';
actDeleteExpense.ShortCut := ShortCut(VK_DELETE, []);
actDeleteExpense.ImageIndex := 4;
actDeleteExpense.Hint := 'Delete the selected expense';
end;
Writing OnUpdate Handlers
The OnUpdate handler is the mechanism that keeps all UI elements synchronized. It runs automatically — typically during the application's idle time (between events). Here are the complete update handlers for PennyWise:
procedure TfrmMain.actSaveUpdate(Sender: TObject);
begin
actSave.Enabled := FHasUnsavedChanges;
end;
procedure TfrmMain.actEditExpenseUpdate(Sender: TObject);
begin
{ Can only edit if an expense is selected }
actEditExpense.Enabled := (sgExpenses.Row > 0) and
(sgExpenses.Cells[0, sgExpenses.Row] <> '');
end;
procedure TfrmMain.actDeleteExpenseUpdate(Sender: TObject);
begin
actDeleteExpense.Enabled := (sgExpenses.Row > 0) and
(sgExpenses.Cells[0, sgExpenses.Row] <> '');
end;
procedure TfrmMain.actExportCSVUpdate(Sender: TObject);
begin
{ Can only export if there are expenses }
actExportCSV.Enabled := sgExpenses.RowCount > 1;
end;
procedure TfrmMain.actShowChartsUpdate(Sender: TObject);
begin
actShowCharts.Checked := pnlCharts.Visible;
end;
Note how each OnUpdate handler examines the current application state and sets the action's Enabled (or Checked) property accordingly. The LCL propagates these changes to all connected UI elements automatically. The Delete menu item, the Delete toolbar button, and the Delete key shortcut all become grayed out when no expense is selected — without any manual synchronization code.
How Actions Connect to UI Elements
When you set a menu item's Action property to an action, the following properties are automatically synchronized:
| Action Property | Menu Item Property | Toolbar Button Property |
|---|---|---|
Caption |
Caption |
Caption |
Enabled |
Enabled |
Enabled |
Checked |
Checked |
Down (for tbsCheck style) |
ShortCut |
ShortCut |
— |
ImageIndex |
ImageIndex |
ImageIndex |
Hint |
Hint |
Hint |
Visible |
Visible |
Visible |
If you change actSave.Caption := 'Save File', the menu item and toolbar button both update their captions automatically. This centralization eliminates an entire category of bugs — the kind where the menu says "Save" but the toolbar says "Save File" because someone updated one but forgot the other.
Why Actions Matter
💡 Intuition: Actions as the Single Source of Truth Without actions, the "Save" logic is spread across three places: the menu item handler, the toolbar button handler, and the keyboard shortcut handler. If you change the logic, you must change it in three places. If you want to disable Save, you must disable it in three places. Actions centralize everything — the code, the enabled state, the caption, the icon — in one object. The menu item and toolbar button are just views of the action. This is the single-responsibility principle applied to UI design.
The TActionList pattern is not unique to Lazarus — it is the same pattern used in Java Swing (AbstractAction), .NET WPF (ICommand), and many other GUI frameworks. Learning it here prepares you for action-based UI design in any framework.
29.8 Project Checkpoint: PennyWise Menus and Dialogs
We now add the complete menu structure, toolbar, status bar, file dialogs, and an About dialog to PennyWise.
Menu Structure
File Edit View Help
├── New Ctrl+N ├── Add Expense ├── Toolbar ├── About PennyWise
├── Open... Ctrl+O ├── Edit Expense ├── Status Bar └── Keyboard Shortcuts
├── ────────────────── ├── Delete Expense ├── ──────────
├── Save Ctrl+S ├── ────────────── └── Show Charts
├── Save As... Ctrl+Sh+S├── Select All
├── ────────────────── └── Clear All
├── Export CSV...
├── ──────────────────
└── Exit Alt+F4
About Dialog
procedure TfrmMain.actAboutExecute(Sender: TObject);
begin
MessageDlg('About PennyWise',
'PennyWise Personal Finance Manager' + LineEnding +
'Version 1.0' + LineEnding +
LineEnding +
'Built with Free Pascal and Lazarus' + LineEnding +
'A progressive project from "Programming with Pascal"' + LineEnding +
LineEnding +
'Rosa Martinelli and Tomas Vieira say hello!',
mtInformation, [mbOK], 0);
end;
For a more polished About dialog, create a dedicated form with your application's icon, version number, license information, and a clickable URL. But for PennyWise, a simple MessageDlg is sufficient.
Export to CSV
procedure TfrmMain.actExportCSVExecute(Sender: TObject);
var
Dialog: TSaveDialog;
F: TextFile;
I: Integer;
begin
Dialog := TSaveDialog.Create(Self);
try
Dialog.Title := 'Export Expenses to CSV';
Dialog.Filter := 'CSV Files (*.csv)|*.csv';
Dialog.DefaultExt := 'csv';
Dialog.FileName := 'expenses.csv';
Dialog.Options := Dialog.Options + [ofOverwritePrompt];
if Dialog.Execute then
begin
AssignFile(F, Dialog.FileName);
try
Rewrite(F);
WriteLn(F, 'Date,Description,Category,Amount');
for I := 1 to sgExpenses.RowCount - 1 do
begin
if sgExpenses.Cells[0, I] <> '' then
WriteLn(F, Format('"%s","%s","%s","%s"',
[sgExpenses.Cells[0, I],
sgExpenses.Cells[1, I],
sgExpenses.Cells[2, I],
sgExpenses.Cells[3, I]]));
end;
CloseFile(F);
StatusBar1.Panels[0].Text := 'Exported to ' + ExtractFileName(Dialog.FileName);
except
on E: Exception do
MessageDlg('Export Error', E.Message, mtError, [mbOK], 0);
end;
end;
finally
Dialog.Free;
end;
end;
The Save/Open Implementation
The complete Save and Open logic, including the "Save changes?" prompt and recent file tracking:
procedure TfrmMain.HandleSave;
begin
if FCurrentFile = '' then
HandleSaveAs
else
begin
SaveToFile(FCurrentFile);
MarkSaved;
AddToRecentFiles(FCurrentFile);
StatusBar1.Panels[0].Text := 'Saved';
end;
end;
procedure TfrmMain.HandleOpen;
begin
if FHasUnsavedChanges then
if not ConfirmSaveChanges then
Exit;
if OpenDialog1.Execute then
begin
LoadFromFile(OpenDialog1.FileName);
FCurrentFile := OpenDialog1.FileName;
MarkSaved;
AddToRecentFiles(FCurrentFile);
UpdateStatusBar;
end;
end;
function TfrmMain.ConfirmSaveChanges: Boolean;
begin
case MessageDlg('Unsaved Changes',
'Save changes before proceeding?',
mtConfirmation, [mbYes, mbNo, mbCancel], 0) of
mrYes:
begin
HandleSave;
Result := True;
end;
mrNo:
Result := True;
mrCancel:
Result := False;
else
Result := False;
end;
end;
procedure TfrmMain.AddToRecentFiles(const AFileName: string);
var
Idx: Integer;
begin
Idx := FRecentFiles.IndexOf(AFileName);
if Idx >= 0 then
FRecentFiles.Delete(Idx); { remove existing entry }
FRecentFiles.Insert(0, AFileName); { add at the top }
while FRecentFiles.Count > 10 do
FRecentFiles.Delete(FRecentFiles.Count - 1); { keep only 10 }
BuildRecentFilesMenu;
end;
Keyboard Shortcuts: Design Conventions
When assigning keyboard shortcuts, follow platform conventions. Users expect certain shortcuts to work in every application:
| Shortcut | Standard Action | Notes |
|---|---|---|
| Ctrl+N | New | Universal |
| Ctrl+O | Open | Universal |
| Ctrl+S | Save | Universal |
| Ctrl+Shift+S | Save As | Common convention |
| Ctrl+Z | Undo | Universal |
| Ctrl+Y or Ctrl+Shift+Z | Redo | Platform-dependent |
| Ctrl+X | Cut | Universal |
| Ctrl+C | Copy | Universal |
| Ctrl+V | Paste | Universal |
| Ctrl+A | Select All | Universal |
| Ctrl+F | Find | Universal |
| Ctrl+P | Universal | |
| F1 | Help | Universal |
| F5 | Refresh | Common in data apps |
| Delete | Delete selected item | Universal |
| Insert | Add new item | Common in data apps |
| Enter | Edit selected item | Common in grids |
| Escape | Cancel/Close dialog | Universal |
Do not reassign these standard shortcuts to non-standard actions. If Ctrl+S does anything other than "Save" in your application, users will be confused and frustrated.
For application-specific shortcuts, use less common key combinations. Ctrl+Alt combinations, F-keys above F5, or Ctrl+Shift combinations are typically available.
Putting It All Together: The Application Architecture
By the end of this chapter, PennyWise has a complete application architecture. Let us review how the pieces fit together:
The main form (TfrmMain) is the application's central hub. It owns the expense grid, the toolbar, the status bar, and the chart panel. It holds private fields for application state: FCurrentFile, FHasUnsavedChanges, FRecentFiles, and FSettings.
The action list centralizes all operations. Each action has an OnExecute handler (what happens) and an OnUpdate handler (when is it available). Menu items and toolbar buttons are connected to actions, not to handlers directly.
Persistent settings (window position, toolbar visibility, recent files) are saved to an INI file in the user's configuration directory. They are loaded during FormCreate and saved during FormClose.
The file operations (New, Open, Save, Save As) form a coherent system. New clears the data and resets the file path. Open loads data from a file. Save writes to the current file (or delegates to Save As if no file is set). Save As prompts for a file path. All four operations update the title bar and the unsaved-changes flag.
The shutdown sequence is: user clicks Close > OnCloseQuery checks for unsaved changes > user confirms > OnClose saves settings > form is freed > application terminates.
This architecture scales. As PennyWise grows (adding charts in Chapter 30, a database in Chapter 31, cross-platform packaging in Chapter 32), the action list, the settings system, and the save/load flow remain unchanged. The architecture accommodates new features without refactoring existing code.
✅ Checkpoint Checklist - [ ] Main menu with File, Edit, View, Help menus and keyboard shortcuts - [ ] Context menu on expense grid with Edit, Delete, Copy Amount options - [ ] File > Open and File > Save As use standard dialogs with .pw filter - [ ] File > Export CSV writes a proper CSV file - [ ] Help > About shows application information - [ ] Toolbar with New, Open, Save, Add Expense, Delete buttons and icons - [ ] Status bar shows message, expense count, and total amount - [ ] TActionList centralizes all actions — menu items and toolbar buttons share actions - [ ] Window title shows filename and unsaved-changes indicator (*) - [ ] OnCloseQuery prompts to save unsaved changes - [ ] Settings persisted to INI file (window position, toolbar visibility, recent files)
Testing the Complete Application Flow
Before moving to the next chapter, test the complete application flow. This is not just about individual features — it is about how they interact:
-
Launch PennyWise. The window should appear at its last saved position (or centered if first launch). The toolbar and status bar should be visible. The expense grid should be empty. The title bar should read "Untitled — PennyWise."
-
Add several expenses. Use the Add Expense dialog. Verify that the grid updates, the total updates, and the status bar updates. The title bar should change to "* Untitled — PennyWise" (the asterisk indicating unsaved changes).
-
Save the file. File > Save As, choose a location, name it "test.pw." The title bar should change to "test.pw — PennyWise" (no asterisk). The status bar should say "Saved."
-
Add one more expense. The asterisk should reappear in the title bar.
-
Close the application. A "Save changes?" prompt should appear. Click Cancel — the application should not close. Click the X again, click No — the application should close without saving. Re-launch, open test.pw, verify the extra expense is not there (because you did not save).
-
Test keyboard shortcuts. Ctrl+N for New, Ctrl+O for Open, Ctrl+S for Save. Insert to add an expense, Delete to remove one. Tab through the expense entry form.
-
Test the context menu. Right-click on an expense in the grid. Edit and Delete should be enabled. Right-click on the empty area below the expenses — Edit and Delete should be disabled.
This kind of end-to-end testing catches integration bugs that unit tests miss. The "Save changes?" dialog might work correctly in isolation, but if FHasUnsavedChanges is never set to True, it will never appear.
29.9 Summary
This chapter gave PennyWise — and your understanding of desktop application architecture — a professional skeleton.
What we covered:
- Main menus (
TMainMenu) organize operations into discoverable categories with keyboard shortcuts. The&prefix creates accelerator keys. Separators group related items. Submenus handle hierarchical structures like recent file lists. Checked and radio items support toggle options. - Context menus (
TPopupMenu) provide relevant actions on right-click. UseOnPopupto enable/disable items based on context. UseMouseToCellto identify what was right-clicked in a grid. - Standard dialogs —
TOpenDialog,TSaveDialog,TColorDialog,TFontDialog,TFindDialog— wrap the operating system's native dialogs for consistent, familiar file and preference selection. Key properties includeFilter,DefaultExt, andOptions. - Message dialogs —
ShowMessage,MessageDlg,QuestionDlg,InputBox,InputQuery— communicate information, request decisions, and collect simple input from the user. Each has a specific use case based on the type of interaction needed. - Application structure covers startup (FormCreate, command-line parameters, loading settings), shutdown (OnCloseQuery for unsaved changes, OnClose for cleanup), and persistent state (TIniFile for settings, GetAppConfigDir for platform-appropriate paths).
- Toolbars (
TToolBar) provide quick access to common actions with icons. Button styles include standard, separator, check, and dropdown. Status bars (TStatusBar) display contextual information in panels with text or owner-drawn styles. - Action lists (
TActionList) are the key architectural pattern: they separate what an action does from how the user triggers it. A single action serves a menu item, a toolbar button, and a keyboard shortcut simultaneously. TheOnUpdatehandler synchronizes enabled state across all UI elements. Standard actions handle common clipboard and file operations automatically. - PennyWise now has a complete menu bar, toolbar, status bar, file dialogs, CSV export, recent files list, persistent settings, and an About dialog — all wired through a centralized action list.
In Chapter 30, we leave the world of standard controls and enter the world of graphics — drawing lines, shapes, colors, and charts on a canvas. PennyWise gets spending visualizations.