# 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.*