Fix 03: Replace inline history dropdown with proper PopupProc popup — scrollable, DPI-scaled, screen-edge aware, renders above EDIT control

This commit is contained in:
Win Dictation Dev
2026-06-11 18:37:40 +12:00
parent ee76328daf
commit 09cac68075
2 changed files with 636 additions and 117 deletions
+509
View File
@@ -0,0 +1,509 @@
# 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.
+127 -117
View File
@@ -153,9 +153,6 @@ RectF g_sBack, g_sSave, g_sCancel;
RECT g_sContent = {0,0,0,0};
struct CatRowRect { RectF row, btn; };
int g_setHot = -1;
int g_histOpen = -1;
int g_histScroll = 0;
int g_histHot = -1;
Downloader g_dl;
enum class DlState { NotInstalled, Downloading, Installed };
struct CatState { DlState state = DlState::NotInstalled; int pct = 0; };
@@ -209,9 +206,22 @@ std::vector<std::wstring> g_audioItems; int g_audioSel = 0;
std::vector<std::wstring> g_modelItems; int g_modelSel = 0;
bool g_initializing = true;
struct PopupState { std::vector<std::wstring> items; int sel; int hot; HWND owner; int ctrlId; };
struct PopupState {
std::vector<std::wstring> items;
int sel = -1;
int hot = -1;
HWND owner = nullptr;
int ctrlId = 0;
int scroll = 0;
int visRows = 0;
int rowH = 30;
int pad = 3;
int wheelAccum = 0;
};
static PopupState g_pop;
static const int kPopupMaxVisible = 8;
enum class WK { RecordHero, Pin, SettingsCog, Copy, Paste, Clear, SelAudio, History, Transcript };
struct Widget {
WK kind;
@@ -639,20 +649,49 @@ void DrawProgress(Graphics& g, const RECT& r, float frac) {
}
}
static int PopupRowFromY(int y) {
int row = (y - g_pop.pad) / g_pop.rowH;
if (row < 0 || row >= g_pop.visRows) return -1;
int abs = g_pop.scroll + row;
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 = GET_Y_LPARAM(l) / 30;
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_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;
g_pop.wheelAccum += GET_WHEEL_DELTA_WPARAM(w);
int steps = g_pop.wheelAccum / WHEEL_DELTA;
if (steps != 0) {
g_pop.wheelAccum -= steps * WHEEL_DELTA;
g_pop.scroll -= steps * 3;
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);
InvalidateRect(h, nullptr, FALSE);
}
return 0;
}
case WM_KEYDOWN:
if (w == VK_ESCAPE) DestroyWindow(h);
return 0;
case WM_LBUTTONUP: {
int row = GET_Y_LPARAM(l) / 30;
if (row >= 0 && row < (int)g_pop.items.size())
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;
DestroyWindow(h);
return 0;
}
case WM_ACTIVATE:
if (LOWORD(w) == WA_INACTIVE) DestroyWindow(h);
@@ -665,21 +704,41 @@ LRESULT CALLBACK PopupProc(HWND h, UINT m, WPARAM w, LPARAM l) {
HBITMAP bmp = CreateCompatibleBitmap(hdc, rc.right, rc.bottom);
HBITMAP old = (HBITMAP)SelectObject(mem, bmp);
{
Graphics g(mem); g.SetSmoothingMode(SmoothingModeAntiAlias);
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);
Font f(mem, g_fUI);
for (int i = 0; i < (int)g_pop.items.size(); ++i) {
Rect row(3, i * 30 + 3, rc.right - 6, 28);
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(), f, (i == g_pop.sel) ? T_ACCENT : T_TEXT,
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;
EndPaint(h, &ps);
return 0;
}
}
return DefWindowProc(h, m, w, l);
@@ -689,28 +748,60 @@ void ShowSelectPopup(HWND owner, int ctrlId, const std::vector<std::wstring>& it
static bool reg = false;
if (!reg) {
WNDCLASSEXW wc{ sizeof(wc) };
wc.lpfnWndProc = PopupProc;
wc.hInstance = hInst;
wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
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;
}
g_pop = { items, sel, -1, owner, ctrlId };
POINT tl{ (LONG)anchor.X, (LONG)(anchor.Y + anchor.Height) };
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{};
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)
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));
POINT tl{ (LONG)anchor.X, (LONG)anchor.Y };
ClientToScreen(owner, &tl);
int h = (int)items.size() * 30 + 6, wdt = (int)anchor.Width;
int anchorTop = tl.y;
int anchorBottom = tl.y + (int)anchor.Height;
int x = tl.x;
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;
else y = anchorTop - h - 2;
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, tl.x, tl.y + 2, wdt, h, owner, nullptr, hInst, nullptr);
if (!p) {
wchar_t dbg[128];
swprintf_s(dbg, L"Popup failed: w=%d h=%d x=%d y=%d err=%d", wdt, h, tl.x, tl.y, GetLastError());
MessageBoxW(owner, dbg, L"Popup Error", MB_OK);
return;
}
int corner = DWMWCP_ROUND; DwmSetWindowAttribute(p, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner));
ShowWindow(p, SW_SHOWNA); SetFocus(p);
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);
}
void LayoutControls(HWND h, int W, int H) {
@@ -973,40 +1064,6 @@ void PaintSurface(HWND hwnd) {
}
}
if (g_histOpen >= 0 && g_view == View::Main) {
RectF& anchor = g_w[(int)WK::History].r;
float s = g_dpiScale;
REAL dropW = (REAL)(int)(anchor.Width);
REAL dropX = anchor.X;
REAL dropY = anchor.Y + anchor.Height + 2 * s;
int maxVis = std::min(g_histOpen, 6);
REAL rowH = 30 * s;
REAL dropH = maxVis * rowH + 6 * s;
Rect dropR((int)dropX, (int)dropY, (int)dropW, (int)dropH);
FillRound(g, T_CARD, dropR, (int)(10 * s));
StrokeRound(g, T_FAINT, dropR, (int)(10 * s), 1.0f);
int startIdx = g_histScroll;
for (int i = 0; i < maxVis && (startIdx + i) < (int)g_history.size(); ++i) {
int gi = startIdx + i;
Rect rowR((int)(dropX + 3 * s), (int)(dropY + 3 * s + i * rowH),
(int)(dropW - 6 * s), (int)(rowH));
if (gi == g_histHot)
FillRound(g, T_CARD_HI, rowR, (int)(7 * s));
RectF tb((REAL)rowR.X + 6 * s, (REAL)rowR.Y, (REAL)rowR.Width - 12 * s, (REAL)rowR.Height);
DrawTextC(g, g_history[gi].label.c_str(), *g_gpUI, T_TEXT,
tb, StringAlignmentNear, StringAlignmentCenter);
}
if (g_histOpen > maxVis) {
REAL scrollH = dropH - 2 * s;
REAL thumbH = scrollH * maxVis / g_histOpen;
REAL thumbY = dropY + 1 * s + (scrollH - thumbH) * g_histScroll / (g_histOpen - maxVis);
Rect thumb((int)(dropX + dropW - 6 * s), (int)thumbY, (int)(4 * s), (int)thumbH);
FillRound(g, T_DIM, thumb, 3);
}
}
RECT vr = g_vuRect;
RectF stripRect((REAL)vr.left, (REAL)vr.top, (REAL)(vr.right - vr.left), (REAL)(vr.bottom - vr.top));
DrawStatusStrip(g, stripRect);
@@ -1057,21 +1114,6 @@ void PaintSurface(HWND hwnd) {
EndPaint(hwnd, &ps);
}
static int HistDropHitTest(POINT p) {
if (g_histOpen <= 0) return -1;
RectF& anchor = g_w[(int)WK::History].r;
float s = g_dpiScale;
REAL dropX = anchor.X, dropY = anchor.Y + anchor.Height + 2*s;
REAL dropW = anchor.Width, rowH = 30*s;
REAL dropH = std::min(g_histOpen, 6) * rowH + 6*s;
if (p.x < dropX || p.x > dropX + dropW || p.y < dropY || p.y > dropY + dropH)
return -1;
int idx = (int)((p.y - dropY - 3*s) / rowH);
int realIdx = g_histScroll + idx;
if (realIdx < 0 || realIdx >= (int)g_history.size()) return -1;
return realIdx;
}
int HitTest(POINT p) {
for (int i = 0; i < (int)std::size(g_w); ++i)
if (g_w[i].kind != WK::Transcript && g_w[i].r.Contains((REAL)p.x, (REAL)p.y)) return i;
@@ -1131,11 +1173,12 @@ void OnClick(HWND hWnd, WK kind) {
ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel, g_w[(int)WK::SelAudio].r);
break;
case WK::History: {
if (g_histOpen >= 0) { g_histOpen = -1; InvalidateRect(hWnd, nullptr, FALSE); break; }
g_history = LoadHistoryIndex();
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);
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;
}
case WK::SettingsCog: SwitchView(hWnd, View::Settings); break;
@@ -1413,10 +1456,6 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
return 0;
}
POINT p{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
if (g_histOpen > 0) {
int dh = HistDropHitTest(p);
if (dh != g_histHot) { g_histHot = dh; InvalidateRect(hWnd, nullptr, FALSE); }
}
int hot = HitTest(p);
if (hot != g_hot) {
if (g_hot >= 0) g_w[g_hot].hover = false;
@@ -1429,14 +1468,9 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
}
case WM_MOUSELEAVE:
if (g_hot >= 0) { g_w[g_hot].hover = false; g_hot = -1; EnsureAnimating(hWnd); }
if (g_histHot >= 0) { g_histHot = -1; InvalidateRect(hWnd, nullptr, FALSE); }
return 0;
case WM_LBUTTONDOWN:
if (g_view == View::Settings) return 0;
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; }
}
g_active = g_hot;
if (g_active >= 0) { g_w[g_active].pressed = true; SetCapture(hWnd); EnsureAnimating(hWnd); }
return 0;
@@ -1446,25 +1480,6 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
OnSettingsClick(hWnd, p);
return 0;
}
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;
}
ReleaseCapture();
POINT p{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
if (g_active >= 0 && HitTest(p) == g_active) OnClick(hWnd, g_w[g_active].kind);
@@ -1481,11 +1496,6 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
LayoutSettings(rc.right, rc.bottom);
InvalidateRect(hWnd, nullptr, FALSE);
}
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);
}
return 0;
case WM_DPICHANGED: {