21 KiB
Fix 03 — History drop-down: draw above everything + dynamic scrolling
Files touched: src/main.cpp only. No changes to history.h, CMakeLists.txt, or any other file.
Estimated effort: 1–2 hours including testing.
Convention reminder: this is a companion fix note (Fix-01, Fix-02, …) — do not edit older docs.
1. Why the drop-down draws behind the transcript box (read this first)
The history drop-down is currently painted onto the main window's surface inside
PaintSurface() (the block that starts if (g_histOpen >= 0 && g_view == View::Main)).
The transcript box, however, is not part of that painted surface. It is a real Win32
child window — the EDIT control with ID ID_EDIT_TEXT. Two Win32 rules make the current
approach impossible to fix by re-ordering paint calls:
- Child windows always render above the parent's client-area painting. Whatever the
parent draws in its own
WM_PAINTsits underneath every child HWND. There is no "draw later so it ends up on top" — the EDIT is simply not on our canvas. - The main window is created with
WS_CLIPCHILDREN(seeCreateWindowExWinwWinMain). That flag explicitly removes the EDIT's rectangle from the region the parent is even allowed to paint into. Our GDI+ drawing inside the EDIT's rect is silently clipped away. That is exactly why you only see the strip of drop-down in the gap below the EDIT in the screenshot.
A surface-painted drop-down also can never extend past the app window's edge, and we are re-implementing hover/scroll/click routing by hand. All three problems go away with the same fix.
2. The fix — strategy
Stop painting the drop-down on the surface. Use a real top-level popup window instead.
The app already contains exactly the right mechanism and we reuse it:
- The microphone selector already opens a small floating window of class
DictPopup(PopupProc+ShowSelectPopupinmain.cpp). It is created withWS_POPUP | WS_EX_TOPMOST | WS_EX_TOOLWINDOW, i.e. a top-level window that floats above the app, above the EDIT control, above everything, and is not clipped by the app window's edges at all. - The selection plumbing for history already exists: in
WndProc, theWM_APP_SELECThandler already has anID_SEL_HISTORYbranch that archives the current text and loads the chosen entry. It was wired up but never used. We will not touch it — we just finally route clicks into it.
So the work is:
- Upgrade the shared popup so it can scroll (max ~8 visible rows, mouse wheel, scrollbar thumb), positions itself above or below the anchor depending on screen space, and uses DPI-scaled row heights.
- Point the History button at
ShowSelectPopup(...)withID_SEL_HISTORY. - Delete every line of the old surface-painted drop-down.
Bonus: the mic selector automatically gains scrolling and screen-edge handling too.
3. Step 1 — Replace PopupState
In src/main.cpp, find this (one line plus the variable under it):
struct PopupState { std::vector<std::wstring> items; int sel; int hot; HWND owner; int ctrlId; };
static PopupState g_pop;
Replace it with:
struct PopupState {
std::vector<std::wstring> items;
int sel = -1; // index drawn in accent colour (current selection), -1 = none
int hot = -1; // ABSOLUTE index of the row under the mouse, -1 = none
HWND owner = nullptr;
int ctrlId = 0; // posted back in WM_APP_SELECT wParam
int scroll = 0; // index of the first visible row
int visRows = 0; // number of rows visible in the popup
int rowH = 30; // row height in px (DPI-scaled at open time)
int pad = 3; // inner padding in px
int wheelAccum = 0; // accumulates wheel deltas < 120 (trackpads)
};
static PopupState g_pop;
static const int kPopupMaxVisible = 8; // rows shown before the list scrolls
Note
hotand the value posted back are now absolute item indices (index into the full list), not visible-row indices. This matters once the list scrolls.
4. Step 2 — Replace PopupProc (and add one helper above it)
Find the existing LRESULT CALLBACK PopupProc(HWND h, UINT m, WPARAM w, LPARAM l) and
replace the entire function with the code below. Also add the small
PopupRowFromY helper immediately above PopupProc (it must be defined before it).
// Convert a client-area Y coordinate inside the popup to an ABSOLUTE item index.
// Returns -1 if the point is not on a row.
static int PopupRowFromY(int y) {
int row = (y - g_pop.pad) / g_pop.rowH; // visible row 0..visRows-1
if (row < 0 || row >= g_pop.visRows) return -1;
int abs = g_pop.scroll + row; // absolute item index
if (abs >= (int)g_pop.items.size()) return -1;
return abs;
}
LRESULT CALLBACK PopupProc(HWND h, UINT m, WPARAM w, LPARAM l) {
switch (m) {
case WM_MOUSEMOVE: {
int row = PopupRowFromY(GET_Y_LPARAM(l));
if (row != g_pop.hot) { g_pop.hot = row; InvalidateRect(h, nullptr, FALSE); }
TRACKMOUSEEVENT t{ sizeof(t), TME_LEAVE, h, 0 }; TrackMouseEvent(&t);
return 0;
}
case WM_MOUSELEAVE:
g_pop.hot = -1; InvalidateRect(h, nullptr, FALSE);
return 0;
case WM_MOUSEWHEEL: {
int maxScroll = (int)g_pop.items.size() - g_pop.visRows;
if (maxScroll <= 0) return 0; // everything fits: nothing to scroll
g_pop.wheelAccum += GET_WHEEL_DELTA_WPARAM(w);
int steps = g_pop.wheelAccum / WHEEL_DELTA; // whole notches only
if (steps != 0) {
g_pop.wheelAccum -= steps * WHEEL_DELTA;
g_pop.scroll -= steps * 3; // 3 rows per wheel notch
g_pop.scroll = std::max(0, std::min(g_pop.scroll, maxScroll));
POINT pt; GetCursorPos(&pt); ScreenToClient(h, &pt);
g_pop.hot = PopupRowFromY(pt.y); // keep hover correct after scroll
InvalidateRect(h, nullptr, FALSE);
}
return 0;
}
case WM_KEYDOWN:
if (w == VK_ESCAPE) DestroyWindow(h); // Esc closes the popup
return 0;
case WM_LBUTTONUP: {
int row = PopupRowFromY(GET_Y_LPARAM(l));
if (row >= 0)
PostMessageW(g_pop.owner, WM_APP_SELECT, (WPARAM)g_pop.ctrlId, (LPARAM)row);
DestroyWindow(h);
return 0;
}
case WM_ACTIVATE:
if (LOWORD(w) == WA_INACTIVE) DestroyWindow(h); // click-away closes
return 0;
case WM_ERASEBKGND: return 1;
case WM_PAINT: {
PAINTSTRUCT ps; HDC hdc = BeginPaint(h, &ps);
RECT rc; GetClientRect(h, &rc);
HDC mem = CreateCompatibleDC(hdc);
HBITMAP bmp = CreateCompatibleBitmap(hdc, rc.right, rc.bottom);
HBITMAP old = (HBITMAP)SelectObject(mem, bmp);
{
Graphics g(mem);
g.SetSmoothingMode(SmoothingModeAntiAlias);
g.SetTextRenderingHint(TextRenderingHintClearTypeGridFit);
Rect all(0, 0, rc.right, rc.bottom);
FillRound(g, T_CARD, all, 10);
StrokeRound(g, T_FAINT, all, 10, 1.0f);
int n = (int)g_pop.items.size();
bool hasBar = n > g_pop.visRows;
int barW = std::max(3, (int)(4 * g_dpiScale));
int rowW = rc.right - 2 * g_pop.pad - (hasBar ? barW + g_pop.pad : 0);
int last = std::min(n, g_pop.scroll + g_pop.visRows);
for (int i = g_pop.scroll; i < last; ++i) {
int vis = i - g_pop.scroll;
Rect row(g_pop.pad, g_pop.pad + vis * g_pop.rowH, rowW, g_pop.rowH - 2);
if (i == g_pop.hot) FillRound(g, T_CARD_HI, row, 7);
RectF tb((REAL)row.X + 9, (REAL)row.Y, (REAL)row.Width - 12, (REAL)row.Height);
DrawTextC(g, g_pop.items[i].c_str(), *g_gpUI,
(i == g_pop.sel) ? T_ACCENT : T_TEXT,
tb, StringAlignmentNear, StringAlignmentCenter);
}
if (hasBar) {
int trackH = rc.bottom - 2 * g_pop.pad;
int thumbH = std::max((int)(20 * g_dpiScale), trackH * g_pop.visRows / n);
int maxScroll = n - g_pop.visRows;
int thumbY = g_pop.pad + (trackH - thumbH) * g_pop.scroll / maxScroll;
Rect thumb(rc.right - g_pop.pad - barW, thumbY, barW, thumbH);
FillRound(g, T_DIM, thumb, barW / 2);
}
}
BitBlt(hdc, 0, 0, rc.right, rc.bottom, mem, 0, 0, SRCCOPY);
SelectObject(mem, old); DeleteObject(bmp); DeleteDC(mem);
EndPaint(h, &ps);
return 0;
}
}
return DefWindowProc(h, m, w, l);
}
Two deliberate changes from the old version, so you don't think something was lost:
- The old paint code built a
Font f(mem, g_fUI)every frame. We now use the cached*g_gpUIGDI+ font — same convention as Fix-02 (cachedg_gp*fonts everywhere). - The mouse-wheel handler works because the popup has keyboard focus
(
SetFocus(p)is called when it opens) — Windows deliversWM_MOUSEWHEELto the focused window. Don't remove theSetFocuscall in Step 3.
5. Step 3 — Replace ShowSelectPopup
Replace the entire existing ShowSelectPopup function with:
void ShowSelectPopup(HWND owner, int ctrlId, const std::vector<std::wstring>& items, int sel, const RectF& anchor) {
static bool reg = false;
if (!reg) {
WNDCLASSEXW wc{ sizeof(wc) };
wc.lpfnWndProc = PopupProc;
wc.hInstance = hInst;
wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
wc.hbrBackground = CreateSolidBrush(CR_SURFACE);
wc.lpszClassName = L"DictPopup";
RegisterClassExW(&wc);
reg = true;
}
if (items.empty()) return;
float s = g_dpiScale;
int rowH = (int)(30 * s);
int pad = (int)(3 * s); if (pad < 3) pad = 3;
int n = (int)items.size();
int visRows = std::min(n, kPopupMaxVisible);
g_pop = PopupState{}; // reset everything (incl. scroll/wheelAccum)
g_pop.items = items;
g_pop.sel = sel;
g_pop.owner = owner;
g_pop.ctrlId = ctrlId;
g_pop.visRows = visRows;
g_pop.rowH = rowH;
g_pop.pad = pad;
if (sel >= 0 && n > visRows) // scroll so the current selection is visible
g_pop.scroll = std::max(0, std::min(sel - visRows / 2, n - visRows));
int h = visRows * rowH + 2 * pad;
int wdt = std::max((int)anchor.Width, (int)(260 * s)); // never narrower than 260px
// Anchor rect is in CLIENT coordinates; convert its top-left to screen.
POINT tl{ (LONG)anchor.X, (LONG)anchor.Y };
ClientToScreen(owner, &tl);
int anchorTop = tl.y;
int anchorBottom = tl.y + (int)anchor.Height;
int x = tl.x;
// Place below the anchor if it fits on the monitor's work area, otherwise above.
HMONITOR mon = MonitorFromWindow(owner, MONITOR_DEFAULTTONEAREST);
MONITORINFO mi{ sizeof(mi) };
GetMonitorInfo(mon, &mi);
int y;
if (anchorBottom + 2 + h <= mi.rcWork.bottom) y = anchorBottom + 2; // open downward
else y = anchorTop - h - 2; // flip upward
if (y < mi.rcWork.top) y = mi.rcWork.top;
if (x + wdt > mi.rcWork.right) x = mi.rcWork.right - wdt;
if (x < mi.rcWork.left) x = mi.rcWork.left;
HWND p = CreateWindowExW(WS_EX_TOOLWINDOW | WS_EX_TOPMOST, L"DictPopup", L"",
WS_POPUP, x, y, wdt, h, owner, nullptr, hInst, nullptr);
if (!p) return;
int corner = DWMWCP_ROUND;
DwmSetWindowAttribute(p, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner));
ShowWindow(p, SW_SHOWNA);
SetFocus(p); // required: wheel + Esc are delivered to the focused window
}
Notes:
- The old debug
MessageBoxW("Popup failed: ...")is intentionally removed (it was scaffolding). Failure now just silently returns. - Because the popup is a top-level window, it may legitimately extend past the app window's edges — that is correct and expected. It only clamps to the monitor's work area.
- It positions below the button when there is monitor space, otherwise flips above —
standard combo-box behaviour. Since the History button sits near the bottom of the
app window, it will usually still have screen room below and will open downward,
floating over whatever is beneath. If you want it to always open upward like
the current build, replace the placement
if/elsewith just:int y = anchorTop - h - 2; if (y < mi.rcWork.top) y = mi.rcWork.top;
6. Step 4 — Rewire the History button
In OnClick(HWND hWnd, WK kind), find the case WK::History: block:
case WK::History: {
if (g_histOpen >= 0) { g_histOpen = -1; InvalidateRect(hWnd, nullptr, FALSE); break; }
if (g_history.empty()) { SetStatus(hWnd, L"No history yet"); break; }
g_histOpen = (int)g_history.size();
g_histScroll = 0; g_histHot = -1;
InvalidateRect(hWnd, nullptr, FALSE);
break;
}
Replace it with:
case WK::History: {
g_history = LoadHistoryIndex(); // refresh in case files changed
if (g_history.empty()) { SetStatus(hWnd, L"No history yet"); break; }
std::vector<std::wstring> labels;
labels.reserve(g_history.size());
for (const auto& e : g_history) labels.push_back(e.label);
ShowSelectPopup(hWnd, ID_SEL_HISTORY, labels, -1, g_w[(int)WK::History].r);
break;
}
Do not write any new selection-handling code. When the user clicks a row, the popup
posts WM_APP_SELECT with wParam = ID_SEL_HISTORY and lParam = absolute index. The
existing else if (ctrlId == ID_SEL_HISTORY ...) branch inside WndProc's
WM_APP_SELECT handler already does everything (archive current text, load the entry,
set g_lastLoadedText, refresh the index, update the placeholder, show "Loaded from
history"). Leave it exactly as it is.
7. Step 5 — Delete the legacy painted drop-down (6 deletions)
All in src/main.cpp. Delete only what is quoted — the surrounding code stays.
5a — globals. Between int g_setHot = -1; and Downloader g_dl;, delete these three lines:
int g_histOpen = -1;
int g_histScroll = 0;
int g_histHot = -1;
(Keep g_setHot, keep std::vector<HistoryEntry> g_history;, keep g_lastLoadedText.)
5b — the paint block in PaintSurface. Delete this whole block (it sits right
before RECT vr = g_vuRect;):
if (g_histOpen >= 0 && g_view == View::Main) {
RectF& anchor = g_w[(int)WK::History].r;
...
if (g_histOpen > maxVis) {
...
FillRound(g, T_DIM, thumb, 3);
}
}
(~30 lines, from if (g_histOpen >= 0 ... down to the matching closing brace.)
5c — the hit-test function. Delete the entire HistDropHitTest function (it sits
just above int HitTest(POINT p)):
static int HistDropHitTest(POINT p) {
...
return realIdx;
}
5d — in WM_MOUSEMOVE. Keep the POINT p{...}; line (it is used by HitTest(p)
below); delete only this block:
if (g_histOpen > 0) {
int dh = HistDropHitTest(p);
if (dh != g_histHot) { g_histHot = dh; InvalidateRect(hWnd, nullptr, FALSE); }
}
5e — in WM_MOUSELEAVE. Delete this line:
if (g_histHot >= 0) { g_histHot = -1; InvalidateRect(hWnd, nullptr, FALSE); }
5f — in WM_LBUTTONDOWN. Delete this block (keep g_active = g_hot; and below):
if (g_histOpen > 0) {
POINT p{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
if (HistDropHitTest(p) < 0) { g_histOpen = -1; g_histHot = -1; g_active = -1; InvalidateRect(hWnd, nullptr, FALSE); return 0; }
}
5g — in WM_LBUTTONUP. Delete this entire block (the popup + WM_APP_SELECT
path replaces it; keep the settings branch above it and ReleaseCapture() below it):
if (g_histOpen > 0) {
POINT p{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
int dh = HistDropHitTest(p);
if (dh >= 0 && dh < (int)g_history.size()) {
std::wstring cur = GetEditText(hWnd);
if (!cur.empty() && cur != g_lastLoadedText)
ArchiveSession(cur);
std::wstring text = ReadFileUtf8(g_history[dh].path);
SetWindowTextW(GetDlgItem(hWnd, ID_EDIT_TEXT), text.c_str());
g_lastLoadedText = text;
g_editDirty = false;
g_history = LoadHistoryIndex();
UpdatePlaceholder(hWnd);
SetStatus(hWnd, L"Loaded from history");
}
g_histOpen = -1; g_histHot = -1;
InvalidateRect(hWnd, nullptr, FALSE);
return 0;
}
5h — in WM_MOUSEWHEEL. Delete this block (keep the View::Settings scroll block
above it):
if (g_histOpen > 6) {
g_histScroll -= GET_WHEEL_DELTA_WPARAM(wParam) / 60;
g_histScroll = std::max(0, std::min(g_histScroll, g_histOpen - 6));
InvalidateRect(hWnd, nullptr, FALSE);
}
Verification after Step 5: Ctrl+F the file for g_hist — the only remaining
matches must be g_history (the vector). Search for HistDropHitTest — zero matches.
If anything else matches, you missed a deletion and the compiler will tell you too
(undeclared identifier g_histOpen).
8. Step 6 — (Optional polish) make the button a true toggle
Known small quirk of popup-based menus: clicking the History button while the popup is
open first closes it (the click deactivates the popup → WM_ACTIVATE destroys it),
then the button's click-handler immediately reopens it. So the button acts as
"reopen", not "toggle closed". The mic selector has always behaved this way; if nobody
has complained, skip this step. To make it a real toggle:
Add next to g_pop:
static DWORD g_popClosedTick = 0; // when the popup last closed
static int g_popClosedId = 0; // which ctrlId it was showing
Add a case to PopupProc:
case WM_DESTROY:
g_popClosedTick = GetTickCount();
g_popClosedId = g_pop.ctrlId;
return 0;
Then make the first line of case WK::History: (and optionally WK::SelAudio):
if (g_popClosedId == ID_SEL_HISTORY && GetTickCount() - g_popClosedTick < 250) {
g_popClosedId = 0; // this click was the user toggling the popup shut — swallow it
break;
}
9. Build & test checklist
Build exactly as usual (cmake --build build --config Release --target win-dictation).
Then verify, in order:
- Core bug: with 3+ history entries, click History. The list must appear fully on top of the transcript box (and on top of the window edge if it extends past it). Nothing hidden behind the EDIT control.
- Scrolling: create 10+ entries (record short clips and press Clear between them, or
copy-paste extra
.txtfiles in thehistory\folder using the sameYYYY-MM-DD_HHMMSS.txtname format). Open History: max 8 rows visible, scrollbar thumb on the right, mouse wheel scrolls 3 rows per notch, hover highlight stays correct while scrolling. - Selection: click an entry → transcript loads it, status shows "Loaded from
history", and the text that was in the box beforehand got archived (check the
history\folder gained a file). - Dismiss: click anywhere outside → closes. Press Esc → closes. Click an entry → closes.
- Regression — mic selector: the Microphone popup must still open and select devices correctly (it shares all this code). It now also scrolls if a machine has 9+ capture devices and never clips at the screen edge.
- Placement: drag the app window to the very bottom of the screen → popup flips upward. Drag it high up → popup opens downward (or stays upward if you chose the always-up variant).
- Few items: with 1–2 entries the popup is exactly that tall, no scrollbar.
- Empty: with an empty
history\folder, clicking History shows "No history yet" and no popup. - DPI: if you can, test at 125%/150% scaling — hover highlight must line up with the cursor (row height is now DPI-scaled; it previously was hard-coded 30px).
10. Tuning knobs (all in one place)
| What | Where | Default |
|---|---|---|
| Visible rows before scrolling | kPopupMaxVisible |
8 |
| Rows per wheel notch | g_pop.scroll -= steps * 3; in PopupProc |
3 |
| Minimum popup width | std::max((int)anchor.Width, (int)(260 * s)) |
260 px |
| Open direction | placement if/else in ShowSelectPopup |
below, flip up if no room |
| Row height | rowH = (int)(30 * s) |
30 px @ 100% DPI |
11. Do NOT touch
#define ID_SEL_HISTORY 1019— stays.- The
ID_SEL_HISTORYbranch insideWM_APP_SELECTinWndProc— stays as-is; it is the selection handler now. g_history,g_lastLoadedText,LoadHistoryIndex(),ArchiveSession(), all ofhistory.h— unchanged.- The
WK::Historywidget rect /DrawSelectSurface(g, w, L"History")painting of the button itself — unchanged; only what happens on click changes.