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

1085 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ≈ 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.*