From 09cac6807541a4fafc0e9cb2b424cb3052ba41d6 Mon Sep 17 00:00:00 2001 From: Win Dictation Dev Date: Thu, 11 Jun 2026 18:37:40 +1200 Subject: [PATCH] =?UTF-8?q?Fix=2003:=20Replace=20inline=20history=20dropdo?= =?UTF-8?q?wn=20with=20proper=20PopupProc=20popup=20=E2=80=94=20scrollable?= =?UTF-8?q?,=20DPI-scaled,=20screen-edge=20aware,=20renders=20above=20EDIT?= =?UTF-8?q?=20control?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fix-history-dropdown.md | 509 ++++++++++++++++++++++++++++++++++++++++ src/main.cpp | 244 ++++++++++--------- 2 files changed, 636 insertions(+), 117 deletions(-) create mode 100644 fix-history-dropdown.md diff --git a/fix-history-dropdown.md b/fix-history-dropdown.md new file mode 100644 index 0000000..701000c --- /dev/null +++ b/fix-history-dropdown.md @@ -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:** 1–2 hours including testing. +**Convention reminder:** this is a companion fix note (Fix-01, Fix-02, …) — do not edit older docs. + +--- + +## 1. Why the drop-down draws *behind* the transcript box (read this first) + +The history drop-down is currently **painted onto the main window's surface** inside +`PaintSurface()` (the block that starts `if (g_histOpen >= 0 && g_view == View::Main)`). + +The transcript box, however, is **not** part of that painted surface. It is a real Win32 +child window — the `EDIT` control with ID `ID_EDIT_TEXT`. Two Win32 rules make the current +approach impossible to fix by re-ordering paint calls: + +1. **Child windows always render above the parent's client-area painting.** Whatever the + parent draws in its own `WM_PAINT` sits *underneath* every child HWND. There is no + "draw later so it ends up on top" — the EDIT is simply not on our canvas. +2. The main window is created with **`WS_CLIPCHILDREN`** (see `CreateWindowExW` in + `wWinMain`). That flag explicitly removes the EDIT's rectangle from the region the + parent is even allowed to paint into. Our GDI+ drawing inside the EDIT's rect is + silently clipped away. That is exactly why you only see the strip of drop-down in the + gap *below* the EDIT in the screenshot. + +A surface-painted drop-down also can never extend past the app window's edge, and we are +re-implementing hover/scroll/click routing by hand. All three problems go away with the +same fix. + +## 2. The fix — strategy + +**Stop painting the drop-down on the surface. Use a real top-level popup window instead.** + +The app already contains exactly the right mechanism and we reuse it: + +- The **microphone selector** already opens a small floating window of class `DictPopup` + (`PopupProc` + `ShowSelectPopup` in `main.cpp`). It is created with + `WS_POPUP | WS_EX_TOPMOST | WS_EX_TOOLWINDOW`, i.e. a top-level window that floats + above the app, above the EDIT control, above *everything*, and is not clipped by the + app window's edges at all. +- The selection plumbing for history **already exists**: in `WndProc`, the + `WM_APP_SELECT` handler already has an `ID_SEL_HISTORY` branch that archives the + current text and loads the chosen entry. It was wired up but never used. We will not + touch it — we just finally route clicks into it. + +So the work is: + +1. Upgrade the shared popup so it can **scroll** (max ~8 visible rows, mouse wheel, + scrollbar thumb), positions itself **above or below** the anchor depending on screen + space, and uses **DPI-scaled** row heights. +2. Point the **History button** at `ShowSelectPopup(...)` with `ID_SEL_HISTORY`. +3. **Delete** every line of the old surface-painted drop-down. + +Bonus: the mic selector automatically gains scrolling and screen-edge handling too. + +--- + +## 3. Step 1 — Replace `PopupState` + +In `src/main.cpp`, find this (one line plus the variable under it): + +```cpp +struct PopupState { std::vector items; int sel; int hot; HWND owner; int ctrlId; }; +static PopupState g_pop; +``` + +Replace it with: + +```cpp +struct PopupState { + std::vector items; + int sel = -1; // index drawn in accent colour (current selection), -1 = none + int hot = -1; // ABSOLUTE index of the row under the mouse, -1 = none + HWND owner = nullptr; + int ctrlId = 0; // posted back in WM_APP_SELECT wParam + int scroll = 0; // index of the first visible row + int visRows = 0; // number of rows visible in the popup + int rowH = 30; // row height in px (DPI-scaled at open time) + int pad = 3; // inner padding in px + int wheelAccum = 0; // accumulates wheel deltas < 120 (trackpads) +}; +static PopupState g_pop; + +static const int kPopupMaxVisible = 8; // rows shown before the list scrolls +``` + +> Note `hot` and the value posted back are now **absolute** item indices (index into the +> full list), not visible-row indices. This matters once the list scrolls. + +## 4. Step 2 — Replace `PopupProc` (and add one helper above it) + +Find the existing `LRESULT CALLBACK PopupProc(HWND h, UINT m, WPARAM w, LPARAM l)` and +replace the **entire function** with the code below. Also add the small +`PopupRowFromY` helper **immediately above** `PopupProc` (it must be defined before it). + +```cpp +// Convert a client-area Y coordinate inside the popup to an ABSOLUTE item index. +// Returns -1 if the point is not on a row. +static int PopupRowFromY(int y) { + int row = (y - g_pop.pad) / g_pop.rowH; // visible row 0..visRows-1 + if (row < 0 || row >= g_pop.visRows) return -1; + int abs = g_pop.scroll + row; // absolute item index + if (abs >= (int)g_pop.items.size()) return -1; + return abs; +} + +LRESULT CALLBACK PopupProc(HWND h, UINT m, WPARAM w, LPARAM l) { + switch (m) { + case WM_MOUSEMOVE: { + int row = PopupRowFromY(GET_Y_LPARAM(l)); + if (row != g_pop.hot) { g_pop.hot = row; InvalidateRect(h, nullptr, FALSE); } + TRACKMOUSEEVENT t{ sizeof(t), TME_LEAVE, h, 0 }; TrackMouseEvent(&t); + return 0; + } + case WM_MOUSELEAVE: + g_pop.hot = -1; InvalidateRect(h, nullptr, FALSE); + return 0; + case WM_MOUSEWHEEL: { + int maxScroll = (int)g_pop.items.size() - g_pop.visRows; + if (maxScroll <= 0) return 0; // everything fits: nothing to scroll + g_pop.wheelAccum += GET_WHEEL_DELTA_WPARAM(w); + int steps = g_pop.wheelAccum / WHEEL_DELTA; // whole notches only + if (steps != 0) { + g_pop.wheelAccum -= steps * WHEEL_DELTA; + g_pop.scroll -= steps * 3; // 3 rows per wheel notch + g_pop.scroll = std::max(0, std::min(g_pop.scroll, maxScroll)); + POINT pt; GetCursorPos(&pt); ScreenToClient(h, &pt); + g_pop.hot = PopupRowFromY(pt.y); // keep hover correct after scroll + InvalidateRect(h, nullptr, FALSE); + } + return 0; + } + case WM_KEYDOWN: + if (w == VK_ESCAPE) DestroyWindow(h); // Esc closes the popup + return 0; + case WM_LBUTTONUP: { + int row = PopupRowFromY(GET_Y_LPARAM(l)); + if (row >= 0) + PostMessageW(g_pop.owner, WM_APP_SELECT, (WPARAM)g_pop.ctrlId, (LPARAM)row); + DestroyWindow(h); + return 0; + } + case WM_ACTIVATE: + if (LOWORD(w) == WA_INACTIVE) DestroyWindow(h); // click-away closes + return 0; + case WM_ERASEBKGND: return 1; + case WM_PAINT: { + PAINTSTRUCT ps; HDC hdc = BeginPaint(h, &ps); + RECT rc; GetClientRect(h, &rc); + HDC mem = CreateCompatibleDC(hdc); + HBITMAP bmp = CreateCompatibleBitmap(hdc, rc.right, rc.bottom); + HBITMAP old = (HBITMAP)SelectObject(mem, bmp); + { + Graphics g(mem); + g.SetSmoothingMode(SmoothingModeAntiAlias); + g.SetTextRenderingHint(TextRenderingHintClearTypeGridFit); + Rect all(0, 0, rc.right, rc.bottom); + FillRound(g, T_CARD, all, 10); + StrokeRound(g, T_FAINT, all, 10, 1.0f); + + int n = (int)g_pop.items.size(); + bool hasBar = n > g_pop.visRows; + int barW = std::max(3, (int)(4 * g_dpiScale)); + int rowW = rc.right - 2 * g_pop.pad - (hasBar ? barW + g_pop.pad : 0); + int last = std::min(n, g_pop.scroll + g_pop.visRows); + + for (int i = g_pop.scroll; i < last; ++i) { + int vis = i - g_pop.scroll; + Rect row(g_pop.pad, g_pop.pad + vis * g_pop.rowH, rowW, g_pop.rowH - 2); + if (i == g_pop.hot) FillRound(g, T_CARD_HI, row, 7); + RectF tb((REAL)row.X + 9, (REAL)row.Y, (REAL)row.Width - 12, (REAL)row.Height); + DrawTextC(g, g_pop.items[i].c_str(), *g_gpUI, + (i == g_pop.sel) ? T_ACCENT : T_TEXT, + tb, StringAlignmentNear, StringAlignmentCenter); + } + if (hasBar) { + int trackH = rc.bottom - 2 * g_pop.pad; + int thumbH = std::max((int)(20 * g_dpiScale), trackH * g_pop.visRows / n); + int maxScroll = n - g_pop.visRows; + int thumbY = g_pop.pad + (trackH - thumbH) * g_pop.scroll / maxScroll; + Rect thumb(rc.right - g_pop.pad - barW, thumbY, barW, thumbH); + FillRound(g, T_DIM, thumb, barW / 2); + } + } + BitBlt(hdc, 0, 0, rc.right, rc.bottom, mem, 0, 0, SRCCOPY); + SelectObject(mem, old); DeleteObject(bmp); DeleteDC(mem); + EndPaint(h, &ps); + return 0; + } + } + return DefWindowProc(h, m, w, l); +} +``` + +Two deliberate changes from the old version, so you don't think something was lost: + +- The old paint code built a `Font f(mem, g_fUI)` every frame. We now use the cached + `*g_gpUI` GDI+ font — same convention as Fix-02 (cached `g_gp*` fonts everywhere). +- The mouse-wheel handler works because the popup has keyboard focus + (`SetFocus(p)` is called when it opens) — Windows delivers `WM_MOUSEWHEEL` to the + focused window. Don't remove the `SetFocus` call in Step 3. + +## 5. Step 3 — Replace `ShowSelectPopup` + +Replace the **entire** existing `ShowSelectPopup` function with: + +```cpp +void ShowSelectPopup(HWND owner, int ctrlId, const std::vector& items, int sel, const RectF& anchor) { + static bool reg = false; + if (!reg) { + WNDCLASSEXW wc{ sizeof(wc) }; + wc.lpfnWndProc = PopupProc; + wc.hInstance = hInst; + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + wc.hbrBackground = CreateSolidBrush(CR_SURFACE); + wc.lpszClassName = L"DictPopup"; + RegisterClassExW(&wc); + reg = true; + } + if (items.empty()) return; + + float s = g_dpiScale; + int rowH = (int)(30 * s); + int pad = (int)(3 * s); if (pad < 3) pad = 3; + int n = (int)items.size(); + int visRows = std::min(n, kPopupMaxVisible); + + g_pop = PopupState{}; // reset everything (incl. scroll/wheelAccum) + g_pop.items = items; + g_pop.sel = sel; + g_pop.owner = owner; + g_pop.ctrlId = ctrlId; + g_pop.visRows = visRows; + g_pop.rowH = rowH; + g_pop.pad = pad; + if (sel >= 0 && n > visRows) // scroll so the current selection is visible + g_pop.scroll = std::max(0, std::min(sel - visRows / 2, n - visRows)); + + int h = visRows * rowH + 2 * pad; + int wdt = std::max((int)anchor.Width, (int)(260 * s)); // never narrower than 260px + + // Anchor rect is in CLIENT coordinates; convert its top-left to screen. + POINT tl{ (LONG)anchor.X, (LONG)anchor.Y }; + ClientToScreen(owner, &tl); + int anchorTop = tl.y; + int anchorBottom = tl.y + (int)anchor.Height; + int x = tl.x; + + // Place below the anchor if it fits on the monitor's work area, otherwise above. + HMONITOR mon = MonitorFromWindow(owner, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi{ sizeof(mi) }; + GetMonitorInfo(mon, &mi); + int y; + if (anchorBottom + 2 + h <= mi.rcWork.bottom) y = anchorBottom + 2; // open downward + else y = anchorTop - h - 2; // flip upward + if (y < mi.rcWork.top) y = mi.rcWork.top; + if (x + wdt > mi.rcWork.right) x = mi.rcWork.right - wdt; + if (x < mi.rcWork.left) x = mi.rcWork.left; + + HWND p = CreateWindowExW(WS_EX_TOOLWINDOW | WS_EX_TOPMOST, L"DictPopup", L"", + WS_POPUP, x, y, wdt, h, owner, nullptr, hInst, nullptr); + if (!p) return; + + int corner = DWMWCP_ROUND; + DwmSetWindowAttribute(p, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner)); + ShowWindow(p, SW_SHOWNA); + SetFocus(p); // required: wheel + Esc are delivered to the focused window +} +``` + +Notes: + +- The old debug `MessageBoxW("Popup failed: ...")` is intentionally removed (it was + scaffolding). Failure now just silently returns. +- Because the popup is a top-level window, it may legitimately extend **past the app + window's edges** — that is correct and expected. It only clamps to the *monitor's* + work area. +- It positions below the button when there is monitor space, otherwise flips above — + standard combo-box behaviour. Since the History button sits near the bottom of the + app window, it will usually still have screen room below and will open downward, + floating *over* whatever is beneath. If you want it to **always open upward** like + the current build, replace the placement `if/else` with just: + `int y = anchorTop - h - 2; if (y < mi.rcWork.top) y = mi.rcWork.top;` + +## 6. Step 4 — Rewire the History button + +In `OnClick(HWND hWnd, WK kind)`, find the `case WK::History:` block: + +```cpp +case WK::History: { + if (g_histOpen >= 0) { g_histOpen = -1; InvalidateRect(hWnd, nullptr, FALSE); break; } + if (g_history.empty()) { SetStatus(hWnd, L"No history yet"); break; } + g_histOpen = (int)g_history.size(); + g_histScroll = 0; g_histHot = -1; + InvalidateRect(hWnd, nullptr, FALSE); + break; +} +``` + +Replace it with: + +```cpp +case WK::History: { + g_history = LoadHistoryIndex(); // refresh in case files changed + if (g_history.empty()) { SetStatus(hWnd, L"No history yet"); break; } + std::vector labels; + labels.reserve(g_history.size()); + for (const auto& e : g_history) labels.push_back(e.label); + ShowSelectPopup(hWnd, ID_SEL_HISTORY, labels, -1, g_w[(int)WK::History].r); + break; +} +``` + +**Do not write any new selection-handling code.** When the user clicks a row, the popup +posts `WM_APP_SELECT` with `wParam = ID_SEL_HISTORY` and `lParam = absolute index`. The +existing `else if (ctrlId == ID_SEL_HISTORY ...)` branch inside `WndProc`'s +`WM_APP_SELECT` handler already does everything (archive current text, load the entry, +set `g_lastLoadedText`, refresh the index, update the placeholder, show "Loaded from +history"). Leave it exactly as it is. + +## 7. Step 5 — Delete the legacy painted drop-down (6 deletions) + +All in `src/main.cpp`. Delete **only** what is quoted — the surrounding code stays. + +**5a — globals.** Between `int g_setHot = -1;` and `Downloader g_dl;`, delete these three lines: + +```cpp +int g_histOpen = -1; +int g_histScroll = 0; +int g_histHot = -1; +``` + +(Keep `g_setHot`, keep `std::vector g_history;`, keep `g_lastLoadedText`.) + +**5b — the paint block in `PaintSurface`.** Delete this whole block (it sits right +before `RECT vr = g_vuRect;`): + +```cpp +if (g_histOpen >= 0 && g_view == View::Main) { + RectF& anchor = g_w[(int)WK::History].r; + ... + if (g_histOpen > maxVis) { + ... + FillRound(g, T_DIM, thumb, 3); + } +} +``` + +(~30 lines, from `if (g_histOpen >= 0 ...` down to the matching closing brace.) + +**5c — the hit-test function.** Delete the entire `HistDropHitTest` function (it sits +just above `int HitTest(POINT p)`): + +```cpp +static int HistDropHitTest(POINT p) { + ... + return realIdx; +} +``` + +**5d — in `WM_MOUSEMOVE`.** Keep the `POINT p{...};` line (it is used by `HitTest(p)` +below); delete only this block: + +```cpp +if (g_histOpen > 0) { + int dh = HistDropHitTest(p); + if (dh != g_histHot) { g_histHot = dh; InvalidateRect(hWnd, nullptr, FALSE); } +} +``` + +**5e — in `WM_MOUSELEAVE`.** Delete this line: + +```cpp +if (g_histHot >= 0) { g_histHot = -1; InvalidateRect(hWnd, nullptr, FALSE); } +``` + +**5f — in `WM_LBUTTONDOWN`.** Delete this block (keep `g_active = g_hot;` and below): + +```cpp +if (g_histOpen > 0) { + POINT p{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; + if (HistDropHitTest(p) < 0) { g_histOpen = -1; g_histHot = -1; g_active = -1; InvalidateRect(hWnd, nullptr, FALSE); return 0; } +} +``` + +**5g — in `WM_LBUTTONUP`.** Delete this entire block (the popup + `WM_APP_SELECT` +path replaces it; keep the settings branch above it and `ReleaseCapture()` below it): + +```cpp +if (g_histOpen > 0) { + POINT p{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; + int dh = HistDropHitTest(p); + if (dh >= 0 && dh < (int)g_history.size()) { + std::wstring cur = GetEditText(hWnd); + if (!cur.empty() && cur != g_lastLoadedText) + ArchiveSession(cur); + std::wstring text = ReadFileUtf8(g_history[dh].path); + SetWindowTextW(GetDlgItem(hWnd, ID_EDIT_TEXT), text.c_str()); + g_lastLoadedText = text; + g_editDirty = false; + g_history = LoadHistoryIndex(); + UpdatePlaceholder(hWnd); + SetStatus(hWnd, L"Loaded from history"); + } + g_histOpen = -1; g_histHot = -1; + InvalidateRect(hWnd, nullptr, FALSE); + return 0; +} +``` + +**5h — in `WM_MOUSEWHEEL`.** Delete this block (keep the `View::Settings` scroll block +above it): + +```cpp +if (g_histOpen > 6) { + g_histScroll -= GET_WHEEL_DELTA_WPARAM(wParam) / 60; + g_histScroll = std::max(0, std::min(g_histScroll, g_histOpen - 6)); + InvalidateRect(hWnd, nullptr, FALSE); +} +``` + +**Verification after Step 5:** Ctrl+F the file for `g_hist` — the only remaining +matches must be `g_history` (the vector). Search for `HistDropHitTest` — zero matches. +If anything else matches, you missed a deletion and the compiler will tell you too +(undeclared identifier `g_histOpen`). + +## 8. Step 6 — (Optional polish) make the button a true toggle + +Known small quirk of popup-based menus: clicking the History button **while the popup is +open** first closes it (the click deactivates the popup → `WM_ACTIVATE` destroys it), +then the button's click-handler immediately reopens it. So the button acts as +"reopen", not "toggle closed". The mic selector has always behaved this way; if nobody +has complained, skip this step. To make it a real toggle: + +Add next to `g_pop`: + +```cpp +static DWORD g_popClosedTick = 0; // when the popup last closed +static int g_popClosedId = 0; // which ctrlId it was showing +``` + +Add a case to `PopupProc`: + +```cpp +case WM_DESTROY: + g_popClosedTick = GetTickCount(); + g_popClosedId = g_pop.ctrlId; + return 0; +``` + +Then make the first line of `case WK::History:` (and optionally `WK::SelAudio`): + +```cpp +if (g_popClosedId == ID_SEL_HISTORY && GetTickCount() - g_popClosedTick < 250) { + g_popClosedId = 0; // this click was the user toggling the popup shut — swallow it + break; +} +``` + +## 9. Build & test checklist + +Build exactly as usual (`cmake --build build --config Release --target win-dictation`). +Then verify, in order: + +1. **Core bug:** with 3+ history entries, click History. The list must appear **fully on + top of the transcript box** (and on top of the window edge if it extends past it). + Nothing hidden behind the EDIT control. +2. **Scrolling:** create 10+ entries (record short clips and press Clear between them, or + copy-paste extra `.txt` files in the `history\` folder using the same + `YYYY-MM-DD_HHMMSS.txt` name format). Open History: max 8 rows visible, scrollbar + thumb on the right, mouse wheel scrolls 3 rows per notch, hover highlight stays + correct while scrolling. +3. **Selection:** click an entry → transcript loads it, status shows "Loaded from + history", and the text that was in the box beforehand got archived (check the + `history\` folder gained a file). +4. **Dismiss:** click anywhere outside → closes. Press Esc → closes. Click an entry → + closes. +5. **Regression — mic selector:** the Microphone popup must still open and select + devices correctly (it shares all this code). It now also scrolls if a machine has + 9+ capture devices and never clips at the screen edge. +6. **Placement:** drag the app window to the very bottom of the screen → popup flips + upward. Drag it high up → popup opens downward (or stays upward if you chose the + always-up variant). +7. **Few items:** with 1–2 entries the popup is exactly that tall, no scrollbar. +8. **Empty:** with an empty `history\` folder, clicking History shows "No history yet" + and no popup. +9. **DPI:** if you can, test at 125%/150% scaling — hover highlight must line up with + the cursor (row height is now DPI-scaled; it previously was hard-coded 30px). + +## 10. Tuning knobs (all in one place) + +| What | Where | Default | +|---|---|---| +| Visible rows before scrolling | `kPopupMaxVisible` | 8 | +| Rows per wheel notch | `g_pop.scroll -= steps * 3;` in `PopupProc` | 3 | +| Minimum popup width | `std::max((int)anchor.Width, (int)(260 * s))` | 260 px | +| Open direction | placement `if/else` in `ShowSelectPopup` | below, flip up if no room | +| Row height | `rowH = (int)(30 * s)` | 30 px @ 100% DPI | + +## 11. Do NOT touch + +- `#define ID_SEL_HISTORY 1019` — stays. +- The `ID_SEL_HISTORY` branch inside `WM_APP_SELECT` in `WndProc` — stays as-is; it is + the selection handler now. +- `g_history`, `g_lastLoadedText`, `LoadHistoryIndex()`, `ArchiveSession()`, all of + `history.h` — unchanged. +- The `WK::History` widget rect / `DrawSelectSurface(g, w, L"History")` painting of the + button itself — unchanged; only what happens on click changes. diff --git a/src/main.cpp b/src/main.cpp index 186337d..47bda3d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 g_audioItems; int g_audioSel = 0; std::vector g_modelItems; int g_modelSel = 0; bool g_initializing = true; -struct PopupState { std::vector items; int sel; int hot; HWND owner; int ctrlId; }; +struct PopupState { + std::vector 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& 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 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: {