# 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.
