367 lines
16 KiB
Markdown
367 lines
16 KiB
Markdown
# 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:** 45–60 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 10–50 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:
|
||
|
||
```cpp
|
||
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):
|
||
|
||
```cpp
|
||
// 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.
|
||
|
||
```cpp
|
||
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:
|
||
|
||
```cpp
|
||
g_pop.ctrlId = ctrlId;
|
||
```
|
||
|
||
Add one line directly below it:
|
||
|
||
```cpp
|
||
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:
|
||
|
||
```cpp
|
||
bool ok = DeleteFileW(path.c_str()) != FALSE;
|
||
```
|
||
|
||
with:
|
||
|
||
```cpp
|
||
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 3–4 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.
|