Fix 04: NoWrap text, full-width popup anchor, always upward placement, longer history previews

This commit is contained in:
Win Dictation Dev
2026-06-11 18:53:07 +12:00
parent 09cac68075
commit a87a12ae7e
3 changed files with 326 additions and 22 deletions
+294
View File
@@ -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<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();
// 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.
+1 -1
View File
@@ -73,7 +73,7 @@ inline std::vector<HistoryEntry> 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";
+31 -21
View File
@@ -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<std::wstring>& 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<std::wstring>& 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<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);
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;