From 61311b3ee851187cec80abaf93de21870275c8aa Mon Sep 17 00:00:00 2001 From: Corey Ryan Dean Date: Wed, 10 Jun 2026 17:31:29 -0500 Subject: [PATCH] feat(loom): mouse-wheel + scrollbar thumb for Timeline & Recents modals The Ctrl+H Session Timeline and Ctrl+R Recents modals scrolled by arrow keys only -- no mouse wheel and no visible scroll-position indicator, unlike every other Loom scroll region (Composer body, browser card grid, Help modal all have wheel + a brass thumb). This adds both, mirroring the established Composer::drawScrollbar geometry and the Loom_MouseWheel / Loom_ConsumeWheel per-frame-delta convention: - Mouse-wheel scroll in each modal's pumpKeyboard (wheel-up scrolls toward the newest entry; consumes the tick so an earlier-painted surface can't double-apply it). - A thin brass scrollbar thumb in the right margin when the list overflows the visible band; thumb height + position reflect rowsVisible/entryCount and the current scrollOffset (row-space port of the pixel-space Composer scrollbar). - A shared maxScroll helper used by both the clamp and the thumb so the last page sits flush against the footer instead of over-scrolling into empty space (tightens the existing arrow-key clamp too). - Footer hint updated "arrows scroll" -> "scroll / arrows" so the new wheel affordance is discoverable. Purely presentational/affordance; no data-model or editing changes (read-only-alpha safe), F-UI stays out, reuses existing Theme.bb tokens (LOOM_STONE_700 track / LOOM_BRASS_500 thumb). Low-collision rationale: the maintainer's active churn is in the Atlas world-editing canvas / ZoneViewport / thread-web / Composer; git log shows Timeline.bb and Recents.bb are otherwise quiet, touched only by the cosmetic #568 immersion-chrome pass (modal-backdrop lines), which this change does not touch -- it edits drawEntries / pumpKeyboard and adds new helper methods. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Modules/Loom/Recents.bb | 73 +++++++++++++++++++++++++++---- src/Modules/Loom/Timeline.bb | 83 +++++++++++++++++++++++++++++++----- 2 files changed, 137 insertions(+), 19 deletions(-) diff --git a/src/Modules/Loom/Recents.bb b/src/Modules/Loom/Recents.bb index 63b9af1f..d3bc0f84 100644 --- a/src/Modules/Loom/Recents.bb +++ b/src/Modules/Loom/Recents.bb @@ -380,7 +380,7 @@ Type Recents Local hy% = modalY + RECENTS_MODAL_H - RECENTS_HINT_H - 4 LoomHRule(modalX + RECENTS_PAD, hy - 2, RECENTS_MODAL_W - RECENTS_PAD * 2, LOOM_BRASS_700_R, LOOM_BRASS_700_G, LOOM_BRASS_700_B) - LoomText(modalX + RECENTS_PAD, hy + 4, "Click a row to jump | arrows scroll | Esc to close", LOOM_STONE_300_R, LOOM_STONE_300_G, LOOM_STONE_300_B) + LoomText(modalX + RECENTS_PAD, hy + 4, "Click a row to jump | scroll / arrows | Esc to close", LOOM_STONE_300_R, LOOM_STONE_300_G, LOOM_STONE_300_B) If clicked = True If mx < modalX Or mx >= modalX + RECENTS_MODAL_W Or my < modalY Or my >= modalY + RECENTS_MODAL_H @@ -415,6 +415,51 @@ Type Recents Recents::drawOneEntry(self, e, rx, ry, rw, mx, my, clicked) EndIf Next + + // Scrollbar thumb in the right margin when the list overflows the + // visible band -- gives the wheel/arrow scroll a position indicator, + // matching the Composer body + browser grid. + If self\entryCount > rowsVisible + Recents::drawScrollbar(self, rx + rw + 4, listY, rowsVisible * RECENTS_ROW_H) + EndIf + End Method + + + // ------------------------------------------------------------------------- + // drawScrollbar -- thin brass thumb in row space (mirrors Composer:: + // drawScrollbar, which works in pixel space). Track spans the visible + // band; thumb height + position reflect rowsVisible / entryCount and the + // current scrollOffset. Only called when the list overflows. + // ------------------------------------------------------------------------- + Method drawScrollbar(barX%, barTopY%, barH%) + If self\entryCount <= 0 Then Return + + LoomFill(barX, barTopY, 4, barH, LOOM_STONE_700_R, LOOM_STONE_700_G, LOOM_STONE_700_B) + + Local thumbH% = (barH * barH) / (self\entryCount * RECENTS_ROW_H) + If thumbH < 16 Then thumbH = 16 + If thumbH > barH Then thumbH = barH + + Local maxScroll% = Recents::maxScroll(self) + Local travelTrack% = barH - thumbH + Local thumbY% = barTopY + If maxScroll > 0 Then thumbY = barTopY + (self\scrollOffset * travelTrack) / maxScroll + + LoomFill(barX, thumbY, 4, thumbH, LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) + End Method + + + // ------------------------------------------------------------------------- + // maxScroll -- highest valid scrollOffset: entryCount minus the rows that + // fit in the list band, floored at 0. Shared by pumpKeyboard's clamp and + // the scrollbar thumb geometry. + // ------------------------------------------------------------------------- + Method maxScroll%() + Local listH% = RECENTS_MODAL_H - RECENTS_HEADER_H - RECENTS_HINT_H - 12 + Local rowsVisible% = listH / RECENTS_ROW_H + Local m% = self\entryCount - rowsVisible + If m < 0 Then m = 0 + Return m End Method @@ -463,14 +508,26 @@ Type Recents Recents::closeModal(self) Return EndIf - If KeyHit(200) And self\scrollOffset > 0 - self\scrollOffset = self\scrollOffset - 1 - EndIf - If KeyHit(208) - self\scrollOffset = self\scrollOffset + 1 - If self\scrollOffset >= self\entryCount Then self\scrollOffset = self\entryCount - 1 - If self\scrollOffset < 0 Then self\scrollOffset = 0 + + // Arrow keys nudge one row. + If KeyHit(200) Then self\scrollOffset = self\scrollOffset - 1 + If KeyHit(208) Then self\scrollOffset = self\scrollOffset + 1 + + // Mouse wheel scroll. Loom_MouseWheel() is the per-frame DELTA; + // wheel-up is positive and scrolls toward the most-recent entry, + // matching the Composer body + browser grid convention. Consume the + // tick so a surface painted earlier this frame can't also act on it. + Local wheel% = Loom_MouseWheel() + If wheel <> 0 + self\scrollOffset = self\scrollOffset - wheel + Loom_ConsumeWheel() EndIf + + // Clamp to [0, maxScroll] so the last page sits flush against the + // footer (keeps the scrollbar thumb honest). + Local maxScroll% = Recents::maxScroll(self) + If self\scrollOffset > maxScroll Then self\scrollOffset = maxScroll + If self\scrollOffset < 0 Then self\scrollOffset = 0 End Method diff --git a/src/Modules/Loom/Timeline.bb b/src/Modules/Loom/Timeline.bb index f454f704..9741cb84 100644 --- a/src/Modules/Loom/Timeline.bb +++ b/src/Modules/Loom/Timeline.bb @@ -190,7 +190,7 @@ Type Timeline // Footer hint Local hy% = modalY + TIMELINE_MODAL_H - TIMELINE_HINT_H - 4 LoomHRule(modalX + TIMELINE_PAD, hy - 2, TIMELINE_MODAL_W - TIMELINE_PAD * 2, LOOM_BRASS_700_R, LOOM_BRASS_700_G, LOOM_BRASS_700_B) - LoomText(modalX + TIMELINE_PAD, hy + 4, "Click revert on a row to undo | arrows scroll | Esc to close", LOOM_STONE_300_R, LOOM_STONE_300_G, LOOM_STONE_300_B) + LoomText(modalX + TIMELINE_PAD, hy + 4, "Click revert on a row to undo | scroll / arrows | Esc to close", LOOM_STONE_300_R, LOOM_STONE_300_G, LOOM_STONE_300_B) // Click-outside-modal closes If clicked = True @@ -230,6 +230,42 @@ Type Timeline EndIf e = Before e Wend + + // Scrollbar thumb in the right margin (between the rows and the + // modal border) whenever the history overflows the visible band. + // Gives the wheel/arrow scroll a position indicator -- every other + // Loom scroll region (Composer body, browser grid) has one. + If self\entryCount > rowsVisible + Timeline::drawScrollbar(self, rx + rw + 4, listY, rowsVisible * TIMELINE_ROW_H) + EndIf + End Method + + + // ------------------------------------------------------------------------- + // drawScrollbar -- thin brass thumb in row space (mirrors Composer:: + // drawScrollbar, which works in pixel space). Track spans the visible + // band; thumb height + position reflect rowsVisible / entryCount and the + // current scrollOffset. Only called when the list overflows. + // ------------------------------------------------------------------------- + Method drawScrollbar(barX%, barTopY%, barH%) + If self\entryCount <= 0 Then Return + + // Track + LoomFill(barX, barTopY, 4, barH, LOOM_STONE_700_R, LOOM_STONE_700_G, LOOM_STONE_700_B) + + // Thumb height proportional to the visible fraction of the content; + // floored at 16px so it stays grabbable with a long history. + Local thumbH% = (barH * barH) / (self\entryCount * TIMELINE_ROW_H) + If thumbH < 16 Then thumbH = 16 + If thumbH > barH Then thumbH = barH + + // Thumb y: scrollOffset (rows) mapped into track travel. + Local maxScroll% = Timeline::maxScroll(self) + Local travelTrack% = barH - thumbH + Local thumbY% = barTopY + If maxScroll > 0 Then thumbY = barTopY + (self\scrollOffset * travelTrack) / maxScroll + + LoomFill(barX, thumbY, 4, thumbH, LOOM_BRASS_500_R, LOOM_BRASS_500_G, LOOM_BRASS_500_B) End Method @@ -310,17 +346,42 @@ Type Timeline Timeline::closeModal(self) Return EndIf - If KeyHit(200) And self\scrollOffset > 0 - self\scrollOffset = self\scrollOffset - 1 - EndIf - If KeyHit(208) - self\scrollOffset = self\scrollOffset + 1 - // Clamp to avoid scrolling past the end (best-effort: cap to - // entryCount-1; the visible-rows check in drawEntries handles - // the rest visually). - If self\scrollOffset >= self\entryCount Then self\scrollOffset = self\entryCount - 1 - If self\scrollOffset < 0 Then self\scrollOffset = 0 + + // Arrow keys nudge one row. + If KeyHit(200) Then self\scrollOffset = self\scrollOffset - 1 + If KeyHit(208) Then self\scrollOffset = self\scrollOffset + 1 + + // Mouse wheel scroll. Loom_MouseWheel() is the per-frame DELTA + // (Loom_BeginFrame derives it from MouseZ's cumulative value); + // wheel-up is positive and scrolls toward the newest entry, matching + // the Composer body + browser grid convention. Consume the tick so a + // surface painted earlier this frame can't also act on it. + Local wheel% = Loom_MouseWheel() + If wheel <> 0 + self\scrollOffset = self\scrollOffset - wheel + Loom_ConsumeWheel() EndIf + + // Clamp to [0, maxScroll] so the last page sits flush against the + // footer instead of scrolling into empty space (keeps the scrollbar + // thumb honest). + Local maxScroll% = Timeline::maxScroll(self) + If self\scrollOffset > maxScroll Then self\scrollOffset = maxScroll + If self\scrollOffset < 0 Then self\scrollOffset = 0 + End Method + + + // ------------------------------------------------------------------------- + // maxScroll -- highest valid scrollOffset: entryCount minus the rows that + // fit in the list band, floored at 0. Shared by pumpKeyboard's clamp and + // the scrollbar thumb geometry so the two never disagree. + // ------------------------------------------------------------------------- + Method maxScroll%() + Local listH% = TIMELINE_MODAL_H - TIMELINE_HEADER_H - TIMELINE_HINT_H - 12 + Local rowsVisible% = listH / TIMELINE_ROW_H + Local m% = self\entryCount - rowsVisible + If m < 0 Then m = 0 + Return m End Method