Merge pull request #369 from pmarcus93/launcher-page-scroll

Scroll the launcher page so the footer stops covering the panel
This commit is contained in:
bryanthaboi
2026-07-29 11:57:47 -04:00
committed by GitHub
4 changed files with 417 additions and 77 deletions
+8
View File
@@ -35,6 +35,14 @@ function love.conf(t)
-- starting size, not the game's resolution.
t.window.width = 1024
t.window.height = 768
-- Floor for the resizable desktop window. The launcher's single-column
-- layout is laid out against ~420 logical px of content, and the game
-- canvas letterboxes fine below that, so this only stops a drag that would
-- squeeze the cards past the point where their buttons still read. Kept
-- well under the smallest supported desktop display; mobile ignores it
-- (fullscreen), so the handheld ports are unaffected.
t.window.minwidth = 480
t.window.minheight = 360
end
t.version = "11.5"
t.window.vsync = 1
+61
View File
@@ -202,3 +202,64 @@ column, slot card below Play, when the window is too narrow for both
(wheel, or drag on touch/desktop) clamped to their own content extent,
recomputed every draw. The tab bar labels only the active chip so it stays
narrow-safe, and content caps out at `~1440 * s` wide, centered.
The desktop window has a floor of 480x360 (`conf.lua` `minwidth`/`minheight`),
under which the cards stop being readable at all. Mobile ignores it: those
windows are fullscreen.
### Page scroll
Two columns fit any window the launcher is likely to open in; one stacked
column does not. On a phone-shaped window the ROM card, SAVE FILES, Play and
SAVE SLOT together run past the bottom, and a footer pinned to the window
bottom painted over them with the overflow unreachable.
So the whole column under the tab bar -- panel, updater banner, footer --
scrolls as one page whenever it is taller than the room below the tab bar:
- The strip, logo and tab bar stay pinned, so navigation is always on screen.
Everything else draws at `contentTop - pageScroll` inside a scissor, and the
footer is laid out downward from `footerTop` right after the content instead
of upward from the window bottom.
- `RomImporter.pageScrollFor(naturalH, viewportH, scroll)` is the whole
decision, pure and pinned by `tests/engine/launcher_page_scroll.lua`. A
window that grows back drags the offset down with it, so the page can never
stay parked past its own end.
- The panels report their natural height as they draw (`_drawGamePanel` and
`_drawModsPanel` return it), so the decision reads the previous frame's
measurement -- the same one-frame settle the two lists already rely on.
- **One scroll axis at a time.** While the page scrolls, the panels draw
`paged`: the slot and mod lists take their natural height, keep no inner
scroll region and report a max of 0, so the wheel, the right stick and a drag
all move the page and never fight a list for the same gesture. Two-column
layouts do not overflow, `paged` stays false, and every one of these behaves
exactly as it did before.
- Hit testing follows the clip: `inside` (clicks) and `_ptIn` (hover) reject a
rect that scrolled out of the viewport, so a control that slid under the tab
bar cannot be clicked through it. Tab chips carry `pinned = true` and are
exempt. `pageScroll` resets on a tab change, each tab being a different
length.
- A press on empty background pans the page, resolved in `_updateSlotDrag` like
every other drag here.
### Dragging on Android
The launcher is handed no move events on any platform: `main.lua` forwards
neither `touchmoved` nor `mousemoved` while it is up, which is why every drag
here is resolved by polling inside `draw` instead. Desktop polls the mouse;
Android used to poll nothing at all ("no reliable pointer polling" meant its
mouse emulation), so it had no scroll gesture whatsoever -- fine while every
scroll region was an inner list with a wheel alternative, useless the moment
the page itself became the thing that scrolls, since a phone is exactly where
it overflows.
`love.touch` is pollable, so `_pointerHold` reads the first active touch there
and hands `_updateSlotDrag` the same (held, y) pair the mouse gives on desktop.
Consequences:
- Slot rows and mod toggles ARM on press and commit on release on Android too,
matching desktop, so a swipe that starts on a card scrolls instead of
selecting the row it started on.
- `touchPollable` (set once in `new`) gates all of it. Where `love.touch` is
missing, every Android path is exactly what it was: act on press, never arm,
no drag.
+285 -77
View File
@@ -490,6 +490,14 @@ function RomImporter.new(onComplete, opts)
forceImport = opts.forceImport or false,
onEditSave = opts.onEditSave,
android = android,
-- 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.
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"
logo = love.graphics.newImage("assets/logo/logo.png"),
bcg = love.graphics.newImage("assets/logo/bcg.png"),
@@ -515,6 +523,10 @@ function RomImporter.new(onComplete, opts)
-- modScroll is the list scroll offset (px, clamped in draw); modNotice is
-- the last install/delete result { ok, text } shown as a line above the list.
mods = nil, modScroll = 0, modNotice = nil,
-- Page scroll offset (px) for the column under the tab bar -- panel, updater
-- banner and footer -- used only while that column is taller than the window
-- (see draw()). Clamped against content in draw, reset on a tab change.
pageScroll = 0,
-- Android SAF: which game tab should receive the next picked_save.sav when
-- focus consumes it (set by chooseSaveImport before opening the picker).
androidPendingVersion = nil,
@@ -1111,12 +1123,16 @@ function RomImporter:_updatePadCursor(dt)
self._padCursor.y = math.max(0, math.min(h, ny))
end
-- Right stick scrolls the active list (save slots or mods).
-- Right stick scrolls the active list (save slots or mods), or the whole page
-- when it is the thing that overflows.
local ry = self._padAxis.righty or 0
if math.abs(ry) > PAD_DEAD then
self:_activatePadCursor()
local step = -ry * 480 * dt
if self.tab == "mods" then
local maxPage = self._pageMax or 0
if maxPage > 0 then
self.pageScroll = math.max(0, math.min(maxPage, (self.pageScroll or 0) + step))
elseif self.tab == "mods" then
local maxS = self._modMax or 0
if maxS > 0 then
local next = (self.modScroll or 0) + step
@@ -1381,6 +1397,24 @@ local function roundedCard(x, y, w, h, r)
love.graphics.rectangle("line", x, y, w, h, r, r)
end
-- {top, bottom} of the scrolling page viewport, or nil while the page fits and
-- nothing scrolls. Written once per frame by draw(); read by the two hit tests
-- (`inside` for clicks, `_ptIn` for hover) so a control scrolled out from under
-- the pinned header, or past the window bottom, stops responding at the moment
-- it stops being visible. Rects that live in the pinned header carry
-- `pinned = true` and are exempt.
local pageBand = nil
-- Page-scroll arithmetic, kept pure (no love, no self) so the engine tier can
-- pin it: given how tall the column under the tab bar wants to be and how much
-- room is left under it, say whether the page scrolls, where it sits, and how
-- far it can go. A window that grew back pulls the offset down with it rather
-- than leaving the page parked past its own end.
function RomImporter.pageScrollFor(naturalH, viewportH, scroll)
local maxPage = math.max(0, (naturalH or 0) - math.max(0, viewportH or 0))
return maxPage > 0, clamp(scroll or 0, 0, maxPage), maxPage
end
function RomImporter:draw()
local width, height = love.graphics.getDimensions()
local s = clamp(height / 768, 0.7, 1.6)
@@ -1538,17 +1572,16 @@ function RomImporter:draw()
end
-- Footer (Boi's Club Games logo + trust warning), measured first so the
-- content region knows where it must stop. Drawn near the end.
-- content region knows where it must stop. Only its height is fixed here:
-- it is laid out from a top edge further down, which is the window bottom
-- while the page fits and the end of the scrolled content when it does not.
local warningWidth = math.min(appW - 32 * s, 640 * s)
local _, warningLines = self.warningFont:getWrap(TRUST_WARNING, warningWidth)
local warningH = #warningLines * self.warningFont:getHeight()
local warningY = height - warningH - 12 * s
local bcgW, bcgH = self.bcg:getDimensions()
local bcgScale = math.min(math.min(appW - 48 * s, 190 * s) / bcgW, height * 0.06 / bcgH)
local bcgDW, bcgDH = bcgW * bcgScale, bcgH * bcgScale
local bcgX, bcgY = appX + (appW - bcgDW) / 2, warningY - bcgDH - 6 * s
self.bcgButton = { x = bcgX, y = bcgY, width = bcgDW, height = bcgDH }
local footerTop = bcgY - 10 * s
local footerH = 10 * s + bcgDH + 6 * s + warningH + 12 * s
-- Logo: centred over the strip, width clamped, gentle bob + glow pulse. The
-- resting metrics fix the tab bar's top so the layout never shifts as it bobs.
@@ -1584,36 +1617,53 @@ function RomImporter:draw()
-- Content region: from below the tab bar down to the footer, minus the
-- updater band when one is showing.
local contentTop = tabBarY + tabBarH + 16 * s
local contentBottom = footerTop - (bannerActive and (bannerH + 20 * s) or 6 * s)
local bannerBand = bannerActive and (bannerH + 20 * s) or 6 * s
local cX = appX + padH
local cW = appW - 2 * padH
local contentBottom = height - footerH - bannerBand
local cH = math.max(0, contentBottom - contentTop)
-- tab bar (rebuilds self.tabRects)
-- Page scroll. Everything under the tab bar -- panel, updater banner and
-- footer -- is one column: too short a window scrolls it instead of letting
-- the panel run under a footer pinned to the window bottom (a stacked
-- single-column layout on a phone-shaped window overflows by a card or two).
-- The panels report their natural height as they draw, so the decision reads
-- the previous frame's measurement, the same one-frame settle the slot and
-- mod lists already rely on. While the page fits, `paged` is false and every
-- measurement below is what it always was.
local viewportH = math.max(0, height - contentTop)
self._panelNaturalH = self._panelNaturalH or {}
local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH
local paged, pageScroll, maxPage =
RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll)
self.pageScroll, self._pageMax = pageScroll, maxPage
-- read by the hit tests; a scrolled control is live only inside the viewport
pageBand = paged and { contentTop, height } or nil
-- tab bar (rebuilds self.tabRects). Pinned: it is the launcher's navigation,
-- and it sits above the scrolling viewport.
self:_drawTabBar(cX, tabBarY, cW, tabBarH, chip)
-- content: game panel for a version tab, mods panel for the mods tab
if self.tab == "mods" then
self:_drawModsPanel(cX, contentTop, cW, cH)
else
self:_drawGamePanel(self.tab, cX, contentTop, cW, cH)
local panelY = contentTop - (paged and self.pageScroll or 0)
if paged then
love.graphics.setScissor(math.floor(appX), math.floor(contentTop),
math.ceil(appW), math.ceil(viewportH))
end
-- logo, over the split, with a gentle bob + gold glow + sweeping shine
local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s
local lx, ly = (width - logoDW) / 2, logoY + bob
love.graphics.setBlendMode("add")
love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6)))
love.graphics.draw(self.logo, (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
logoScale * 1.05, logoScale * 1.05)
love.graphics.setBlendMode("alpha")
local shineW = 0.16
self.shineShader:send("shinePos", -shineW + ((pulse % 2.8) / 2.8) * (1 + 2 * shineW))
self.shineShader:send("shineW", shineW)
love.graphics.setShader(self.shineShader)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.logo, lx, ly, 0, logoScale, logoScale)
love.graphics.setShader()
-- content: game panel for a version tab, mods panel for the mods tab
local panelH
if self.tab == "mods" then
panelH = self:_drawModsPanel(cX, panelY, cW, cH, paged)
else
panelH = self:_drawGamePanel(self.tab, cX, panelY, cW, cH, paged)
end
panelH = panelH or 0
self._panelNaturalH[self.tab] = panelH
-- The updater band and the footer follow the content: pinned to the window
-- bottom while the page fits, riding at the end of the scroll when it does not.
local bandTop = paged and (panelY + panelH) or contentBottom
local footerTop = bandTop + bannerBand
-- Self-updater banner: a compact pill centred in the reserved band just above
-- the footer, on every tab. Same green "Play" treatment on its CTA.
@@ -1621,7 +1671,7 @@ function RomImporter:draw()
if bannerActive then
local bannerW = math.min(appW - 32 * s, 560 * s)
local bx = appX + (appW - bannerW) / 2
local by = contentBottom + math.max(0, (footerTop - contentBottom - bannerH) / 2)
local by = bandTop + math.max(0, (footerTop - bandTop - bannerH) / 2)
local r = 12 * s
local accent = PAL.gold
@@ -1702,11 +1752,17 @@ function RomImporter:draw()
end
-- footer: a hairline top border, the BCG mark (inverted to white, glowing
-- brighter on hover) + the trust warning with its live bois.icu link.
-- brighter on hover) + the trust warning with its live bois.icu link. Laid
-- out downward from footerTop, so the same code serves the pinned and the
-- scrolled position.
love.graphics.setLineWidth(1)
col(PAL.cardBorder, 0.18)
love.graphics.line(appX + padH, footerTop, appX + appW - padH, footerTop)
local bcgX, bcgY = appX + (appW - bcgDW) / 2, footerTop + 10 * s
local warningY = bcgY + bcgDH + 6 * s
self.bcgButton = { x = bcgX, y = bcgY, width = bcgDW, height = bcgDH }
local bcgHot = self:_hover(self.bcgButton)
love.graphics.setShader(self.invertShader)
love.graphics.setBlendMode("add")
@@ -1745,6 +1801,35 @@ function RomImporter:draw()
end
end
-- End of the scrolling column; the logo and the page scrollbar are pinned and
-- draw outside it.
if paged then love.graphics.setScissor() end
-- logo, over the split, with a gentle bob + gold glow + sweeping shine
local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s
local lx, ly = (width - logoDW) / 2, logoY + bob
love.graphics.setBlendMode("add")
love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6)))
love.graphics.draw(self.logo, (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
logoScale * 1.05, logoScale * 1.05)
love.graphics.setBlendMode("alpha")
local shineW = 0.16
self.shineShader:send("shinePos", -shineW + ((pulse % 2.8) / 2.8) * (1 + 2 * shineW))
self.shineShader:send("shineW", shineW)
love.graphics.setShader(self.shineShader)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.logo, lx, ly, 0, logoScale, logoScale)
love.graphics.setShader()
-- page scrollbar: the same thin thumb the lists use, against the app edge
if paged then
local thumbH = math.max(24 * s, viewportH * (viewportH / naturalH))
local thumbY = contentTop + (viewportH - thumbH) * (self.pageScroll / maxPage)
col(PAL.cardBorder, 0.35)
love.graphics.rectangle("fill", appX + appW - padH * 0.5, thumbY, 3 * s, thumbH,
1.5 * s, 1.5 * s)
end
-- CRT scanlines + vignette, over everything
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.scanlineImage, self.scanlineQuad, 0, 0)
@@ -1840,11 +1925,22 @@ function RomImporter:draw()
end
local function inside(r, x, y)
return r and x >= r.x and x <= r.x + r.width and y >= r.y and y <= r.y + r.height
if not (r and x >= r.x and x <= r.x + r.width and y >= r.y and y <= r.y + r.height) then
return false
end
-- Page-scroll mode: only the header is pinned, so any other rect is a
-- scrolled one and is live only where the viewport actually shows it.
if pageBand and not r.pinned and (y < pageBand[1] or y > pageBand[2]) then
return false
end
return true
end
function RomImporter:mousepressed(x, y, button)
if self._rename then return end -- the rename modal swallows all clicks
-- Whether a press can be ARMED and resolved on release, which needs a
-- pollable pointer: always on desktop, on Android only where love.touch is.
local armDrag = (not self.android) or self.touchPollable
-- right-click a save-slot row to rename it (#205); desktop only (touch
-- has no secondary button)
if button == 2 then
@@ -1883,6 +1979,10 @@ function RomImporter:mousepressed(x, y, button)
self.tab = t.id
self._slotPress = nil -- drop any half-started slot drag on tab change
self._modPress = nil -- and any half-started mod toggle press
self._pagePress = nil -- and any half-started page pan
-- Each tab is its own column of a different length; carrying one tab's
-- offset into another lands somewhere arbitrary.
self.pageScroll = 0
return
end
end
@@ -1911,11 +2011,12 @@ function RomImporter:mousepressed(x, y, button)
return
end
-- SAVE SLOT rows / Edit / Delete. The two labels are checked first so a tap
-- on either never also selects the row. On desktop a press only ARMS a row
-- click: _updateSlotDrag commits it on release when the pointer did not move
-- (a moved pointer scrolls instead). Android has no reliable pointer
-- polling, so it selects on press. Edit and Delete fire immediately (small
-- fixed targets, no scroll conflict).
-- on either never also selects the row. A press only ARMS a row click:
-- _updateSlotDrag commits it on release when the pointer did not move (a
-- moved pointer scrolls instead). Android arms too wherever love.touch can
-- be polled; without that there is nothing to resolve a release with, so it
-- keeps selecting on press. Edit and Delete fire immediately (small fixed
-- targets, no scroll conflict).
for _, r in ipairs(self.slotDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteSlot(self.panelVersion, r.id)
@@ -1930,11 +2031,12 @@ function RomImporter:mousepressed(x, y, button)
end
for _, r in ipairs(self.slotRects or {}) do
if inside(r, x, y) then
if self.android then
if not armDrag then
self:_selectSlot(self.panelVersion, r.id)
else
self._slotPress = { version = self.panelVersion, id = r.id, y0 = y,
scroll0 = self.slotScroll[self.panelVersion] or 0, moved = false }
scroll0 = self.slotScroll[self.panelVersion] or 0,
pageScroll0 = self.pageScroll or 0, moved = false }
end
return
end
@@ -1957,15 +2059,20 @@ function RomImporter:mousepressed(x, y, button)
end
for _, r in ipairs(self.modRects or {}) do
if inside(r, x, y) then
if self.android then
if not armDrag then
self:_toggleMod(r.id)
else
self._modPress = { id = r.id, y0 = y,
scroll0 = self.modScroll or 0, moved = false }
self._modPress = { id = r.id, y0 = y, scroll0 = self.modScroll or 0,
pageScroll0 = self.pageScroll or 0, moved = false }
end
return
end
end
-- Nothing was hit. On a scrolling page that is a press on empty background,
-- which is the natural place to grab and pan from.
if armDrag and (self._pageMax or 0) > 0 then
self._pagePress = { y0 = y, scroll0 = self.pageScroll or 0 }
end
end
function RomImporter:keypressed(key)
@@ -1997,7 +2104,14 @@ end
function RomImporter:_ptIn(r)
local mx, my = self._mx, self._my
return r and mx >= r.x and mx <= r.x + r.width and my >= r.y and my <= r.y + r.height
if not (r and mx >= r.x and mx <= r.x + r.width and my >= r.y and my <= r.y + r.height) then
return false
end
-- Same clip the click path applies, so nothing glows outside the viewport.
if pageBand and not r.pinned and (my < pageBand[1] or my > pageBand[2]) then
return false
end
return true
end
function RomImporter:_hover(r)
@@ -2126,8 +2240,9 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
col(PAL.bgBot, 0.62)
love.graphics.rectangle("fill", cursorX, chipY, chip, chip, r, r)
end
-- pinned: the tab bar never scrolls, so it stays live above the viewport
self.tabRects[#self.tabRects + 1] =
{ x = cursorX, y = chipY, width = chip, height = chip, id = t.id }
{ x = cursorX, y = chipY, width = chip, height = chip, id = t.id, pinned = true }
local segEnd = cursorX + chip
if active then
love.graphics.setFont(self.tabLabelFont)
@@ -2159,7 +2274,12 @@ end
-- One version's game panel: header (name + status pill), then a responsive
-- two-column grid (left: ROM + SAVE FILES cards + Play; right: SAVE SLOT).
function RomImporter:_drawGamePanel(version, x, y, w, h)
-- `paged`: the whole page is scrolling (see draw()), so nothing stretches to
-- fill `h` -- Play sits right under the SAVE FILES card instead of being pinned
-- to the column bottom, and the slot card takes its natural height. Returns
-- the panel's natural height either way, which is what draw() measures the page
-- against on the next frame.
function RomImporter:_drawGamePanel(version, x, y, w, h, paged)
local s, pulse = self._s, self.pulse
self.panelVersion = version
-- Defensive: only lock when the version is absent from GameVersion (never
@@ -2294,8 +2414,9 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
-- vertical placement of the left column
local romY = bodyTop
local saveFilesY = romY + romCardH + 12 * s
local leftNaturalH = romCardH + 12 * s + saveFilesH + 12 * s + playH
local playY
if twoCol then
if twoCol and not paged then
playY = bodyTop + bodyH - playH -- pinned to the column's bottom
else
playY = saveFilesY + saveFilesH + 12 * s
@@ -2372,15 +2493,29 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
-- SAVE SLOT card (right column, or stacked below Play when single-column).
-- Skip only when the version is absent from GameVersion (no save backend).
local slotNaturalH = 0
if not locked then
if twoCol then
self:_drawSaveSlotPanel(version, rightX, bodyTop, colW, bodyH)
_, slotNaturalH = self:_drawSaveSlotPanel(version, rightX, bodyTop, colW, bodyH, paged)
else
local slotY = playY + playH + 12 * s
local slotH = math.max(160 * s, (bodyTop + bodyH) - slotY)
self:_drawSaveSlotPanel(version, leftX, slotY, colW, slotH)
_, slotNaturalH = self:_drawSaveSlotPanel(version, leftX, slotY, colW, slotH, paged)
end
end
-- Natural height: side by side the two columns overlap, stacked they add up.
-- Measured from the panel's own top (y), so draw() can compare it against the
-- viewport without knowing anything about the cards inside.
local bodyNaturalH
if twoCol then
bodyNaturalH = math.max(leftNaturalH, slotNaturalH)
elseif locked then
bodyNaturalH = leftNaturalH
else
bodyNaturalH = leftNaturalH + 12 * s + slotNaturalH
end
return (bodyTop - y) + bodyNaturalH
end
-- Reload a version's slot list + active id from SaveData (the source of truth).
@@ -2457,17 +2592,53 @@ end
-- launcher, so a press only ARMS a click (see mousepressed) and this resolves
-- it: a pointer that moved past the threshold scrolls; one that did not, on
-- release, selects. Desktop only -- Android selects on press instead.
-- Where the pointer is this frame and whether it is held, read by polling
-- because no move event ever reaches the launcher: the mouse on desktop, the
-- first active touch on Android. A nil y means "nothing to read" -- the
-- release branches below do not need one.
function RomImporter:_pointerHold()
if not self.android then return love.mouse.isDown(1), self._my end
if not self.touchPollable then return false, nil end
local ok, list = pcall(love.touch.getTouches)
if not ok or type(list) ~= "table" or list[1] == nil then return false, nil end
local ok2, _, ty = pcall(love.touch.getPosition, list[1])
if not ok2 or type(ty) ~= "number" then return false, nil end
return true, ty
end
function RomImporter:_updateSlotDrag()
if self.android then return end
local down = love.mouse.isDown(1)
if self.android and not self.touchPollable then return end
local down, py = self:_pointerHold()
py = py or self._my
local maxPage = self._pageMax or 0
-- A press on empty background pans the page while it overflows. Nothing is
-- armed by it, so there is no release action to resolve.
local pp = self._pagePress
if pp then
if down then
if maxPage > 0 then
self.pageScroll = clamp(pp.scroll0 - (py - pp.y0), 0, maxPage)
end
else
self._pagePress = nil
end
end
local p = self._slotPress
if p then
if down then
local d = self._my - p.y0
local d = py - p.y0
if math.abs(d) > 4 * (self._s or 1) then p.moved = true end
if p.moved then
local maxS = (self._slotMax and self._slotMax[p.version]) or 0
self.slotScroll[p.version] = clamp(p.scroll0 - d, 0, maxS)
-- Paged, the list has no scroll of its own: the drag pans the page, so
-- a swipe that starts on a slot row behaves like one starting beside it.
if maxPage > 0 then
self.pageScroll = clamp(p.pageScroll0 - d, 0, maxPage)
else
local maxS = (self._slotMax and self._slotMax[p.version]) or 0
self.slotScroll[p.version] = clamp(p.scroll0 - d, 0, maxS)
end
end
else
if not p.moved then self:_selectSlot(p.version, p.id) end
@@ -2479,10 +2650,14 @@ function RomImporter:_updateSlotDrag()
local mp = self._modPress
if mp then
if down then
local d = self._my - mp.y0
local d = py - mp.y0
if math.abs(d) > 4 * (self._s or 1) then mp.moved = true end
if mp.moved then
self.modScroll = clamp(mp.scroll0 - d, 0, self._modMax or 0)
if maxPage > 0 then
self.pageScroll = clamp(mp.pageScroll0 - d, 0, maxPage)
else
self.modScroll = clamp(mp.scroll0 - d, 0, self._modMax or 0)
end
end
else
if not mp.moved then self:_toggleMod(mp.id) end
@@ -2496,6 +2671,13 @@ end
-- content extent draw computed for that version.
function RomImporter:wheelmoved(_, dy)
local step = 48 * (self._s or 1)
-- An overflowing page scrolls as a whole; the panels' own lists are flattened
-- in that mode, so there is never a second scroll region competing for this.
local maxPage = self._pageMax or 0
if maxPage > 0 then
self.pageScroll = clamp((self.pageScroll or 0) - dy * step, 0, maxPage)
return
end
if self.tab == "mods" then
local maxS = self._modMax or 0
if maxS <= 0 then return end
@@ -2512,15 +2694,35 @@ end
-- SAVE SLOT card: header ("SAVE SLOT" + "N slots"), a scrollable list of slot
-- rows (name + meta, LOADED pill on the active one), and a dashed "+ New save
-- slot" button pinned to the bottom. Empty registries show a dashed hint box.
function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
-- `paged` (the whole launcher page is scrolling, see draw()) drops the inner
-- scroll region: the card grows to its natural height, every row is drawn, and
-- the page's own scrollbar is the only one on screen. Returns the height the
-- card actually took, which is what the caller measures the page against.
function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged)
local s = self._s
local pad = 16 * s
roundedCard(x, y, w, h, 16 * s)
self:_ensureSlots(version)
local slots = self.slots[version] or {}
local active = self.activeSlot[version]
local n = #slots
-- Row metrics up front: the natural height needs them, and the natural height
-- decides the card's height before anything is drawn.
local labelH = self.labelFont:getHeight()
local newBtnH = math.max(38 * s, self.saveBtnFont:getHeight() + 18 * s)
local nameH = self.slotNameFont:getHeight()
local metaH = self.labelFont:getHeight()
local rowPadV = 10 * s
local rowH = rowPadV * 2 + nameH + 4 * s + metaH
local rowGap = 8 * s
local rr = 12 * s
-- an empty registry shows a fixed-height dashed hint box instead of rows
local totalH = (n > 0) and (n * rowH + (n - 1) * rowGap) or (96 * s)
local naturalH = pad + labelH + 12 * s + totalH + 10 * s + newBtnH + pad
if paged then h = naturalH end
roundedCard(x, y, w, h, 16 * s)
-- header: "SAVE SLOT" (left) + "N slots" / "1 slot" (right)
love.graphics.setFont(self.labelFont)
col(PAL.labelGray)
@@ -2529,11 +2731,9 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
local cw = self.labelFont:getWidth(countTxt)
love.graphics.print(countTxt, x + w - pad - cw, y + pad)
local labelH = self.labelFont:getHeight()
local listTop = y + pad + labelH + 12 * s
-- "+ New save slot" pinned to the card bottom; the list fills the gap above.
local newBtnH = math.max(38 * s, self.saveBtnFont:getHeight() + 18 * s)
local newBtnY = y + h - pad - newBtnH
local listBottom = newBtnY - 10 * s
local listH = math.max(0, listBottom - listTop)
@@ -2553,16 +2753,10 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotDeleteRects = {}
self.slotEditRects = {}
elseif listH > 0 then
local nameH = self.slotNameFont:getHeight()
local metaH = self.labelFont:getHeight()
local rowPadV = 10 * s
local rowH = rowPadV * 2 + nameH + 4 * s + metaH
local rowGap = 8 * s
local rr = 12 * s
-- clamp scroll against the current content extent, and stash the max so the
-- wheel handler (which has no geometry) can clamp against the same value
local totalH = n * rowH + (n - 1) * rowGap
-- wheel handler (which has no geometry) can clamp against the same value.
-- Paged, listH already equals totalH, so this is 0 and the wheel falls
-- through to the page scroll.
local maxScroll = math.max(0, totalH - listH)
self._slotMax = self._slotMax or {}
self._slotMax[version] = maxScroll
@@ -2572,8 +2766,12 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotRects = {}
self.slotDeleteRects = {}
self.slotEditRects = {}
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
math.ceil(rw), math.ceil(listH))
-- Paged, the page viewport's scissor is already set and nothing here
-- overflows the card, so leave it alone rather than replace and clear it.
if not paged then
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
math.ceil(rw), math.ceil(listH))
end
for i, slot in ipairs(slots) do
local ry = listTop - scroll + (i - 1) * (rowH + rowGap)
if ry + rowH >= listTop and ry <= listBottom then
@@ -2676,7 +2874,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
end
end
end
love.graphics.setScissor()
if not paged then love.graphics.setScissor() end
-- thin scrollbar thumb when the list overflows
if maxScroll > 0 then
@@ -2700,6 +2898,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
printfB("+ New save slot", nrect.x,
nrect.y + (newBtnH - self.saveBtnFont:getHeight()) / 2, nrect.width, "center")
self.newSlotRect = nrect
return h, naturalH
end
-- Reload the mods list from LauncherMods (the source of truth: it reads the
@@ -2762,7 +2961,10 @@ end
-- install-result / drag-drop notice line, then a scrollable list of mod cards
-- (name + badge chip + description, a status chip, and a toggle switch). An
-- empty install shows a friendly dashed hint box.
function RomImporter:_drawModsPanel(x, y, w, h)
-- `paged` behaves as it does on the game panel: no inner scroll region, the
-- card list is drawn whole, and the returned natural height is what draw()
-- measures the page against.
function RomImporter:_drawModsPanel(x, y, w, h, paged)
local s = self._s
self:_ensureMods()
local mods = self.mods or {}
@@ -2807,7 +3009,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
-- empty state: a dashed box with a centred hint
if #mods == 0 then
local boxH = math.min(listH, 120 * s)
local boxH = paged and (120 * s) or math.min(listH, 120 * s)
love.graphics.setLineWidth(math.max(1, 1 * s))
col(PAL.cardBorder, 0.45)
dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s)
@@ -2821,7 +3023,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
self.modRects = {}
self.modDeleteRects = {}
self._modMax = 0
return
return (top - y) + boxH
end
-- card metrics (design: rounded 14, padding 14x16; toggle 56x28; Delete under)
@@ -2858,6 +3060,9 @@ function RomImporter:_drawModsPanel(x, y, w, h)
end
total = total + (#mods - 1) * cardGap
-- Paged, the list band is the list itself: nothing to clip, nothing to scroll
-- here, and the page's scrollbar covers the overflow.
if paged then listH = total end
local maxScroll = math.max(0, total - listH)
self._modMax = maxScroll
local scroll = clamp(self.modScroll or 0, 0, maxScroll)
@@ -2865,8 +3070,10 @@ function RomImporter:_drawModsPanel(x, y, w, h)
self.modRects = {}
self.modDeleteRects = {}
love.graphics.setScissor(math.floor(x), math.floor(top),
math.ceil(w), math.ceil(listH))
if not paged then
love.graphics.setScissor(math.floor(x), math.floor(top),
math.ceil(w), math.ceil(listH))
end
local cy = top - scroll
for i, m in ipairs(mods) do
local L = layout[i]
@@ -2962,7 +3169,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
end
cy = cy + cardH + cardGap
end
love.graphics.setScissor()
if not paged then love.graphics.setScissor() end
-- thin scrollbar thumb when the list overflows
if maxScroll > 0 then
@@ -2971,6 +3178,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
col(PAL.cardBorder, 0.35)
love.graphics.rectangle("fill", x + w - 3 * s, thumbY, 3 * s, thumbH, 1.5 * s, 1.5 * s)
end
return (top - y) + total
end
return RomImporter
+63
View File
@@ -0,0 +1,63 @@
-- Launcher page scroll (src/import/RomImporter.lua): the column under the tab
-- bar -- panel, updater banner, footer -- scrolls as one when the window is too
-- short to hold it. Before this, a stacked single-column layout on a narrow
-- window ran under a footer pinned to the window bottom, and the part below the
-- fold could not be reached at all. The arithmetic is pure, so pin it here
-- rather than in a screenshot.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.modkit")
local RomImporter = require("src.import.RomImporter")
local pageScrollFor = RomImporter.pageScrollFor
-- ------------------------------------------------------------- fits: inert
local paged, scroll, maxPage = pageScrollFor(400, 600, 0)
T.eq(paged, false, "a column shorter than the viewport does not scroll")
T.eq(maxPage, 0, "and has nowhere to scroll to")
T.eq(scroll, 0, "and sits at the top")
-- An exact fit is still not a scroll: one pixel of slack would show a thumb
-- for nothing and put the wheel on the page instead of the slot list.
paged, _, maxPage = pageScrollFor(600, 600, 0)
T.eq(paged, false, "a column exactly as tall as the viewport does not scroll")
T.eq(maxPage, 0, "an exact fit has no scroll extent")
-- --------------------------------------------------------- overflows: scrolls
paged, scroll, maxPage = pageScrollFor(900, 600, 0)
T.eq(paged, true, "a column taller than the viewport scrolls")
T.eq(maxPage, 300, "the extent is exactly the overflow")
T.eq(scroll, 0, "a fresh page starts at the top")
-- The bottom of the travel shows the footer: the whole overflow is reachable,
-- which is the point of the change (#footer under the fold).
_, scroll = pageScrollFor(900, 600, 300)
T.eq(scroll, 300, "the offset can reach the end of the column")
_, scroll = pageScrollFor(900, 600, 5000)
T.eq(scroll, 300, "an offset past the end clamps to it")
_, scroll = pageScrollFor(900, 600, -40)
T.eq(scroll, 0, "an offset above the top clamps to it")
-- ------------------------------------------------------- the window grows back
-- Resizing taller has to pull the page back down with it; leaving the offset
-- where it was would park the content above the viewport with no way back.
_, scroll, maxPage = pageScrollFor(900, 800, 300)
T.eq(maxPage, 100, "a taller window leaves less to scroll")
T.eq(scroll, 100, "and drags a deeper offset back to the new end")
paged, scroll = pageScrollFor(900, 900, 300)
T.eq(paged, false, "growing past the content stops the scrolling")
T.eq(scroll, 0, "and returns the page to the top")
-- ---------------------------------------------------------------- degenerate
-- draw() computes the viewport from the window height, so a window smaller than
-- the pinned header hands this a negative number; it must not become extra
-- travel.
_, _, maxPage = pageScrollFor(500, -120, 0)
T.eq(maxPage, 500, "a negative viewport counts as no room, not as more of it")
paged, scroll, maxPage = pageScrollFor(nil, nil, nil)
T.eq(paged, false, "a first frame with nothing measured yet does not scroll")
T.eq(scroll, 0, "and sits at the top")
T.eq(maxPage, 0, "with no extent")
T.finish("launcher page scroll")