Files
win-dictate/old_text_files/hisory-delete-instructions.md
T

16 KiB
Raw Blame History

Fix 06 — Delete individual history entries from the popup (✕ on hover)

Builds on: Fix-03/04 (popup) and Fix-05 (live session archiving) — apply Fix-05 first. One block below references g_sessionPath from Fix-05; it is clearly marked in case you must build without it. Files touched: src/main.cpp only. history.h unchanged. Estimated effort: 4560 minutes including testing.


1. What we're building

  • Hovering a row in the History popup reveals a small at the row's right edge.
  • Clicking the deletes that entry's file from history\ and refreshes the list in place — the popup stays open, so you can clean out several entries in one go.
  • Clicking anywhere else on the row still loads the entry (unchanged behaviour).
  • The Delete key also deletes the hovered row (the popup already owns keyboard focus).
  • The microphone popup is untouched — it shares the same code, so the feature is gated by a canDelete flag that is only true for ID_SEL_HISTORY.

Design decisions (so the dev doesn't relitigate them)

  1. Popup stays open after a delete. Closing after each delete would make cleaning up 1050 entries miserable. The list, scrollbar, and popup height all refresh in place; deleting the last entry closes the popup.
  2. No confirmation dialog — and this is load-bearing, not laziness. The popup destroys itself on WM_ACTIVATE / WA_INACTIVE (that's the click-away-to-close behaviour). A MessageBox shown from inside PopupProc would deactivate the popup, destroy it mid-handler, and then resume the handler with a dead HWND — undefined behaviour. Never open a modal dialog from PopupProc. If a safety net is wanted later, use the soft-delete variant in §8 instead.
  3. Deleting the LIVE session's entry detaches the session (see §7) so Clear/exit don't instantly resurrect the file you just deleted.

2. Step 1 — Extend PopupState

Find the PopupState struct and add the two marked fields:

struct PopupState {
    std::vector<std::wstring> items;
    int  sel     = -1;
    int  hot     = -1;
    HWND owner   = nullptr;
    int  ctrlId  = 0;
    int  scroll  = 0;
    int  visRows = 0;
    int  rowH    = 30;
    int  pad     = 3;
    int  wheelAccum = 0;
    bool canDelete = false;   // NEW: rows show a ✕ delete button (history popup only)
    int  hotX      = -1;      // NEW: ABSOLUTE index of the row whose ✕ is hovered, -1 = none
};

(g_pop = PopupState{}; in ShowSelectPopup already resets the new fields each open — no extra reset code needed.)

3. Step 2 — Three small helpers

Paste these between PopupRowFromY and PopupProc (they must be above PopupProc; PopupRowFromY itself is unchanged):

// The ✕ hit square for a VISIBLE row (0..visRows-1), in popup client coords.
// Sits at the right edge of the row, inside the scrollbar gutter if present.
static Rect PopupDeleteRect(const RECT& rc, int visIdx) {
    int  n      = (int)g_pop.items.size();
    bool hasBar = n > g_pop.visRows;
    int  barW   = std::max(3, (int)(4 * g_dpiScale));
    int  rowW   = rc.right - 2 * g_pop.pad - (hasBar ? barW + g_pop.pad : 0);
    int  xW     = g_pop.rowH;                                // square zone, one row tall
    return Rect(g_pop.pad + rowW - xW, g_pop.pad + visIdx * g_pop.rowH, xW, g_pop.rowH - 2);
}

// Recompute hot row + hovered-✕ from the current cursor position. Used after
// scrolling and after a delete (the list moved under a stationary cursor).
static void PopupRefreshHotFromCursor(HWND h) {
    POINT pt; GetCursorPos(&pt); ScreenToClient(h, &pt);
    int row = PopupRowFromY(pt.y);
    int hx  = -1;
    if (g_pop.canDelete && row >= 0) {
        RECT rc; GetClientRect(h, &rc);
        if (PopupDeleteRect(rc, row - g_pop.scroll).Contains(pt.x, pt.y)) hx = row;
    }
    g_pop.hot = row; g_pop.hotX = hx;
}

// Delete the history file behind ABSOLUTE row `row`, then refresh the popup
// in place. The popup stays open so several entries can be deleted in a row.
static void PopupDeleteRow(HWND h, int row) {
    if (row < 0 || row >= (int)g_history.size()) return;
    std::wstring path = g_history[row].path;
    bool ok = DeleteFileW(path.c_str()) != FALSE;

    // ---- Requires Fix-05 (g_sessionPath). Omit this block ONLY if building
    // ---- without Fix-05, and apply it when Fix-05 lands.
    if (ok && path == g_sessionPath) {
        // The LIVE session's file was deleted: detach the session so Clear/exit
        // don't immediately re-archive the same text. If the user dictates MORE,
        // a new file is created — history always mirrors the transcript box.
        g_sessionPath.clear();
        g_lastLoadedText = GetEditText(g_pop.owner);
    }
    // ---- end Fix-05-dependent block

    g_history = LoadHistoryIndex();                          // re-scan the folder
    g_pop.items.clear();
    g_pop.items.reserve(g_history.size());
    for (const auto& e : g_history) g_pop.items.push_back(e.label);

    SetStatus(g_pop.owner, ok ? L"Deleted" : L"Delete failed");

    int n = (int)g_pop.items.size();
    if (n == 0) { DestroyWindow(h); return; }                // nothing left to show

    // Shrink the popup when fewer rows remain than were visible. It opens
    // upward, so keep the BOTTOM edge fixed and move the top edge down.
    if (n < g_pop.visRows) {
        g_pop.visRows = n;
        int newH = n * g_pop.rowH + 2 * g_pop.pad;
        RECT wr; GetWindowRect(h, &wr);
        SetWindowPos(h, nullptr, wr.left, wr.bottom - newH,
                     wr.right - wr.left, newH, SWP_NOZORDER | SWP_NOACTIVATE);
    }
    int maxScroll = std::max(0, n - g_pop.visRows);
    g_pop.scroll  = std::min(g_pop.scroll, maxScroll);

    PopupRefreshHotFromCursor(h);
    InvalidateRect(h, nullptr, FALSE);
}

Why this is safe to do from PopupProc: everything here runs on the UI thread, g_history / g_sessionPath / g_lastLoadedText are main-thread globals declared earlier in the file, and GetEditText / SetStatus / LoadHistoryIndex are all declared above the popup code already. Indices stay valid because g_pop.items is rebuilt 1:1 from the freshly reloaded g_history — the existing WM_APP_SELECT handler keeps working unchanged.

4. Step 3 — Replace PopupProc (entire function)

Replace the whole LRESULT CALLBACK PopupProc(...) with the version below. Changes vs Fix-04: ✕ hover tracking in WM_MOUSEMOVE, WM_MOUSEWHEEL now uses PopupRefreshHotFromCursor, WM_KEYDOWN gains VK_DELETE, WM_LBUTTONUP routes ✕-clicks to PopupDeleteRow (and does NOT close), and WM_PAINT draws the ✕ and reserves label space for it. Everything else is byte-for-byte the Fix-04 code.

LRESULT CALLBACK PopupProc(HWND h, UINT m, WPARAM w, LPARAM l) {
    switch (m) {
    case WM_MOUSEMOVE: {
        int row = PopupRowFromY(GET_Y_LPARAM(l));
        int hx  = -1;
        if (g_pop.canDelete && row >= 0) {
            RECT rc; GetClientRect(h, &rc);
            if (PopupDeleteRect(rc, row - g_pop.scroll).Contains(GET_X_LPARAM(l), GET_Y_LPARAM(l)))
                hx = row;
        }
        if (row != g_pop.hot || hx != g_pop.hotX) {
            g_pop.hot = row; g_pop.hotX = hx;
            InvalidateRect(h, nullptr, FALSE);
        }
        TRACKMOUSEEVENT t{ sizeof(t), TME_LEAVE, h, 0 }; TrackMouseEvent(&t);
        return 0;
    }
    case WM_MOUSELEAVE:
        g_pop.hot = -1; g_pop.hotX = -1; InvalidateRect(h, nullptr, FALSE);
        return 0;
    case WM_MOUSEWHEEL: {
        int maxScroll = (int)g_pop.items.size() - g_pop.visRows;
        if (maxScroll <= 0) return 0;
        g_pop.wheelAccum += GET_WHEEL_DELTA_WPARAM(w);
        int steps = g_pop.wheelAccum / WHEEL_DELTA;
        if (steps != 0) {
            g_pop.wheelAccum -= steps * WHEEL_DELTA;
            g_pop.scroll -= steps * 3;
            g_pop.scroll  = std::max(0, std::min(g_pop.scroll, maxScroll));
            PopupRefreshHotFromCursor(h);
            InvalidateRect(h, nullptr, FALSE);
        }
        return 0;
    }
    case WM_KEYDOWN:
        if (w == VK_ESCAPE) DestroyWindow(h);
        else if (w == VK_DELETE && g_pop.canDelete && g_pop.hot >= 0)
            PopupDeleteRow(h, g_pop.hot);
        return 0;
    case WM_LBUTTONUP: {
        int row = PopupRowFromY(GET_Y_LPARAM(l));
        if (g_pop.canDelete && row >= 0) {
            RECT rc; GetClientRect(h, &rc);
            if (PopupDeleteRect(rc, row - g_pop.scroll).Contains(GET_X_LPARAM(l), GET_Y_LPARAM(l))) {
                PopupDeleteRow(h, row);          // delete — popup STAYS OPEN
                return 0;
            }
        }
        if (row >= 0)
            PostMessageW(g_pop.owner, WM_APP_SELECT, (WPARAM)g_pop.ctrlId, (LPARAM)row);
        DestroyWindow(h);
        return 0;
    }
    case WM_ACTIVATE:
        if (LOWORD(w) == WA_INACTIVE) DestroyWindow(h);
        return 0;
    case WM_ERASEBKGND: return 1;
    case WM_PAINT: {
        PAINTSTRUCT ps; HDC hdc = BeginPaint(h, &ps);
        RECT rc; GetClientRect(h, &rc);
        HDC mem = CreateCompatibleDC(hdc);
        HBITMAP bmp = CreateCompatibleBitmap(hdc, rc.right, rc.bottom);
        HBITMAP old = (HBITMAP)SelectObject(mem, bmp);
        {
            Graphics g(mem);
            g.SetSmoothingMode(SmoothingModeAntiAlias);
            g.SetTextRenderingHint(TextRenderingHintClearTypeGridFit);
            Rect all(0, 0, rc.right, rc.bottom);
            FillRound(g, T_CARD, all, 10);
            StrokeRound(g, T_FAINT, all, 10, 1.0f);
            int  n       = (int)g_pop.items.size();
            bool hasBar  = n > g_pop.visRows;
            int  barW    = std::max(3, (int)(4 * g_dpiScale));
            int  rowW    = rc.right - 2 * g_pop.pad - (hasBar ? barW + g_pop.pad : 0);
            int  reserve = g_pop.canDelete ? g_pop.rowH : 0;   // keep labels clear of the ✕ zone
            int  last    = std::min(n, g_pop.scroll + g_pop.visRows);
            for (int i = g_pop.scroll; i < last; ++i) {
                int vis = i - g_pop.scroll;
                Rect row(g_pop.pad, g_pop.pad + vis * g_pop.rowH, rowW, g_pop.rowH - 2);
                if (i == g_pop.hot) FillRound(g, T_CARD_HI, row, 7);
                RectF tb((REAL)row.X + 9, (REAL)row.Y, (REAL)(row.Width - 12 - reserve), (REAL)row.Height);
                DrawTextC(g, g_pop.items[i].c_str(), *g_gpUI,
                          (i == g_pop.sel) ? T_ACCENT : T_TEXT,
                          tb, StringAlignmentNear, StringAlignmentCenter);
                if (g_pop.canDelete && i == g_pop.hot) {       // ✕ only on the hovered row
                    Rect xr = PopupDeleteRect(rc, vis);
                    if (i == g_pop.hotX) FillRound(g, T_CARD_LO, xr, 6);
                    RectF xb((REAL)xr.X, (REAL)xr.Y, (REAL)xr.Width, (REAL)xr.Height);
                    DrawTextC(g, L"✕", *g_gpUI,
                              (i == g_pop.hotX) ? T_DANGER : T_FAINT,
                              xb, StringAlignmentCenter, StringAlignmentCenter);
                }
            }
            if (hasBar) {
                int trackH    = rc.bottom - 2 * g_pop.pad;
                int thumbH    = std::max((int)(20 * g_dpiScale), trackH * g_pop.visRows / n);
                int maxScroll = n - g_pop.visRows;
                int thumbY    = g_pop.pad + (trackH - thumbH) * g_pop.scroll / maxScroll;
                Rect thumb(rc.right - g_pop.pad - barW, thumbY, barW, thumbH);
                FillRound(g, T_DIM, thumb, barW / 2);
            }
        }
        BitBlt(hdc, 0, 0, rc.right, rc.bottom, mem, 0, 0, SRCCOPY);
        SelectObject(mem, old); DeleteObject(bmp); DeleteDC(mem);
        EndPaint(h, &ps);
        return 0;
    }
    }
    return DefWindowProc(h, m, w, l);
}

Visual spec, matching the app's design language: the ✕ () appears only on the hovered row, drawn in T_FAINT; when the cursor is over the ✕ itself it turns T_DANGER red on a subtle T_CARD_LO chip. Labels always reserve the ✕ column in the history popup so text doesn't shift when hover reveals the button.

5. Step 4 — Enable it for the History popup only

In ShowSelectPopup, find:

    g_pop.ctrlId  = ctrlId;

Add one line directly below it:

    g_pop.ctrlId  = ctrlId;
    g_pop.canDelete = (ctrlId == ID_SEL_HISTORY);   // ✕ delete buttons: history only

That's the entire gating — the mic popup keeps canDelete == false and renders/behaves exactly as before.

6. No other changes

  • OnClick (History / SelAudio cases), ShowSelectPopup's sizing/placement, WM_APP_SELECT, history.h — all untouched.
  • No new message IDs, no new globals beyond the two PopupState fields.

7. Interaction with the live session (Fix-05) — read this

Fix-05 keeps the current dictation session mirrored to a history file (g_sessionPath). If the user deletes THAT entry from the popup:

  • Without special handling, FinalizeSession would re-archive the box text on the next Clear/exit — the file the user just deleted would instantly come back.
  • The marked block in PopupDeleteRow prevents that: it clears g_sessionPath and sets g_lastLoadedText to the current box text, so Clear/exit treat the box content as "already accounted for" and do NOT re-archive it. (This is the same guard mechanism Fix-05 documented — used deliberately here, because the user explicitly said "forget this".)
  • Documented behaviour, not a bug: if the user deletes the live entry and then dictates more, a NEW history file is created containing the box's full text — history always mirrors the transcript box. To make a session vanish completely: delete the entry, then Clear.

8. Optional variant — soft delete (trash folder)

If you ever want an undo path, change ONE line in PopupDeleteRow. Replace:

    bool ok = DeleteFileW(path.c_str()) != FALSE;

with:

    std::wstring trashDir = HistoryDir() + L"\\trash";
    CreateDirectoryW(trashDir.c_str(), nullptr);
    std::wstring dest = trashDir + path.substr(path.find_last_of(L'\\'));
    bool ok = MoveFileExW(path.c_str(), dest.c_str(), MOVEFILE_REPLACE_EXISTING) != FALSE;

LoadHistoryIndex only scans history\*.txt (not subfolders), so trashed entries disappear from the popup but remain recoverable by hand. Not wired to any UI — purely a safety net. Skip this for v1 unless asked.

9. Build & test checklist

  1. Reveal: open History, move the mouse down the rows — a faint ✕ appears at the right edge of the hovered row only; it turns red when the cursor reaches it.
  2. Delete: click a ✕ → the row disappears, the file is gone from the history\ folder, status shows "Deleted", and the popup stays open.
  3. Multi-delete: delete 34 entries in a row without the popup closing; hover highlight stays correct after each (the list shifts under the cursor).
  4. Row click still loads: clicking a row anywhere left of the ✕ loads the entry and closes the popup, exactly as before.
  5. Scrollbar transition: with 9+ entries, delete down to 8 → the scrollbar disappears and rows widen slightly; scrolled state stays sane (no blank gaps).
  6. Shrink: with ≤7 entries left, delete more — the popup gets shorter with its bottom edge fixed just above the selects row (it shrinks downward-in-place, never floats away).
  7. Last entry: deleting the final entry closes the popup; clicking History again shows "No history yet".
  8. Delete key: hover a row, press Delete → same as clicking its ✕.
  9. Live session: dictate (entry appears per Fix-05) → open History → delete that entry → press Clear → status "Cleared" and the entry does NOT come back. Then dictate again → a new entry appears (documented in §7).
  10. Mic popup regression: the Microphone popup shows NO ✕, full-width labels, selection works — completely unchanged.
  11. Esc / click-away: still close the popup; no delete is triggered.
  12. DPI 125/150%: ✕ hit zone lines up with the drawn glyph.

10. Do NOT touch / do NOT add

  • Do NOT add a MessageBox confirmation inside PopupProc — the popup destroys itself on deactivation (WM_ACTIVATE/WA_INACTIVE), so a modal dialog kills the popup mid-handler and the code resumes with a destroyed HWND. If confirmation is ever required, use the §8 trash variant or build it into the popup's own surface.
  • PopupRowFromY, ShowSelectPopup (besides the one-line flag), history.h, WM_APP_SELECT, FinalizeSession — unchanged.
  • g_pop.sel needs no remapping on delete: the history popup always opens with sel = -1, and the mic popup (which uses sel) can't delete.