From 5c041ea857a3cac3f8e085476123180ea20205b8 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 3 Aug 2026 12:39:36 -0400 Subject: [PATCH] snip for scroll --- libs/flexlove/FlexLove.lua | 142 ++++++++++++++++++++---- main.lua | 38 ++----- src/import/LauncherView.lua | 50 ++++++++- src/import/RomImporter.lua | 37 ++++-- tests/rom_importer_double_pick_test.lua | 11 +- 5 files changed, 210 insertions(+), 68 deletions(-) diff --git a/libs/flexlove/FlexLove.lua b/libs/flexlove/FlexLove.lua index ef17edbf..2f72f720 100644 --- a/libs/flexlove/FlexLove.lua +++ b/libs/flexlove/FlexLove.lua @@ -189,6 +189,12 @@ flexlove._accumulatedDt = 0 ---@type table flexlove._touchOwners = {} +-- Touch-drag scroll tracking: survives immediate-mode element recreation. +-- Maps touch ID -> { id, lastX, lastY }. Scroll position is persisted into +-- StateManager on every move (same contract as wheelmoved). +---@type table +flexlove._touchScroll = {} + ---@type table flexlove._mouseButtonStates = {} @@ -1412,6 +1418,82 @@ function flexlove._getTouchElementAtPosition(x, y) return candidates[1] end +local function elementIsTouchScrollable(element) + if not element or not element._scrollManager then + return false + end + local overflowX = element.overflowX or element.overflow + local overflowY = element.overflowY or element.overflow + return overflowX == "scroll" + or overflowX == "auto" + or overflowY == "scroll" + or overflowY == "auto" +end + +-- Walk parents from a hit target so a finger on a button/row still scrolls +-- the containing list. Falls back to the wheel path's scrollable lookup when +-- the press lands on empty space inside a scroller. +local function findTouchScrollTarget(x, y, startElement) + local el = startElement + while el do + if elementIsTouchScrollable(el) then + return el + end + el = el.parent + end + return Context.findScrollableAtPosition(x, y) +end + +local function findElementByStateId(stateId) + if not stateId or stateId == "" then + return nil + end + local function walk(element) + if element.id == stateId or element._stateId == stateId then + return element + end + for _, child in ipairs(element.children or {}) do + local found = walk(child) + if found then + return found + end + end + return nil + end + for _, element in ipairs(flexlove.topElements or {}) do + local found = walk(element) + if found then + return found + end + end + for _, element in ipairs(flexlove._currentFrameElements or {}) do + local found = walk(element) + if found then + return found + end + end + return nil +end + +local function persistTouchScroll(element) + if flexlove._immediateMode and element and element._stateId and element._scrollManager then + StateManager.updateState(element._stateId, { + scrollManager = element._scrollManager:getState(), + }) + end +end + +local function resolveTouchScrollElement(track) + if not track then + return nil + end + local element = findElementByStateId(track.id) + if elementIsTouchScrollable(element) then + return element + end + return nil +end + --- Handle touch press events from LÖVE's touch input system --- Routes touch to the topmost element at the touch position and assigns touch ownership --- Hook this to love.touchpressed() to enable touch interaction @@ -1452,15 +1534,22 @@ function flexlove.touchpressed(id, x, y, dx, dy, pressure) end end end + end - -- Route to scroll manager for scrollable elements - if element._scrollManager then - local overflowX = element.overflowX or element.overflow - local overflowY = element.overflowY or element.overflow - if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then - element._scrollManager:handleTouchPress(touchX, touchY) - end + -- Scroll target is the nearest scrollable ancestor (or the scroller under + -- empty space). Tracked by stable id so immediate-mode recreation can resume. + local scrollEl = findTouchScrollTarget(touchX, touchY, element) + if scrollEl and scrollEl._scrollManager then + local scrollId = scrollEl._stateId or scrollEl.id + if scrollId and scrollId ~= "" then + flexlove._touchScroll[touchId] = { + id = scrollId, + lastX = touchX, + lastY = touchY, + } end + scrollEl._scrollManager:handleTouchPress(touchX, touchY) + persistTouchScroll(scrollEl) end end @@ -1500,15 +1589,21 @@ function flexlove.touchmoved(id, x, y, dx, dy, pressure) end end end + end - -- Route to scroll manager for scrollable elements - if element._scrollManager then - local overflowX = element.overflowX or element.overflow - local overflowY = element.overflowY or element.overflow - if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then - element._scrollManager:handleTouchMove(touchX, touchY) - end + local track = flexlove._touchScroll[touchId] + local scrollEl = resolveTouchScrollElement(track) + if track and scrollEl then + local sm = scrollEl._scrollManager + -- Immediate mode recreates managers each frame; re-arm drag from the + -- last persisted touch point so a move after beginFrame still scrolls. + if not sm._touchScrolling then + sm:handleTouchPress(track.lastX, track.lastY) end + sm:handleTouchMove(touchX, touchY) + persistTouchScroll(scrollEl) + track.lastX = touchX + track.lastY = touchY end end @@ -1548,19 +1643,23 @@ function flexlove.touchreleased(id, x, y, dx, dy, pressure) end end end + end - -- Route to scroll manager for scrollable elements - if element._scrollManager then - local overflowX = element.overflowX or element.overflow - local overflowY = element.overflowY or element.overflow - if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then - element._scrollManager:handleTouchRelease() - end + local track = flexlove._touchScroll[touchId] + local scrollEl = resolveTouchScrollElement(track) + if track and scrollEl then + local sm = scrollEl._scrollManager + if not sm._touchScrolling then + sm:handleTouchPress(track.lastX, track.lastY) end + sm:handleTouchMove(touchX, touchY) + sm:handleTouchRelease() + persistTouchScroll(scrollEl) end -- Clean up touch ownership (touch is complete) flexlove._touchOwners[touchId] = nil + flexlove._touchScroll[touchId] = nil end --- Get the number of currently active touches being tracked @@ -1659,6 +1758,7 @@ function flexlove.destroy() -- Clean up touch state flexlove._touchOwners = {} + flexlove._touchScroll = {} flexlove._mouseButtonStates = {} if flexlove._gestureRecognizer then flexlove._gestureRecognizer:reset() diff --git a/main.lua b/main.lua index 20488a26..d5874db4 100644 --- a/main.lua +++ b/main.lua @@ -422,10 +422,10 @@ function love.touchpressed(id, x, y, dx, dy, pressure) return TouchEditor.touchpressed(id, x, y) end if Importer then - if love.system.getOS() == "iOS" then - return Importer:touchpressed(id, x, y) - end - return Importer:mousepressed(x, y, 1) + -- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are + -- polled inside the view; the istouch filter on mousepressed still drops + -- Android's synthesized mouse twin so Import cannot double-fire (#553). + return Importer:touchpressed(id, x, y, dx, dy, pressure) end Game:touchpressed(id, x, y) end @@ -437,10 +437,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure) return TouchEditor.touchmoved(id, x, y) end if Importer then - if love.system.getOS() == "iOS" then - return Importer:touchmoved(id, x, y) - end - return + return Importer:touchmoved(id, x, y, dx, dy, pressure) end Game:touchmoved(id, x, y) end @@ -452,10 +449,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure) return TouchEditor.touchreleased(id, x, y) end if Importer then - if love.system.getOS() == "iOS" then - return Importer:touchreleased(id, x, y) - end - return + return Importer:touchreleased(id, x, y, dx, dy, pressure) end Game:touchreleased(id, x, y) end @@ -478,20 +472,12 @@ function love.mousepressed(x, y, button, istouch) return TouchEditor.mousepressed(x, y, button) end if Importer then - -- The same double-fire TouchEditor guards against, which the launcher was - -- missing: love.touchpressed above forwards the primary touch to the - -- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that - -- same touch, so one tap ran every launcher button twice. On Import that - -- meant two choose() calls and two stacked SAF picker activities: the - -- player picked their ROM, the top picker closed, and the second was still - -- underneath asking for it again, which is the "import the file twice" - -- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a - -- USB mouse) working, which an Android-wide return would have broken. - -- - -- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above - -- returns early on iOS and never forwards, so there the synthesized mouse - -- press is the ONLY event the launcher gets. Filtering istouch on both - -- killed every tap on iOS outright. + -- love.touchpressed already forwards the primary touch into FlexLove for + -- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both + -- reached a press handler, one tap ran every launcher button twice and + -- stacked two SAF pickers (#553). Clicks are polled inside FlexLove from + -- love.touch / mouse.isDown, so dropping the synthesized istouch press is + -- safe. A real mouse (DeX, Chromebook, USB) still reaches mousepressed. if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end return Importer:mousepressed(x, y, button) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 0a076c5b..bb0a562c 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -146,11 +146,48 @@ function LauncherView.update(imp, dt) end end +-- One dedup window covers a touch release plus the mouse click SDL +-- synthesizes for the same tap. +local ACT_DEDUP = 0.35 +-- Finger travel past this (px) is a scroll drag, not a tap — so dragging a +-- list row does not also fire that row's button. +local TAP_SLOP2 = 16 * 16 + function LauncherView.wheelmoved(imp, dx, dy) if not imp._flex then return end pcall(FlexLove.wheelmoved, dx, dy) end +-- Touch drag scroll: FlexLove's ScrollManager only moves when these are +-- hooked. Clicks still come from EventHandler's love.touch / mouse polling; +-- the view's action dedupe covers a tap that also synthesizes a mouse click, +-- and a drag past TAP_SLOP suppresses the click that would otherwise fire +-- on the row under the finger. +function LauncherView.touchpressed(imp, id, x, y, dx, dy, pressure) + if not imp._flex then return end + imp._touchAt = imp._touchAt or {} + imp._touchAt[tostring(id)] = { x = x, y = y } + pcall(FlexLove.touchpressed, id, x, y, dx, dy, pressure) +end + +function LauncherView.touchmoved(imp, id, x, y, dx, dy, pressure) + if not imp._flex then return end + local start = imp._touchAt and imp._touchAt[tostring(id)] + if start then + local ddx, ddy = x - start.x, y - start.y + if ddx * ddx + ddy * ddy > TAP_SLOP2 then + imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP + end + end + pcall(FlexLove.touchmoved, id, x, y, dx, dy, pressure) +end + +function LauncherView.touchreleased(imp, id, x, y, dx, dy, pressure) + if not imp._flex then return end + if imp._touchAt then imp._touchAt[tostring(id)] = nil end + pcall(FlexLove.touchreleased, id, x, y, dx, dy, pressure) +end + -- Synthetic click for the gamepad virtual cursor: find the element under the -- pad pointer and run its handler with a click-shaped event. function LauncherView.clickAt(imp, x, y) @@ -166,10 +203,6 @@ end -- ------- shared widget helpers --- One dedup window covers a touch release plus the mouse click SDL --- synthesizes for the same tap. -local ACT_DEDUP = 0.35 - local function queueAction(imp, key, fn, keepArm) local now = love.timer.getTime() local last = imp._actAt[key] @@ -188,6 +221,15 @@ local function handler(imp, key, action, keepArm) elseif ev.type == "unhover" then imp._hot[key] = nil elseif action and (ev.type == "click" or ev.type == "touchrelease") then + if ev.type == "touchrelease" then + local dx, dy = ev.dx or 0, ev.dy or 0 + if dx * dx + dy * dy > TAP_SLOP2 then + imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP + return + end + end + local untilT = imp._suppressClickUntil + if untilT and love.timer.getTime() < untilT then return end queueAction(imp, key, action, keepArm) end end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index d1b6553d..b39458b4 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -617,12 +617,9 @@ function RomImporter.new(onComplete, opts) -- boot armed and let the first poll tick consume it, rather than making the -- player tap Import a second time to trigger the scan by hand (#553). pickPending = android or nil, - -- Android drag: the launcher is handed no move events at all (main.lua - -- forwards neither touchmoved nor mousemoved while it is up), and its mouse - -- emulation is what "no reliable pointer polling" below refers to. - -- love.touch IS pollable, so where it exists a touch drag can be resolved - -- inside draw the same way the desktop mouse is. Where it does not, every - -- Android path stays exactly as it was: act on press, never arm. + -- Mobile drag-scroll goes through FlexLove.touch* (main.lua forwards the + -- full touch stream while the launcher is up). love.touch remains pollable + -- for click hit-testing inside EventHandler. touchPollable = android and love.touch ~= nil and love.touch.getTouches ~= nil and love.touch.getPosition ~= nil, tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods" @@ -1703,13 +1700,29 @@ function RomImporter:pressDelete(kind, id, version, commit) return false end --- Pointer input is polled by the FlexLove view (mouse and touch alike), so --- the host-forwarded press events are inert. The methods stay because --- main.lua forwards to them unconditionally while the launcher is up. +-- Clicks are polled inside FlexLove (mouse + love.touch); host-forwarded +-- mousepressed stays inert so Android's synthesized mouse path cannot +-- double-fire a tap (#553). Touch move/press/release must still reach +-- FlexLove.touch* or scroll containers never drag on phones. function RomImporter:mousepressed() end -function RomImporter:touchpressed() end -function RomImporter:touchmoved() end -function RomImporter:touchreleased() end + +function RomImporter:touchpressed(id, x, y, dx, dy, pressure) + if not self._flex then return end + require("src.import.LauncherView").touchpressed( + self, id, x, y, dx, dy, pressure) +end + +function RomImporter:touchmoved(id, x, y, dx, dy, pressure) + if not self._flex then return end + require("src.import.LauncherView").touchmoved( + self, id, x, y, dx, dy, pressure) +end + +function RomImporter:touchreleased(id, x, y, dx, dy, pressure) + if not self._flex then return end + require("src.import.LauncherView").touchreleased( + self, id, x, y, dx, dy, pressure) +end -- Switch the active tab (chips, shoulder buttons). The find search caret and -- the soft keyboard drop with the panel they belonged to; each tab's scroll diff --git a/tests/rom_importer_double_pick_test.lua b/tests/rom_importer_double_pick_test.lua index e8462e2d..e7eba41e 100644 --- a/tests/rom_importer_double_pick_test.lua +++ b/tests/rom_importer_double_pick_test.lua @@ -145,10 +145,9 @@ for os, forwards in pairs(touchForwardsToImporter) do os .. ": the synthesized mouse press is dropped only where touch already forwarded") end --- The FlexLove view polls love.touch itself and dedupes a tap's synthesized --- mouse click in its action layer (LauncherView queueAction), so the --- host-forwarded touch events are inert stubs: they must accept any id --- without capturing state or throwing. +-- Before the FlexLove view attaches (_flex), touch handlers are no-ops: they +-- must accept any id without capturing importer state or throwing. Once the +-- view is up they forward into FlexLove.touch* for list drag-scroll. local touch = importer("iOS") touch:touchpressed(101, 20, 20) touch:touchmoved(101, 22, 22) @@ -156,7 +155,9 @@ touch:touchreleased(202, 20, 20) touch:touchpressed(303, 20, 20) touch:touchreleased(303, 20, 20) check(touch._activeTouch == nil, - "touch events stay inert: the view's own polling owns touch input") + "touch events before the view attaches leave importer touch state alone") +check(not touch._flex, + "and do not attach the FlexLove view on their own") love.system.getOS = saved.getOS love.system.pickFile = saved.pickFile