31 KiB
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#defineblock 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.
- In
wWinMain, the preload thread setsg_modelLoaded = trueeven whenpreload()returns false (model file not found). So the UI shows "Ready" and lets you record with no model loaded. transcribe_worker()callswhisper_reset_timings(m_ctx)before theif (m_ctx)check. Withm_ctx == nullptrthat'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):
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:
std::atomic<bool> g_modelLoaded{false};
std::atomic<bool> g_modelOk{false};
Fix the preload lambda so failure is recorded:
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:
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.
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)
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:
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 whenm_ctxis 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
bool reload(const WhisperConfig& cfg); // free + re-init with a new model
int threads() const { return m_cfg.n_threads; }
transcriber.cpp — implement reload
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:
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);
}
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)
} 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, 10–12px 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:
#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:
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):
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)
// 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
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.
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).
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:
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.
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:
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:
// 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.
case WM_MEASUREITEM: {
auto* mi = (LPMEASUREITEMSTRUCT)lParam;
if (mi->CtlType == ODT_COMBOBOX) mi->itemHeight = 26;
return TRUE;
}
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:
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):
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:
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.
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:
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.
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):
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_ERASEBKGNDreturns 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:
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. (comctl32forSetWindowSubclassis already linked.) - Build:
cmake --build build --config Releaseas 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:
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.