Fix 04: NoWrap text, full-width popup anchor, always upward placement, longer history previews
This commit is contained in:
@@ -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
@@ -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";
|
||||
|
||||
+26
-16
@@ -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);
|
||||
}
|
||||
@@ -762,7 +763,21 @@ void ShowSelectPopup(HWND owner, int ctrlId, const std::vector<std::wstring>& it
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user