big ui moment

This commit is contained in:
bryanthaboi
2026-08-03 11:50:49 -04:00
parent 8e5501a23b
commit f0f5bc7551
78 changed files with 34435 additions and 3519 deletions
+50 -84
View File
@@ -1,7 +1,10 @@
-- Launcher Delete affordance (src/import/RomImporter.lua): the per-frame hit
-- rects a draw() clears, and the two-click arm that guards both save-slot and
-- mod deletes (#433). Drives RomImporter:mousepressed / :_resetFrameRects on a
-- bare instance, so no window, no cache and no real save files are involved.
-- Launcher Delete affordance (src/import/RomImporter.lua): the two-click arm
-- that guards both save-slot and mod deletes (#433). Every Delete control in
-- the FlexLove view routes through RomImporter:pressDelete, and every other
-- queued action clears self._confirmDelete (LauncherView's queueAction), so
-- the guarantees live on this seam: the first press only arms, the second
-- press on the SAME target commits, any other target or a cleared arm asks
-- again, and a stale arm expires instead of committing much later.
-- luajit tests/engine/launcher_delete_confirm.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
@@ -10,125 +13,88 @@ local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- mousepressed timestamps the arm and expires it, so the clock has to move
-- pressDelete timestamps the arm and expires it, so the clock has to move
local clock = 1000
love.timer.getTime = function() return clock end
local RomImporter = require("src.import.RomImporter")
local function rect(id, y)
return { x = 100, y = y or 200, width = 40, height = 14, id = id }
end
-- Only the fields mousepressed reads on its way to the Delete loops, plus
-- recorders in place of the two destructive calls.
local function launcher()
local self = setmetatable({}, RomImporter)
self.android = false
self.panelVersion = "red"
self.tab = "red"
self.slotScroll = {}
self.deletedSlots = {}
self.deletedMods = {}
self.selected = {}
self._deleteSlot = function(_, version, id)
table.insert(self.deletedSlots, version .. "/" .. id)
end
self._deleteMod = function(_, id) table.insert(self.deletedMods, id) end
self._selectSlot = function(_, version, id)
table.insert(self.selected, version .. "/" .. id)
end
self.deleted = {}
return self
end
local function clickDelete(self, r)
self:mousepressed(r.x + 2, r.y + 2, 1)
end
-- ------- a frame that draws no panel leaves no Delete rect behind
do
local self = launcher()
self.slotDeleteRects = { rect("slot1") }
self.modDeleteRects = { rect("bigmod", 260) }
self.slotRects = { rect("slot1") }
self.modRects = { rect("bigmod", 260) }
self:_resetFrameRects()
eq(self.slotDeleteRects, nil, "a frame reset drops the save Delete rects")
eq(self.modDeleteRects, nil, "a frame reset drops the mod Delete rects")
eq(self.slotRects, nil, "and the slot rows they sit on")
eq(self.modRects, nil, "and the mod toggles")
-- the reporter's click: mods tab is up, the press lands where the game tab
-- drew Delete last time it was shown
self.tab = "mods"
clickDelete(self, rect("slot1"))
eq(#self.deletedSlots, 0, "a press on a stale Delete spot deletes nothing")
local function press(self, kind, id, version)
return self:pressDelete(kind, id, version, function()
table.insert(self.deleted, tostring(kind) .. "/" .. tostring(version)
.. "/" .. tostring(id))
end)
end
-- ------- a save Delete needs two clicks on the same row
do
local self = launcher()
local r = rect("slot1")
self.slotDeleteRects = { r }
clickDelete(self, r)
eq(#self.deletedSlots, 0, "the first click on Delete does not delete")
eq(press(self, "slot", "slot1", "red"), false,
"the first click on Delete does not delete")
eq(#self.deleted, 0, "nothing was committed by the arm")
check(self._confirmDelete ~= nil and self._confirmDelete.id == "slot1",
"the first click arms that row")
clickDelete(self, r)
eq(self.deletedSlots[1], "red/slot1", "the second click on it deletes")
eq(press(self, "slot", "slot1", "red"), true,
"the second click on it deletes")
eq(self.deleted[1], "slot/red/slot1", "the commit ran for that row")
eq(self._confirmDelete, nil, "the arm is spent")
end
-- ------- the arm is per row, per version, and any other press clears it
-- ------- the arm is per row, per version
do
local self = launcher()
local one, two = rect("slot1", 200), rect("slot2", 230)
self.slotDeleteRects = { one, two }
clickDelete(self, one)
clickDelete(self, two)
eq(#self.deletedSlots, 0, "a click on another row's Delete only arms that row")
press(self, "slot", "slot1", "red")
eq(press(self, "slot", "slot2", "red"), false,
"a click on another row's Delete only arms that row")
eq(self._confirmDelete.id, "slot2", "the arm moved to the row just clicked")
self.slotRects = { rect("slot3", 260) }
clickDelete(self, one) -- re-arm slot1
self:mousepressed(102, 262, 1) -- press somewhere else entirely
clickDelete(self, one)
eq(#self.deletedSlots, 0, "a press elsewhere disarms, so Delete asks again")
press(self, "slot", "slot1", "red") -- re-arm slot1
eq(press(self, "slot", "slot1", "blue"), false,
"an arm from one game's tab cannot fire on another")
eq(#self.deleted, 0, "no cross-target pair ever committed")
end
self:mousepressed(102, 262, 1) -- clear the arm left by the pair above
clickDelete(self, one)
self.panelVersion = "blue"
clickDelete(self, one)
eq(#self.deletedSlots, 0, "an arm from one game's tab cannot fire on another")
-- ------- any other action press disarms (the view clears the arm)
do
local self = launcher()
press(self, "slot", "slot1", "red")
self._confirmDelete = nil -- what queueAction does on any
-- non-delete action press
eq(press(self, "slot", "slot1", "red"), false,
"a press elsewhere disarms, so Delete asks again")
eq(#self.deleted, 0, "and the cleared arm never committed")
end
-- ------- a stale arm expires instead of committing much later
do
local self = launcher()
local r = rect("slot1")
self.slotDeleteRects = { r }
clickDelete(self, r)
press(self, "slot", "slot1", "red")
clock = clock + 30
clickDelete(self, r)
eq(#self.deletedSlots, 0, "an arm older than the confirm window is dead")
clickDelete(self, r)
eq(self.deletedSlots[1], "red/slot1", "and the fresh pair still deletes")
eq(press(self, "slot", "slot1", "red"), false,
"an arm older than the confirm window is dead")
eq(press(self, "slot", "slot1", "red"), true,
"and the fresh pair still deletes")
end
-- ------- mods delete arms the same way
-- ------- mods delete arms the same way (version is nil for mods)
do
local self = launcher()
local r = rect("bigmod", 260)
self.modDeleteRects = { r }
clickDelete(self, r)
eq(#self.deletedMods, 0, "the first click on a mod's Delete does not delete")
clickDelete(self, r)
eq(self.deletedMods[1], "bigmod", "the second click removes the mod")
eq(press(self, "mod", "bigmod", nil), false,
"the first click on a mod's Delete does not delete")
eq(press(self, "mod", "bigmod", nil), true,
"the second click removes the mod")
eq(self.deleted[1], "mod/nil/bigmod", "the mod commit ran")
end
T.finish("launcher delete confirm")
+17 -18
View File
@@ -75,14 +75,10 @@ check(ri.findNotice and ri.findNotice.ok == false,
-- ---- PASTE chip: same entry point the touch screen uses -------------------
ri:_promptAddIndex()
-- the chip rect is what draw() published last frame (pinned: modal chrome
-- ignores the page-scroll band); mousepressed hit-tests it while the
-- prompt is up and everywhere else the prompt swallows the press
ri._indexPasteRect = { x = 10, y = 10, width = 60, height = 24, pinned = true }
-- the prompt's Paste button (LauncherView) queues _pasteIndexUrl, the same
-- funnel ctrl/cmd+V uses, so both paths share the strip and the cap
clipboard = " https://example.com/mods/index.json\n"
ri:mousepressed(200, 200, 1)
eq(ri._indexPrompt.text, "", "a press outside the chip pastes nothing")
ri:mousepressed(20, 20, 1)
ri:_pasteIndexUrl()
eq(ri._indexPrompt.text, "https://example.com/mods/index.json",
"the PASTE chip lands the clipboard with whitespace stripped (#578)")
@@ -90,7 +86,7 @@ eq(ri._indexPrompt.text, "https://example.com/mods/index.json",
-- overflow MAX_INDEX_URL (200)
ri._indexPrompt.text = ""
clipboard = string.rep("a", 300)
ri:mousepressed(20, 20, 1)
ri:_pasteIndexUrl()
eq(#ri._indexPrompt.text, 200, "the PASTE chip enforces MAX_INDEX_URL")
-- and through ctrl/cmd+V, which used to skip the cap entirely
@@ -117,22 +113,25 @@ ri:keypressed("return")
eq(lastArm(), false, "committing the rename disarms setTextInput")
eq(renamed and renamed[3], "OLD!", "the commit reaches SaveData.renameSlot")
-- ---- find-search field: arm on rect press, disarm on escape ---------------
-- ---- find-search field: arm on focus, disarm on escape / tab change -------
ri.findSearchRect = { x = 100, y = 100, width = 80, height = 20 }
ri:mousepressed(110, 110, 1)
check(ri._findSearchFocus == true, "pressing the search field takes focus")
eq(lastArm(), true, "and arms setTextInput")
-- the search field's click handler (LauncherView) takes focus and arms;
-- drive the same pair the handler queues
ri._findSearchFocus = true
ri:_armTextInput()
eq(lastArm(), true, "focusing the search field arms setTextInput")
ri:keypressed("escape")
check(ri._findSearchFocus == false, "escape drops the search caret")
eq(lastArm(), false, "and disarms setTextInput")
-- a press elsewhere on the find tab also drops the caret and disarms
ri:mousepressed(110, 110, 1)
eq(lastArm(), true, "refocus for the click-away case")
ri:mousepressed(400, 400, 1)
check(ri._findSearchFocus == false, "a click away drops the caret")
-- switching tabs (chips, shoulder buttons) also drops the caret and disarms
ri._findSearchFocus = true
ri:_armTextInput()
eq(lastArm(), true, "refocus for the tab-change case")
ri:_switchTab("mods")
check(ri._findSearchFocus == false, "a tab change drops the caret")
eq(lastArm(), false, "and disarms setTextInput")
ri.tab = "find"
-- ---- desktop contract (#529): disarm never lowers off Android -------------
+7 -3
View File
@@ -145,14 +145,18 @@ 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.
local touch = importer("iOS")
touch:touchpressed(101, 20, 20)
check(touch._activeTouch == 101, "iOS touch press captures the active touch")
touch:touchmoved(101, 22, 22)
touch:touchreleased(202, 20, 20)
check(touch._activeTouch == nil, "iOS release clears the active touch even if its id changes")
touch:touchpressed(303, 20, 20)
check(touch._activeTouch == 303, "iOS accepts the next touch after release")
touch:touchreleased(303, 20, 20)
check(touch._activeTouch == nil,
"touch events stay inert: the view's own polling owns touch input")
love.system.getOS = saved.getOS
love.system.pickFile = saved.pickFile
+213 -16
View File
@@ -864,11 +864,12 @@ do
end
do
-- #497: the editor drew a desktop layout into a phone window. Kit.layout
-- scaled off height alone, and a phone in portrait (720x1560) is TALLER
-- than the 768px desktop reference while being barely half as wide, so the
-- scale came back clamped at 1.6 and every right-aligned cluster in the
-- chrome landed on top of the block to its left. Both axes now pay.
-- #497 shrank the layout to fit a phone's width; #715 replaced that with
-- reflow. The scale never dips below the 0.9 readability floor now: a
-- narrow window keeps readable fonts and 26px tap targets and the panels
-- stack / drop columns / scroll instead of shrinking. The width term
-- (width/640) only stops a portrait phone from inflating to the 1.6 cap
-- its height alone would buy.
local Kit = require("Kit")
local Theme = require("Theme")
local function about(got, want, msg)
@@ -876,19 +877,21 @@ do
msg .. string.format(" (got %.4f, want %.4f)", got, want))
end
about(Kit.layout(720, 1560), 0.72, "portrait phone scales off its width")
check(Kit.layout(720, 1560) < 1.0,
"a portrait phone no longer draws a larger-than-desktop layout")
about(Kit.layout(720, 1560), 720 / 640,
"portrait phone scales off its width, gently")
check(Kit.layout(720, 1560) >= 0.9,
"a portrait phone never drops below the readability floor")
about(Kit.layout(1560, 720), 720 / 768, "landscape phone still scales off height")
about(Kit.layout(360, 640), 0.62, "a tiny window stops at the floor")
about(Kit.layout(360, 640), 0.9,
"a tiny window stops at the readable floor and reflows instead of shrinking")
about(Kit.layout(500, 800), 0.9, "500px wide sits on the floor too")
-- desktop and laptop sizes have to be pixel-identical to before the fix:
-- everything at or above the 1000px reference width lands on the height
-- term, exactly as it always did
-- desktop and laptop sizes keep the height-only scale they always had
for _, size in ipairs({ { 1280, 800 }, { 1024, 768 }, { 1920, 1080 },
{ 1440, 900 }, { 2560, 1440 } }) do
about(Kit.layout(size[1], size[2]), Theme.clamp(size[2] / 768, 0.7, 1.6),
("%dx%d keeps its old height-only scale"):format(size[1], size[2]))
{ 1440, 900 }, { 2560, 1440 }, { 900, 700 } }) do
about(Kit.layout(size[1], size[2]),
Theme.clamp(math.min(size[1] / 640, size[2] / 768), 0.9, 1.6),
("%dx%d keeps its height-based scale"):format(size[1], size[2]))
end
end
@@ -917,8 +920,13 @@ do
end
end
-- 720x1280 / 1280x720 are the #715 report's shapes (Android, both
-- orientations): the Map tab used to lay its viewport out at a negative
-- width in portrait and crash on the scissor. The desktop sizes pin that
-- the responsive reflow does not disturb the layouts that already worked.
for _, size in ipairs({ { 720, 1560 }, { 1560, 720 }, { 480, 1040 },
{ 1280, 800 } }) do
{ 1280, 800 }, { 720, 1280 }, { 1280, 720 },
{ 1024, 768 }, { 1920, 1080 }, { 360, 640 } }) do
love.graphics.getDimensions = function() return size[1], size[2] end
App.load(tmpPath, { version = "red" })
local S = App.getState()
@@ -928,6 +936,8 @@ do
local ok, err = pcall(App.draw)
check(ok, ("the %s tab draws at %s: %s"):format(tab, label, tostring(err)))
end
check((S._mapViewW or 0) >= 0 and (S._mapViewH or 0) >= 0,
("the map viewport stays non-negative at %s (#715)"):format(label))
S.tab = "party"
Ops.selectParty(S, 1)
local ok, err = pcall(App.draw)
@@ -947,5 +957,192 @@ do
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
end
do
-- #715 reflow audit. Kit records every control that could take a click
-- while Kit.audit is set (shielded widgets are skipped, since a modal
-- legitimately covers what it shields). The sweep below drives every tab
-- at the window shapes the reflow has to serve and FAILS if any two
-- controls overlap or any control escapes the window, which is exactly
-- the "buttons covering things" class of bug the shrink-to-fit layout
-- kept producing. Rects clip to the region that bounds their hit test,
-- so a row scrolled out of a list is not a phantom overlap.
local Kit = require("Kit")
local function clipped(r)
local x1, y1, x2, y2 = r.x, r.y, r.x + r.w, r.y + r.h
if r.clip then
x1 = math.max(x1, r.clip.x); y1 = math.max(y1, r.clip.y)
x2 = math.min(x2, r.clip.x + r.clip.w); y2 = math.min(y2, r.clip.y + r.clip.h)
end
if x2 - x1 <= 1 or y2 - y1 <= 1 then return nil end
return x1, y1, x2, y2
end
local function overlap(a, b)
local ax1, ay1, ax2, ay2 = clipped(a)
if not ax1 then return false end
local bx1, by1, bx2, by2 = clipped(b)
if not bx1 then return false end
return math.min(ax2, bx2) - math.max(ax1, bx1) > 1
and math.min(ay2, by2) - math.max(ay1, by1) > 1
end
local function auditFrame(label, W, H)
local rects = Kit.audit
local controls = {}
for _, r in ipairs(rects) do
if r.class == "control" then controls[#controls + 1] = r end
end
check(#controls > 0, label .. ": the frame dispatched controls at all")
local collisions, escapes = 0, 0
for i = 1, #controls do
local a = controls[i]
local x1, y1, x2, y2 = clipped(a)
if x1 and (x1 < -0.5 or y1 < -0.5 or x2 > W + 0.5 or y2 > H + 0.5) then
escapes = escapes + 1
print((" escape: %s (%.0f,%.0f %.0fx%.0f)")
:format(a.label, a.x, a.y, a.w, a.h))
end
for j = i + 1, #controls do
if overlap(a, controls[j]) then
collisions = collisions + 1
print((" overlap: '%s' vs '%s' at (%.0f,%.0f) / (%.0f,%.0f)")
:format(a.label, controls[j].label, a.x, a.y,
controls[j].x, controls[j].y))
end
end
end
check(collisions == 0, label .. ": no two controls overlap")
check(escapes == 0, label .. ": every control stays inside the window")
end
local tmpPath = os.tmpname() .. "-audit-save.lua"
local data = SaveData.newGame()
data.party = {}
for i = 1, require("src.pokemon.Party").MAX do
data.party[i] = MonOps.create(Data, i % 2 == 0 and "PIDGEY" or "CHARIZARD",
10 * i)
end
local f = io.open(tmpPath, "wb")
f:write(SaveData.encode(data))
f:close()
local oldDimensions = love.graphics.getDimensions
local sizes = { { 500, 800 }, { 720, 1280 }, { 1280, 720 },
{ 1024, 768 }, { 900, 700 }, { 1920, 1080 } }
for _, size in ipairs(sizes) do
local W, H = size[1], size[2]
love.graphics.getDimensions = function() return W, H end
App.load(tmpPath, { version = "red" })
local S = App.getState()
-- populate the panels the fresh save leaves empty, so their controls
-- (quantity rows, box cells, dock rows, flags) are exercised too
Ops.selectParty(S, 1)
Ops.boxAdd(S); Ops.boxAdd(S)
Ops.addToBag(S, S.cat.items[1])
Ops.addToPc(S, S.cat.items[2])
Ops.setFlag(S, "EVENT_GOT_POKEDEX", true)
for _, tab in ipairs({ "party", "boxes", "items", "events", "map", "dex" }) do
S.tab = tab
Kit.audit = {}
local ok, err = pcall(App.draw)
check(ok, ("%dx%d %s draws: %s"):format(W, H, tab, tostring(err)))
if ok then auditFrame(("%dx%d %s"):format(W, H, tab), W, H) end
Kit.audit = nil
end
-- the species picker dialog reflows too; frame 2, since the opening
-- frame is fully shielded by design (#541) and would audit empty
S.tab = "party"
Ops.openSpeciesPicker(S, Kit)
App.draw()
Kit.audit = {}
local ok, err = pcall(App.draw)
check(ok, ("%dx%d species picker draws: %s"):format(W, H, tostring(err)))
if ok then auditFrame(("%dx%d species picker"):format(W, H), W, H) end
Kit.audit = nil
Ops.closeSpeciesPicker(S, Kit)
end
love.graphics.getDimensions = oldDimensions
os.remove(tmpPath)
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
end
do
-- Box add flow: the Boxes panel's "+ Add mon here" and its dashed empty
-- cells open the SAME species picker the inspector uses, in box-add mode,
-- and the committed species lands in the selected box as a Lv5 mon built
-- by the same MonOps path Ops.partyAdd uses.
local Kit = require("Kit")
local BoxesMod = require("src.pokemon.Boxes")
local tmpPath = os.tmpname() .. "-boxadd-save.lua"
local f = io.open(tmpPath, "wb")
f:write(SaveData.encode(SaveData.newGame()))
f:close()
App.load(tmpPath, { version = "red" })
local S = App.getState()
S.tab = "boxes"
check(Ops.openBoxAddPicker(S, Kit) == true, "box-add picker opens")
check(S.speciesPicker ~= nil, "the picker is up")
eq(S.speciesPicker.mode, "box-add", "and it is in box-add mode")
eq(Kit.focus, "species-picker", "with the search field focused (#529)")
local ok, err = pcall(App.draw)
check(ok, "the box-add picker draws headlessly: " .. tostring(err))
App.textinput("PIKACHU")
App.draw()
App.keypressed("return")
local box = Ops.boxes(S)[S.selectedBox]
check(S.speciesPicker == nil, "committing closes the picker")
eq(#box, 1, "the commit added exactly one mon to the box")
local mon = box[1]
eq(mon.species, "PIKACHU", "the picked species landed in the box")
eq(mon.level, 5, "as a Lv5 mon, matching partyAdd's default")
check(mon.stats and mon.stats.hp and mon.stats.hp > 0,
"with real Gen1 stats from MonOps.create")
eq(mon.ot, S.save.player.name, "owned by the save's player")
eq(mon.otId, S.save.player.id, "with the player's trainer id")
check(S.editingMon == mon, "and the inspector now points at it")
check(S.dirty, "and the save is dirty")
-- Escape leaves without adding anything
Ops.openBoxAddPicker(S, Kit)
App.textinput("BULBASAUR")
App.draw()
App.keypressed("escape")
check(S.speciesPicker == nil, "Escape closes the box-add picker")
eq(#box, 1, "Escape added nothing")
-- an unusable (mod-partial) record refuses instead of crashing (#541)
Data.pokemon.TESTMON_BOXADD = { name = "TESTMON", dex = 0,
baseStats = { hp = 40 }, growthRate = "MEDIUM_FAST",
types = { "NORMAL" }, learnset = {} }
S.cat = Catalog.build(Data)
S.dirty = false
check(Ops.boxAddSpecies(S, "TESTMON_BOXADD") == false,
"a record without usable base stats is refused")
eq(#box, 1, "and nothing was added")
check(S.status:match("base stats") ~= nil, "and the refusal explains itself")
check(S.dirty == false, "and the save stays clean")
Data.pokemon.TESTMON_BOXADD = nil
S.cat = Catalog.build(Data)
-- a full box refuses to even open the picker
while #box < BoxesMod.CAPACITY do Ops.boxAdd(S) end
check(Ops.openBoxAddPicker(S, Kit) == false, "a full box refuses the picker")
check(S.speciesPicker == nil, "and it stays closed")
check(S.status:match("full") ~= nil, "and says why")
-- ...and a commit raced against a filling box refuses too
check(Ops.boxAddSpecies(S, "PIKACHU") == false,
"boxAddSpecies refuses a full box")
os.remove(tmpPath)
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
end
print(string.format("save editor tests: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end
+43
View File
@@ -49,6 +49,49 @@ Kit.blockClicks = false
Kit.endFrame()
eq(Kit.wheelY, 0, "an unclaimed notch retires with the frame")
-- #715: a phone has no wheel, so Kit.scroll also follows a held pointer
-- dragging vertically over the list body. Kit.beginFrame polls
-- love.mouse.isDown for this (neither host routes mousereleased), so the
-- stub grows one here.
local held = false
love.mouse = love.mouse or {}
love.mouse.getPosition = love.mouse.getPosition or function() return 0, 0 end
love.mouse.isDown = function() return held end
held = true
Kit.beginFrame(50, 90, false, 0)
eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 0,
"the press frame starts a drag without moving the list")
Kit.endFrame()
Kit.beginFrame(50, 60, false, 0) -- dragged 30px up, 10 rows / 100px = 3 rows
eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 3,
"dragging upward reveals lower rows")
Kit.endFrame()
Kit.beginFrame(50, -2910, false, 0) -- a wild fling clamps like the wheel does
eq(Kit.scroll(0, 0, 100, 100, 3, 250, 10), 240,
"a drag past the end clamps to the last page")
Kit.endFrame()
held = false
Kit.beginFrame(50, 60, false, 0)
eq(Kit.scroll(0, 0, 100, 100, 3, 250, 10), 3,
"releasing the pointer ends the drag")
Kit.endFrame()
held = true
Kit.beginFrame(500, 500, false, 0) -- press outside the list body
Kit.scroll(0, 0, 100, 100, 0, 250, 10)
Kit.endFrame()
Kit.beginFrame(500, 400, false, 0)
eq(Kit.scroll(0, 0, 100, 100, 0, 250, 10), 0,
"a drag that never entered the list does not scroll it")
Kit.endFrame()
held = false
Kit.beginFrame(0, 0, false, 0)
Kit.endFrame()
-- The wheel has to reach the Items lists without touching the map camera,
-- which is the only thing App.wheelmoved used to drive (#595). Loading the
-- whole editor needs data/generated/, so pin the routing at the source seam