Files
win-dictate/popup-width-fix.md
T

13 KiB

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:

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):

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:

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):

// 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:

ShowSelectPopup(hWnd, ID_SEL_HISTORY, labels, -1, g_w[(int)WK::History].r);

Replace with:

ShowSelectPopup(hWnd, ID_SEL_HISTORY, labels, -1, SelectsRowFullWidthAnchor());

Call site 2 — OnClick, the mic selector case. Find:

case WK::SelAudio:
    ShowSelectPopup(hWnd, ID_SEL_AUDIO, g_audioItems, g_audioSel, g_w[(int)WK::SelAudio].r);
    break;

Replace with:

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:

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:

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.