11 KiB
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):
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:
- Why the chevrons survive in
DrawSelectSurface: the twog.DrawLine(...)calls happen afterg.ReleaseHDC(hdc)— outside the lock — so they render. - 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 aFontfrom an HDC does not lock anything — onlyGraphics::GetHDC()does. (Same reason the popup'sFont f(mem, g_fUI)still works:memis the raw memory HDC, not aGetHDC()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)
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
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:
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:
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):
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:
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:
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:
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:
} 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
- 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.
- Hover Copy/Paste/Clear → soft fill fades in, label brightens.
- Copy something → status briefly shows "Copied" (issue B fix).
- On a HiDPI display, text is correctly sized at first launch (issue A fix).
- 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.