Files
win-dictate/Win-Dictation-features-01.md
T

46 KiB
Raw Blame History

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:

                    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:

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:

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:

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:

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:

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.

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:

        } 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:

        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:
        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:

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():

                    {
                        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 GetWindowTextLengthWappend_transcriptSetWindowTextW sequence) with:

                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.cppleave the file alone, tests/test_core.cpp still exercises it.


F3. Usage statistics

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

#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(...):
                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)

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)

#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:

#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 ClearOnClick, WK::Clear:

        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 exitWM_DESTROY, before PersistNow():

        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):

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):

    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:

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

Click (in OnClick):

        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):

            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

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:

    // 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):

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):

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;
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:

        if (g_view == View::Settings) { PaintSettings(g, hwnd, W, H); }
        else { /* existing card + widgets + strip + status + placeholder */ }
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:

        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;
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

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)

#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

#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:

    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):

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):

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):

        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:

    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 ≈ 24 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.