diff --git a/popup-width-fix.md b/popup-width-fix.md new file mode 100644 index 0000000..9f73dc2 --- /dev/null +++ b/popup-width-fix.md @@ -0,0 +1,294 @@ +# Fix 04 — History popup: full app width, always opens upward, no squashed text + +**Builds on:** Fix-03 (the popup-window history list). Apply this only after Fix-03 is in. +**Files touched:** `src/main.cpp` (plus one optional line in `src/history.h`). +**Estimated effort:** ~30 minutes including testing. +**Important:** `PopupProc` (the popup's message handler) needs **NO changes**. All the +scroll / hover / click machinery from Fix-03 stays exactly as it is. + +--- + +## 1. What's actually wrong (two separate root causes) + +### 1a. The "squashed" rows are caused by TEXT WRAPPING, not just narrowness + +Look closely at the screenshot: each history row shows the timestamp on one line and a +clipped second line of preview text under it. That is GDI+ **wrapping** the label. + +`DrawTextC()` builds a `StringFormat` and sets ellipsis trimming, but never sets +`StringFormatFlagsNoWrap`. GDI+ `DrawString` **wraps by default** when given a layout +rectangle. So a long label inside a 30px-tall row wraps to a second line, which doesn't +fit vertically, and gets clipped → the cramped, squashed look. The ellipsis trimming only +kicks in on the wrapped last line, which is why you see `…` mid-text. + +This must be fixed at the `DrawTextC` level. Widening the popup alone is NOT enough — a +long enough entry would still wrap and squash again. + +### 1b. The popup is anchored to the half-width History button, and prefers opening down + +- `ShowSelectPopup` is called with `g_w[(int)WK::History].r` as the anchor, so the popup + is only as wide as the History button (half the content width, min 260px). +- The Fix-03 placement logic opens **downward** whenever the monitor has room below the + button, which is almost always (the screenshot shows it covering Copy/Paste and + spilling below the window). The requirement is now: **always open upward**, over the + transcript, inside the app's footprint. + +## 2. The fix — summary + +1. Add `StringFormatFlagsNoWrap` to `DrawTextC` → every label renders on exactly one + line and ellipsizes cleanly. (Safe app-wide: every `DrawTextC` caller — buttons, + status line, settings rows, stats lines, popup rows — is a single-line label. Nothing + in the app intentionally wraps.) +2. Replace `ShowSelectPopup`'s placement logic: **always upward**, with a + shrink-to-fit fallback if the window sits near the top of the screen, and use the + anchor's width **exactly** (drop the 260px minimum — the anchor itself becomes + full-width in step 3). +3. Anchor the popup to a **full-content-width rect at the selects row** (from the left + edge of the mic select to the right edge of the History select) instead of to the + individual button. Apply to History (required) and to the mic selector (same row, same + one-liner — keeps the two popups consistent and stops long device names truncating). + +--- + +## 3. Step 1 — Stop GDI+ wrapping in `DrawTextC` + +In `src/main.cpp`, find: + +```cpp +static void DrawTextC(Graphics& g, const wchar_t* s, const Font& f, const Color& c, + const RectF& box, StringAlignment h, StringAlignment v) { + StringFormat sf; sf.SetAlignment(h); sf.SetLineAlignment(v); + sf.SetTrimming(StringTrimmingEllipsisCharacter); + SolidBrush b(c); g.DrawString(s, -1, &f, box, &sf, &b); +} +``` + +Replace with (one added line): + +```cpp +static void DrawTextC(Graphics& g, const wchar_t* s, const Font& f, const Color& c, + const RectF& box, StringAlignment h, StringAlignment v) { + StringFormat sf; sf.SetAlignment(h); sf.SetLineAlignment(v); + sf.SetFormatFlags(StringFormatFlagsNoWrap); // single line: ellipsize, never wrap + sf.SetTrimming(StringTrimmingEllipsisCharacter); + SolidBrush b(c); g.DrawString(s, -1, &f, box, &sf, &b); +} +``` + +Why this is safe globally: `DrawTextC` is used for the Record/Copy/Paste/Clear labels, +the status line, the settings catalog rows, the statistics lines, the placeholder, and +the popup rows. Every one of those is a one-line label drawn into a one-line box. None +of them relies on wrapping. This change also future-proofs the rest of the UI against +the same bug. + +## 4. Step 2 — Replace `ShowSelectPopup` (always upward + exact anchor width) + +Replace the **entire** `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(); + + // 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 x = tl.x; + + HMONITOR mon = MonitorFromWindow(owner, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi{ sizeof(mi) }; + GetMonitorInfo(mon, &mi); + + // ALWAYS open upward. If the window sits so close to the top of the screen + // that the popup wouldn't fit, show fewer rows instead of spilling off-screen + // (the wheel still reaches every item). + int visRows = std::min(n, kPopupMaxVisible); + int spaceAbove = anchorTop - mi.rcWork.top - 2; // px available above the row + int fitRows = (spaceAbove - 2 * pad) / rowH; + if (fitRows < 1) fitRows = 1; + if (visRows > fitRows) visRows = fitRows; + + 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 = (int)anchor.Width; // use the anchor's width EXACTLY + + int y = anchorTop - h - 2; // bottom edge sits just above the anchor row + 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); +} +``` + +Deliberate changes vs Fix-03 — so nothing looks accidentally lost: + +- The downward-first / flip-up `if/else` is **gone**: it now always computes + `y = anchorTop - h - 2` (upward). +- New shrink-to-fit block: if the selects row is near the top of the *monitor*, the + popup shows fewer rows rather than going off-screen. Scrolling still reaches all items + because `PopupProc` keys off `g_pop.visRows`. +- `int wdt = std::max((int)anchor.Width, (int)(260 * s));` became + `int wdt = (int)anchor.Width;` — the 260px minimum is no longer needed because the + anchor itself is now full content width (Step 3). +- Everything else (DPI row height, scroll-to-selected, monitor clamps, window style, + rounded corners, `SetFocus` for wheel/Esc) is unchanged. + +## 5. Step 3 — Full-width anchor for the selects row + +### 5a. Add a tiny helper + +Paste this **immediately above** `void OnClick(HWND hWnd, WK kind)`: + +```cpp +// Full content-width anchor at the selects row: spans from the left edge of the +// mic select to the right edge of the History select. Used so the popups open +// as wide as the app's content area, not as wide as one button. +static RectF SelectsRowFullWidthAnchor() { + const RectF& a = g_w[(int)WK::SelAudio].r; // leftmost widget on the row + const RectF& b = g_w[(int)WK::History].r; // rightmost widget on the row + return RectF(a.X, b.Y, (b.X + b.Width) - a.X, b.Height); +} +``` + +(These two rects are laid out by `LayoutWidgets`: SelAudio starts at the left margin, +History ends at the right margin, so the result is exactly the inner content width — +and it stays correct automatically when the window is resized or DPI changes.) + +### 5b. Use it at all three call sites + +**Call site 1 — `OnClick`, the History case.** Find: + +```cpp +ShowSelectPopup(hWnd, ID_SEL_HISTORY, labels, -1, g_w[(int)WK::History].r); +``` + +Replace with: + +```cpp +ShowSelectPopup(hWnd, ID_SEL_HISTORY, labels, -1, SelectsRowFullWidthAnchor()); +``` + +**Call site 2 — `OnClick`, the mic selector case.** Find: + +```cpp +case WK::SelAudio: + ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel, g_w[(int)WK::SelAudio].r); + break; +``` + +Replace with: + +```cpp +case WK::SelAudio: + ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel, SelectsRowFullWidthAnchor()); + break; +``` + +(Same row, same treatment — keeps the two popups visually consistent and stops long +device names like "Microphone (Realtek High Definition Audio)" being truncated.) + +**Call site 3 — `WM_COMMAND`, near the bottom of the big switch.** Find: + +```cpp +case ID_SEL_AUDIO: + ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel, g_w[(int)WK::SelAudio].r); + break; +``` + +Replace with the same `SelectsRowFullWidthAnchor()` version. (This branch is vestigial — +it belongs to a hidden legacy child button and never fires — but update it anyway so a +future grep doesn't find two different anchoring styles.) + +## 6. Optional polish — longer previews (now that there's room) + +`history.h` truncates each preview to 28 characters, which was sized for the old +half-width dropdown. With the full-width popup there's room for roughly double that. + +In `src/history.h`, inside `LoadHistoryIndex()`, find: + +```cpp +std::wstring preview = ReadFileUtf8(e.path).substr(0, 28); +``` + +Change `28` to `60`. No migration needed — labels are rebuilt from the files every time +`LoadHistoryIndex()` runs, so existing history files immediately show longer previews. +Anything that still doesn't fit ellipsizes on one line (thanks to Step 1). + +## 7. Build & test checklist + +1. **No more squash:** open History — every row is exactly ONE line; entries too long + for the row end in a clean `…`. No second clipped line anywhere. +2. **Full width:** the popup spans from the left edge of the Microphone select to the + right edge of the History select (the whole content width), at any window size. + Resize the window wider → reopen → popup matches the new width. +3. **Always upward:** the popup's bottom edge sits just above the selects row and the + list extends UP over the transcript. It never opens downward, no matter where the + window is on the screen (test with the window at the bottom, middle, and top of the + monitor). +4. **Near the top of the screen:** drag the window so the selects row is close to the + top of the monitor → the popup shows fewer rows instead of going off-screen, and the + wheel still scrolls through all entries. +5. **Scrolling regression (from Fix-03):** with 10+ entries — max 8 rows, thumb on the + right, wheel scrolls 3 rows/notch, hover highlight tracks correctly. +6. **Mic selector:** also opens full-width and upward; picking a device still works. +7. **Dismissal:** click-away and Esc still close the popup; clicking an entry loads it + ("Loaded from history") and archives whatever was in the box. +8. **NoWrap regression sweep:** glance over the rest of the UI — status line, Record + pill, settings model rows, statistics lines. All should look identical to before + (none of them ever wrapped). If any text now shows `…` where it used to wrap onto a + second line, that was the same bug manifesting there — widen that box, don't remove + NoWrap. +9. **DPI sanity:** 125%/150% — rows align with the cursor, popup width matches the row. + +## 8. Tuning knobs + +| What | Where | Default | +|---|---|---| +| Visible rows before scrolling | `kPopupMaxVisible` | 8 | +| Gap between popup and the row | `y = anchorTop - h - 2` | 2 px | +| Rows per wheel notch | `PopupProc` (`steps * 3`) — unchanged | 3 | +| Preview length in labels | `history.h` `substr(0, 60)` (optional step) | 60 chars | + +## 9. Do NOT touch + +- **`PopupProc` and `PopupRowFromY`** — completely unchanged from Fix-03. The scroll, + wheel-accumulator, hover, Esc, and click-away logic all still work because they read + `g_pop.visRows` / `rowH` / `pad`, which this fix still populates. +- `PopupState`, `kPopupMaxVisible` — unchanged. +- The `WM_APP_SELECT` handler (including the `ID_SEL_HISTORY` branch) — unchanged. +- `LayoutWidgets` — unchanged; the helper in Step 3 derives the full-width rect from the + existing widget rects, so there is nothing new to lay out. diff --git a/src/history.h b/src/history.h index d50cd20..a038937 100644 --- a/src/history.h +++ b/src/history.h @@ -73,7 +73,7 @@ inline std::vector LoadHistoryIndex() { e.path = HistoryDir() + L"\\" + fd.cFileName; std::wstring stem(fd.cFileName); stem = stem.substr(0, stem.find(L'.')); - std::wstring preview = ReadFileUtf8(e.path).substr(0, 28); + std::wstring preview = ReadFileUtf8(e.path).substr(0, 60); for (wchar_t& c : preview) if (c == L'\r' || c == L'\n') c = L' '; e.label = stem.substr(0, 10) + L" " + stem.substr(11, 2) + L":" + stem.substr(13, 2) + L" \u2014 " + preview + L"\u2026"; diff --git a/src/main.cpp b/src/main.cpp index 47bda3d..93aa0b5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -364,6 +364,7 @@ static void StrokeRound(Graphics& g, const Color& c, const Rect& r, int rad, REA static void DrawTextC(Graphics& g, const wchar_t* s, const Font& f, const Color& c, const RectF& box, StringAlignment h, StringAlignment v) { StringFormat sf; sf.SetAlignment(h); sf.SetLineAlignment(v); + sf.SetFormatFlags(StringFormatFlagsNoWrap); sf.SetTrimming(StringTrimmingEllipsisCharacter); SolidBrush b(c); g.DrawString(s, -1, &f, box, &sf, &b); } @@ -758,11 +759,25 @@ void ShowSelectPopup(HWND owner, int ctrlId, const std::vector& it } 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); + float s = g_dpiScale; + int rowH = (int)(30 * s); + int pad = (int)(3 * s); if (pad < 3) pad = 3; + int n = (int)items.size(); + + POINT tl{ (LONG)anchor.X, (LONG)anchor.Y }; + ClientToScreen(owner, &tl); + int anchorTop = tl.y; + int x = tl.x; + + HMONITOR mon = MonitorFromWindow(owner, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi{ sizeof(mi) }; + GetMonitorInfo(mon, &mi); + + int visRows = std::min(n, kPopupMaxVisible); + int spaceAbove = anchorTop - mi.rcWork.top - 2; + int fitRows = (spaceAbove - 2 * pad) / rowH; + if (fitRows < 1) fitRows = 1; + if (visRows > fitRows) visRows = fitRows; g_pop = PopupState{}; g_pop.items = items; @@ -776,20 +791,9 @@ void ShowSelectPopup(HWND owner, int ctrlId, const std::vector& it 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)); + int wdt = (int)anchor.Width; - 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; - - 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; + int 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; @@ -1120,6 +1124,12 @@ int HitTest(POINT p) { return -1; } +static RectF SelectsRowFullWidthAnchor() { + const RectF& a = g_w[(int)WK::SelAudio].r; + const RectF& b = g_w[(int)WK::History].r; + return RectF(a.X, b.Y, (b.X + b.Width) - a.X, b.Height); +} + void OnClick(HWND hWnd, WK kind) { if (!g_modelLoaded.load() && kind != WK::RecordHero && kind != WK::Clear && kind != WK::Copy && kind != WK::Paste && kind != WK::Pin @@ -1170,7 +1180,7 @@ void OnClick(HWND hWnd, WK kind) { PersistNow(); break; case WK::SelAudio: - ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel, g_w[(int)WK::SelAudio].r); + ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel, SelectsRowFullWidthAnchor()); break; case WK::History: { g_history = LoadHistoryIndex(); @@ -1178,7 +1188,7 @@ void OnClick(HWND hWnd, WK kind) { 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); + ShowSelectPopup(hWnd, ID_SEL_HISTORY, labels, -1, SelectsRowFullWidthAnchor()); break; } case WK::SettingsCog: SwitchView(hWnd, View::Settings); break; @@ -1814,7 +1824,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) } break; case ID_SEL_AUDIO: - ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel, g_w[(int)WK::SelAudio].r); + ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel, SelectsRowFullWidthAnchor()); break; } break;