# 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<std::wstring> items; int sel; int hot; HWND owner; int ctrlId; };
static PopupState g_pop;
```

Replace it with:

```cpp
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 `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<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/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<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:

```cpp
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;`):

```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.
