snip for scroll

This commit is contained in:
bryanthaboi
2026-08-03 12:39:36 -04:00
parent d72e5db0a5
commit 5c041ea857
5 changed files with 210 additions and 68 deletions
+121 -21
View File
@@ -189,6 +189,12 @@ flexlove._accumulatedDt = 0
---@type table<string, Element>
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<string, {id: string, lastX: number, lastY: number}>
flexlove._touchScroll = {}
---@type table<number, boolean>
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()
+12 -26
View File
@@ -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)
+46 -4
View File
@@ -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
+25 -12
View File
@@ -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
+6 -5
View File
@@ -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