The Velo CE Primer

Chapter 4Screen Layout Cookbook

Designing for 480 x 240

The Velo's screen is wide and short, and the taskbar takes a strip off the bottom. Every layout in this chapter follows the same rules, which are also how Microsoft's CE programs look:

The examples below are in docs/primer/examples. Each one builds for both CE versions and starts in the state shown.

Recipe 1: Two Panes

A list on the left, the selected item on the right: the mail reader layout, and velo-bluesky's. Here the list is a LISTBOX and the reader a read-only multi-line EDIT, which wraps and scrolls text for free.

Notes example on CE 1.0: command bar with File and View menus, two toolbar buttons and a folder drop-down; a list of notes on the left, the selected note on the right, and a status bar
CE 1.0
Notes example on CE 2.0
CE 2.0
frame: command bar with menus, buttons from CE's standard bitmap, a combo box and the help and close buttons.

The command bar

static void create_command_bar(HWND frame) {
    static const int commands[] = {ID_FILE_NEW, ID_FILE_DELETE};
    static const int bitmaps[] = {STD_FILENEW, STD_DELETE};
    TBBUTTON buttons[3] = {0};
    command_bar = CommandBar_Create(instance, frame, IDC_COMMAND_BAR);
    CommandBar_InsertMenubar(command_bar, instance, IDM_MAIN, 0);
    CommandBar_AddBitmap(command_bar, HINST_COMMCTRL, IDB_STD_SMALL_COLOR, 15, 16, 16);
    buttons[0].fsStyle = TBSTYLE_SEP;
    for (int index = 0; index < 2; index++) {
        buttons[index + 1].iBitmap = bitmaps[index];
        buttons[index + 1].idCommand = commands[index];
        buttons[index + 1].fsState = TBSTATE_ENABLED;
        buttons[index + 1].fsStyle = TBSTYLE_BUTTON;
    }
    CommandBar_AddButtons(command_bar, 3, buttons);
    HWND folder = CommandBar_InsertComboBox(command_bar, instance, FOLDER_WIDTH, CBS_DROPDOWNLIST | WS_VSCROLL, IDC_FOLDER, 4);
    SendMessageW(folder, CB_ADDSTRING, 0, (LPARAM)L"All Notes");
    SendMessageW(folder, CB_ADDSTRING, 0, (LPARAM)L"Personal");
    SendMessageW(folder, CB_ADDSTRING, 0, (LPARAM)L"Work");
    SendMessageW(folder, CB_SETCURSEL, 0, 0);
    CommandBar_AddAdornments(command_bar, CMDBAR_HELP, 0);
}

Things go on the bar left to right, in the order you add them:

  1. CommandBar_InsertMenubar puts a menu resource on the bar. It counts as button 0.
  2. CommandBar_AddBitmap adds button pictures. HINST_COMMCTRL and IDB_STD_SMALL_COLOR give CE's standard 15: STD_FILENEW, STD_DELETE, STD_FIND and the rest. For other pictures, pass your instance and a BITMAP resource with the pictures side by side, and give CommandBar_AddBitmap their size. CE's standard pictures are 16 x 15.
  3. CommandBar_AddButtons adds buttons, each a TBBUTTON with a picture index and the command ID it sends in WM_COMMAND. A TBSTYLE_SEP entry is a gap.
  4. CommandBar_InsertComboBox puts a drop-down list before the given button position (4: after the menu, a gap and two buttons).
  5. CommandBar_AddAdornments comes last and adds the close button, and the ? button with CMDBAR_HELP, which sends WM_HELP.

The menu is an ordinary resource. Text after \t is shown on the right, as a reminder of the shortcut, but it's the ACCELERATORS table that makes the key work:

#include <windows.h>
#include "resource.h"

IDM_MAIN MENU
BEGIN
    POPUP "&File"
    BEGIN
        MENUITEM "&New Note\tCtrl+N", ID_FILE_NEW
        MENUITEM "&Delete Note\tDel", ID_FILE_DELETE
        MENUITEM SEPARATOR
        MENUITEM "E&xit", ID_FILE_EXIT
    END
    POPUP "&View"
    BEGIN
        MENUITEM "&Next Note\tCtrl+Down", ID_VIEW_NEXT
        MENUITEM "&Previous Note\tCtrl+Up", ID_VIEW_PREVIOUS
        MENUITEM SEPARATOR
        MENUITEM "&About Notes...", ID_HELP_ABOUT
    END
END

IDA_MAIN ACCELERATORS
BEGIN
    "N", ID_FILE_NEW, VIRTKEY, CONTROL, NOINVERT
    VK_DOWN, ID_VIEW_NEXT, VIRTKEY, CONTROL, NOINVERT
    VK_UP, ID_VIEW_PREVIOUS, VIRTKEY, CONTROL, NOINVERT
END

Laying out

static void layout(HWND frame) {
    RECT client;
    RECT status;
    GetClientRect(frame, &client);
    SendMessageW(status_bar, WM_SIZE, 0, 0);
    GetWindowRect(status_bar, &status);
    int top = CommandBar_Height(command_bar);
    int height = client.bottom - top - (status.bottom - status.top);
    int split = client.right * LIST_PERCENT / 100;
    MoveWindow(note_list, 0, top, split, height, TRUE);
    MoveWindow(reader, split, top, client.right - split, height, TRUE);
}

The status bar sizes itself when it gets WM_SIZE, so pass that on first and then measure it. SB_SETPARTS divides it into sections, given as the right edge of each, with -1 meaning "to the end".

Microsoft's CE 1.0 documentation says a menu can't contain a submenu there, so keep menus one level deep if you build for both.

For a pane divider the user can drag, leave a gap between the panes, paint it in WM_PAINT, and handle the stylus on the frame itself with SetCapture. velo-bluesky's src/main.c does this in about 30 lines.

Recipe 2: List and Details

A list view in report mode shows rows with columns, icons and full-row selection. The details on the right are drawn straight onto the frame window in WM_PAINT, with a progress bar and a button as child windows below them. This is the shape of an app store.

App store example on CE 1.0: a list view with Name, Version and Size columns, details for Bluesky on the right with a progress bar and a disabled Install button, and a status bar showing free space
CE 1.0
App store example on CE 2.0
CE 2.0
appstore: it starts a pretend download at launch so the progress bar shows. Free space comes from GetStoreInformation.

Filling a list view

static void add_column(int index, const WCHAR *title, int width, int format) {
    LVCOLUMNW column = {0};
    column.mask = LVCF_TEXT | LVCF_WIDTH | LVCF_FMT | LVCF_SUBITEM;
    column.fmt = format;
    column.cx = width;
    column.pszText = (LPWSTR)title;
    column.iSubItem = index;
    SendMessageW(app_list, LVM_INSERTCOLUMNW, index, (LPARAM)&column);
}

static void set_cell(int row, int column, const WCHAR *text) {
    LVITEMW item = {0};
    item.iSubItem = column;
    item.pszText = (LPWSTR)text;
    SendMessageW(app_list, LVM_SETITEMTEXTW, row, (LPARAM)&item);
}

static void fill_list(void) {
    HIMAGELIST icons = ImageList_Create(16, 16, ILC_COLOR | ILC_MASK, 1, 0);
    ImageList_AddIcon(icons, LoadImageW(instance, MAKEINTRESOURCEW(1), IMAGE_ICON, 16, 16, 0));
    SendMessageW(app_list, LVM_SETIMAGELIST, LVSIL_SMALL, (LPARAM)icons);
    SendMessageW(app_list, LVM_SETEXTENDEDLISTVIEWSTYLE, LVS_EX_FULLROWSELECT, LVS_EX_FULLROWSELECT);
    add_column(0, L"Name", 100, LVCFMT_LEFT);
    add_column(1, L"Version", 50, LVCFMT_LEFT);
    add_column(2, L"Size", 50, LVCFMT_RIGHT);
    for (int index = 0; index < (int)APP_COUNT; index++) {
        WCHAR size[16];
        LVITEMW item = {0};
        item.mask = LVIF_TEXT | LVIF_IMAGE | LVIF_PARAM;
        item.iItem = index;
        item.pszText = (LPWSTR)apps[index].name;
        item.iImage = 0;
        item.lParam = index;
        SendMessageW(app_list, LVM_INSERTITEMW, 0, (LPARAM)&item);
        wsprintfW(size, L"%d KB", apps[index].size_kb);
        set_cell(index, 1, apps[index].version);
        set_cell(index, 2, size);
    }
}

Insert a row with LVM_INSERTITEMW, which sets the first column, then fill the others with LVM_SETITEMTEXTW. lParam on each row is yours: store an index or pointer there, because rows move if the list is sorted. Icons come from an image list made with ImageList_Create.

Hearing about selection

Common controls report through WM_NOTIFY. The NMHDR at the start says which control and what happened. For list views it's the start of an NMLISTVIEW:

case WM_NOTIFY: {
    NMLISTVIEW *change = (NMLISTVIEW *)lparam;
    if (change->hdr.idFrom == IDC_APPS && change->hdr.code == LVN_ITEMCHANGED && (change->uNewState & LVIS_SELECTED)) {
        selected = (int)change->lParam;
        update_details();
    }
    return 0;
}

LVN_ITEMCHANGED fires for every state change of every row, including the old selection being cleared, so check that the new state includes LVIS_SELECTED.

Drawing the details

static void paint_details(HWND frame) {
    PAINTSTRUCT paint;
    WCHAR line[64];
    HDC dc = BeginPaint(frame, &paint);
    if (selected >= 0) {
        const app_t *app = &apps[selected];
        RECT area = details;
        SetBkMode(dc, TRANSPARENT);
        HGDIOBJ previous = SelectObject(dc, title_font);
        DrawTextW(dc, app->name, -1, &area, DT_LEFT | DT_SINGLELINE | DT_NOPREFIX);
        SelectObject(dc, previous);
        area.top += 22;
        wsprintfW(line, L"Version %s by %s", app->version, app->author);
        DrawTextW(dc, line, -1, &area, DT_LEFT | DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS);
        area.top += 20;
        DrawTextW(dc, app->description, -1, &area, DT_LEFT | DT_WORDBREAK | DT_NOPREFIX);
    }
    EndPaint(frame, &paint);
}

DrawTextW does the work: single lines with DT_SINGLELINE, an ellipsis when they don't fit with DT_END_ELLIPSIS, and wrapped paragraphs with DT_WORDBREAK. DT_NOPREFIX stops & being treated as a shortcut marker, which you want for any text you didn't write.

Recipe 3: A Dialog

Forms are easiest as dialog resources: you describe the controls in the .rc file and CE creates them, handles Tab between them, and presses the default button on Enter. This one also loads and saves its settings in the registry.

Account dialog on CE 1.0: handle, password and server fields, a Show pictures check box, a Refresh group of three radio buttons, and OK and Cancel
CE 1.0
Account dialog on CE 2.0
CE 2.0
form: a modal dialog with no main window behind it.
#include <windows.h>
#include "resource.h"

IDD_ACCOUNT DIALOG 0, 0, 230, 100
STYLE DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU
CAPTION "Account"
BEGIN
    LTEXT "&Handle:", IDC_STATIC, 6, 8, 40, 10
    EDITTEXT IDC_HANDLE, 50, 6, 104, 13, ES_AUTOHSCROLL | WS_TABSTOP
    LTEXT "&Password:", IDC_STATIC, 6, 26, 40, 10
    EDITTEXT IDC_PASSWORD, 50, 24, 104, 13, ES_AUTOHSCROLL | ES_PASSWORD | WS_TABSTOP
    LTEXT "&Server:", IDC_STATIC, 6, 44, 40, 10
    COMBOBOX IDC_SERVER, 50, 42, 104, 60, CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
    AUTOCHECKBOX "Show &pictures", IDC_PICTURES, 50, 62, 100, 10, WS_TABSTOP
    AUTORADIOBUTTON "&Manually", IDC_MANUAL, 168, 14, 52, 10, WS_GROUP | WS_TABSTOP
    AUTORADIOBUTTON "Ho&urly", IDC_HOURLY, 168, 28, 52, 10
    AUTORADIOBUTTON "&Daily", IDC_DAILY, 168, 42, 52, 10
    GROUPBOX "Refresh", IDC_STATIC, 162, 2, 62, 58, WS_GROUP
    DEFPUSHBUTTON "OK", IDOK, 122, 80, 48, 14, WS_TABSTOP
    PUSHBUTTON "Cancel", IDCANCEL, 176, 80, 48, 14, WS_TABSTOP
END

Positions are in dialog units, which scale with the dialog font: this 230-unit-wide dialog is 351 pixels wide on CE 1.0 and 408 on CE 2.0, frame included, about one and a half and 1.8 pixels per unit. & in a label underlines the next letter. WS_GROUP starts a group of radio buttons, which ends at the next control with WS_GROUP.

Running it

CE's DLLs don't export DialogBoxParamW. Microsoft's SDK headers made it a macro that finds the template and calls the Indirect version, which is all this does:

static int run_dialog(HINSTANCE instance, int id, HWND owner, DLGPROC procedure, void *parameter) {
    HRSRC resource = FindResource(instance, MAKEINTRESOURCEW(id), RT_DIALOG);
    HGLOBAL loaded = resource ? LoadResource(instance, resource) : NULL;
    if (!loaded) {
        return -1;
    }
    return DialogBoxIndirectParamW(instance, LockResource(loaded), owner, procedure, (LPARAM)parameter);
}

DialogBoxIndirectParamW runs a message loop and returns the value passed to EndDialog. The last argument arrives in WM_INITDIALOG's lParam: pass a pointer to the data the dialog edits, and keep it with DWL_USER.

static BOOL CALLBACK account_procedure(HWND dialog, UINT message, WPARAM wparam, LPARAM lparam) {
    account_t *account = (account_t *)GetWindowLongW(dialog, DWL_USER);
    switch (message) {
    case WM_INITDIALOG:
        account = (account_t *)lparam;
        SetWindowLongW(dialog, DWL_USER, (LONG)account);
        fill_dialog(dialog, account);
        SetFocus(GetDlgItem(dialog, IDC_PASSWORD));
        return FALSE;
    case WM_COMMAND:
        if (LOWORD(wparam) == IDOK) {
            if (read_dialog(dialog, account)) {
                EndDialog(dialog, IDOK);
            }
            return TRUE;
        }
        if (LOWORD(wparam) == IDCANCEL) {
            EndDialog(dialog, IDCANCEL);
            return TRUE;
        }
        break;
    }
    return FALSE;
}

A dialog procedure returns TRUE when it handled a message and FALSE to let the dialog manager do the default. From WM_INITDIALOG, return FALSE if you set the focus yourself. To refuse OK, show why and return without calling EndDialog.

For a dialog that stays open beside your main window, such as a find box, use CreateDialogIndirectParamW, pass messages through IsDialogMessageW in your main loop, and close it with DestroyWindow, not EndDialog.

The CE way: OK in the title bar

This dialog has desktop-style OK and Cancel buttons. Many CE dialogs instead put OK in the title bar, beside the close button, which saves a row on a 240-pixel screen. Add WS_EX_CAPTIONOKBTN to the dialog's extended style (an EXSTYLE line in the .rc file): the title bar's OK sends IDOK and its close button sends IDCANCEL, so the dialog procedure doesn't change. It works on both versions.

Recipe 4: Tabs and Common Controls

A tab control is only the row of tabs. The pages are your child windows, shown and hidden when the selection changes. This page also has a tree view, a trackbar driving a progress bar, a number box with up and down arrows, a check box and radio buttons.

Controls example on CE 1.0: Settings and About tabs; a tree of Velo and Network settings on the left; a volume trackbar, a level progress bar, a repeats box with arrows, a Key clicks check box, Loud and Soft radio buttons and an Apply button
CE 1.0
Controls example on CE 2.0
CE 2.0
controls: everything here is a stock control.
static void create_settings_page(HWND frame) {
    HWND tree = add_control(0, 0, WC_TREEVIEW, NULL, WS_BORDER | TVS_HASBUTTONS | TVS_HASLINES | TVS_LINESATROOT | TVS_SHOWSELALWAYS,
                            IDC_TREE, frame);
    HTREEITEM device = add_tree_item(tree, TVI_ROOT, L"Velo");
    HTREEITEM sound = add_tree_item(tree, device, L"Sound");
    add_tree_item(tree, device, L"Display");
    add_tree_item(tree, device, L"Power");
    HTREEITEM network = add_tree_item(tree, TVI_ROOT, L"Network");
    add_tree_item(tree, network, L"Modem");
    add_tree_item(tree, network, L"Proxy");
    SendMessageW(tree, TVM_EXPAND, TVE_EXPAND, (LPARAM)device);
    SendMessageW(tree, TVM_EXPAND, TVE_EXPAND, (LPARAM)network);
    SendMessageW(tree, TVM_SELECTITEM, TVGN_CARET, (LPARAM)sound);

    add_control(0, 0, L"STATIC", L"Volume:", SS_LEFT, IDC_VOLUME_LABEL, frame);
    HWND volume = add_control(0, 0, TRACKBAR_CLASS, NULL, TBS_HORZ | TBS_AUTOTICKS | WS_TABSTOP, IDC_VOLUME, frame);
    SendMessageW(volume, TBM_SETRANGE, TRUE, MAKELPARAM(0, 10));
    SendMessageW(volume, TBM_SETTICFREQ, 1, 0);
    SendMessageW(volume, TBM_SETPOS, TRUE, 7);

    add_control(0, 0, L"STATIC", L"Level:", SS_LEFT, IDC_LEVEL_LABEL, frame);
    HWND level = add_control(0, 0, PROGRESS_CLASS, NULL, WS_BORDER, IDC_LEVEL, frame);
    SendMessageW(level, PBM_SETRANGE, 0, MAKELPARAM(0, 10));
    SendMessageW(level, PBM_SETPOS, 7, 0);

    add_control(0, 0, L"STATIC", L"Repeats:", SS_LEFT, IDC_COPIES_LABEL, frame);
    HWND copies = add_control(0, 0, L"EDIT", NULL, WS_BORDER | ES_NUMBER | WS_TABSTOP, IDC_COPIES, frame);
    HWND arrows = add_control(0, 0, UPDOWN_CLASS, NULL, UDS_SETBUDDYINT | UDS_ALIGNRIGHT | UDS_ARROWKEYS, IDC_COPIES_ARROWS, frame);
    SendMessageW(arrows, UDM_SETBUDDY, (WPARAM)copies, 0);
    SendMessageW(arrows, UDM_SETRANGE, 0, MAKELPARAM(9, 1));
    SendMessageW(arrows, UDM_SETPOS, 0, MAKELPARAM(3, 0));

    HWND sounds = add_control(0, 0, L"BUTTON", L"Key clicks", BS_AUTOCHECKBOX | WS_TABSTOP, IDC_SOUNDS, frame);
    SendMessageW(sounds, BM_SETCHECK, BST_CHECKED, 0);
    add_control(0, 0, L"BUTTON", L"Loud", BS_AUTORADIOBUTTON | WS_GROUP | WS_TABSTOP, IDC_LARGE, frame);
    add_control(0, 0, L"BUTTON", L"Soft", BS_AUTORADIOBUTTON, IDC_SMALL, frame);
    CheckRadioButton(frame, IDC_LARGE, IDC_SMALL, IDC_SMALL);
    add_control(0, 0, L"BUTTON", L"Apply", BS_PUSHBUTTON | WS_TABSTOP, IDC_APPLY, frame);
}

Things to notice:

case WM_CREATE:
    command_bar = CommandBar_Create(instance, frame, IDC_COMMAND_BAR);
    CommandBar_AddAdornments(command_bar, 0, 0);
    create_settings_page(frame);
    create_about_page(frame);
    tabs = CreateWindowExW(0, WC_TABCONTROL, NULL, WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS, 0, 0, 0, 0, frame, (HMENU)IDC_TABS,
                           instance, NULL);
    add_tab(0, L"Settings");
    add_tab(1, L"About");
    show_page(0);
    return 0;

case WM_HSCROLL:
    if ((HWND)lparam == GetDlgItem(frame, IDC_VOLUME)) {
        int volume = (int)SendMessageW((HWND)lparam, TBM_GETPOS, 0, 0);
        SendMessageW(GetDlgItem(frame, IDC_LEVEL), PBM_SETPOS, volume, 0);
    }
    return 0;

Create the pages first and the tab control last, so the pages are on top of it. CE gives every window WS_CLIPSIBLINGS and WS_CLIPCHILDREN whether you ask or not, so creation order is what decides who paints over whom.

What's there

Tested by creating each class on both ROMs. InitCommonControls registers all of them except the calendar, date picker and rebar. CE 2.0 adds those with InitCommonControlsEx and ICC_DATE_CLASSES or ICC_COOL_CLASSES. CE 1.0 doesn't have them.

ClassControlCE 1.0CE 2.0
BUTTON, EDIT, STATIC, LISTBOX, COMBOBOX, SCROLLBARStandard controlsYesYes
SysListView32 (WC_LISTVIEW)List viewYesYes
SysTreeView32 (WC_TREEVIEW)Tree viewYesYes
SysTabControl32 (WC_TABCONTROL)TabsYesYes
msctls_progress32 (PROGRESS_CLASS)Progress barYesYes
msctls_trackbar32 (TRACKBAR_CLASS)Trackbar (slider)YesYes
msctls_updown32 (UPDOWN_CLASS)Up-down arrowsYesYes
ToolbarWindow32, msctls_statusbar32, SysHeader32Toolbar, status bar, column headerYesYes
SysMonthCal32, SysDateTimePick32Calendar, date pickerNoYes, with ICC_DATE_CLASSES
ReBarWindow32Rebar (CE 2.0's command bands use it)NoYes, with ICC_COOL_CLASSES
tooltips_class32TooltipsNoNo
RICHEDIT, RichInkRich textNoNo

Recipe 5: Drawing Your Own

When stock controls don't fit, paint it yourself. velo-bluesky's timeline and post views are custom windows drawn this way. This example shows the greys, fonts, shapes and a post card.

Drawing example on CE 1.0: a sixteen-step grey ramp that repeats black, dark grey, light grey, white four times; four safe greys; fonts including a badly drawn italic; edges, a zigzag, a dashed ellipse, an inverted rectangle; a post card; 2 bits per pixel
CE 1.0
Drawing example on CE 2.0: a smooth sixteen-step grey ramp, the same shapes and card, 4 bits per pixel
CE 2.0
drawing: look at the top row on CE 1.0.

The four safe greys

CE 2.0 maps any colour to the nearest of its 16 greys. CE 1.0 doesn't match colours at all: it uses the bottom two bits of the red value as the grey's number. So RGB(128, 128, 128) is black on CE 1.0 (128 ends in binary 00), RGB(17, 17, 17) is dark grey, and pure red is white. The top row above is RGB(17n, 17n, 17n) for n from 0 to 15: smooth on CE 2.0, four repeats of the same four greys on CE 1.0.

UseValueCE 1.0CE 2.0
BlackRGB(0, 0, 0)BlackBlack
Dark greyRGB(85, 85, 85)Dark greyDark grey
Light greyRGB(170, 170, 170)Light greyLight grey
WhiteRGB(255, 255, 255)WhiteWhite
Stick to these for anything that has to look right on both, or use the system colours.
static void fill(HDC dc, int left, int top, int right, int bottom, COLORREF colour) {
    RECT area = {left, top, right, bottom};
    HBRUSH brush = CreateSolidBrush(colour);
    FillRect(dc, &area, brush);
    DeleteObject(brush);
}

static int draw_greys(HDC dc, int x, int y) {
    static const int safe[] = {0, 85, 170, 255};
    WCHAR label[8];
    y = text_line(dc, fonts.normal, x, y, L"RGB(17n, 17n, 17n):");
    for (int step = 0; step < GREY_STEPS; step++) {
        int level = step * 17;
        fill(dc, x + step * STEP_WIDTH, y, x + (step + 1) * STEP_WIDTH, y + SWATCH_SIZE / 2, RGB(level, level, level));
    }
    y = text_line(dc, fonts.normal, x, y + SWATCH_SIZE / 2 + 2, L"Safe on CE 1.0 and 2.0:");
    for (int index = 0; index < 4; index++) {
        int left = x + index * (SWATCH_SIZE + 40);
        fill(dc, left, y, left + SWATCH_SIZE, y + SWATCH_SIZE, RGB(safe[index], safe[index], safe[index]));
        Rectangle(dc, left - 1, y - 1, left + SWATCH_SIZE + 1, y + SWATCH_SIZE + 1);
        fill(dc, left, y, left + SWATCH_SIZE, y + SWATCH_SIZE, RGB(safe[index], safe[index], safe[index]));
        wsprintfW(label, L"%d", safe[index]);
        text_line(dc, fonts.normal, left + SWATCH_SIZE + 4, y + 4, label);
    }
    return y + SWATCH_SIZE;
}

Every GDI object you create (brush, pen, font, bitmap) must be deleted with DeleteObject, and not while it's selected into a device context. Stock objects from GetStockObject are never deleted.

Fonts

Start from the system font and change what you need, so text matches the rest of CE:

static HFONT make_font(const LOGFONTW *base, LONG weight, LONG height, BYTE italic, const WCHAR *face) {
    LOGFONTW font = *base;
    font.lfWeight = weight;
    font.lfHeight = height;
    font.lfWidth = 0;
    font.lfItalic = italic;
    if (face) {
        int length = 0;
        while (face[length] && length < 31) {
            font.lfFaceName[length] = face[length];
            length++;
        }
        font.lfFaceName[length] = 0;
        font.lfPitchAndFamily = FIXED_PITCH | FF_MODERN;
    }
    return CreateFontIndirectW(&font);
}

static void create_fonts(void) {
    LOGFONTW base = {0};
    GetObjectW(GetStockObject(SYSTEM_FONT), sizeof base, &base);
    fonts.normal = make_font(&base, FW_NORMAL, base.lfHeight, 0, NULL);
    fonts.bold = make_font(&base, FW_BOLD, base.lfHeight, 0, NULL);
    fonts.large = make_font(&base, FW_BOLD, base.lfHeight * 2, 0, NULL);
    fonts.italic = make_font(&base, FW_NORMAL, base.lfHeight, 1, NULL);
    fonts.fixed = make_font(&base, FW_NORMAL, base.lfHeight, 0, L"Courier New");
}

Make fonts once at startup, keep them, and delete them at exit. GetTextMetricsW gives a font's line height, and DrawTextW with DT_CALCRECT measures wrapped text without drawing it, which is how you lay out a list of variable-height items.

No flicker

Drawing a busy window piece by piece makes it flash. Draw into a bitmap the size of the window, then copy it to the screen in one go:

static void paint_buffered(HWND frame) {
    PAINTSTRUCT paint_info;
    RECT client;
    HDC dc = BeginPaint(frame, &paint_info);
    GetClientRect(frame, &client);
    HDC buffer = CreateCompatibleDC(dc);
    HBITMAP bitmap = CreateCompatibleBitmap(dc, client.right, client.bottom);
    HGDIOBJ previous_bitmap = SelectObject(buffer, bitmap);
    HGDIOBJ previous_font = SelectObject(buffer, fonts.normal);
    HGDIOBJ previous_brush = SelectObject(buffer, GetStockObject(WHITE_BRUSH));
    paint(frame, buffer, &client);
    BitBlt(dc, 0, 0, client.right, client.bottom, buffer, 0, 0, SRCCOPY);
    SelectObject(buffer, previous_brush);
    SelectObject(buffer, previous_font);
    SelectObject(buffer, previous_bitmap);
    DeleteObject(bitmap);
    DeleteDC(buffer);
    EndPaint(frame, &paint_info);
}

With that, return 1 from WM_ERASEBKGND, because the bitmap covers everything. A 480 x 240 bitmap is 29 KB at 2 bits per pixel and 57 KB at 4: you can make one per paint, or keep one.

CE 1.0's BitBlt only does four raster operations: SRCCOPY, SRCPAINT, SRCAND and SRCINVERT. Anything else fails. CE 2.0 does them all. Those four are enough to draw a picture through a mask.

Pens and CE 1.0

CE 1.0 has no CreatePen. CreatePenIndirect works on both:

static void draw_shapes(HDC dc, int x, int y) {
    LOGPEN dashed = {PS_DASH, {1, 0}, RGB(0, 0, 0)};
    RECT edge = {x, y, x + 40, y + 28};
    POINT zigzag[] = {{x + 150, y + 28}, {x + 162, y}, {x + 174, y + 28}, {x + 186, y}, {x + 198, y + 28}};
    SelectObject(dc, GetStockObject(WHITE_BRUSH));
    DrawEdge(dc, &edge, EDGE_RAISED, BF_RECT);
    SetRect(&edge, x + 48, y, x + 88, y + 28);
    DrawEdge(dc, &edge, EDGE_SUNKEN, BF_RECT);
    SetRect(&edge, x + 96, y, x + 136, y + 28);
    DrawEdge(dc, &edge, EDGE_ETCHED, BF_RECT);
    Polyline(dc, zigzag, 5);
    HPEN pen = CreatePenIndirect(&dashed);
    HGDIOBJ previous = SelectObject(dc, pen);
    Ellipse(dc, x + 206, y, x + 246, y + 28);
    SelectObject(dc, previous);
    DeleteObject(pen);
    SelectObject(dc, GetStockObject(GRAY_BRUSH));
    Rectangle(dc, x + 254, y, x + 294, y + 28);
    PatBlt(dc, x + 264, y + 7, 20, 14, DSTINVERT);
}