Files
win-dictate/old_text_files/UI-and-progress-bar-rebuild-part-3.md
T

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:

  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)

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

  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.