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

510 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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):
```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 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.