v2 rebuild: GDI+ single-surface UI, self-calibrating progress, compact push-to-talk, GGML+Whisper integration
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
# 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.*
|
||||
Reference in New Issue
Block a user