Files
win-dictate/bug-fixes-and-visual-upgrade.md
T

791 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Win Dictation — Crash Fixes & Modern UI (Build 2)
This document has two parts:
- **Part 1 — Critical fixes** (do these first): the Stop crash, resizing, model switching, thread count, model path.
- **Part 2 — Modern UI**: replace the boxy Win32 look with a flat, rounded, dark "web-app" interface (GDI+ custom drawing).
Notes before you start:
- All snippets target your current `main.cpp` / `transcriber.cpp` / `transcriber.h`.
- I can't compile on my side — integrate a piece at a time and build often. If a `DWMWA_*` or glyph constant is missing, see the `#define` block in §2.3.
- Part 2's `LayoutControls()` (§2.12) is the final layout and supersedes any basic one from Part 1.
---
# Part 1 — Critical Fixes
## 1.1 The Stop crash (null Whisper context)
**Symptom:** record + VU work, then it crashes the moment you hit Stop.
**Root cause:** two defects combine.
1. In `wWinMain`, the preload thread sets `g_modelLoaded = true` *even when `preload()` returns false* (model file not found). So the UI shows "Ready" and lets you record with no model loaded.
2. `transcribe_worker()` calls `whisper_reset_timings(m_ctx)` **before** the `if (m_ctx)` check. With `m_ctx == nullptr` that's an immediate access violation. Recording never touches Whisper (that's why capture + VU work) — the crash only fires when transcription starts.
The empty model dropdown in your screenshot confirms it: `RefreshModelList()` found no model files, so `preload()` failed silently.
### Fix A — make the worker null-safe (`transcriber.cpp`)
Replace the top of `transcribe_worker` and delete the `whisper_reset_timings` call entirely (it isn't needed for one-shot transcription):
```cpp
void Transcriber::transcribe_worker(std::vector<float> audio) {
// Hard guard: if the model failed to load, never touch whisper.
if (!m_ctx) {
m_busy = false;
if (m_on_result) m_on_result(""); // UI will show "No speech / not loaded"
return;
}
if (m_cfg.trim_silence) trim_silence(audio);
whisper_full_params wp = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
wp.print_progress = false; wp.print_realtime = false; wp.print_timestamps = false;
wp.no_timestamps = true; wp.translate = false;
wp.language = m_cfg.language.c_str();
wp.n_threads = m_cfg.n_threads;
wp.no_context = true; wp.suppress_blank = true; wp.suppress_nst = true;
wp.temperature = 0.0f;
// (whisper_reset_timings removed — it was the crash site and is unnecessary)
std::string out;
if (whisper_full(m_ctx, wp, audio.data(), (int)audio.size()) == 0) {
int n = whisper_full_n_segments(m_ctx);
for (int i = 0; i < n; ++i) {
const char* t = whisper_full_get_segment_text(m_ctx, i);
if (t) out += t;
}
out = clean_text(out);
}
m_busy = false;
if (m_on_result) m_on_result(out);
}
```
### Fix B — track load success honestly (`main.cpp`)
Add an atomic next to `g_modelLoaded`:
```cpp
std::atomic<bool> g_modelLoaded{false};
std::atomic<bool> g_modelOk{false};
```
Fix the preload lambda so failure is recorded:
```cpp
std::thread([] {
bool ok = g_tx.preload(g_config); // false if the .bin isn't found
g_modelOk = ok;
g_modelLoaded = true;
}).detach();
```
### Fix C — refuse to record without a model (`main.cpp`, `WM_HOTKEY`)
This both prevents the crash path and tells you *why* nothing happens:
```cpp
case WM_HOTKEY:
if (wParam == HK_TOGGLE) {
if (g_tx.is_busy()) break; // mid-transcription: ignore
if (!g_tx.is_recording()) {
if (!g_modelLoaded.load()) { SetStatus(hWnd, L"Loading model…"); break; }
if (!g_modelOk.load()) {
std::wstring m = L"Model not found:\n" + to_w(g_config.model_path)
+ L"\n\nPut the .bin there and restart.";
MessageBoxW(hWnd, m.c_str(), L"Dictation", MB_OK | MB_ICONWARNING);
break;
}
g_prevForeground = GetForegroundWindow();
ShowWindow(hWnd, SW_SHOWNA);
g_recordingSecs = 0;
if (g_tx.start_recording()) {
SetDlgItemText(hWnd, ID_BTN_RECORD, L"■ Stop");
SetStatus(hWnd, L"Recording…");
} else SetStatus(hWnd, L"Microphone error");
} else {
g_tx.stop_and_transcribe();
SetDlgItemText(hWnd, ID_BTN_RECORD, L"Record");
SetStatus(hWnd, L"Transcribing…");
}
} else if (wParam == HK_HIDE) {
if (g_tx.is_recording()) g_tx.cancel();
ShowWindow(hWnd, SW_HIDE);
}
break;
```
After this, a missing model shows **"Model not found: …\models\ggml-tiny.en.bin"** instead of crashing.
## 1.2 Verify the model file
The app now loads the model from a path relative to the **.exe**:
`exe_dir() + "\\models\\ggml-tiny.en.bin"`. Confirm it's actually there:
```
dir build\bin\Release\models
```
You should see `ggml-tiny.en.bin`. If it's missing, re-run `build.ps1`, or drop the `.bin` into that `models\` folder manually. (Your CMake post-build step copies `models/` next to the exe — if the source `models/` had no `.bin` at build time, nothing got copied.)
## 1.3 Resizable window
Two reasons it's frozen: the window style has no sizing border, and there's no `WM_SIZE` handler so controls never reflow.
### Window style (`main.cpp`, `CreateWindowExW`)
> If you do Part 2 Option A (§2.3), keep a normal frame as shown here. If you do the borderless Option B (appendix), that section replaces this.
```cpp
hMainWnd = CreateWindowExW(
WS_EX_TOPMOST,
L"WhisperDictationClass", L"Dictation",
WS_OVERLAPPEDWINDOW, // caption + sysmenu + THICKFRAME + min/max = resizable
CW_USEDEFAULT, CW_USEDEFAULT, 400, 340,
nullptr, nullptr, hInstance, nullptr);
```
### Handlers (`main.cpp`, `WndProc`)
```cpp
case WM_SIZE:
LayoutControls(hWnd, LOWORD(lParam), HIWORD(lParam)); // defined in §2.12
return 0;
case WM_GETMINMAXINFO:
((MINMAXINFO*)lParam)->ptMinTrackSize.x = 340;
((MINMAXINFO*)lParam)->ptMinTrackSize.y = 280;
return 0;
```
Control positions in `InitializeUI` no longer matter — `LayoutControls` owns geometry. Add one explicit call right after `ShowWindow` so the first frame is laid out:
```cpp
RECT rc; GetClientRect(hMainWnd, &rc);
LayoutControls(hMainWnd, rc.right, rc.bottom);
```
## 1.4 Model switching + correct thread count
Two real bugs you'll hit:
- The model dropdown only lists files that **exist**, so its selected index does **not** map to `kModelFiles[]`.
- `preload()` early-returns when `m_ctx` is already set, so switching models **never reloads**.
- The status bar shows `hardware_concurrency()` (4) instead of the threads actually used (2).
### transcriber.h — add a reload + a threads getter
```cpp
bool reload(const WhisperConfig& cfg); // free + re-init with a new model
int threads() const { return m_cfg.n_threads; }
```
### transcriber.cpp — implement reload
```cpp
bool Transcriber::reload(const WhisperConfig& cfg) {
if (m_recording.load() || m_busy.load()) return false; // not mid-use
if (m_worker.joinable()) m_worker.join();
if (m_ctx) { whisper_free(m_ctx); m_ctx = nullptr; }
return preload(cfg);
}
```
### main.cpp — track the real file paths the combo shows
Replace the static arrays + `RefreshModelList` + the `ID_COMBO_MODEL` handler:
```cpp
static const wchar_t* kModelNames[] = { L"tiny.en", L"tiny.en-q8_0", L"base.en-q5_1", L"base.en" };
static const char* kModelFiles[] = {
"models\\ggml-tiny.en.bin", "models\\ggml-tiny.en-q8_0.bin",
"models\\ggml-base.en-q5_1.bin", "models\\ggml-base.en.bin",
};
std::vector<std::string> g_modelComboPaths; // parallel to combo entries
void RefreshModelList(HWND hwnd) {
HWND hCombo = GetDlgItem(hwnd, ID_COMBO_MODEL);
SendMessage(hCombo, CB_RESETCONTENT, 0, 0);
g_modelComboPaths.clear();
std::string dir = exe_dir();
for (int i = 0; i < 4; ++i) {
std::string full = dir + "\\" + kModelFiles[i];
if (GetFileAttributesA(full.c_str()) != INVALID_FILE_ATTRIBUTES) {
SendMessage(hCombo, CB_ADDSTRING, 0, (LPARAM)kModelNames[i]);
g_modelComboPaths.push_back(kModelFiles[i]); // remember the real file
}
}
if (!g_modelComboPaths.empty()) SendMessage(hCombo, CB_SETCURSEL, 0, 0);
}
```
```cpp
case ID_COMBO_MODEL:
if (HIWORD(wParam) == CBN_SELCHANGE) {
int idx = (int)SendMessage(GetDlgItem(hWnd, ID_COMBO_MODEL), CB_GETCURSEL, 0, 0);
if (idx >= 0 && idx < (int)g_modelComboPaths.size()) {
g_config.model_path = exe_dir() + "\\" + g_modelComboPaths[idx];
g_modelLoaded = false; g_modelOk = false;
SetStatus(hWnd, L"Loading model…");
std::thread([] {
bool ok = g_tx.reload(g_config); // <- reload, not preload
g_modelOk = ok; g_modelLoaded = true;
}).detach();
}
}
break;
```
### Correct thread count in the status (`UpdateStatus`)
```cpp
} else if (g_modelLoaded.load()) {
if (!g_modelOk.load()) { SetStatus(hwnd, L"Model not found — check models folder"); return; }
swprintf_s(buf, L"Ready • %d threads", g_tx.threads());
SetStatus(hwnd, buf);
}
```
---
# Part 2 — Modern UI (flat, rounded, dark)
## 2.1 Design language
The "boxy" feeling comes from three things: the gray 3-D system buttons, the chunky title bar, and zero breathing room. We fix all three.
- **Palette** — near-black canvas, one elevated surface tone, a single indigo accent, hairline borders. (Linear/Raycast register.)
- **Shape** — everything rounded: 10px buttons/fields, 14px panels, rounded window corners.
- **Type** — Segoe UI Variable / Segoe UI, a clear size hierarchy, no monospace for the transcript (sans reads more "app").
- **Space** — 16px outer margin, 1012px gaps, the transcript is the hero and grows with the window.
- **Motion** — a soft pulse on the record button while recording; subtle hover/press on buttons.
| Token | Hex | Use |
|---|---|---|
| Bg | `#0F1115` | window canvas |
| Surface | `#181B22` | fields, transcript panel |
| SurfaceHi | `#20242D` | hover |
| Border | `#262B36` | 1px hairlines |
| Text | `#E7E9EE` | primary text |
| TextDim | `#9AA0AB` | status, placeholders |
| Accent | `#6E8BFF` | record idle, focus |
| AccentHi | `#5B7BFF` | accent hover |
| Danger | `#FF5C5C` | recording / stop |
| Good | `#46D39A` | VU meter |
## 2.2 How it's built
- **GDI+** does the drawing — it anti-aliases rounded rectangles and supports alpha, so we get smooth corners without Direct2D.
- **Owner-drawn child buttons** for Record / Copy / Paste / Pin (we paint them; Windows still gives us click + focus).
- **Parent-painted** background, transcript panel frame, and the VU meter (in `WM_PAINT`).
- **Native EDIT** stays for the transcript (so selection/scroll work) but flat-themed with padding; a STATIC shows placeholder text when empty.
- **Owner-drawn comboboxes** for mic/model so they match the dark theme.
- A tiny **button subclass** gives reliable hover.
This keeps real controls (accessibility, IME, scrolling) while looking custom.
## 2.3 Setup: GDI+ and a dark, rounded window (Option A — recommended)
Option A keeps the native frame but recolors it dark and rounds the corners — low-risk and modern. (Option B, fully borderless custom title bar, is in the appendix.)
At the top of `main.cpp`:
```cpp
#include <gdiplus.h>
#pragma comment(lib, "gdiplus.lib")
using namespace Gdiplus;
// DWM attributes (define in case your SDK headers are older)
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
#endif
#ifndef DWMWA_BORDER_COLOR
#define DWMWA_BORDER_COLOR 34
#endif
#ifndef DWMWA_CAPTION_COLOR
#define DWMWA_CAPTION_COLOR 35
#endif
#ifndef DWMWA_TEXT_COLOR
#define DWMWA_TEXT_COLOR 36
#endif
#ifndef DWMWA_WINDOW_CORNER_PREFERENCE
#define DWMWA_WINDOW_CORNER_PREFERENCE 33
#endif
#ifndef DWMWCP_ROUND
#define DWMWCP_ROUND 2
#endif
```
Start/stop GDI+ in `wWinMain`:
```cpp
ULONG_PTR g_gdipToken = 0;
// near the top of wWinMain, before creating the window:
GdiplusStartupInput gdipIn;
GdiplusStartup(&g_gdipToken, &gdipIn, nullptr);
// ... after the message loop, before return:
GdiplusShutdown(g_gdipToken);
```
After the window is created, theme the frame (Win11 honors all of these; Win10 ignores caption/border color but keeps dark mode):
```cpp
BOOL dark = TRUE;
DwmSetWindowAttribute(hMainWnd, DWMWA_USE_IMMERSIVE_DARK_MODE, &dark, sizeof(dark));
COLORREF cap = RGB(0x0F,0x11,0x15), bord = RGB(0x26,0x2B,0x36), txt = RGB(0xE7,0xE9,0xEE);
DwmSetWindowAttribute(hMainWnd, DWMWA_CAPTION_COLOR, &cap, sizeof(cap));
DwmSetWindowAttribute(hMainWnd, DWMWA_BORDER_COLOR, &bord, sizeof(bord));
DwmSetWindowAttribute(hMainWnd, DWMWA_TEXT_COLOR, &txt, sizeof(txt));
int corner = DWMWCP_ROUND;
DwmSetWindowAttribute(hMainWnd, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner));
```
## 2.4 Theme constants + fonts (`main.cpp`)
```cpp
// GDI+ colors
static const Color C_BG (255,0x0F,0x11,0x15);
static const Color C_SURFACE (255,0x18,0x1B,0x22);
static const Color C_SURFACEHI(255,0x20,0x24,0x2D);
static const Color C_BORDER (255,0x26,0x2B,0x36);
static const Color C_TEXT (255,0xE7,0xE9,0xEE);
static const Color C_TEXTDIM (255,0x9A,0xA0,0xAB);
static const Color C_ACCENT (255,0x6E,0x8B,0xFF);
static const Color C_ACCENTHI (255,0x5B,0x7B,0xFF);
static const Color C_DANGER (255,0xFF,0x5C,0x5C);
static const Color C_GOOD (255,0x46,0xD3,0x9A);
// COLORREF mirrors for GDI (WM_CTLCOLOR*)
#define CR_BG RGB(0x0F,0x11,0x15)
#define CR_SURFACE RGB(0x18,0x1B,0x22)
#define CR_TEXT RGB(0xE7,0xE9,0xEE)
HFONT g_fUI=nullptr, g_fUISemi=nullptr, g_fSmall=nullptr, g_fText=nullptr;
HBRUSH g_brBg=nullptr, g_brSurface=nullptr;
static HFONT MakeFont(int px, int weight) {
return CreateFontW(-px,0,0,0,weight,FALSE,FALSE,FALSE,DEFAULT_CHARSET,
OUT_DEFAULT_PRECIS,CLIP_DEFAULT_PRECIS,CLEARTYPE_QUALITY,
DEFAULT_PITCH|FF_DONTCARE,L"Segoe UI Variable Display");
}
// build once in wWinMain:
// g_fUI = MakeFont(15, FW_NORMAL); g_fUISemi = MakeFont(15, FW_SEMIBOLD);
// g_fSmall = MakeFont(12, FW_NORMAL); g_fText = MakeFont(16, FW_NORMAL);
// g_brBg = CreateSolidBrush(CR_BG); g_brSurface = CreateSolidBrush(CR_SURFACE);
// (If "Segoe UI Variable Display" is unavailable it falls back to Segoe UI.)
```
## 2.5 GDI+ helpers
```cpp
static void RoundPath(GraphicsPath& p, const Rect& r, int rad) {
int d = rad * 2;
p.AddArc(r.X, r.Y, d, d, 180, 90);
p.AddArc(r.GetRight()-d, r.Y, d, d, 270, 90);
p.AddArc(r.GetRight()-d, r.GetBottom()-d, d, d, 0, 90);
p.AddArc(r.X, r.GetBottom()-d, d, d, 90, 90);
p.CloseFigure();
}
static void FillRound(Graphics& g, const Color& c, const Rect& r, int rad) {
GraphicsPath p; RoundPath(p, r, rad); SolidBrush b(c); g.FillPath(&b, &p);
}
static void StrokeRound(Graphics& g, const Color& c, const Rect& r, int rad, REAL w=1.0f) {
GraphicsPath p; RoundPath(p, r, rad); Pen pen(c, w); g.DrawPath(&pen, &p);
}
static void DrawTextC(Graphics& g, const wchar_t* s, const Font& f, const Color& c,
const RectF& box, StringAlignment h, StringAlignment v) {
StringFormat sf; sf.SetAlignment(h); sf.SetLineAlignment(v);
sf.SetTrimming(StringTrimmingEllipsisCharacter);
SolidBrush b(c); g.DrawString(s, -1, &f, box, &sf, &b);
}
```
## 2.6 Reliable hover (button subclass)
Owner-draw buttons don't reliably get hover. This subclass tracks it and stores a 0/1 hover flag in the window's user data, repainting on enter/leave.
```cpp
LRESULT CALLBACK BtnProc(HWND h, UINT m, WPARAM w, LPARAM l, UINT_PTR, DWORD_PTR) {
if (m == WM_MOUSEMOVE) {
if (!GetWindowLongPtr(h, GWLP_USERDATA)) {
SetWindowLongPtr(h, GWLP_USERDATA, 1);
TRACKMOUSEEVENT t{ sizeof(t), TME_LEAVE, h, 0 };
TrackMouseEvent(&t);
InvalidateRect(h, nullptr, FALSE);
}
} else if (m == WM_MOUSELEAVE) {
SetWindowLongPtr(h, GWLP_USERDATA, 0);
InvalidateRect(h, nullptr, FALSE);
}
return DefSubclassProc(h, m, w, l); // needs <commctrl.h> (already included)
}
// after creating each owner-draw button:
// SetWindowSubclass(hBtn, BtnProc, 1, 0);
```
(Add `#pragma comment(lib, "comctl32.lib")` is already present; `SetWindowSubclass` lives in `<commctrl.h>`.)
## 2.7 The record button (hero, pulsing)
`ID_BTN_RECORD` is an owner-draw button drawn as a rounded pill: accent "● Record" when idle, danger "■ Stop" with a soft brightness pulse while recording. The dot/square are drawn shapes (no icon-font dependency).
```cpp
void DrawRecordButton(LPDRAWITEMSTRUCT d) {
Graphics g(d->hDC); g.SetSmoothingMode(SmoothingModeAntiAlias);
Rect rc(d->rcItem.left, d->rcItem.top,
d->rcItem.right-d->rcItem.left, d->rcItem.bottom-d->rcItem.top);
// paint the parent canvas in the corners first
SolidBrush bg(C_BG); g.FillRectangle(&bg, rc);
bool rec = g_tx.is_recording();
bool hover = GetWindowLongPtr(d->hwndItem, GWLP_USERDATA) != 0;
bool pressed = (d->itemState & ODS_SELECTED) != 0;
Color fill = rec ? C_DANGER : (hover ? C_ACCENTHI : C_ACCENT);
if (rec) { // gentle pulse
double ph = (GetTickCount() % 1400) / 1400.0;
int add = (int)(18 * (0.5 + 0.5 * sin(ph * 6.2831853)));
fill = Color(255, min(255,0xFF), min(255,0x5C+add), min(255,0x5C+add));
}
if (pressed) fill = Color(255, GetRValue(0)+ (BYTE)(fill.GetR()*0.85),
(BYTE)(fill.GetG()*0.85), (BYTE)(fill.GetB()*0.85));
Rect pill = rc; pill.Inflate(-1,-1);
FillRound(g, fill, pill, pill.Height/2); // full pill
// icon: filled circle (idle) or rounded square (recording)
int cx = pill.X + 22, cy = pill.Y + pill.Height/2;
SolidBrush white(Color(255,255,255,255));
if (rec) { Rect sq(cx-7, cy-7, 14, 14); FillRound(g, Color(255,255,255,255), sq, 3); }
else { g.FillEllipse(&white, cx-7, cy-7, 14, 14); }
Font f(d->hDC, g_fUISemi);
RectF tb((REAL)(pill.X+36), (REAL)pill.Y, (REAL)(pill.Width-44), (REAL)pill.Height);
DrawTextC(g, rec ? L"Stop" : L"Record", f, Color(255,255,255,255),
tb, StringAlignmentNear, StringAlignmentCenter);
}
```
While recording, repaint it each tick so the pulse animates — in `WM_TIMER`:
```cpp
if (g_tx.is_recording()) InvalidateRect(GetDlgItem(hWnd, ID_BTN_RECORD), nullptr, FALSE);
```
## 2.8 Flat buttons (Copy / Paste / Pin)
Generic owner-draw for secondary buttons — a flat surface chip that lifts on hover, accent text. Pin shows a filled dot when active.
```cpp
void DrawFlatButton(LPDRAWITEMSTRUCT d, const wchar_t* label, bool active=false) {
Graphics g(d->hDC); g.SetSmoothingMode(SmoothingModeAntiAlias);
Rect rc(d->rcItem.left, d->rcItem.top,
d->rcItem.right-d->rcItem.left, d->rcItem.bottom-d->rcItem.top);
SolidBrush bg(C_BG); g.FillRectangle(&bg, rc);
bool hover = GetWindowLongPtr(d->hwndItem, GWLP_USERDATA) != 0;
bool pressed = (d->itemState & ODS_SELECTED) != 0;
Rect chip = rc; chip.Inflate(-1,-1);
FillRound(g, pressed ? C_SURFACE : (hover ? C_SURFACEHI : C_SURFACE), chip, 10);
StrokeRound(g, C_BORDER, chip, 10, 1.0f);
Font f(d->hDC, g_fUI);
Color tc = active ? C_ACCENT : C_TEXT;
RectF tb((REAL)chip.X, (REAL)chip.Y, (REAL)chip.Width, (REAL)chip.Height);
DrawTextC(g, label, f, tc, tb, StringAlignmentCenter, StringAlignmentCenter);
}
```
In `WM_DRAWITEM`, route each control:
```cpp
case WM_DRAWITEM: {
LPDRAWITEMSTRUCT d = (LPDRAWITEMSTRUCT)lParam;
switch (d->CtlID) {
case ID_BTN_RECORD: DrawRecordButton(d); return TRUE;
case ID_BTN_COPY: DrawFlatButton(d, L"Copy"); return TRUE;
case ID_BTN_PASTE: DrawFlatButton(d, L"Paste"); return TRUE;
case ID_BTN_PIN: DrawFlatButton(d, g_pinned ? L"Pinned" : L"Pin", g_pinned); return TRUE;
case ID_COMBO_AUDIO:
case ID_COMBO_MODEL: DrawCombo(d); return TRUE; // §2.9
}
break;
}
```
Make Copy/Paste/Pin owner-draw too (add `BS_OWNERDRAW`) and subclass them for hover:
```cpp
// in InitializeUI, create with BS_OWNERDRAW, then:
SetWindowSubclass(hCopy, BtnProc, 1, 0);
SetWindowSubclass(hPaste, BtnProc, 1, 0);
SetWindowSubclass(hPin, BtnProc, 1, 0);
SetWindowSubclass(GetDlgItem(hwnd, ID_BTN_RECORD), BtnProc, 1, 0);
```
## 2.9 Mic & model selectors (dark combos)
Make both combos `CBS_DROPDOWNLIST | CBS_OWNERDRAWFIXED | CBS_HASSTRINGS`. Set row height once, then draw field + list items dark with a drawn chevron.
```cpp
case WM_MEASUREITEM: {
auto* mi = (LPMEASUREITEMSTRUCT)lParam;
if (mi->CtlType == ODT_COMBOBOX) mi->itemHeight = 26;
return TRUE;
}
```
```cpp
void DrawCombo(LPDRAWITEMSTRUCT d) {
Graphics g(d->hDC); g.SetSmoothingMode(SmoothingModeAntiAlias);
Rect rc(d->rcItem.left, d->rcItem.top,
d->rcItem.right-d->rcItem.left, d->rcItem.bottom-d->rcItem.top);
bool inField = (d->itemState & ODS_COMBOBOXEDIT) != 0; // the closed field
bool sel = (d->itemState & ODS_SELECTED) != 0;
SolidBrush bg(inField ? C_SURFACE : (sel ? C_SURFACEHI : C_SURFACE));
g.FillRectangle(&bg, rc);
if (inField) { Rect b=rc; b.Inflate(-1,-1); StrokeRound(g, C_BORDER, b, 9); }
wchar_t txt[256] = L"";
if ((int)d->itemID >= 0)
SendMessageW(d->hwndItem, CB_GETLBTEXT, d->itemID, (LPARAM)txt);
Font f(d->hDC, g_fUI);
RectF tb((REAL)rc.X+10, (REAL)rc.Y, (REAL)(rc.Width-28), (REAL)rc.Height);
DrawTextC(g, txt, f, C_TEXT, tb, StringAlignmentNear, StringAlignmentCenter);
if (inField) { // chevron
int cx = rc.X + rc.Width - 16, cy = rc.Y + rc.Height/2;
Pen pen(C_TEXTDIM, 1.6f);
g.DrawLine(&pen, cx-4, cy-2, cx, cy+2);
g.DrawLine(&pen, cx, cy+2, cx+4, cy-2);
}
}
```
Keep your existing `WM_CTLCOLORLISTBOX` returning the surface brush so the dropdown list background is dark too.
> Minimal-by-default option: hide both combos and reveal them only when a small "settings" toggle in the header is clicked — so the resting UI is just record + transcript. Skip if you'd rather keep them always visible.
## 2.10 Transcript field (flat panel + placeholder)
Keep the native multiline EDIT but make it flat: no client edge, surface background, light text, inner padding. Draw a rounded surface panel behind it in `WM_PAINT`; inset the EDIT a few px inside that panel so the rounded corners show.
Create it without `WS_EX_CLIENTEDGE` (flat) and give it inner margins:
```cpp
HWND hEdit = CreateWindowExW(0, L"EDIT", L"",
WS_CHILD | WS_VISIBLE | WS_VSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_READONLY,
0,0,0,0, hwnd, (HMENU)ID_EDIT_TEXT, hInst, nullptr);
SendMessage(hEdit, WM_SETFONT, (WPARAM)g_fText, TRUE);
SendMessage(hEdit, EM_SETMARGINS, EC_LEFTMARGIN|EC_RIGHTMARGIN, MAKELONG(10,10));
```
Theme it (you already have `WM_CTLCOLOREDIT` — point it at the surface):
```cpp
case WM_CTLCOLOREDIT: {
HDC hdc=(HDC)wParam; SetTextColor(hdc, CR_TEXT); SetBkColor(hdc, CR_SURFACE);
return (LRESULT)g_brSurface;
}
```
Placeholder via a STATIC shown only when empty (`ID_STATIC_PLACEHOLDER`), created after the edit so it sits on top:
```cpp
CreateWindowW(L"STATIC", L"Your transcription will appear here…",
WS_CHILD | WS_VISIBLE | SS_LEFT, 0,0,0,0, hwnd,
(HMENU)ID_STATIC_PLACEHOLDER, hInst, nullptr);
// font g_fText; color via WM_CTLCOLORSTATIC -> C_TEXTDIM on C_SURFACE
// toggle it:
void UpdatePlaceholder(HWND hwnd) {
bool empty = GetWindowTextLengthW(GetDlgItem(hwnd, ID_EDIT_TEXT)) == 0;
ShowWindow(GetDlgItem(hwnd, ID_STATIC_PLACEHOLDER), empty ? SW_SHOW : SW_HIDE);
}
// call after setting/clearing transcript text (WM_APP_RESULT, Clear).
```
## 2.11 Custom VU meter
Drop the `PROGRESS_CLASS` control; draw a row of rounded segments in `WM_PAINT` that light up green with the input level. Store its rect in a global from `LayoutControls`.
```cpp
RECT g_vuRect = {0,0,0,0};
float g_energy = 0.0f; // updated in WM_TIMER from g_tx.get_audio_energy()
void DrawVU(Graphics& g, const RECT& r, float level) {
const int N = 14, gap = 3;
int w = (r.right-r.left); int segW = (w - gap*(N-1)) / N;
int h = r.bottom - r.top;
int lit = (int)(level * N + 0.5f);
for (int i = 0; i < N; ++i) {
int x = r.left + i*(segW+gap);
Rect seg(x, r.top, segW, h);
Color c = (i < lit) ? C_GOOD : C_SURFACEHI;
FillRound(g, c, seg, 2);
}
}
```
In `WM_TIMER`, refresh the level and invalidate just the VU rect:
```cpp
g_energy = g_tx.is_recording() ? g_tx.get_audio_energy() : 0.0f;
InvalidateRect(hWnd, &g_vuRect, FALSE);
```
## 2.12 Responsive layout (final)
This is the single source of truth for geometry — referenced by §1.3.
```cpp
void LayoutControls(HWND h, int W, int H) {
const int M = 16, row = 36, gap = 10;
int x = M, y = M, innerW = W - 2*M;
// Row 1: Record (left, prominent) + Pin (right)
int pinW = 78, recW = innerW - pinW - gap;
MoveWindow(GetDlgItem(h, ID_BTN_RECORD), x, y, recW, row, TRUE);
MoveWindow(GetDlgItem(h, ID_BTN_PIN), x + recW + gap, y, pinW, row, TRUE);
// Row 2: VU meter (custom-painted) — reserve a slim strip
y += row + gap;
g_vuRect = { x, y, x + innerW, y + 8 };
// Row 3: status line
y += 8 + gap;
MoveWindow(GetDlgItem(h, ID_STATIC_STATUS), x, y, innerW, 18, TRUE);
// Row 4: transcript panel (hero) — fills the middle
y += 18 + gap;
int bottom = (row + gap) * 2; // selectors row + copy/paste row
int panelTop = y;
int textH = H - y - M - bottom;
if (textH < 70) textH = 70;
// EDIT inset 10px inside the rounded panel drawn in WM_PAINT
MoveWindow(GetDlgItem(h, ID_EDIT_TEXT), x+10, panelTop+10, innerW-20, textH-20, TRUE);
MoveWindow(GetDlgItem(h, ID_STATIC_PLACEHOLDER), x+16, panelTop+16, innerW-32, 22, TRUE);
// Row 5: mic + model selectors
y += textH + gap;
int halfW = (innerW - gap) / 2;
MoveWindow(GetDlgItem(h, ID_COMBO_AUDIO), x, y, halfW, row, TRUE);
MoveWindow(GetDlgItem(h, ID_COMBO_MODEL), x + halfW + gap, y, halfW, row, TRUE);
// Row 6: Copy + Paste
y += row + gap;
MoveWindow(GetDlgItem(h, ID_BTN_COPY), x, y, halfW, row, TRUE);
MoveWindow(GetDlgItem(h, ID_BTN_PASTE), x + halfW + gap, y, halfW, row, TRUE);
// remember the transcript panel rect for WM_PAINT
extern RECT g_panelRect; g_panelRect = { x, panelTop, x+innerW, panelTop+textH };
InvalidateRect(h, nullptr, FALSE);
}
```
## 2.13 Putting it together (paint + flicker-free)
Flicker-free background and the painted bits (canvas, transcript panel frame, VU):
```cpp
RECT g_panelRect = {0,0,0,0};
case WM_ERASEBKGND: return 1; // we paint everything in WM_PAINT
case WM_PAINT: {
PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps);
RECT rc; GetClientRect(hWnd, &rc);
// double buffer
HDC mem = CreateCompatibleDC(hdc);
HBITMAP bmp = CreateCompatibleBitmap(hdc, rc.right, rc.bottom);
HBITMAP old = (HBITMAP)SelectObject(mem, bmp);
{
Graphics g(mem); g.SetSmoothingMode(SmoothingModeAntiAlias);
SolidBrush bg(C_BG); g.FillRectangle(&bg, 0,0,rc.right,rc.bottom);
// transcript panel
Rect panel(g_panelRect.left, g_panelRect.top,
g_panelRect.right-g_panelRect.left, g_panelRect.bottom-g_panelRect.top);
FillRound(g, C_SURFACE, panel, 14);
StrokeRound(g, C_BORDER, panel, 14, 1.0f);
// VU
DrawVU(g, g_vuRect, g_energy);
}
BitBlt(hdc, 0,0, rc.right, rc.bottom, mem, 0,0, SRCCOPY);
SelectObject(mem, old); DeleteObject(bmp); DeleteDC(mem);
EndPaint(hWnd, &ps);
return 0;
}
```
> Child controls (EDIT, combos, buttons) paint themselves on top of this — they're opaque where they sit, so the panel fill shows only in its 10px inset border. Because `WM_ERASEBKGND` returns 1 and we double-buffer, there's no flicker on resize.
`WM_CTLCOLORSTATIC` should return the bg brush for the status line and the surface brush for the placeholder:
```cpp
case WM_CTLCOLORSTATIC: {
HDC hdc=(HDC)wParam;
if (GetDlgCtrlID((HWND)lParam) == ID_STATIC_PLACEHOLDER) {
SetTextColor(hdc, RGB(0x9A,0xA0,0xAB)); SetBkColor(hdc, CR_SURFACE);
return (LRESULT)g_brSurface;
}
SetTextColor(hdc, CR_TEXT); SetBkColor(hdc, CR_BG);
return (LRESULT)g_brBg;
}
```
Define `#define ID_STATIC_PLACEHOLDER 1014` with your other IDs, and remember to call `UpdatePlaceholder(hWnd)` after `WM_APP_RESULT` sets text and after Clear.
---
# Part 3 — Build & checklist
- **Link GDI+:** the `#pragma comment(lib,"gdiplus.lib")` covers MSVC; no CMake change needed. (`comctl32` for `SetWindowSubclass` is already linked.)
- **Build:** `cmake --build build --config Release` as before.
- **Verify, in order:**
- [ ] Launch → window has rounded corners + dark title bar, no gray 3-D buttons.
- [ ] Record pill fills accent; hover lightens it; recording turns it red and it pulses; VU segments track your voice.
- [ ] Stop → transcript appears in the flat panel; placeholder hides; "Copied/Pasted".
- [ ] **No crash on Stop.** If you see "Model not found", fix the path (§1.2).
- [ ] Drag the window edges → it resizes and the transcript grows; min size respected.
- [ ] Switch model in the dropdown → status shows "Loading…" then "Ready • 2 threads"; next transcription uses it.
- **OS notes:** caption color / rounded corners need Windows 11 (build 22000+). On Windows 10 you still get dark mode (immersive) but square corners and the default caption color — everything else is identical since it's all custom-drawn.
---
# Appendix — Option B: fully borderless custom title bar (advanced)
If you want **zero** system chrome (no native title bar at all), use the "extend client over the caption" technique instead of Option A's recoloring. Keep `WS_OVERLAPPEDWINDOW` (so resize/snap/shadow/rounding stay native) and reclaim the caption area:
```cpp
case WM_NCCALCSIZE:
if (wParam) {
NCCALCSIZE_PARAMS* p = (NCCALCSIZE_PARAMS*)lParam;
int top = p->rgrc[0].top;
LRESULT r = DefWindowProc(hWnd, message, wParam, lParam); // default frame calc
p->rgrc[0].top = top; // give the caption height back to the client area
return r;
}
break;
case WM_NCHITTEST: {
LRESULT ht = DefWindowProc(hWnd, message, wParam, lParam); // handles resize edges
if (ht != HTCLIENT) return ht;
POINT pt{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
ScreenToClient(hWnd, &pt);
const int HEADER = 40;
if (pt.y < HEADER) {
// exclude your custom min/close/pin hit-rects here, else:
return HTCAPTION; // drag region
}
return HTCLIENT;
}
```
Then draw your own header (title text on the left, custom min/close buttons on the right) in `WM_PAINT`, and shift `LayoutControls`' starting `y` down by `HEADER`. This is more finicky across DPI/OS — only take it on if Option A's dark caption isn't minimal enough for you.