# Win Dictation — Fix Note 02

**All text missing from the interface (fills render, labels don't)**

**Applies to:** the single-surface UI after applying Fix Note 01 (`g_w[i].kind` stamping).
**Status:** root cause confirmed. One pattern to remove, used in five places. Two smaller adjacent issues documented below.

---

## Symptom

Widgets now render in their correct *shapes* — blue Record pill with white dot, two select fields with chevrons, the card, the dark scrollbar — but **no text appears anywhere**:

- Record pill has no "Record" label
- Pin is completely invisible (empty space right of the pill)
- Copy / Paste / Clear are completely invisible (empty strip at the bottom)
- Mic / model selects are empty except for the chevron
- No status line ("Ready · 2 threads" etc.)

The tell: everything drawn with Graphics *primitives* (`FillRound`, `FillEllipse`, `DrawLine`, `DrawPath`) renders; everything drawn with `DrawTextC` doesn't. Pin and the ghost buttons are text-only at rest, so they vanish entirely.

## What you missed: `Graphics::GetHDC()` locks the Graphics object

Every text call sits inside this pattern (from `DrawHero`, and repeated in the other draw functions):

```cpp
HDC hdc = g.GetHDC();                  // <-- locks `g`
Font f(hdc, g_fUISemi);                //     (constructing the Font is fine)
RectF tb(...);
DrawTextC(g, L"Record", f, ...);       // <-- call on locked `g` → fails silently
g.ReleaseHDC(hdc);                     // <-- unlock, too late
```

`Graphics::GetHDC()` is documented to put the Graphics object into a **locked state**: between `GetHDC()` and `ReleaseHDC()`, *any* method called on that `Graphics` fails with `Status::ObjectBusy`. GDI+ reports errors via return codes, not exceptions — so `g.DrawString(...)` inside `DrawTextC` returns an error and draws nothing, with no crash and no debugger output. The result is exactly what you see: silent, total text loss, while every primitive drawn *outside* a lock window renders fine.

This also explains two details that look confusing at first:

1. **Why the chevrons survive in `DrawSelectSurface`:** the two `g.DrawLine(...)` calls happen *after* `g.ReleaseHDC(hdc)` — outside the lock — so they render.
2. **Why the *old* owner-draw code's text worked:** it wrote `Font f(d->hDC, g_fUISemi)` using the **raw owner-draw HDC** it already had. Constructing a `Font` from an HDC does not lock anything — only `Graphics::GetHDC()` does. (Same reason the popup's `Font f(mem, g_fUI)` still works: `mem` is the raw memory HDC, not a `GetHDC()` result.)

The `GetHDC()` calls were added because the new draw functions receive only a `Graphics&` and the `Font(HDC, HFONT)` constructor needs an HDC. The intent was right; the mechanism poisons the Graphics.

### Affected call sites (all five must change)

| Function | What's invisible |
|---|---|
| `DrawHero` | "Record" / "Stop" label |
| `DrawGhost` | Copy / Paste / Clear (entire control) |
| `DrawPinSurface` | Pin / Pinned (entire control) |
| `DrawSelectSurface` | mic / model value text |
| `PaintSurface` | status line, transcript placeholder |

## The fix: cached GDI+ fonts, zero `GetHDC()` calls

Create GDI+ `Font` objects **once** from the existing HFONTs using a *screen* DC (never the Graphics), cache them, and use them everywhere. This removes every `GetHDC()`/`ReleaseHDC()` pair and is also a per-frame win — no font construction inside a 60 fps paint loop.

### 1. Add globals (near the HFONT globals)

```cpp
Gdiplus::Font* g_gpUI     = nullptr;   // labels (15px)
Gdiplus::Font* g_gpUISemi = nullptr;   // hero label (15px semibold)
Gdiplus::Font* g_gpSmall  = nullptr;   // status line (12px)
Gdiplus::Font* g_gpText   = nullptr;   // transcript placeholder (16px)
```

### 2. Add the builder and fold it into `RecreateFonts`

```cpp
static Gdiplus::Font* GdipFontFromHFont(HFONT hf) {
    HDC sdc = GetDC(nullptr);                       // screen DC — no Graphics involved
    Gdiplus::Font* f = new Gdiplus::Font(sdc, hf);
    ReleaseDC(nullptr, sdc);
    if (f->GetLastStatus() != Ok) { delete f; return nullptr; }
    return f;
}

static void RebuildGdipFonts() {
    delete g_gpUI; delete g_gpUISemi; delete g_gpSmall; delete g_gpText;
    g_gpUI     = GdipFontFromHFont(g_fUI);
    g_gpUISemi = GdipFontFromHFont(g_fUISemi);
    g_gpSmall  = GdipFontFromHFont(g_fSmall);
    g_gpText   = GdipFontFromHFont(g_fText);
    // safety net if HFONT conversion ever fails:
    if (!g_gpUI)     g_gpUI     = new Gdiplus::Font(L"Segoe UI", 15.0f * g_dpiScale, FontStyleRegular,  UnitPixel);
    if (!g_gpUISemi) g_gpUISemi = new Gdiplus::Font(L"Segoe UI", 15.0f * g_dpiScale, FontStyleBold,     UnitPixel);
    if (!g_gpSmall)  g_gpSmall  = new Gdiplus::Font(L"Segoe UI", 12.0f * g_dpiScale, FontStyleRegular,  UnitPixel);
    if (!g_gpText)   g_gpText   = new Gdiplus::Font(L"Segoe UI", 16.0f * g_dpiScale, FontStyleRegular,  UnitPixel);
}
```

At the **end of `RecreateFonts(float scale)`**, add:

```cpp
    RebuildGdipFonts();
```

So HFONTs and GDI+ fonts always change together (startup and `WM_DPICHANGED`).

### 3. Call it at startup — ordering matters

`RebuildGdipFonts` needs GDI+ started (it already is — `GdiplusStartup` runs first) and benefits from the real DPI. In `wWinMain`, right after the DPI is known:

```cpp
    g_dpiScale = GetDpiForWindow(hMainWnd) / 96.0f;
    if (g_dpiScale <= 0.0f) g_dpiScale = 1.0f;
    RecreateFonts(g_dpiScale);          // <-- ADD: rebuilds HFONTs at real DPI + GDI+ fonts
```

(See "Adjacent issue A" below for why `RecreateFonts` belongs here anyway.)

### 4. Shutdown ordering — GDI+ objects must die before `GdiplusShutdown`

In the shutdown block, delete the GDI+ fonts **before** `GdiplusShutdown(g_gdipToken)`:

```cpp
    delete g_gpUI; delete g_gpUISemi; delete g_gpSmall; delete g_gpText;
    g_gpUI = g_gpUISemi = g_gpSmall = g_gpText = nullptr;
    GdiplusShutdown(g_gdipToken);
    DeleteObject(g_fUI); ...            // HFONTs are plain GDI; their order is fine as-is
```

(Destroying GDI+ objects after shutdown is undefined behavior — worth getting right even though it "usually" doesn't crash.)

### 5. Strip the lock pattern from all five call sites

**`DrawHero` — before:**

```cpp
    HDC hdc = g.GetHDC();
    Font f(hdc, g_fUISemi);
    RectF tb((REAL)(pill.X + (int)(36 * g_dpiScale)), (REAL)pill.Y,
             (REAL)(pill.Width - (int)(44 * g_dpiScale)), (REAL)pill.Height);
    DrawTextC(g, rec ? L"Stop" : L"Record", f, Color(255, 255, 255, 255),
              tb, StringAlignmentNear, StringAlignmentCenter);
    g.ReleaseHDC(hdc);
```

**`DrawHero` — after:**

```cpp
    RectF tb((REAL)(pill.X + (int)(36 * g_dpiScale)), (REAL)pill.Y,
             (REAL)(pill.Width - (int)(44 * g_dpiScale)), (REAL)pill.Height);
    DrawTextC(g, rec ? L"Stop" : L"Record", *g_gpUISemi, Color(255, 255, 255, 255),
              tb, StringAlignmentNear, StringAlignmentCenter);
```

Apply the identical transformation to the rest — delete the `GetHDC`/`Font(hdc, …)`/`ReleaseHDC` lines and pass the cached font:

| Call site | Replace local `Font f(hdc, …)` with |
|---|---|
| `DrawGhost` | `*g_gpUI` |
| `DrawPinSurface` | `*g_gpUI` |
| `DrawSelectSurface` | `*g_gpUI` |
| `PaintSurface` (status line) | `*g_gpSmall` |
| `PaintSurface` (placeholder) | `*g_gpText` |

After this, there must be **zero** calls to `g.GetHDC()` anywhere in the paint path. (The popup's `Font f(mem, g_fUI)` is fine and can stay — `mem` is a raw HDC; consider migrating it to `*g_gpUI` later for consistency.)

---

## Adjacent issue A — fonts are built at the wrong DPI on startup

In `wWinMain`, the HFONTs are created with `g_dpiScale` still at its initial `1.0f` (the window doesn't exist yet), and `g_dpiScale` is only set *after* `CreateWindowExW`. Nothing recreates the fonts at startup, so on a 125%/150% display, all text is undersized until the first `WM_DPICHANGED`. The `RecreateFonts(g_dpiScale)` call added in step 3 fixes this — and because it runs *before* the child controls are created, the `EDIT` receives the correctly-scaled `g_fText` at creation.

## Adjacent issue B — transient status messages are now invisible

`SetStatus(...)` writes to the `ID_STATIC_STATUS` control — which is hidden (`SW_HIDE`). The painted status line in `PaintSurface` derives its text purely from state (recording/busy/ready/loading), so these messages can never appear: **"Copied", "Pasted", "Cancelled", "No speech detected", "Microphone error", "Hotkey in use — edit win-dictation.ini"**.

Minimal repair — route `SetStatus` into the painted surface as a transient override:

```cpp
std::wstring g_statusOverride;          // shown instead of the derived idle status
DWORD        g_statusOverrideUntil = 0; // GetTickCount() deadline

void SetStatus(HWND hwnd, const wchar_t* text) {
    g_statusOverride      = text;
    g_statusOverrideUntil = GetTickCount() + 2500;   // visible for 2.5s
    InvalidateRect(hwnd, nullptr, FALSE);
}
```

In `PaintSurface`'s status-text branch, prefer the override when idle:

```cpp
        } else if (g_modelLoaded.load()) {
            if (!g_statusOverride.empty() && GetTickCount() < g_statusOverrideUntil) {
                wcscpy_s(statusBuf, g_statusOverride.c_str());
            } else if (!g_modelOk.load()) {
                swprintf_s(statusBuf, L"Model not found — check models folder");
            } else {
                swprintf_s(statusBuf, L"Ready  •  %d threads", g_tx.threads());
            }
        }
```

(Recording/busy branches still win, which is correct — those states are more important than a stale "Copied".)

---

## Verify after rebuilding

1. **Labels everywhere:** "Record" on the pill; "Pinned" top-right in accent; "Copy / Paste / Clear" as dim labels; mic + model names in the selects; "Loading model…" → "Ready · 2 threads" on the status line.
2. **Hover** Copy/Paste/Clear → soft fill fades in, label brightens.
3. **Copy something** → status briefly shows "Copied" (issue B fix).
4. On a HiDPI display, text is correctly sized at first launch (issue A fix).
5. Run a transcription → smooth rising % and counting-down ETA (unrelated path, but confirm while you're there).

## Optional cleanup (safe to defer)

The legacy chrome is now dead weight: the nine hidden child windows and their `Create…`/`SetWindowTheme`/`SetWindowSubclass`/`ShowWindow(SW_HIDE)` calls, `LayoutControls`, `BtnProc`, `DrawRecordButton`, `DrawFlatButton`, `DrawSelect`, `UpdatePlaceholder`'s STATIC logic, and the `WM_DRAWITEM`/`WM_MEASUREITEM` handlers (keep the `EDIT` and everything for it). Removing them deletes ~150 lines and removes the double layout work in `WM_SIZE` (`LayoutControls` + `LayoutWidgets` both run and both `MoveWindow` the EDIT). Functionally harmless today, so treat as a tidy-up pass, not part of this fix.

---

*Companion to `UI-and-Progress-Rebuild.md` and `UI-Progress-Rebuild-Fix-01.md`. Per project convention, this note is a new document; prior documents are unchanged.*
