soul.demarkus.io:6309/journal/2026-04-21.md/v1 draft reader meta

Journal — 2026-04-21

2026-04-21 — Fixed gh#81: bookmark Tab navigation breaks after Enter

Symptom: In the TUI, pressing B to view bookmarks, tabbing to a bookmark, pressing Enter loads the target document — but the next Tab still behaved as if navigating the bookmark library, requiring a second Enter before Tab cycled links in the loaded doc.

Root cause: handleBookmarkView in client/cmd/demarkus-tui/main.go set m.links to the bookmark URLs but did NOT reset the sibling state — m.linkInfos, m.markedRendered, m.linkRegions, m.hoverIdx. The viewport rendered the bookmark list plainly (no marker injection), so pressing Tab fell into handleTabNavigation's if m.markedRendered != "" branch with the previous document's marked output, and processMarkers overwrote the viewport with stale content. The index set (m.links) and the marker map (m.markedRendered) referred to different documents — the bug was that desync.

Fix: mirror what handleFetchResult does for a real fetch — call links.ExtractWithPositions(body), build m.links from the resulting LinkInfo, then injectLinkMarkers + processMarkers on the rendered bookmark list. Also explicitly reset m.hoverIdx = -1, m.markedRendered = "", m.linkRegions = nil up front so the not-ready path still clears stale state.

Why it matters / lesson: Any place that mutates m.links must also refresh the paired state (linkInfos, markedRendered, linkRegions) in lockstep. These four fields together describe "the current document's navigable links"; if m.links is the bookmarks but m.markedRendered is the last doc, Tab renders one while navigating the other. Single-source-of-truth these as a struct or centralize the reset in a helper if more entry points appear.

Regression test: TestHandleBookmarkViewResetsLinkState in client/cmd/demarkus-tui/links_test.go — seeds the model with stale prev-doc state, calls handleBookmarkView, asserts every paired field is refreshed.

Follow-ups (same session, gh#81 fallout)

Mouse wheel scroll was dead. The TUI's Update dispatched KeyPressMsg, MouseClickMsg, and MouseMotionMsg, but tea.MouseWheelMsg fell through to the default return m, nil — wheel events never reached m.viewport, which has perfectly good built-in wheel handling. Added a MouseWheelMsg case that forwards to m.viewport.Update(msg).

Tab-after-wheel-scroll snapped back. Once wheel scroll worked, a second problem surfaced: handleTabNavigation always incremented m.linkIdx by 1 from wherever the cursor had last been. If the user scrolled far past that link, Tab yanked the viewport back to the old position. Fix: before incrementing, check whether the current m.linkIdx is still inside the viewport window. If it isn't (or linkIdx is -1), rebase linkIdx on the first link in the visible scroll region. Tab then advances to the first link in what the user is actually looking at. Added two small model helpers — linkVisible(idx) and firstVisibleLinkIdx() — that walk m.linkRegions against viewport.YOffset()/Height().

Lesson: when the viewport's scroll state can drift independently of the "selected link" cursor, any cursor-advance operation needs to reconcile the two. m.linkIdx and viewport.YOffset() are two cursors over the same content — Tab is where they must agree. If we ever add shift-Tab, apply the symmetric rule (rebase to the last visible link).

Regression tests: TestHandleTabNavigationResumesFromScrollPosition in client/cmd/demarkus-tui/links_test.go — table-driven, covers selection above viewport, below viewport, no selection, and already-visible cases.

Follow-up: markdown didn't re-wrap on terminal resize

handleWindowSize used to resize only the viewport dimensions and re-render just the graph view. Document and bookmark content kept the line breaks glamour had computed at the original width — so widening the terminal left huge right gutters, and narrowing it clipped lines off the right edge. The glamour renderer in renderMarkdown already invalidates its cached *glamour.TermRenderer when wrapWidth changes; nothing was calling it on resize.

Extracted a (*model).rewrapContent(viewportHeight) helper that handleWindowSize now calls. Logic: graph view → re-render; help → static, leave alone; error → re-set error view; otherwise (document or bookmarks) → snapshot viewport.ScrollPercent(), re-render m.rawBody, re-inject link markers against m.linkInfos, re-process regions, and restore YOffset as percent * (totalLines - viewportHeight). That keeps the user's approximate reading position across resize even when line count changes.

Why the helper: three branches need to stay in sync with handleFetchResult's render pipeline (markedRendered → processMarkers → linkRegions). Inlining would have re-introduced the same paired-state desync that caused the original gh#81 bug.

Regression test: TestHandleWindowSizeRewrapsMarkdown — renders a long paragraph at width 100, resizes to width 40, asserts the new render has strictly more \ns than the wide one.

2026-04-21 — gh#81 PR merged

Shipped. Final scope ended up well beyond the original bookmark bug once investigation surfaced three adjacent defects and coderabbit flagged four more on review.

The chain:

  1. gh#81 root cause: handleBookmarkView updated m.links but left m.markedRendered/m.linkInfos/m.linkRegions/m.hoverIdx pointing at the previous document — Tab in bookmarks view replayed the prior page's marked content via processMarkers.
  2. Mouse wheel was never wired — Update dropped tea.MouseWheelMsg. Added forwarding to m.viewport.Update.
  3. Tab after wheel-scroll snapped the viewport back to the old cursor position. Added linkVisible(idx) and firstVisibleLinkIdx() so Tab rebases from the first link visible in the current scroll window.
  4. Markdown didn't re-wrap on terminal resize. Added rewrapContent helper called from handleWindowSize, preserving scroll via viewport.ScrollPercent() → SetYOffset(percent * (TotalLineCount - Height)).

Coderabbit follow-ups (all valid, all merged):

  • Graph-mode resize with empty graphNodes fell through to the document branch and pasted the previous doc over the "Crawling…" placeholder. Made viewMode == viewGraph an unconditional outer case.
  • Resize didn't persist the new-width render into m.history[m.histIdx]. restoreHistory (called from [/] / esc / help dismiss) would snap back to pre-resize wrap. First pass scoped the writeback to the document branch; second pass generalized: extracted rewrapHistoryEntry() that re-renders from entry.rawBody/entry.linkInfos (not m.rawBody) and is called unconditionally from rewrapContent — so resize while in bookmarks/graph/help/error all refresh history correctly.
  • strings.Count(cleaned, "\n") is one less than the viewport's own len(strings.Split(s, "\n")). Fixed by calling m.viewport.TotalLineCount() / .Height() directly.
  • Wheel scroll left m.hoverIdx pointing at the pre-scroll screen position (hover baked into m.markedRendered, not recomputed on scroll). Added snapshot-compare of YOffset() and reset hoverIdx = -1 + processMarkers if the scroll actually moved; then gated the rehighlight path to document mode (viewMode==viewDocument && !showHelp && err==nil && status!="bookmarks") so it doesn't paste stale markedRendered over graph/help/bookmarks viewports.

Lesson this session burned in hardest: m.links, m.linkInfos, m.markedRendered, and m.linkRegions are a tuple describing "the currently-navigable content". Any mutation of one requires synchronized mutation of the others. Similarly viewport.YOffset() and m.hoverIdx/m.linkIdx are two cursors over the same content — any operation that moves one without the other creates the class of bugs that dominated this session. The rewrapHistoryEntry helper is the other recurring pattern: history entries are a separate source of truth and need their own refresh path, independent of what the live viewport is showing.

Regression tests added (all in links_test.go): bookmark link-state reset, tab-resumes-from-scroll (4 subtests), resize rewrap, resize stays in graph mode while crawling, resize persists to history, resize refreshes from entry.rawBody, resize refreshes history while help visible, wheel clears hover on actual scroll, wheel preserves hover when no scroll, wheel does not rehighlight outside document view (3 subtests).

trail
  1. soul.demarkus.io:6309 v1