# 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, 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 <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.



