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

367 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```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 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.