Rebuild level editor sidebar with a fixed virtual-space layout

Replace the fraction-of-window positioning (which overlapped and didn't
scale) with a small layout system: the sidebar is authored in one fixed
virtual coordinate space and rendered at native panel resolution, so
element sizes stay constant relative to the panel at any window size and
fonts scale with it.

- level_editor/layout.lua: sections, scrolling list (with clip + hit-test
  by point), scrollbar. Clip rects are scaled to real canvas pixels.
- Room view split into non-overlapping divs: search/back header, a
  scrollable asset list (replaces the old row-paging + forward buttons),
  a status strip, and a compact bottom control cluster.
- Mouse wheel scrolls the list; clicks map from window coords into the
  virtual space via the same transform used to render.
- Sidebar canvas is panel-sized and blitted 1:1 (nearest filter, never
  averaged) to stay crisp; editorDraw runs under a scale transform.
- Center text-only fields (level name) in their rect.

Also carried in main.lua (share the file, can't split cleanly here):
narrator require path fixed via love.filesystem.setRequirePath; sfxr
static uses waveform=NOISE again after the field rename; low-pass +
lower volume on the static loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-08-11 12:24:54 -04:00
parent 03532d29e4
commit 5b7ea9ec20
4 changed files with 336 additions and 144 deletions

View file

@ -1,11 +1,11 @@
require "level_editor/object_attributes"
require "level_editor/gui"
require "level_editor/layout"
editorView = "room" --can be "off", "room", "world", "asset"
selectedColor = "red"
local bacSprite = love.graphics.newImage("art/_ui/bac.png")
local forwardSprite = love.graphics.newImage("art/_ui/forward.png")
local folderSprite = love.graphics.newImage("art/_ui/folder.png")
local cancelSprite = love.graphics.newImage("art/_ui/cancel.png")
local confirmSprite = love.graphics.newImage("art/_ui/confirm.png")
@ -14,7 +14,6 @@ local clearSavesSprite = love.graphics.newImage("art/_ui/clear_saves.png")
local selectedFolder = nil
local selectedAsset = nil
local selectedRoom = nil
local selectedRow = 1
local gameAssets = {}
local inputText = ""
local assetProperties = {}
@ -25,7 +24,8 @@ local activeField = nil --this is for whenwe are doing text input things
local uiButtons = {}
local folderButtons = {}
local assetButtons = { {} } --table for rows so we can do stuff
local assetListButtons = {} -- flat list of the selected folder's assets (scrolls)
local listScroll = { scroll = 0 } -- shared vertical scroll for the room list
local editingAssetButtons = {}
local labels = {}
@ -45,6 +45,110 @@ local assetSearchButtons = {}
local worldSearchText = ""
local worldSearchBox = { x = 10, y = 34, w = 0, h = 28 }
-- ── Room-view sidebar layout ────────────────────────────────────────────────
-- Declared up here (not next to editorDraw) so editorMouseHandler, defined
-- earlier in the file, can still capture these as upvalues.
-- Which flat list the room view is currently showing (search > folder > roots).
local function currentRoomList()
if assetSearchText ~= "" then return assetSearchButtons
elseif not selectedFolder then return folderButtons
else return assetListButtons end
end
-- Carve the sidebar into fixed, non-overlapping "divs": a search header pinned
-- to the top, a control cluster pinned to the bottom, a status strip above it,
-- and a flexible list filling whatever is left. Recomputed every frame so it
-- tracks window resizes.
local function roomSections()
local pad = 10
local w = math.max(0, sb.w - pad * 2)
local controlsH = 104
local statusH = 44
local search = { x = pad, y = pad, w = w, h = 34 }
local controls = { x = pad, y = sb.h - pad - controlsH, w = w, h = controlsH }
local status = { x = pad, y = controls.y - statusH, w = w, h = statusH }
local listY = search.y + search.h + 8
local list = { x = pad, y = listY, w = w, h = math.max(0, status.y - 8 - listY) }
return { search = search, listRect = list, status = status, controls = controls }
end
-- Position the bottom control cluster inside its section: level-name field, a
-- row of colour buttons, then a row of action buttons. Runs for both drawing
-- and hit-testing so the two never disagree.
local function layoutRoomControls(c, s)
local vgap = 4 -- vertical space between rows
local hgap = 8 -- horizontal space between buttons in a row
local y = c.y
s.labelY = y
y = y + 15
local field = uiButtons.roomName
if field then field.x = c.x; field.y = y; field.w = c.w; field.h = 22 end
y = y + 22 + vgap
-- Resize a sprite button to a target height in place, so all control sizing
-- lives here instead of being scattered across the creation calls.
local function sizeBtn(name, target)
local b = uiButtons[name]
if b and b.sprite then
b.scale = target / b.sprite:getHeight()
b.w = b.sprite:getWidth() * b.scale
b.h = b.sprite:getHeight() * b.scale
end
return b
end
-- Pack the row centered with a fixed gap, rather than spreading each button
-- across a full-width slot (which left big gaps between small icons).
local function placeRow(names, target)
local btns, total = {}, 0
for _, name in ipairs(names) do
local b = sizeBtn(name, target)
if b then table.insert(btns, b); total = total + b.w end
end
total = total + hgap * math.max(0, #btns - 1)
local x = c.x + (c.w - total) / 2
for _, b in ipairs(btns) do
b.x = x
b.y = y + (target - b.h) / 2
x = x + b.w + hgap
end
y = y + target + vgap
end
placeRow({ "white", "red", "green", "blue" }, 22)
placeRow({ "save", "clearSaves", "world" }, 28)
end
-- Build the full room-view layout (sections + a scroll list + a back/search
-- header) without drawing. Shared by editorDraw and editorMouseHandler.
local function buildRoomLayout()
local s = roomSections()
local showBack = assetSearchText ~= "" or selectedFolder
local sx, sw = s.search.x, s.search.w
if showBack then
s.back = { x = s.search.x, y = s.search.y, w = s.search.h, h = s.search.h }
sx = s.back.x + s.back.w + 6
sw = s.search.w - s.back.w - 6
end
s.searchBox = { x = sx, y = s.search.y, w = sw, h = s.search.h }
s.items = currentRoomList()
s.list = Layout.list(listScroll, s.listRect, 40, #s.items, 2)
layoutRoomControls(s.controls, s)
return s
end
-- Mouse wheel scrolls the room list; clamped by Layout.list next frame.
function editorScrollRoomList(dy)
listScroll.scroll = math.max(0, (listScroll.scroll or 0) - dy * 40)
end
-- ────────────────────────────────────────────────────────────────────────────
local function fuzzyMatches(value, query)
query = (query or ""):lower():gsub("%s+", "")
if query == "" then return true end
@ -66,6 +170,7 @@ end
local function rebuildAssetSearchButtons()
assetSearchButtons = {}
listScroll.scroll = 0
if assetSearchText == "" then return end
local names = {}
for asset in pairs(gameAssets) do
@ -103,12 +208,12 @@ local function rebuildWorldRoomButtons()
for index = first, math.min(first + worldPageSize - 1, #names) do
local roomName = names[index]
local row = index - first
table.insert(worldRoomButtons, { name = roomName, x = 10, y = 70 + row * 68, w = sideBarBox.w - 20, h = 60 })
table.insert(worldRoomButtons, { name = roomName, x = 10, y = 70 + row * 68, w = sb.w - 20, h = 60 })
end
worldControls = {
{ text = worldMode == "place" and "MODE: PLACE ROOMS" or "MODE: OPEN LEVELS", x = 10, y = sideBarBox.h - 194, w = sideBarBox.w - 20, h = 34,
{ text = worldMode == "place" and "MODE: PLACE ROOMS" or "MODE: OPEN LEVELS", x = 10, y = sb.h - 194, w = sb.w - 20, h = 34,
onClic = function() worldMode = worldMode == "place" and "jump" or "place"; rebuildWorldRoomButtons() end },
{ text = "RESEED OLD EXITS", x = 10, y = sideBarBox.h - 152, w = sideBarBox.w - 20, h = 34,
{ text = "RESEED OLD EXITS", x = 10, y = sb.h - 152, w = sb.w - 20, h = 34,
onClic = function()
local now = love.timer.getTime()
if now > worldSeedConfirmationExpires then
@ -121,9 +226,9 @@ local function rebuildWorldRoomButtons()
editorStatus = "Seeded " .. result.placed .. " rooms; " .. result.links .. " legacy links (" .. result.conflicts .. " conflicts kept separate)."
rebuildWorldRoomButtons()
end },
{ text = "< PAGE " .. worldPage .. "/" .. pages .. " >", x = 10, y = sideBarBox.h - 110, w = sideBarBox.w - 20, h = 34,
{ text = "< PAGE " .. worldPage .. "/" .. pages .. " >", x = 10, y = sb.h - 110, w = sb.w - 20, h = 34,
onClic = function(buttonX)
if buttonX < sideBarBox.w / 2 then worldPage = math.max(1, worldPage - 1) else worldPage = math.min(pages, worldPage + 1) end
if buttonX < sb.w / 2 then worldPage = math.max(1, worldPage - 1) else worldPage = math.min(pages, worldPage + 1) end
rebuildWorldRoomButtons()
end },
}
@ -131,8 +236,8 @@ end
local function drawWorldSidebar()
love.graphics.setColor(gColor.white:set())
love.graphics.printf("ROOMS — " .. (worldMode == "place" and "select to place; re-click to clear" or "click a map room to open"), 8, 6, sideBarBox.w - 16, "center")
worldSearchBox.w = sideBarBox.w - 20
love.graphics.printf("ROOMS — " .. (worldMode == "place" and "select to place; re-click to clear" or "click a map room to open"), 8, 6, sb.w - 16, "center")
worldSearchBox.w = sb.w - 20
love.graphics.rectangle("line", worldSearchBox.x, worldSearchBox.y, worldSearchBox.w, worldSearchBox.h)
love.graphics.printf("SEARCH: " .. worldSearchText, worldSearchBox.x + 6, worldSearchBox.y + 7, worldSearchBox.w - 12, "left")
for _, button in ipairs(worldRoomButtons) do
@ -147,6 +252,10 @@ local function drawWorldSidebar()
love.graphics.rectangle("line", control.x, control.y, control.w, control.h)
love.graphics.printf(control.text, control.x, control.y + 9, control.w, "center")
end
-- Pin the assets/world toggle to a fixed spot here; the room view owns its
-- position otherwise, so give the world view a stable one of its own.
uiButtons.world.x = sb.w / 2 - uiButtons.world.w / 2
uiButtons.world.y = sb.h - uiButtons.world.h - 12
uiButtons.world:draw()
end
@ -254,7 +363,7 @@ function _initLevelEditor()
name = "folder",
text = folder,
x = 30,
y = (sideBarBox.h - 50) * (counter / 15),
y = (sb.h - 50) * (counter / 15),
sprite = folderSprite,
onClic = {
left = function(self)
@ -283,9 +392,9 @@ function _initLevelEditor()
end
},
x = sideBarBox.w / 4,
y = sideBarBox.h * (9 / 10)
})
x = sb.w / 4,
y = sb.h * (9 / 10)
}, "save")
_addUIButton(createUIElement{
name = "world",
@ -298,8 +407,8 @@ function _initLevelEditor()
end
},
x = sideBarBox.w * (3 / 4),
y = sideBarBox.h * (9 / 10)
x = sb.w * (3 / 4),
y = sb.h * (9 / 10)
}, "world")
_addUIButton(createUIElement{
@ -308,10 +417,10 @@ function _initLevelEditor()
onClic = {
left = clearRuntimeSaves,
},
x = sideBarBox.w / 2,
y = sideBarBox.h * (9 / 10),
scale = drawScale / 3
})
x = sb.w / 2,
y = sb.h * (9 / 10),
scale = editorScale / 3
}, "clearSaves")
for _, color in ipairs({"white", "red", "green", "blue"}) do
_addUIButton(createUIElement{
@ -327,16 +436,16 @@ function _initLevelEditor()
end
},
x = sideBarBox.w * (_ / 5),
y = sideBarBox.h * (8 / 10)
})
x = sb.w * (_ / 5),
y = sb.h * (8 / 10)
}, color)
end
roomNameField = createUIElement{
name = "roomName",
text = editorRoomName,
x = 25,
y = sideBarBox.h * (7 / 10),
y = sb.h * (7 / 10),
onClic = {
left = beginRoomNameEdit,
}
@ -356,74 +465,29 @@ end
function selectFolder(folder)
selectedFolder = folder
local bacButton = _addUIButton(createUIElement{
name = "bacToFolderView",
sprite = bacSprite,
x = sideBarBox.w / 2,
y = 37.5,
onClic = {
left = function(self)
print(self, self.x)
self = nil
folderView(self)
end,
right = function(self) return end
}
}, "bacToFolderView")
local row = 1
local counter = 1
local limit = 10
listScroll.scroll = 0
assetListButtons = {}
table.sort(hierarchy[selectedFolder])
for _, asset in ipairs(hierarchy[selectedFolder]) do
if counter % limit == 0 then
counter = 1
local forwardButton = createUIElement{
name = "forward",
sprite = forwardSprite,
x = sideBarBox.w / 2,
y = sideBarBox.h - 250,
onClic = {
left = function()
selectedRow = selectedRow + 1
end,
right = function()
end
}
}
table.insert(assetButtons[row], forwardButton)
row = row + 1
table.insert(assetButtons, {})
end
local sprites = {}
for color, location in pairs(gameAssets[asset].sprites) do
sprites[color] = love.graphics.newImage(location)
end
local assetButton = createUIElement{
-- x/y are placeholders; the room-view layout repositions every item
-- each frame from the list section (see buildRoomLayout).
table.insert(assetListButtons, createUIElement{
sprites = sprites,
name = "asset",
text = asset,
x = 30,
y = (sideBarBox.h - 225) * (counter / limit) + 20,
y = 0,
onClic = {
left = function()
print(asset)
selectAsset(asset)
end,
right = function()
editAsset(asset)
end
left = function() selectAsset(asset) end,
right = function() editAsset(asset) end,
}
}
table.insert(assetButtons[row], assetButton)
counter = counter + 1
})
end
end
@ -444,8 +508,8 @@ function editAsset(asset)
local cancelButton = createUIElement{
name = "cancel",
sprite = cancelSprite,
x = sideBarBox.w / 3,
y = sideBarBox.h - 75,
x = sb.w / 3,
y = sb.h - 75,
onClic = {
left = function(self)
assetsView()
@ -457,8 +521,8 @@ function editAsset(asset)
local confirmButton = createUIElement{
name = "confirm",
sprite = confirmSprite,
x = sideBarBox.w * 2 / 3,
y = sideBarBox.h - 75,
x = sb.w * 2 / 3,
y = sb.h - 75,
onClic = {
left = function(self)
saveAsset()
@ -478,12 +542,12 @@ function editAsset(asset)
local assetButton = createUIElement{
name = asset,
sprites = assetSprites,
x = sideBarBox.w / 2 - 18,
x = sb.w / 2 - 18,
y = 80,
}
local classFieldLabel = {
draw = function() love.graphics.printf("CLASS", 0, 125, sideBarBox.w, "center") end
draw = function() love.graphics.printf("CLASS", 0, 125, sb.w, "center") end
}
labels[classFieldLabel] = classFieldLabel
@ -501,7 +565,7 @@ function editAsset(asset)
}
local preloadLabel = {
draw = function() love.graphics.printf("preload", 0, 225, sideBarBox.w, "center") end
draw = function() love.graphics.printf("preload", 0, 225, sb.w, "center") end
}
labels[preloadLabel] = preloadLabel
@ -540,10 +604,9 @@ function _addUIButton(button, name)
end
function folderView()
selectedRow = 1
selectedFolder = nil
assetButtons = {{}}
uiButtons.bacToFolderView = nil
assetListButtons = {}
listScroll.scroll = 0
labels = {}
end
@ -594,39 +657,45 @@ end
function editorMouseHandler(x, y, button)
if x > width then
--we need to adjust for sidebar coords
x = x - width
-- map from real window coords into the sidebar's fixed virtual space
-- (same transform used to scale the sidebar canvas up in love.draw)
x = (x - width) * sb.w / sideBarBox.w
y = y * sb.h / sideBarBox.h
if editorView == "room" then
if buttonContains({ x = 10, y = 5, w = sideBarBox.w - 20, h = 28 }, x, y) then
beginSearch("assetSearch")
local s = buildRoomLayout()
-- back button: drop the search, else leave the folder
if s.back and Layout.hit(s.back, x, y) then
if assetSearchText ~= "" then
assetSearchText = ""
inputText = ""
activeField = nil
rebuildAssetSearchButtons()
elseif selectedFolder then
folderView()
end
return
elseif assetSearchText ~= "" then
for _, uiElement in ipairs(assetSearchButtons) do
if uiElement:clicIsInBox(x, y) then
uiElement:onClic(button)
return
end
end
elseif not selectedFolder then
for _, uiElement in pairs(folderButtons) do
if uiElement:clicIsInBox(x, y) then
uiElement:onClic(button)
return
end
end
else
for _, uiElement in pairs(assetButtons[selectedRow]) do
if uiElement:clicIsInBox(x, y) then
uiElement:onClic(button)
return
end
end
end
for _, uiElement in pairs(uiButtons) do
if uiElement:clicIsInBox(x, y) then
uiElement:onClic(button)
if Layout.hit(s.searchBox, x, y) then
beginSearch("assetSearch")
return
end
-- scrolling list item
local i = s.list.at(x, y)
if i and s.items[i] then
s.items[i]:onClic(button)
return
end
-- control cluster (only the buttons that live there)
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "save", "clearSaves", "world" }) do
local b = uiButtons[name]
if b and b:clicIsInBox(x, y) then
b:onClic(button)
return
end
end
elseif editorView == "asset" then
@ -722,25 +791,44 @@ end
function editorDraw()
if editorView == "room" then
local s = buildRoomLayout()
love.graphics.setColor(gColor.white:set())
love.graphics.rectangle("line", 10, 5, sideBarBox.w - 20, 28)
love.graphics.printf("ASSET SEARCH: " .. assetSearchText, 16, 12, sideBarBox.w - 32, "left")
love.graphics.setColor(gColor.white:set())
love.graphics.printf("LEVEL NAME", 0, sideBarBox.h * (7 / 10) - 24, sideBarBox.w, "center")
for _, button in pairs(uiButtons) do
button:draw()
-- header: optional back button + search box
if s.back then
love.graphics.rectangle("line", s.back.x, s.back.y, s.back.w, s.back.h)
local sc = (s.back.h - 8) / bacSprite:getHeight()
love.graphics.draw(bacSprite, s.back.x + 4, s.back.y + 4, 0, sc, sc)
end
if assetSearchText ~= "" then
for _, asset in ipairs(assetSearchButtons) do asset:draw() end
elseif not selectedFolder then
for _, folder in pairs(folderButtons) do
folder:draw()
end
else
for _, entity in ipairs(assetButtons[selectedRow]) do
entity:draw()
love.graphics.rectangle("line", s.searchBox.x, s.searchBox.y, s.searchBox.w, s.searchBox.h)
love.graphics.printf("SEARCH: " .. assetSearchText, s.searchBox.x + 6, s.searchBox.y + 9, s.searchBox.w - 12, "left")
-- scrolling list, clipped to its section so nothing bleeds into others
Layout.clip(s.listRect)
for i, item in ipairs(s.items) do
if s.list.visible(i) then
item.x, item.y = s.list.pos(i)
item.w = s.listRect.w
item:draw()
end
end
Layout.unclip()
Layout.scrollbar(s.list)
-- control cluster
love.graphics.setColor(gColor.white:set())
love.graphics.printf("LEVEL NAME", s.controls.x, s.labelY, s.controls.w, "center")
for _, name in ipairs({ "roomName", "white", "red", "green", "blue", "save", "clearSaves", "world" }) do
if uiButtons[name] then uiButtons[name]:draw() end
end
-- status strip between the list and the controls
if editorStatus then
Layout.clip(s.status)
love.graphics.setColor(gColor.white:set())
love.graphics.printf(editorStatus, s.status.x, s.status.y + 4, s.status.w, "center")
Layout.unclip()
end
elseif editorView == "asset" then
for _, button in pairs(editingAssetButtons) do
button:draw()
@ -752,8 +840,10 @@ function editorDraw()
for _, label in pairs(labels) do
label:draw()
end
if editorStatus then
-- The room view draws its status inside its own strip (see the room branch);
-- the other views keep the simple centered line.
if editorStatus and editorView ~= "room" then
love.graphics.setColor(gColor.white:set())
love.graphics.printf(editorStatus, 10, sideBarBox.h * (6 / 10), sideBarBox.w - 20, "center")
love.graphics.printf(editorStatus, 10, sb.h * (6 / 10), sb.w - 20, "center")
end
end

View file

@ -26,7 +26,7 @@ function GUI:initialize(t)
if t.sprite then
print(self.name)
self.scale = t.scale or drawScale * 2 / 3
self.scale = t.scale or editorScale * 2 / 3
self.w = t.sprite:getWidth() * self.scale
self.h = t.sprite:getHeight() * self.scale
self.sprite = t.sprite
@ -41,22 +41,22 @@ function GUI:initialize(t)
h = sprite:getHeight()
end
if w > h then
self.scale = (drawScale * (2 / 3)) / (w / 16)
self.scale = (editorScale * (2 / 3)) / (w / 16)
elseif w < h then
self.scale = (drawScale * (2 / 3)) / (h / 16)
self.scale = (editorScale * (2 / 3)) / (h / 16)
else
self.scale = (drawScale * (2 / 3)) / (w / 16)
self.scale = (editorScale * (2 / 3)) / (w / 16)
end
self.w = sideBarBox.w - (self.x * 2)
self.w = sb.w - (self.x * 2)
self.h = h * self.scale
else
-- this is text
self.x, self.y = t.x, t.y
self.w, self.h = sideBarBox.w - self.x * 2, 50
self.w, self.h = sb.w - self.x * 2, 50
end
if self.name == "folder" then
self.w = sideBarBox.w - self.x * 2
self.w = sb.w - self.x * 2
end
end
@ -106,9 +106,14 @@ function GUI:draw()
if self.text then
love.graphics.setColor(gColor.white:set())
if not self.sprites and not self.sprite then
-- text-only element (e.g. the level-name field): center in its rect
love.graphics.rectangle("line", box.x, box.y, box.w, box.h)
local th = love.graphics.getFont():getHeight()
love.graphics.printf(self.text, box.x, box.y + (box.h - th) / 2, box.w, "center")
else
-- label sitting to the right of an icon
love.graphics.print(self.text, box.x + 50, box.y + 15)
end
love.graphics.print(self.text, box.x + 50, box.y + 15)
end
end

63
level_editor/layout.lua Normal file
View file

@ -0,0 +1,63 @@
-- A dead-simple layout helper for the editor sidebar. Everything works in the
-- sidebar's local coordinate space (0,0 = top-left of sideBarCanvas) — the same
-- space editorDraw / editorMouseHandler already use — so section rects double as
-- love.graphics.setScissor rects and as hit boxes. No dependencies, no state of
-- its own beyond the scroll table the caller hands in.
Layout = {}
-- point-in-rect
function Layout.hit(r, x, y)
return x >= r.x and x < r.x + r.w and y >= r.y and y < r.y + r.h
end
-- Clip drawing to a section. setScissor ignores the active graphics transform,
-- so multiply the virtual-space rect by editorRenderScale to hit real canvas
-- pixels. Clamp to non-negative: a too-narrow/short sidebar (e.g. a square
-- window with no room for it) would otherwise hand setScissor a negative size.
function Layout.clip(r)
local s = editorRenderScale or 1
love.graphics.setScissor(r.x * s, r.y * s, math.max(0, r.w * s), math.max(0, r.h * s))
end
function Layout.unclip() love.graphics.setScissor() end
-- A vertical scrolling list confined to rect `r`. `state` is any table the
-- caller keeps between frames to hold `state.scroll`; it is clamped here.
-- Returns helpers to place / cull / hit-test `count` items of height `itemH`.
function Layout.list(state, r, itemH, count, gap)
gap = gap or 6
local step = itemH + gap
local content = count * step
local maxScroll = math.max(0, content - r.h)
state.scroll = math.max(0, math.min(state.scroll or 0, maxScroll))
local scroll = state.scroll
return {
rect = r, itemH = itemH, step = step, count = count, maxScroll = maxScroll, scroll = scroll,
-- top-left corner of item i (1-based), in sidebar space
pos = function(i) return r.x, r.y + (i - 1) * step - scroll end,
-- is item i at all inside the visible window? (cheap cull)
visible = function(i)
local y = r.y + (i - 1) * step - scroll
return y + itemH > r.y and y < r.y + r.h
end,
-- index at a sidebar-space point, or nil if outside the list / no item
at = function(x, y)
if not Layout.hit(r, x, y) then return nil end
local i = math.floor((y - r.y + scroll) / step) + 1
if i >= 1 and i <= count then return i end
return nil
end,
}
end
-- Thin scrollbar thumb on the right edge of a list, drawn only when it scrolls.
function Layout.scrollbar(list)
if list.maxScroll <= 0 then return end
local r = list.rect
local thumb = math.max(16, r.h * (r.h / (r.h + list.maxScroll)))
local ty = r.y + (r.h - thumb) * (list.scroll / list.maxScroll)
local pr, pg, pb, pa = love.graphics.getColor()
love.graphics.setColor(255, 255, 255, 90)
love.graphics.rectangle("fill", r.x + r.w - 4, ty, 3, thumb)
love.graphics.setColor(pr, pg, pb, pa)
end