46 KiB
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:
- Delete the
UpdateStatusfunction and its forward declaration. - Delete the
UpdateStatus(hWnd);call at the end of theID_TIMER_UPDATEhandler. SetStatusremains exactly as-is: transient override + 2.5 s expiry, consumed byPaintSurface'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 usesWM_CTLCOLOREDIT. You handle both with the same dark brush, so the look is unchanged. - Add an
EN_CHANGEcase 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 GetWindowTextLengthW → append_transcript → SetWindowTextW 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+Zremoves 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)
#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", globalUsageStats g_stats;- Startup (next to
LoadTiming):LoadStats(g_stats); - In
WM_APP_RESULT, inside the success branch (res && !res->empty()), next to the existingg_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 Clear — OnClick, 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 exit — WM_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;
F5.3 — Settings layout (fixed header + footer, scrollable middle)
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 theID_SEL_MODELbranch there, theWK::SelModelwidget, itsDrawSelectSurfacecase andOnClickcase. (Mic + History keep using the popup.) - Recording while Settings is open: at the top of the
WM_HOTKEYstart branch addif (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
- Click Copy → "Copied" stays visible ~2.5 s (not one flicker).
- 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.
mediumis 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_PROXYhonors 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_DRAWITEMpaths) 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.