Files
win-dictate/old_text_files/fix-history-dropdown.md

21 KiB
Raw Permalink Blame History

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: 12 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):

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

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

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:

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:

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