# 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: 1. **Child windows always render above the parent's client-area painting.** Whatever the parent draws in its own `WM_PAINT` sits *underneath* every child HWND. There is no "draw later so it ends up on top" — the EDIT is simply not on our canvas. 2. The main window is created with **`WS_CLIPCHILDREN`** (see `CreateWindowExW` in `wWinMain`). 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` + `ShowSelectPopup` in `main.cpp`). It is created with `WS_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`, the `WM_APP_SELECT` handler already has an `ID_SEL_HISTORY` branch 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: 1. 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. 2. Point the **History button** at `ShowSelectPopup(...)` with `ID_SEL_HISTORY`. 3. **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): ```cpp struct PopupState { std::vector items; int sel; int hot; HWND owner; int ctrlId; }; static PopupState g_pop; ``` Replace it with: ```cpp struct PopupState { std::vector 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 `hot` and 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). ```cpp // 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_gpUI` GDI+ font — same convention as Fix-02 (cached `g_gp*` fonts everywhere). - The mouse-wheel handler works because the popup has keyboard focus (`SetFocus(p)` is called when it opens) — Windows delivers `WM_MOUSEWHEEL` to the focused window. Don't remove the `SetFocus` call in Step 3. ## 5. Step 3 — Replace `ShowSelectPopup` Replace the **entire** existing `ShowSelectPopup` function with: ```cpp void ShowSelectPopup(HWND owner, int ctrlId, const std::vector& 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/else` with 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: ```cpp 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: ```cpp case WK::History: { g_history = LoadHistoryIndex(); // refresh in case files changed if (g_history.empty()) { SetStatus(hWnd, L"No history yet"); break; } std::vector 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: ```cpp int g_histOpen = -1; int g_histScroll = 0; int g_histHot = -1; ``` (Keep `g_setHot`, keep `std::vector g_history;`, keep `g_lastLoadedText`.) **5b — the paint block in `PaintSurface`.** Delete this whole block (it sits right before `RECT vr = g_vuRect;`): ```cpp 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)`): ```cpp 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: ```cpp 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: ```cpp 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): ```cpp 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): ```cpp 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): ```cpp 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`: ```cpp 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`: ```cpp 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`): ```cpp 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: 1. **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. 2. **Scrolling:** create 10+ entries (record short clips and press Clear between them, or copy-paste extra `.txt` files in the `history\` folder using the same `YYYY-MM-DD_HHMMSS.txt` name 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. 3. **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). 4. **Dismiss:** click anywhere outside → closes. Press Esc → closes. Click an entry → closes. 5. **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. 6. **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). 7. **Few items:** with 1–2 entries the popup is exactly that tall, no scrollbar. 8. **Empty:** with an empty `history\` folder, clicking History shows "No history yet" and no popup. 9. **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_HISTORY` branch inside `WM_APP_SELECT` in `WndProc` — stays as-is; it is the selection handler now. - `g_history`, `g_lastLoadedText`, `LoadHistoryIndex()`, `ArchiveSession()`, all of `history.h` — unchanged. - The `WK::History` widget rect / `DrawSelectSurface(g, w, L"History")` painting of the button itself — unchanged; only what happens on click changes.