# 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 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 g_modelLoaded{false}; std::atomic 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 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, 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`: ```cpp #include #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 (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 ``.) ## 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.