27 min read

> "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

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

  1. Drop a TMainMenu component 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).
  2. Double-click the TMainMenu icon to open the Menu Editor.
  3. 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.).
  4. 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.

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.

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:

  1. Set a descriptive Title that tells the user what the dialog is for ("Open PennyWise File", not just "Open").
  2. Set appropriate Filters for file dialogs. Put the most common format first. Always include "All Files (.)" as the last option.
  3. Set DefaultExt for save dialogs so users do not need to type the extension.
  4. Set InitialDir to a sensible default — the user's documents directory, the last-used directory, or the directory of the current file.
  5. Check the return value of Execute. If it returns False, the user cancelled — do nothing.
  6. 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:

  1. Drop a TImageList on the form. Set its Width and Height (typically 16x16 or 24x24).
  2. Double-click it to open the image list editor. Add PNG or BMP icons.
  3. Set the toolbar's Images property to the image list.
  4. Set each button's ImageIndex to 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 }

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

  1. Drop a TActionList on the form.
  2. Double-click it to open the Action List Editor.
  3. Create actions: actNew, actOpen, actSave, actSaveAs, actExit, actDelete, etc.
  4. Set each action's properties: Caption, ShortCut, ImageIndex, Hint.
  5. Write the action's OnExecute handler (the code that runs when the action is triggered).
  6. Write the action's OnUpdate handler (code that enables/disables the action based on state).
  7. Assign the action to multiple UI elements: set the menu item's Action property to the action, set the toolbar button's Action property 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.

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 Print 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:

  1. 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."

  2. 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).

  3. 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."

  4. Add one more expense. The asterisk should reappear in the title bar.

  5. 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).

  6. 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.

  7. 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. Use OnPopup to enable/disable items based on context. Use MouseToCell to identify what was right-clicked in a grid.
  • Standard dialogsTOpenDialog, TSaveDialog, TColorDialog, TFontDialog, TFindDialog — wrap the operating system's native dialogs for consistent, familiar file and preference selection. Key properties include Filter, DefaultExt, and Options.
  • Message dialogsShowMessage, 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. The OnUpdate handler 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.