# Win Dictation — Feature Pack 01

**Settings view · Model downloader · Progress "still working" pulse · Usage statistics · Persistent history · Editable transcript with insert-at-cursor**

**Applies to:** the current working build (single-surface UI, cached GDI+ fonts, self-calibrating progress — i.e. after Fix Notes 01 & 02).
**Convention:** this is a new companion document; prior docs are unchanged.

---

## 0. Scope & build order

Six features, ordered so each step compiles and is testable on its own:

| Step | Feature | Size | Depends on |
|---|---|---|---|
| G0 | Groundwork fixes (status override conflict, model catalog table, popup anchoring) | small | — |
| F1 | Progress bar "still working" pulse at 95% | tiny | — |
| F2 | Editable transcript + insert-at-cursor | small | — |
| F3 | Usage statistics (tracking + persistence) | small | — |
| F4 | Persistent history (archive on Clear, reload popup) | medium | G0 (popup anchoring) |
| F5 | Settings view (cog, back/save/cancel, scroll, model selection moves here, stats display) | large | G0, F3 |
| F6 | Model downloader (WinHTTP, progress, cancel) | large | F5 |

New files: `src/stats.h`, `src/history.h`, `src/downloader.h`. All header-only, matching the project style. One new system lib: **winhttp** (via `#pragma comment(lib, "winhttp.lib")` — MSVC honors the pragma, so no CMake change is strictly required; adding `winhttp` to `target_link_libraries` is fine too).

---

## G0. Groundwork fixes

### G0.1 — The status override is being stomped every 50 ms (fix before anything else)

`UpdateStatus` runs on every `ID_TIMER_UPDATE` tick and calls `SetStatus(...)` — but `SetStatus` is now the *transient override* setter. So 50 ms after you click Copy, `UpdateStatus` overwrites the "Copied" override with "Ready • 2 threads". Transient messages currently flash for one timer tick at most. The features below ("Model downloaded", "Loaded from history", "Settings saved") all rely on the override working.

`PaintSurface` already derives the recording/busy/ready/loading text itself, which makes `UpdateStatus` fully redundant:

1. **Delete the `UpdateStatus` function** and its forward declaration.
2. **Delete the `UpdateStatus(hWnd);` call** at the end of the `ID_TIMER_UPDATE` handler.
3. `SetStatus` remains exactly as-is: transient override + 2.5 s expiry, consumed by `PaintSurface`'s idle branch.

One more line while here — the busy branch of the timer invalidates only `g_vuRect`, but the status *text* sits below that rect. Make the busy tick repaint both:

```cpp
                    if (g_tx.is_busy()) {
                        ...
                        g_est.tick(dt, g_progressFrac, g_progressRemain);
                        InvalidateRect(hWnd, nullptr, FALSE);   // was &g_vuRect — text lives below the bar
                    }
```

### G0.2 — One model catalog table (replaces `kModelNames` / `kModelFiles`)

The downloader, the settings page, and `RefreshModelList` all need the same model list. Replace the two parallel arrays (and the hardcoded `i < 4` loop) with one table:

```cpp
struct ModelInfo {
    const wchar_t* display;    // shown in UI
    const char*    fileName;   // file in models\ AND the HuggingFace file name
    const wchar_t* sizeLabel;  // approximate download size
    const wchar_t* hint;       // speed/accuracy hint for this 2-core machine
};
static const ModelInfo kCatalog[] = {
    { L"tiny.en",       "ggml-tiny.en.bin",       L"~75 MB",  L"fastest" },
    { L"tiny.en-q8_0",  "ggml-tiny.en-q8_0.bin",  L"~42 MB",  L"fastest, smaller file" },
    { L"base.en-q5_1",  "ggml-base.en-q5_1.bin",  L"~59 MB",  L"good balance" },
    { L"base.en",       "ggml-base.en.bin",       L"~142 MB", L"more accurate" },
    { L"small.en-q5_1", "ggml-small.en-q5_1.bin", L"~182 MB", L"accurate — slow on this CPU" },
    { L"small.en",      "ggml-small.en.bin",      L"~466 MB", L"most accurate — slowest" },
};
static const int kCatalogCount = (int)std::size(kCatalog);
```

`RefreshModelList` becomes catalog-driven:

```cpp
void RefreshModelList(HWND hwnd) {
    g_modelItems.clear();
    g_modelComboPaths.clear();
    std::string dir = exe_dir();
    for (int i = 0; i < kCatalogCount; ++i) {
        std::string rel = std::string("models\\") + kCatalog[i].fileName;
        if (GetFileAttributesA((dir + "\\" + rel).c_str()) != INVALID_FILE_ATTRIBUTES) {
            g_modelItems.push_back(kCatalog[i].display);
            g_modelComboPaths.push_back(rel);
        }
    }
    g_modelSel = 0;
}
```

Download URL for entry *i* (same endpoint your download scripts use):
`https://huggingface.co/ggerganov/whisper.cpp/resolve/main/` + `kCatalog[i].fileName`.

### G0.3 — Popups currently anchor to invisible legacy children (fix before F4)

`ShowSelectPopup` positions itself with `GetWindowRect(GetDlgItem(owner, ctrlId))` — i.e. it anchors to the **hidden** legacy child buttons that `LayoutControls` still moves around. It works today by accident. The History widget (F4) has no child window at all, so switch the popup to an explicit client-space anchor:

```cpp
void ShowSelectPopup(HWND owner, int ctrlId, const std::vector<std::wstring>& items,
                     int sel, const RectF& anchor) {
    ... // class registration unchanged
    g_pop = { items, sel, -1, owner, ctrlId };
    POINT tl{ (LONG)anchor.X, (LONG)(anchor.Y + anchor.Height) };
    ClientToScreen(owner, &tl);
    int h = (int)items.size() * 30 + 6, wdt = (int)anchor.Width;
    HWND p = CreateWindowExW(WS_EX_TOOLWINDOW | WS_EX_TOPMOST, L"DictPopup", L"",
        WS_POPUP, tl.x, tl.y + 2, wdt, h, owner, nullptr, hInst, nullptr);
    ...
}
```

Call sites pass the painted widget rect, e.g. the mic select:

```cpp
case WK::SelAudio:
    ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel, g_w[(int)WK::SelAudio].r);
    break;
```

(This also frees you to delete the legacy hidden children + `LayoutControls` whenever you do the cleanup pass — the popups no longer need them.)

A small helper used by several features below:

```cpp
std::wstring GetEditText(HWND hwnd) {
    HWND e = GetDlgItem(hwnd, ID_EDIT_TEXT);
    int len = GetWindowTextLengthW(e);
    std::wstring s;
    if (len > 0) { s.resize(len + 1); int got = GetWindowTextW(e, &s[0], len + 1); s.resize(got); }
    return s;
}
```

---

## F1. Progress pulse at 95%

The estimator deliberately parks at 95% until the real result lands. Make that state visibly *alive*: pulse the unfilled tail of the bar.

```cpp
void DrawProgress(Graphics& g, const RECT& r, float frac) {
    Rect track(r.left, r.top, r.right - r.left, r.bottom - r.top);
    FillRound(g, C_SURFACEHI, track, 4);
    frac = frac < 0.0f ? 0.0f : (frac > 1.0f ? 1.0f : frac);
    int fullW = r.right - r.left;
    int w = (int)(fullW * frac);
    if (w > 0) { Rect fill(r.left, r.top, w, r.bottom - r.top); FillRound(g, C_ACCENT, fill, 4); }

    // NEW: while parked near 95%, breathe the remaining tail so "still working" is obvious
    if (frac >= 0.949f && frac < 1.0f) {
        double ph = (GetTickCount() % 1100) / 1100.0;
        BYTE a = (BYTE)(70 + 150 * (0.5 + 0.5 * sin(ph * 6.2831853)));
        Rect tail(r.left + w, r.top, fullW - w, r.bottom - r.top);
        FillRound(g, Color(a, 0x6E, 0x8B, 0xFF), tail, 4);   // accent at oscillating alpha
    }
}
```

And make the label honest in that state — in `PaintSurface`'s busy branch:

```cpp
        } else if (g_tx.is_busy()) {
            ...
            if (g_progressFrac >= 0.949f)
                swprintf_s(statusBuf, L"Transcribing %d:%02d  •  95%%  •  finishing…", mm, ss);
            else
                swprintf_s(statusBuf, L"Transcribing %d:%02d  •  %d%%  •  %ds left", mm, ss, pct, rem);
        }
```

No new timers needed: the busy path already repaints every 50 ms (G0.1's invalidate), which animates the pulse at 20 fps — plenty, and kind to the 2-core CPU.

---

## F2. Editable transcript + insert-at-cursor

### F2.1 — Make the EDIT editable

Remove `ES_READONLY` from the creation flags:

```cpp
        HWND hEdit = CreateWindowExW(0, L"EDIT", L"",
            WS_CHILD | WS_VISIBLE | WS_VSCROLL | ES_MULTILINE | ES_AUTOVSCROLL,
            0, 0, 0, 0, hMainWnd, (HMENU)ID_EDIT_TEXT, hInst, nullptr);
```

Two knock-on details, both already handled by existing code — verify, don't change:
- A read-only EDIT colors itself via `WM_CTLCOLORSTATIC`; an editable one uses `WM_CTLCOLOREDIT`. You handle both with the same dark brush, so the look is unchanged.
- Add an `EN_CHANGE` case so typing/deleting toggles the (hidden-STATIC) placeholder bookkeeping and lets History (F4) detect edits:

```cpp
        case WM_COMMAND:
            if (LOWORD(wParam) == ID_EDIT_TEXT && HIWORD(wParam) == EN_CHANGE) {
                g_editDirty = true;             // used by F4
                break;
            }
            switch (LOWORD(wParam)) { ... }     // existing
```

with a global `bool g_editDirty = false;`.

### F2.2 — Capture the insertion point when recording starts

Globals:

```cpp
DWORD g_insStart = 0, g_insEnd = 0;     // selection at the moment Record was pressed
```

In the `WM_HOTKEY` start branch, right before `g_tx.start_recording()`:

```cpp
                    {
                        DWORD s = 0, e = 0;
                        SendMessageW(GetDlgItem(hWnd, ID_EDIT_TEXT), EM_GETSEL, (WPARAM)&s, (LPARAM)&e);
                        g_insStart = s; g_insEnd = e;
                    }
```

`EM_GETSEL` works even when the EDIT doesn't currently have focus (it reports the last selection), so this is correct for both the on-window Record click and the global hotkey from another app.

### F2.3 — Insert at that point on result

Replace the append block in `WM_APP_RESULT` (the `GetWindowTextLengthW` → `append_transcript` → `SetWindowTextW` sequence) with:

```cpp
                HWND hEdit = GetDlgItem(hWnd, ID_EDIT_TEXT);
                std::wstring full = GetEditText(hWnd);
                // user may have edited (shortened) the text while transcribing — clamp
                DWORD s = std::min<DWORD>(g_insStart, (DWORD)full.size());
                DWORD e = std::min<DWORD>(g_insEnd,  (DWORD)full.size());
                std::wstring add = to_w(*res);
                bool needLead  = (s > 0)            && !iswspace(full[s - 1]);
                bool needTrail = (e < full.size())  && !iswspace(full[e]);
                std::wstring ins = (needLead ? L" " : L"") + add + (needTrail ? L" " : L"");
                SendMessageW(hEdit, EM_SETSEL, s, e);
                SendMessageW(hEdit, EM_REPLACESEL, TRUE, (LPARAM)ins.c_str());  // TRUE = undoable
                SendMessageW(hEdit, EM_SCROLLCARET, 0, 0);
                UpdatePlaceholder(hWnd);
```

Behavior this gives you, all standard editor semantics:
- Caret in the middle of existing text → dictation is inserted there, with smart spacing on both sides.
- A selection existed when Record was pressed → dictation **replaces** the selection.
- No caret ever placed → `(0,0)` → inserts at the start; after each insert the caret sits after the new text, so back-to-back dictations chain naturally.
- The insert is on the undo stack (`Ctrl+Z` removes a bad dictation).

`append_transcript` in `text_util.h` is no longer used by `main.cpp` — **leave the file alone**, `tests/test_core.cpp` still exercises it.

---

## F3. Usage statistics

### F3.1 — `src/stats.h` (new file)

```cpp
#pragma once
#include <windows.h>
#include <string>
#include <cwctype>

struct UsageStats {
    double totalAudioSec  = 0;   // audio dictated
    double totalProcSec   = 0;   // CPU time spent transcribing
    double totalWords     = 0;
    double totalClips     = 0;
    double longestClipSec = 0;
};

inline std::wstring StatsIniPath() {
    wchar_t buf[MAX_PATH]; GetModuleFileNameW(nullptr, buf, MAX_PATH);
    std::wstring p(buf); p = p.substr(0, p.find_last_of(L"\\/"));
    return p + L"\\win-dictation.ini";
}
inline void   StPut(const wchar_t* k, double v) {
    wchar_t b[64]; swprintf_s(b, L"%.3f", v);
    WritePrivateProfileStringW(L"stats", k, b, StatsIniPath().c_str());
}
inline double StGet(const wchar_t* k) {
    wchar_t out[64];
    GetPrivateProfileStringW(L"stats", k, L"0", out, 64, StatsIniPath().c_str());
    return wcstod(out, nullptr);
}
inline void LoadStats(UsageStats& s) {
    s.totalAudioSec = StGet(L"audioSec");  s.totalProcSec   = StGet(L"procSec");
    s.totalWords    = StGet(L"words");     s.totalClips     = StGet(L"clips");
    s.longestClipSec= StGet(L"longest");
}
inline void SaveStats(const UsageStats& s) {
    StPut(L"audioSec", s.totalAudioSec);   StPut(L"procSec", s.totalProcSec);
    StPut(L"words", s.totalWords);         StPut(L"clips", s.totalClips);
    StPut(L"longest", s.longestClipSec);
}

inline int CountWords(const std::wstring& s) {
    int n = 0; bool in = false;
    for (wchar_t c : s) { bool w = !iswspace(c); if (w && !in) ++n; in = w; }
    return n;
}
inline void RecordUsage(UsageStats& st, double audioSec, double procSec, const std::wstring& text) {
    st.totalAudioSec += audioSec;
    st.totalProcSec  += procSec;
    st.totalWords    += CountWords(text);
    st.totalClips    += 1;
    if (audioSec > st.longestClipSec) st.longestClipSec = audioSec;
    SaveStats(st);
}

inline std::wstring FormatHMS(double sec) {
    int s = (int)(sec + 0.5), h = s / 3600, m = (s % 3600) / 60; s %= 60;
    wchar_t b[64];
    if (h)      swprintf_s(b, L"%dh %dm", h, m);
    else if (m) swprintf_s(b, L"%dm %ds", m, s);
    else        swprintf_s(b, L"%ds", s);
    return b;
}
```

### F3.2 — Hooks in `main.cpp`

- `#include "stats.h"`, global `UsageStats g_stats;`
- Startup (next to `LoadTiming`): `LoadStats(g_stats);`
- In `WM_APP_RESULT`, inside the success branch (`res && !res->empty()`), next to the existing `g_timing.add_sample(...)`:

```cpp
                RecordUsage(g_stats, g_lastAudioLen, actual, to_w(*res));
```

(Use the same `actual` already computed for the timing sample; don't record on cancel/no-speech — the existing `!g_cancelRequested` guard around that block stays.)

### F3.3 — The display lines (rendered by the settings page, F5)

```cpp
std::vector<std::wstring> BuildStatsLines() {
    std::vector<std::wstring> out;
    wchar_t b[160];
    swprintf_s(b, L"Dictated      %s of audio across %d clips",
               FormatHMS(g_stats.totalAudioSec).c_str(), (int)g_stats.totalClips);
    out.push_back(b);
    double mins = g_stats.totalAudioSec / 60.0;
    swprintf_s(b, L"Words         %d   (%d wpm speaking)",
               (int)g_stats.totalWords, mins > 0.05 ? (int)(g_stats.totalWords / mins + 0.5) : 0);
    out.push_back(b);
    swprintf_s(b, L"Processing    %s total   (%.1fx real-time on this machine)",
               FormatHMS(g_stats.totalProcSec).c_str(),
               g_stats.totalProcSec > 0.5 ? g_stats.totalAudioSec / g_stats.totalProcSec : 0.0);
    out.push_back(b);
    swprintf_s(b, L"Longest clip  %s", FormatHMS(g_stats.longestClipSec).c_str());
    out.push_back(b);
    // the fun one: time saved vs typing the same words at 40 wpm
    double typingSec = (g_stats.totalWords / 40.0) * 60.0;
    double savedSec  = typingSec - g_stats.totalAudioSec;
    if (savedSec > 60)
        { swprintf_s(b, L"Time saved    ~%s vs typing at 40 wpm", FormatHMS(savedSec).c_str()); out.push_back(b); }
    return out;
}
```

---

## F4. Persistent history

**Model:** one UTF-8 text file per session in `history\` next to the exe — trivially robust (no separator parsing), chronological by filename, easy to cap. Archived automatically when you press **Clear** (and on exit), browsable from a **History** select on the main view.

### F4.1 — `src/history.h` (new file)

```cpp
#pragma once
#include <windows.h>
#include <string>
#include <vector>
#include <algorithm>

struct HistoryEntry { std::wstring path; std::wstring label; };

inline std::wstring HistoryDir() {
    wchar_t buf[MAX_PATH]; GetModuleFileNameW(nullptr, buf, MAX_PATH);
    std::wstring p(buf); p = p.substr(0, p.find_last_of(L"\\/"));
    return p + L"\\history";
}

inline bool WriteFileUtf8(const std::wstring& path, const std::wstring& text) {
    int n = WideCharToMultiByte(CP_UTF8, 0, text.c_str(), (int)text.size(), nullptr, 0, nullptr, nullptr);
    std::string u8(n, '\0');
    if (n) WideCharToMultiByte(CP_UTF8, 0, text.c_str(), (int)text.size(), &u8[0], n, nullptr, nullptr);
    HANDLE h = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
    if (h == INVALID_HANDLE_VALUE) return false;
    DWORD wr; const unsigned char bom[3] = {0xEF,0xBB,0xBF};
    WriteFile(h, bom, 3, &wr, nullptr);
    WriteFile(h, u8.data(), (DWORD)u8.size(), &wr, nullptr);
    CloseHandle(h);
    return true;
}

inline std::wstring ReadFileUtf8(const std::wstring& path) {
    HANDLE h = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);
    if (h == INVALID_HANDLE_VALUE) return L"";
    DWORD size = GetFileSize(h, nullptr), rd = 0;
    std::string u8(size, '\0');
    if (size) ReadFile(h, &u8[0], size, &rd, nullptr);
    CloseHandle(h);
    size_t off = (u8.size() >= 3 && (unsigned char)u8[0]==0xEF && (unsigned char)u8[1]==0xBB && (unsigned char)u8[2]==0xBF) ? 3 : 0;
    int n = MultiByteToWideChar(CP_UTF8, 0, u8.data()+off, (int)(u8.size()-off), nullptr, 0);
    std::wstring w(n, L'\0');
    if (n) MultiByteToWideChar(CP_UTF8, 0, u8.data()+off, (int)(u8.size()-off), &w[0], n);
    return w;
}

inline void PruneHistory(int keep) {
    std::vector<std::wstring> files;
    WIN32_FIND_DATAW fd;
    HANDLE h = FindFirstFileW((HistoryDir() + L"\\*.txt").c_str(), &fd);
    if (h == INVALID_HANDLE_VALUE) return;
    do { files.push_back(HistoryDir() + L"\\" + fd.cFileName); } while (FindNextFileW(h, &fd));
    FindClose(h);
    std::sort(files.begin(), files.end());                  // timestamp names → chronological
    for (int i = 0; i < (int)files.size() - keep; ++i) DeleteFileW(files[i].c_str());
}

inline std::wstring ArchiveSession(const std::wstring& text) {
    // skip empty / whitespace-only
    bool any = false; for (wchar_t c : text) if (!iswspace(c)) { any = true; break; }
    if (!any) return L"";
    CreateDirectoryW(HistoryDir().c_str(), nullptr);
    SYSTEMTIME t; GetLocalTime(&t);
    wchar_t name[64];
    swprintf_s(name, L"%04d-%02d-%02d_%02d%02d%02d.txt", t.wYear, t.wMonth, t.wDay, t.wHour, t.wMinute, t.wSecond);
    std::wstring path = HistoryDir() + L"\\" + name;
    WriteFileUtf8(path, text);
    PruneHistory(100);
    return path;
}

inline std::vector<HistoryEntry> LoadHistoryIndex() {
    std::vector<HistoryEntry> out;
    WIN32_FIND_DATAW fd;
    HANDLE h = FindFirstFileW((HistoryDir() + L"\\*.txt").c_str(), &fd);
    if (h == INVALID_HANDLE_VALUE) return out;
    do {
        HistoryEntry e;
        e.path = HistoryDir() + L"\\" + fd.cFileName;
        std::wstring stem(fd.cFileName);                    // "2026-06-11_143205.txt"
        stem = stem.substr(0, stem.find(L'.'));
        std::wstring preview = ReadFileUtf8(e.path).substr(0, 28);
        for (wchar_t& c : preview) if (c == L'\r' || c == L'\n') c = L' ';
        e.label = stem.substr(0, 10) + L" " + stem.substr(11, 2) + L":" + stem.substr(13, 2)
                + L"  —  " + preview + L"…";
        out.push_back(e);
    } while (FindNextFileW(h, &fd));
    FindClose(h);
    std::sort(out.begin(), out.end(), [](auto& a, auto& b){ return a.path > b.path; });  // newest first
    return out;
}
```

### F4.2 — Hooks in `main.cpp`

Globals + startup:

```cpp
#include "history.h"
std::vector<HistoryEntry> g_history;
std::wstring g_lastLoadedText;       // what we last put into the box (guards duplicate archives)
#define ID_SEL_HISTORY 1019
// startup, near LoadStats:
g_history = LoadHistoryIndex();
```

**Archive on Clear** — `OnClick`, `WK::Clear`:

```cpp
        case WK::Clear: {
            std::wstring cur = GetEditText(hWnd);
            if (!cur.empty() && cur != g_lastLoadedText)
                ArchiveSession(cur);
            g_history = LoadHistoryIndex();
            g_lastLoadedText.clear();
            g_editDirty = false;
            SetDlgItemText(hWnd, ID_EDIT_TEXT, L"");
            UpdatePlaceholder(hWnd);
            SetStatus(hWnd, L"Saved to history");
            break;
        }
```

**Archive on exit** — `WM_DESTROY`, before `PersistNow()`:

```cpp
        case WM_DESTROY: {
            std::wstring cur = GetEditText(hWnd);
            if (!cur.empty() && cur != g_lastLoadedText) ArchiveSession(cur);
            PersistNow();
            PostQuitMessage(0);
            break;
        }
```

**The History widget** — extend the main-view enum (new kinds go **before** `Transcript` so the array sizing and the Fix-01 stamping loop keep working):

```cpp
enum class WK { RecordHero, Pin, SettingsCog, Copy, Paste, Clear, SelAudio, History, Transcript };
```

Layout (in `LayoutWidgets`): the selects row becomes mic on the left, History on the right — the slot the model select used to occupy (the model select moves to Settings in F5):

```cpp
    REAL halfW = (innerW - gap) / 2;
    g_w[(int)WK::SelAudio].r = RectF(x, y, halfW, row);
    g_w[(int)WK::History].r  = RectF(x + halfW + gap, y, halfW, row);
```

Paint (in `PaintSurface`'s widget switch): reuse the select look:

```cpp
                case WK::History:  DrawSelectSurface(g, w, L"History"); break;
```

Click (in `OnClick`):

```cpp
        case WK::History: {
            std::vector<std::wstring> items;
            for (auto& e : g_history) items.push_back(e.label);
            if (items.empty()) items.push_back(L"No history yet");
            ShowSelectPopup(hWnd, ID_SEL_HISTORY, items, -1, g_w[(int)WK::History].r);
            break;
        }
```

Selection (in `WM_APP_SELECT`, new branch):

```cpp
            else if (ctrlId == ID_SEL_HISTORY && idx >= 0 && idx < (int)g_history.size()) {
                std::wstring cur = GetEditText(hWnd);
                if (!cur.empty() && cur != g_lastLoadedText)
                    ArchiveSession(cur);                       // never lose the current text
                std::wstring text = ReadFileUtf8(g_history[idx].path);
                SetWindowTextW(GetDlgItem(hWnd, ID_EDIT_TEXT), text.c_str());
                g_lastLoadedText = text;                       // guard: re-archiving an untouched load = duplicate
                g_editDirty = false;
                g_history = LoadHistoryIndex();
                UpdatePlaceholder(hWnd);
                SetStatus(hWnd, L"Loaded from history");
            }
```

The `g_lastLoadedText` comparison is what stops you generating a duplicate archive every time you flip between two history entries without editing anything.

---

## F5. Settings view

**Approach:** a second *view* on the same painted surface — no dialogs, no new windows, fully consistent with the single-surface architecture. The transcript `EDIT` (the only real child) is hidden while Settings is open.

### F5.1 — View plumbing

```cpp
enum class View { Main, Settings };
View g_view = View::Main;

// staged (apply on Save, discard on Back/Cancel)
std::string g_pendingModelPath;

// settings layout state
int   g_setScroll = 0, g_setScrollMax = 0;
RectF g_sBack, g_sSave, g_sCancel;       // fixed header/footer chrome
RECT  g_sContent = {0,0,0,0};            // scrollable clip region
struct CatRowRect { RectF row, btn; };
CatRowRect g_catRect[kCatalogCount];
int   g_setHot = -1;   // -1 none, 0..N-1 row, 100+i btn, 200 back, 201 save, 202 cancel

void SwitchView(HWND hwnd, View v) {
    g_view = v;
    ShowWindow(GetDlgItem(hwnd, ID_EDIT_TEXT), v == View::Main ? SW_SHOW : SW_HIDE);
    if (v == View::Settings) {
        g_pendingModelPath = g_config.model_path;   // stage current selection
        g_setScroll = 0;
        RefreshCatalogStates();                     // F6; in F5-only builds this just checks files
    }
    RECT rc; GetClientRect(hwnd, &rc);
    LayoutWidgets(rc.right, rc.bottom);
    if (v == View::Settings) LayoutSettings(rc.right, rc.bottom);
    g_setHot = -1;
    InvalidateRect(hwnd, nullptr, FALSE);
}
```

### F5.2 — The cog on the main view

`WK::SettingsCog` was added to the enum in F4.2. Header row becomes `[Record][Pin][Cog]` with Pin and Cog as compact icon chips:

```cpp
    // LayoutWidgets, header row:
    REAL iconW = 44 * s;
    REAL recW  = innerW - 2 * (iconW + gap);
    g_w[(int)WK::RecordHero].r  = RectF(x, y, recW, row);
    g_w[(int)WK::Pin].r         = RectF(x + recW + gap, y, iconW, row);
    g_w[(int)WK::SettingsCog].r = RectF(x + recW + gap + iconW + gap, y, iconW, row);
```

Icons: add one cached icon font in `RebuildGdipFonts` (Segoe MDL2 Assets ships with Windows 10/11):

```cpp
Gdiplus::Font* g_gpIcon = nullptr;
// in RebuildGdipFonts():
delete g_gpIcon;
g_gpIcon = new Gdiplus::Font(L"Segoe MDL2 Assets", 15.0f * g_dpiScale, FontStyleRegular, UnitPixel);
if (g_gpIcon->GetLastStatus() != Ok) { delete g_gpIcon; g_gpIcon = nullptr; }  // fallback below
// and delete g_gpIcon in the shutdown block (before GdiplusShutdown)
```

A shared icon-chip drawer (replaces `DrawPinSurface`; also draws the cog):

```cpp
void DrawIconChip(Graphics& g, const Widget& w, const wchar_t* glyph,
                  const wchar_t* fallback, bool active) {
    Rect chip((int)w.r.X, (int)w.r.Y, (int)w.r.Width, (int)w.r.Height);
    chip.Inflate(-1, -1);
    float a = w.anim;
    if (a > 0.001f) {
        BYTE al = (BYTE)std::min(255, (int)(255 * a));
        FillRound(g, Color(al, T_CARD_HI.GetR(), T_CARD_HI.GetG(), T_CARD_HI.GetB()),
                  chip, (int)(10 * g_dpiScale));
    }
    Color tc = active ? T_ACCENT : (a > 0.01f ? T_TEXT : T_FAINT);
    RectF tb((REAL)chip.X, (REAL)chip.Y, (REAL)chip.Width, (REAL)chip.Height);
    if (g_gpIcon) DrawTextC(g, glyph,    *g_gpIcon, tc, tb, StringAlignmentCenter, StringAlignmentCenter);
    else          DrawTextC(g, fallback, *g_gpUI,   tc, tb, StringAlignmentCenter, StringAlignmentCenter);
}
// PaintSurface switch:
case WK::Pin:         DrawIconChip(g, w, L"", L"P",      g_pinned); break;  // MDL2 Pin
case WK::SettingsCog: DrawIconChip(g, w, L"", L"⚙", false);    break;  // MDL2 Settings / ⚙
// OnClick:
case WK::SettingsCog: SwitchView(hWnd, View::Settings); break;
```

### F5.3 — Settings layout (fixed header + footer, scrollable middle)

```cpp
void LayoutSettings(int W, int H) {
    float s = g_dpiScale;
    const REAL M = 16 * s, row = 36 * s, gap = 10 * s;
    g_sBack   = RectF(M, M, 90 * s, row);
    g_sSave   = RectF(M, H - M - row, (W - 2*M - gap) / 2, row);
    g_sCancel = RectF(M + (W - 2*M - gap)/2 + gap, H - M - row, (W - 2*M - gap)/2, row);
    g_sContent = { (int)M, (int)(M + row + gap), (int)(W - M), (int)(H - M - row - gap) };

    // scrollable content, in *content* coordinates offset by -g_setScroll
    REAL cy = (REAL)g_sContent.top - g_setScroll;
    REAL cw = (REAL)(g_sContent.right - g_sContent.left);
    cy += 22 * s;                                   // "MODEL" caption sits above first row
    for (int i = 0; i < kCatalogCount; ++i) {
        g_catRect[i].row = RectF(M, cy, cw, 34 * s);
        REAL bw = 112 * s;
        g_catRect[i].btn = RectF(M + cw - bw, cy + 3 * s, bw, 28 * s);
        cy += 40 * s;
    }
    cy += 26 * s;                                   // "STATISTICS" caption
    REAL statsTop = cy;
    cy += (REAL)BuildStatsLines().size() * 20 * s;
    g_statsTopY = statsTop;                         // global REAL, used by the painter
    int contentH = (int)(cy + g_setScroll) - g_sContent.top + (int)(8 * s);
    int viewH    = g_sContent.bottom - g_sContent.top;
    g_setScrollMax = std::max(0, contentH - viewH);
    if (g_setScroll > g_setScrollMax) g_setScroll = g_setScrollMax;
}
```

(Declare `REAL g_statsTopY = 0;` with the other settings globals. Call `LayoutSettings` from `WM_SIZE`/`WM_DPICHANGED` too, guarded by `if (g_view == View::Settings)`.)

### F5.4 — Settings painter

In `PaintSurface`, right after the background fill, branch:

```cpp
        if (g_view == View::Settings) { PaintSettings(g, hwnd, W, H); }
        else { /* existing card + widgets + strip + status + placeholder */ }
```

```cpp
void PaintSettings(Graphics& g, HWND hwnd, int W, int H) {
    float s = g_dpiScale;
    // header
    bool hb = (g_setHot == 200);
    if (hb) FillRound(g, T_CARD_HI, Rect((int)g_sBack.X,(int)g_sBack.Y,(int)g_sBack.Width,(int)g_sBack.Height), (int)(10*s));
    DrawTextC(g, L"←  Back", *g_gpUI, hb ? T_TEXT : T_DIM, g_sBack, StringAlignmentCenter, StringAlignmentCenter);
    RectF title((REAL)g_sContent.left, g_sBack.Y, (REAL)(g_sContent.right - g_sContent.left), g_sBack.Height);
    DrawTextC(g, L"Settings", *g_gpUISemi, T_TEXT, title, StringAlignmentCenter, StringAlignmentCenter);

    // scrollable middle
    g.SetClip(Rect(g_sContent.left, g_sContent.top,
                   g_sContent.right - g_sContent.left, g_sContent.bottom - g_sContent.top));
    RectF cap(g_catRect[0].row.X, g_catRect[0].row.Y - 20*s, 300*s, 18*s);
    DrawTextC(g, L"MODEL", *g_gpSmall, T_FAINT, cap, StringAlignmentNear, StringAlignmentNear);
    std::string dir = exe_dir();
    for (int i = 0; i < kCatalogCount; ++i) {
        const RectF& r = g_catRect[i].row;
        std::string rel = std::string("models\\") + kCatalog[i].fileName;
        bool installed = GetFileAttributesA((dir + "\\" + rel).c_str()) != INVALID_FILE_ATTRIBUTES;
        bool selected  = (g_pendingModelPath == dir + "\\" + rel);
        if (g_setHot == i && installed)
            FillRound(g, T_CARD_HI, Rect((int)r.X,(int)r.Y,(int)r.Width,(int)r.Height), (int)(9*s));
        // radio
        int rx = (int)(r.X + 12*s), ry = (int)(r.Y + r.Height/2);
        Pen ring(installed ? T_DIM : T_FAINT, 1.4f);
        g.DrawEllipse(&ring, rx-6, ry-6, 12, 12);
        if (selected) { SolidBrush dot(T_ACCENT); g.FillEllipse(&dot, rx-3, ry-3, 6, 6); }
        // name + size/hint
        RectF nameBox(r.X + 30*s, r.Y, 150*s, r.Height);
        DrawTextC(g, kCatalog[i].display, *g_gpUI, installed ? T_TEXT : T_DIM,
                  nameBox, StringAlignmentNear, StringAlignmentCenter);
        wchar_t meta[96]; swprintf_s(meta, L"%s  ·  %s", kCatalog[i].sizeLabel, kCatalog[i].hint);
        RectF metaBox(r.X + 30*s + 150*s, r.Y, r.Width - 30*s - 150*s - 120*s, r.Height);
        DrawTextC(g, meta, *g_gpSmall, T_FAINT, metaBox, StringAlignmentNear, StringAlignmentCenter);
        // action button (state text supplied by F6; without F6 just show Installed / Download-disabled)
        DrawCatalogButton(g, i, installed);
    }
    RectF scap(g_catRect[0].row.X, g_statsTopY - 20*s, 300*s, 18*s);
    DrawTextC(g, L"STATISTICS", *g_gpSmall, T_FAINT, scap, StringAlignmentNear, StringAlignmentNear);
    auto lines = BuildStatsLines();
    for (size_t i = 0; i < lines.size(); ++i) {
        RectF lr(g_catRect[0].row.X, g_statsTopY + (REAL)i * 20*s, (REAL)(g_sContent.right - g_sContent.left), 18*s);
        DrawTextC(g, lines[i].c_str(), *g_gpUI, T_DIM, lr, StringAlignmentNear, StringAlignmentNear);
    }
    g.ResetClip();

    // footer
    Rect sv((int)g_sSave.X,(int)g_sSave.Y,(int)g_sSave.Width,(int)g_sSave.Height);
    FillRound(g, g_setHot == 201 ? T_ACCENT_HI : T_ACCENT, sv, (int)(10*s));
    DrawTextC(g, L"Save", *g_gpUISemi, Color(255,255,255,255), g_sSave, StringAlignmentCenter, StringAlignmentCenter);
    if (g_setHot == 202)
        FillRound(g, T_CARD_HI, Rect((int)g_sCancel.X,(int)g_sCancel.Y,(int)g_sCancel.Width,(int)g_sCancel.Height), (int)(10*s));
    DrawTextC(g, L"Cancel", *g_gpUI, g_setHot == 202 ? T_TEXT : T_DIM, g_sCancel, StringAlignmentCenter, StringAlignmentCenter);
}
```

### F5.5 — Settings interaction (mouse routing + wheel)

At the **top** of the existing `WM_MOUSEMOVE`, `WM_LBUTTONDOWN`, `WM_LBUTTONUP` handlers, branch on view; add `WM_MOUSEWHEEL`:

```cpp
        case WM_MOUSEMOVE:
            if (g_view == View::Settings) {
                POINT p{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
                int hot = SettingsHitTest(p);
                if (hot != g_setHot) { g_setHot = hot; InvalidateRect(hWnd, nullptr, FALSE); }
                TRACKMOUSEEVENT t{ sizeof(t), TME_LEAVE, hWnd, 0 }; TrackMouseEvent(&t);
                return 0;
            }
            ... // existing main-view code
        case WM_LBUTTONDOWN:
            if (g_view == View::Settings) return 0;          // click handled on button-up
            ...
        case WM_LBUTTONUP:
            if (g_view == View::Settings) {
                POINT p{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
                OnSettingsClick(hWnd, p);
                return 0;
            }
            ...
        case WM_MOUSEWHEEL:
            if (g_view == View::Settings) {
                g_setScroll -= GET_WHEEL_DELTA_WPARAM(wParam) / 2;
                g_setScroll = std::max(0, std::min(g_setScroll, g_setScrollMax));
                RECT rc; GetClientRect(hWnd, &rc);
                LayoutSettings(rc.right, rc.bottom);
                InvalidateRect(hWnd, nullptr, FALSE);
            }
            return 0;
```

```cpp
static bool PtIn(const RectF& r, POINT p) { return r.Contains((REAL)p.x, (REAL)p.y); }

int SettingsHitTest(POINT p) {
    if (PtIn(g_sBack, p))   return 200;
    if (PtIn(g_sSave, p))   return 201;
    if (PtIn(g_sCancel, p)) return 202;
    if (!PtInRect(&g_sContent, p)) return -1;                // rows clipped by header/footer
    for (int i = 0; i < kCatalogCount; ++i) {
        if (PtIn(g_catRect[i].btn, p)) return 100 + i;
        if (PtIn(g_catRect[i].row, p)) return i;
    }
    return -1;
}

void OnSettingsClick(HWND hwnd, POINT p) {
    int hit = SettingsHitTest(p);
    if (hit == 200 || hit == 202) { SwitchView(hwnd, View::Main); return; }     // Back/Cancel = discard
    if (hit == 201)               { ApplySettings(hwnd); return; }              // Save
    if (hit >= 100 && hit < 100 + kCatalogCount) { OnCatalogButton(hwnd, hit - 100); return; }  // F6
    if (hit >= 0 && hit < kCatalogCount) {
        std::string full = exe_dir() + "\\models\\" + kCatalog[hit].fileName;
        if (GetFileAttributesA(full.c_str()) != INVALID_FILE_ATTRIBUTES) {
            g_pendingModelPath = full;
            InvalidateRect(hwnd, nullptr, FALSE);
        }
    }
}
```

### F5.6 — Save / Cancel semantics

```cpp
void ApplySettings(HWND hwnd) {
    if (!g_pendingModelPath.empty() && g_pendingModelPath != g_config.model_path) {
        g_config.model_path = g_pendingModelPath;
        SeedDefaults(g_timing, g_config.model_path);
        LoadTiming(g_timing, g_config.model_path);
        g_modelLoaded = false; g_modelOk = false;
        std::thread([] {
            bool ok = g_tx.reload(g_config);
            g_modelOk = ok; g_modelLoaded = true;
        }).detach();
        PersistNow();
        SetStatus(hwnd, L"Settings saved — loading model…");
    }
    SwitchView(hwnd, View::Main);
}
```

- **Back** and **Cancel** both discard the staged model selection (matches "if no changes, just close it" — and if there *were* staged changes, they're thrown away, which is what Cancel means). Downloads in progress are **not** cancelled by leaving the page — they're files arriving on disk, and finishing in the background is the right behavior.
- This replaces the model branch of `WM_APP_SELECT` — the model is no longer chosen via popup. Delete the `ID_SEL_MODEL` branch there, the `WK::SelModel` widget, its `DrawSelectSurface` case and `OnClick` case. (Mic + History keep using the popup.)
- **Recording while Settings is open:** at the top of the `WM_HOTKEY` start branch add `if (g_view == View::Settings) SwitchView(hWnd, View::Main);` so a global-hotkey dictation always lands on the main view.

---

## F6. Model downloader

### F6.1 — `src/downloader.h` (new file)

```cpp
#pragma once
#include <windows.h>
#include <winhttp.h>
#include <atomic>
#include <string>
#include <thread>
#include <vector>
#include <cstdio>
#include <algorithm>
#pragma comment(lib, "winhttp.lib")

#define WM_APP_DLPROGRESS (WM_USER + 7)   // wParam = catalog index; lParam = 0..100 pct, 101 done, -1 fail, -2 cancelled

struct Downloader {
    std::atomic<bool> active{false};
    std::atomic<bool> cancel{false};
    int itemIndex = -1;
    std::thread th;

    void start(HWND notify, int index, std::wstring url, std::wstring dest);
    void requestCancel() { cancel = true; }
    void join() { if (th.joinable()) th.join(); }
};

static void DlThread(HWND notify, int index, std::wstring url, std::wstring dest, Downloader* dl) {
    std::wstring tmp = dest + L".part";
    int result = -1;
    HINTERNET hSes = nullptr, hCon = nullptr, hReq = nullptr;
    FILE* f = nullptr;
    do {
        URL_COMPONENTS uc{}; uc.dwStructSize = sizeof(uc);
        wchar_t host[256] = {0}, path[2048] = {0};
        uc.lpszHostName = host; uc.dwHostNameLength = _countof(host);
        uc.lpszUrlPath  = path; uc.dwUrlPathLength  = _countof(path);
        if (!WinHttpCrackUrl(url.c_str(), 0, 0, &uc)) break;
        hSes = WinHttpOpen(L"win-dictation/1.0", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
                           WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
        if (!hSes) break;
        hCon = WinHttpConnect(hSes, host, uc.nPort, 0);
        if (!hCon) break;
        hReq = WinHttpOpenRequest(hCon, L"GET", path, nullptr, WINHTTP_NO_REFERER,
                                  WINHTTP_DEFAULT_ACCEPT_TYPES,
                                  (uc.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0);
        if (!hReq) break;
        // WinHTTP follows the HuggingFace -> CDN redirects automatically (https->https)
        if (!WinHttpSendRequest(hReq, WINHTTP_NO_ADDITIONAL_HEADERS, 0,
                                WINHTTP_NO_REQUEST_DATA, 0, 0, 0)) break;
        if (!WinHttpReceiveResponse(hReq, nullptr)) break;
        DWORD status = 0, sz = sizeof(status);
        WinHttpQueryHeaders(hReq, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
                            WINHTTP_HEADER_NAME_BY_INDEX, &status, &sz, WINHTTP_NO_HEADER_INDEX);
        if (status != 200) break;
        ULONGLONG total = 0;
        {
            wchar_t cl[40]; DWORD cls = sizeof(cl);
            if (WinHttpQueryHeaders(hReq, WINHTTP_QUERY_CONTENT_LENGTH,
                                    WINHTTP_HEADER_NAME_BY_INDEX, cl, &cls, WINHTTP_NO_HEADER_INDEX))
                total = (ULONGLONG)_wtoi64(cl);
        }
        if (_wfopen_s(&f, tmp.c_str(), L"wb") != 0 || !f) break;
        std::vector<char> buf(64 * 1024);
        ULONGLONG got = 0; int lastPct = -1; bool ioOk = true;
        for (;;) {
            if (dl->cancel.load()) { result = -2; ioOk = false; break; }
            DWORD avail = 0;
            if (!WinHttpQueryDataAvailable(hReq, &avail)) { ioOk = false; break; }
            if (avail == 0) break;                                          // complete
            DWORD toRead = std::min<DWORD>(avail, (DWORD)buf.size()), rd = 0;
            if (!WinHttpReadData(hReq, buf.data(), toRead, &rd) || rd == 0) { ioOk = false; break; }
            if (fwrite(buf.data(), 1, rd, f) != rd) { ioOk = false; break; }   // disk full etc.
            got += rd;
            int pct = total ? (int)(got * 100 / total) : 0;
            if (pct != lastPct) { lastPct = pct; PostMessage(notify, WM_APP_DLPROGRESS, index, pct); }
        }
        fclose(f); f = nullptr;
        if (ioOk && (total == 0 || got == total))
            if (MoveFileExW(tmp.c_str(), dest.c_str(), MOVEFILE_REPLACE_EXISTING))
                result = 101;
    } while (false);
    if (f) fclose(f);
    if (result != 101) DeleteFileW(tmp.c_str());
    if (hReq) WinHttpCloseHandle(hReq);
    if (hCon) WinHttpCloseHandle(hCon);
    if (hSes) WinHttpCloseHandle(hSes);
    PostMessage(notify, WM_APP_DLPROGRESS, index, result);
}

inline void Downloader::start(HWND notify, int index, std::wstring url, std::wstring dest) {
    if (active.exchange(true)) return;       // one at a time
    cancel = false; itemIndex = index;
    if (th.joinable()) th.join();
    th = std::thread(DlThread, notify, index, std::move(url), std::move(dest), this);
}
```

Key safety properties: the file lands as `*.part` and is renamed only on a verified-complete download, so `RefreshModelList` (which matches exact names) can never pick up a partial model; cancel/failure deletes the `.part`.

### F6.2 — State + wiring in `main.cpp`

```cpp
#include "downloader.h"
Downloader g_dl;
enum class DlState { NotInstalled, Downloading, Installed };
struct CatState { DlState state = DlState::NotInstalled; int pct = 0; };
CatState g_cat[kCatalogCount];

void RefreshCatalogStates() {
    std::string dir = exe_dir();
    for (int i = 0; i < kCatalogCount; ++i) {
        if (g_dl.active.load() && g_dl.itemIndex == i) { g_cat[i].state = DlState::Downloading; continue; }
        std::string full = dir + "\\models\\" + kCatalog[i].fileName;
        g_cat[i].state = (GetFileAttributesA(full.c_str()) != INVALID_FILE_ATTRIBUTES)
                       ? DlState::Installed : DlState::NotInstalled;
        g_cat[i].pct = 0;
    }
}
```

Startup hygiene (one-time, near `RefreshModelList()` in `wWinMain`): create the dir and delete any stray partials from a crashed run:

```cpp
    CreateDirectoryA((exe_dir() + "\\models").c_str(), nullptr);
    {
        WIN32_FIND_DATAA fd;
        HANDLE h = FindFirstFileA((exe_dir() + "\\models\\*.part").c_str(), &fd);
        if (h != INVALID_HANDLE_VALUE) {
            do { DeleteFileA((exe_dir() + "\\models\\" + fd.cFileName).c_str()); } while (FindNextFileA(h, &fd));
            FindClose(h);
        }
    }
```

**The catalog button** (drawer used by `PaintSettings`):

```cpp
void DrawCatalogButton(Graphics& g, int i, bool installed) {
    const RectF& b = g_catRect[i].btn;
    Rect br((int)b.X, (int)b.Y, (int)b.Width, (int)b.Height);
    bool hov = (g_setHot == 100 + i);
    wchar_t label[32]; Color tc = T_DIM;
    switch (g_cat[i].state) {
        case DlState::Installed:
            wcscpy_s(label, L"Installed"); tc = T_GOOD; break;
        case DlState::Downloading:
            swprintf_s(label, L"%d%%  ✕", g_cat[i].pct); tc = T_ACCENT; break;   // "57% ✕" = click to cancel
        default:
            wcscpy_s(label, L"Download"); tc = hov ? T_TEXT : T_DIM; break;
    }
    if (hov && g_cat[i].state != DlState::Installed)
        FillRound(g, T_CARD_HI, br, (int)(8 * g_dpiScale));
    DrawTextC(g, label, *g_gpSmall, tc, b, StringAlignmentCenter, StringAlignmentCenter);
}
```

**Button click** (called from `OnSettingsClick`):

```cpp
void OnCatalogButton(HWND hwnd, int i) {
    switch (g_cat[i].state) {
        case DlState::Installed: {
            std::string full = exe_dir() + "\\models\\" + kCatalog[i].fileName;
            g_pendingModelPath = full;                     // clicking "Installed" selects it too
            break;
        }
        case DlState::Downloading:
            g_dl.requestCancel();
            break;
        default: {
            if (g_dl.active.load()) { SetStatus(hwnd, L"One download at a time"); return; }
            std::wstring url  = L"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/"
                              + to_w(kCatalog[i].fileName);
            std::wstring dest = to_w(exe_dir()) + L"\\models\\" + to_w(kCatalog[i].fileName);
            g_cat[i].state = DlState::Downloading; g_cat[i].pct = 0;
            g_dl.start(hwnd, i, url, dest);
            break;
        }
    }
    InvalidateRect(hwnd, nullptr, FALSE);
}
```

**Progress handler** (new `WndProc` case):

```cpp
        case WM_APP_DLPROGRESS: {
            int idx = (int)wParam, code = (int)lParam;
            if (idx >= 0 && idx < kCatalogCount) {
                if (code >= 0 && code <= 100) {
                    g_cat[idx].state = DlState::Downloading; g_cat[idx].pct = code;
                } else {
                    g_dl.join(); g_dl.active = false;
                    if (code == 101) {
                        RefreshModelList(hWnd);
                        SetStatus(hWnd, L"Model downloaded");
                    } else {
                        SetStatus(hWnd, code == -2 ? L"Download cancelled" : L"Download failed");
                    }
                    RefreshCatalogStates();
                }
            }
            InvalidateRect(hWnd, nullptr, FALSE);
            return 0;
        }
```

**Shutdown** — after the message loop, before GDI+ teardown:

```cpp
    g_dl.requestCancel();
    g_dl.join();
```

---

## Verification checklist

**G0/F1 — status + pulse**
1. Click Copy → "Copied" stays visible ~2.5 s (not one flicker).
2. Dictate something long enough to under-predict → bar parks at 95% with a clearly breathing tail, status reads "… 95% • finishing…", then snaps to done.

**F2 — editing**
3. Type into the transcript; click mid-sentence; Record; speak; Stop → text inserted at that point with sensible spaces, caret after it. `Ctrl+Z` undoes the insert.
4. Select a word, Record, speak → dictation replaces the selection.
5. Delete most of the text *while* a transcription is running → result still inserts safely (clamped), no crash.

**F3 — stats**
6. After a few dictations, open Settings → Statistics shows totals; values survive an app restart (check `[stats]` in `win-dictation.ini`).

**F4 — history**
7. Dictate, press Clear → `history\YYYY-MM-DD_HHMMSS.txt` appears; History popup lists it newest-first with a preview.
8. Pick an entry → current text is auto-archived first, entry loads. Flip between two entries without editing → **no** duplicate files appear.
9. Quit with text in the box → archived on exit.

**F5 — settings**
10. Cog (top right) opens Settings; transcript hides; Back/Cancel discard a changed model selection; Save applies it ("Settings saved — loading model…", thread count returns when ready) and persists across restart.
11. Mouse wheel scrolls the middle section; rows never paint over header/footer.
12. Global hotkey while Settings open → returns to main view and starts recording.

**F6 — downloader**
13. Download `base.en` → live % on the row button, "Model downloaded" toast, row flips to Installed, model selectable immediately.
14. Cancel mid-download → no `.part` left in `models\`. Kill the app mid-download → stray `.part` removed on next launch.
15. Start a second download while one runs → "One download at a time".

## Notes & expectations

- **small.en on this machine:** the seeds say roughly 3× slower than real-time on the 2-core i5 — a 1-minute clip ≈ 2–4 minutes of processing. The timing model will learn the true figure after one run, so the progress bar stays honest. `medium` is deliberately not in the catalog; it's not a good experience on this hardware.
- **Disk:** small.en is ~466 MB — the catalog shows sizes so the user can judge.
- **Proxy/offline:** WinHTTP with `AUTOMATIC_PROXY` honors system proxy settings; with no network the download fails cleanly with the "Download failed" toast.
- **Cleanup unlock:** after G0.3 nothing depends on the hidden legacy child windows anymore (popups anchored to painted rects) — the dead-chrome deletion pass (hidden children, `LayoutControls`, `WM_DRAWITEM` paths) can happen any time.

---

*Companion to `UI-and-Progress-Rebuild.md`, `UI-Progress-Rebuild-Fix-01.md`, and `UI-Progress-Rebuild-Fix-02.md`. New document; prior documents unchanged.*
