From 1ac5b867bb5ee9d642bb5cb518d8897794f7f7e8 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 20 Aug 2026 11:54:08 -0400 Subject: [PATCH] for that one guy who has a mouse but for some reason it doesnt have a scroll wheel --- src/import/LauncherView.lua | 97 +++++++++++++++++++++++---- src/ui/kit/Kit.lua | 52 ++++++++++++-- tests/engine/launcher_scroll_test.lua | 82 ++++++++++++++++++++++ 3 files changed, 214 insertions(+), 17 deletions(-) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 49f3a8e9..221226b4 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -124,12 +124,48 @@ function LauncherView.detach(imp) Kit.clearCaches() end +local function markNoDrag(imp, x, y, w, h) + if Kit.blockClicks then return end + local t = imp._noDragRects + if not t then t = {}; imp._noDragRects = t end + local n = (imp._noDragN or 0) + 1 + imp._noDragN = n + local r = t[n] + if not r then r = {}; t[n] = r end + r.x, r.y, r.w, r.h = x, y, w, h +end + +local function noDragAt(imp, x, y) + local rects = imp._noDragRects + for i = 1, imp._noDragN or 0 do + if inRect(rects[i], x, y) then return true end + end + return false +end + +local function armMouse(imp, x, y) + if noDragAt(imp, x, y) then + imp._clickPt = { x = x, y = y } + return + end + local shielded = imp._modalUpNow + imp._mouseAt = { + x = x, y = y, + region = not shielded and tabScrollMax(imp) > 0 + and inRect(imp._tabRegionRect, x, y) or false, + page = not shielded and (imp._pageScrollMax or 0) > 0 or false, + } + Kit.dragBegin(x, y) +end + -- ---------------------------------------------------------------- input --- The kit is polled, not evented: update() samples the mouse and turns a --- rising edge into a click point that the next draw consumes. Host-forwarded --- mousepressed stays unused, exactly as before, so Android's synthesized --- mouse path cannot double-fire a tap (#553) -- the dedup window below is the --- other half of that guarantee. +-- The kit is polled, not evented: update() samples the mouse. A press arms +-- a drag that scrolls like a finger, and the click dispatches on RELEASE so +-- the drag can disqualify it, exactly like the touch path below; only the +-- cartridge (which owns its own spin-drag) keeps the press-down click. +-- Host-forwarded mousepressed stays unused, exactly as before, so Android's +-- synthesized mouse path cannot double-fire a tap (#553) -- the dedup window +-- below is the other half of that guarantee. function LauncherView.update(imp, dt) if not imp._flex then return end if imp._launchFade then return end @@ -154,7 +190,39 @@ function LauncherView.update(imp, dt) if not touching and now >= (imp._suppressMouseUntil or 0) and now >= (imp._suppressClickUntil or 0) then local mx, my = love.mouse.getPosition() - imp._clickPt = { x = mx, y = my } + armMouse(imp, mx, my) + end + elseif down and imp._mouseAt then + local start = imp._mouseAt + local mx, my = love.mouse.getPosition() + local ddx, ddy = mx - start.x, my - start.y + if ddx * ddx + ddy * ddy > TAP_SLOP2 then + start.dragged = true + end + if start.dragged then + local last = start.lastY or start.y + local move = -(my - last) + if move ~= 0 and start.region then + local at, leftover = Kit.scrollHandoff(tabScrollAt(imp), + tabScrollMax(imp), move) + setTabScroll(imp, at) + move = leftover + end + if move ~= 0 and start.page and (imp._pageScrollMax or 0) > 0 then + local at, leftover = Kit.scrollHandoff(imp._pageScroll or 0, + imp._pageScrollMax, move) + imp._pageScroll = at + move = leftover + end + if move ~= 0 then Kit.dragAdd(move) end + end + start.lastY = my + elseif not down and imp._mouseAt then + local start = imp._mouseAt + imp._mouseAt = nil + Kit.dragEnd() + if not start.dragged then + imp._clickPt = { x = start.x, y = start.y } end end imp._prevMouseDown = down @@ -233,10 +301,12 @@ function LauncherView.clickAt(imp, x, y) imp._clickPt = { x = x, y = y } end --- Event-driven click: a macOS trackpad tap delivers press+release inside one --- frame, so update()'s love.mouse.isDown poll never sees it. Mint the click --- from the press event under the poll's own suppression rules, and mark the --- press seen so the poll cannot mint a second one when isDown does catch it. +-- Event-driven press: a macOS trackpad tap delivers press+release inside one +-- frame, so update()'s love.mouse.isDown poll never sees it. Arm the drag +-- from the press event under the poll's own suppression rules -- the poll's +-- release branch then mints the tap, still within the same frame for a +-- one-frame tap -- and mark the press seen so the poll cannot arm a second +-- one when isDown does catch it. function LauncherView.mousepressed(imp, x, y) if not imp._flex then return end local now = love.timer.getTime() @@ -245,7 +315,7 @@ function LauncherView.mousepressed(imp, x, y) or now < (imp._suppressClickUntil or 0) then return end - imp._clickPt = { x = x, y = y } + if not imp._mouseAt then armMouse(imp, x, y) end imp._prevMouseDown = true end @@ -495,6 +565,7 @@ end local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) local state = cartridgeState(imp, version) + markNoDrag(imp, x, y, w, h) local focused = Kit.focusable(key, x, y, w, h) local hot = Kit.hover(x, y, w, h) local active = state.active @@ -4868,13 +4939,15 @@ function LauncherView.draw(imp) Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0) imp._clickPt = nil imp._wheelY = 0 + imp._noDragN = 0 Theme.field() -- Everything from here to buildModals sits UNDER any open modal, so the -- whole stage draws shielded (no clicks, no hover, no focus ring) while -- one is up; buildModals lowers the shield for the modal's own controls. - Kit.blockClicks = modalUp(imp) + imp._modalUpNow = modalUp(imp) + Kit.blockClicks = imp._modalUpNow local step = Kit.scrollStep(m.s) do diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index 7ae2464f..18718dd2 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -918,16 +918,58 @@ function Kit.rowsThatFit(h, rowH, gap, minRows, maxRows) return math.max(minRows or 1, math.min(maxRows or 99, per)) end +Kit.dragX = nil +Kit.dragY = nil +Kit.dragAccum = 0 + +function Kit.dragBegin(x, y) + Kit.dragX, Kit.dragY, Kit.dragAccum = x, y, 0 +end + +function Kit.dragAdd(dy) + if Kit.dragX then Kit.dragAccum = Kit.dragAccum + (dy or 0) end +end + +function Kit.dragEnd() + Kit.dragX, Kit.dragY, Kit.dragAccum = nil, nil, 0 +end + +local function dragOriginIn(x, y, w, h) + if not Kit.dragX then return false end + local x1, y1, x2, y2 = x, y, x + w, y + h + local c = Kit._clipRect + if c then + x1, y1 = math.max(x1, c.x), math.max(y1, c.y) + x2, y2 = math.min(x2, c.x + c.w), math.min(y2, c.y + c.h) + end + return Kit.dragX >= x1 and Kit.dragX <= x2 + and Kit.dragY >= y1 and Kit.dragY <= y2 +end + -- Mouse wheel over a paginated list turns PAGES. The wheel still has to do -- something (users expect it), but it moves a bounded page index rather than -- driving a pixel offset, so there is no scroll state and no interpolation. function Kit.wheelPage(x, y, w, h, page, total, perPage) - if Kit.blockClicks or (Kit.wheelY or 0) == 0 then return page end - if not Kit.hit(x, y, w, h) then return page end + if Kit.blockClicks then return page end local pages = math.max(1, math.ceil(total / math.max(1, perPage))) - local moved = Theme.clamp((page or 1) + (Kit.wheelY > 0 and -1 or 1), 1, pages) - Kit.wheelY = 0 - return math.floor(moved) + local out = page + if (Kit.wheelY or 0) ~= 0 and Kit.hit(x, y, w, h) then + out = math.floor(Theme.clamp((out or 1) + (Kit.wheelY > 0 and -1 or 1), + 1, pages)) + Kit.wheelY = 0 + end + local acc = Kit.dragAccum or 0 + if acc ~= 0 and dragOriginIn(x, y, w, h) then + local stepPx = math.max(1, math.floor((h or 0) / 2)) + local flips = acc >= 0 and math.floor(acc / stepPx) + or -math.floor(-acc / stepPx) + if flips ~= 0 then + local want = (out or 1) + flips + out = math.floor(Theme.clamp(want, 1, pages)) + Kit.dragAccum = out ~= want and 0 or acc - flips * stepPx + end + end + return out end function Kit.scrollExtent(contentH, viewH) diff --git a/tests/engine/launcher_scroll_test.lua b/tests/engine/launcher_scroll_test.lua index 0816897c..b896fba3 100644 --- a/tests/engine/launcher_scroll_test.lua +++ b/tests/engine/launcher_scroll_test.lua @@ -300,6 +300,88 @@ LauncherView.draw(edgeImp) check((edgeImp._tabScroll.skins or 0) > 0, "which reaches the tab region even though the cursor is below it") +Kit.blockClicks = false +Kit._clipRect = nil +Kit.wheelY = 0 +Kit.mouseX, Kit.mouseY = 400, 400 +Kit.dragBegin(50, 50) +Kit.dragAdd(120) +local pg = Kit.wheelPage(0, 0, 100, 100, 1, 100, 10) +eq(pg, 3, "a drag that crossed two half-heights turns two pages") +eq(Kit.dragAccum, 20, "and keeps the remainder for the next flip") +Kit.dragAdd(-140) +pg = Kit.wheelPage(0, 0, 100, 100, pg, 100, 10) +eq(pg, 1, "dragging back down returns those pages") +eq(Kit.dragAccum, -20, "with the remainder's sign preserved") +Kit.dragAdd(-80) +pg = Kit.wheelPage(0, 0, 100, 100, pg, 100, 10) +eq(pg, 1, "a drag past the first page clamps to it") +eq(Kit.dragAccum, 0, "and drops the pile-up so reversing is instant") +Kit.dragAdd(200) +pg = Kit.wheelPage(200, 200, 100, 100, pg, 100, 10) +eq(pg, 1, "a drag that began outside the list is not the list's") +eq(Kit.dragAccum, 200, "and its travel stays queued") +Kit.dragEnd() +eq(Kit.dragAccum, 0, "releasing the button retires the gesture") + +window(360, 780) +local mouseImp = skinLauncher(12) +LauncherView.draw(mouseImp) +LauncherView.draw(mouseImp) +local mreg = mouseImp._tabRegionRect +local mmax = mouseImp._tabScrollMax.skins +check(mmax > 0, "the mouse-dragged panel has travel") +local mdown = false +love.mouse.isDown = function() return mdown end + +pointer(mreg.x + 20, mreg.y + 40) +mdown = true +LauncherView.update(mouseImp, 0.016) +check(mouseImp._clickPt == nil, + "a press over a scrollable region mints no click") +check(mouseImp._mouseAt ~= nil, "it arms a drag instead") +pointer(mreg.x + 20, mreg.y + 40 - 200) +LauncherView.update(mouseImp, 0.016) +eq(mouseImp._tabScroll.skins, math.min(200, mmax), + "dragging the held mouse scrolls the panel by its travel") +eq(mouseImp._pageScroll or 0, 0, + "while the panel still has travel, the page waits") +pointer(mreg.x + 20, mreg.y + 40 - 200 - mmax * 2) +LauncherView.update(mouseImp, 0.016) +eq(mouseImp._tabScroll.skins, mmax, + "a longer mouse drag reaches the panel's bottom") +check((mouseImp._pageScroll or 0) > 0, "and spills into the page from there") +mdown = false +LauncherView.update(mouseImp, 0.016) +check(mouseImp._clickPt == nil, "a released drag is not a click") +check(mouseImp._mouseAt == nil, "and the gesture is retired") + +pointer(mreg.x + 20, mreg.y + 40) +mdown = true +LauncherView.update(mouseImp, 0.016) +check(mouseImp._clickPt == nil, "a fresh press still holds its click back") +mdown = false +LauncherView.update(mouseImp, 0.016) +check(mouseImp._clickPt ~= nil, + "press and release without travel is still a tap, on release") +mouseImp._clickPt = nil + +LauncherView.draw(gameImp) +LauncherView.draw(gameImp) +check((gameImp._noDragN or 0) > 0, "the game tab publishes its cartridge rect") +local cart = gameImp._noDragRects[1] +pointer(cart.x + cart.w / 2, cart.y + cart.h / 2) +mdown = true +LauncherView.update(gameImp, 0.016) +check(gameImp._clickPt ~= nil, + "a press on the cartridge clicks at once so its own spin-drag still owns " + .. "the gesture") +check(gameImp._mouseAt == nil, "and never arms the scroll drag") +gameImp._clickPt = nil +mdown = false +LauncherView.update(gameImp, 0.016) +love.mouse.isDown = nil + local function read(path) local f = assert(io.open(path, "r")) local src = f:read("*a")