diff --git a/docs/launcher.md b/docs/launcher.md index e2c323e7..f105a8d8 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -69,6 +69,15 @@ keeps working unchanged. - **Registry.** The ordered slot list and which one is active persist in `options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`): `options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`. + Custom slot labels (#205) live alongside them in the same registry: + `options.saveSlots[version].names = { slot1 = "Nuzlocke" }`, written by + `SaveData.renameSlot` (trimmed; an empty label clears it) and surfaced on + each `listSlots` row as `label` (the launcher row shows `label`, falling + back to the player name). `deleteSlot` drops the label with the slot. + Renaming never touches the save file, so an empty slot can be labeled. + On desktop, right-clicking a slot row opens the inline rename modal + (Enter commits, Esc cancels); touch has no secondary button, so the + affordance is desktop-only. - **Active slot resolution.** `saveNames(version)`, the function every existing caller (`TitleState` hasSave/load/save, recovery order) already goes through, now resolves the *active* slot instead of a fixed flat name. diff --git a/main.lua b/main.lua index 007fe074..a965f599 100644 --- a/main.lua +++ b/main.lua @@ -302,7 +302,7 @@ function love.mousemoved(x, y) end function love.textinput(text) - if Importer then return end + if Importer then return Importer:textinput(text) end if editorMode and EditorApp.textinput then return EditorApp.textinput(text) end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 3d540cc7..d02d3177 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -458,11 +458,44 @@ function SaveData.listSlots(version) for _, id in ipairs(list) do local save = decodeSlot(fs, version, id) local name, meta = SaveData.slotSummary(save) - out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta } + out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta, + label = reg.names and reg.names[id] or nil } end return out end +-- Give a registered slot a custom label (#205: "a way to name save slots so +-- you can see that in the launcher"). The label lives in the options +-- registry next to list/active, never in the save file itself, so renaming +-- needs no save rewrite and an empty slot can be labeled too. The label is +-- trimmed; an empty (or whitespace-only) one clears it. Returns true, or +-- false + an error string when the id is not registered. +function SaveData.renameSlot(version, slotId, name) + version = version or GameVersion.get() + if not knownVersion(version) then return false, "unknown version" end + if type(slotId) ~= "string" or slotId == "" then + return false, "missing slot id" + end + local fs = persistFs(nil) + local opts = SaveData.loadOptions(fs) + opts.saveSlots = opts.saveSlots or {} + local reg = opts.saveSlots[version] + if not reg or not reg.list then return false, "slot not registered" end + local found = false + for _, id in ipairs(reg.list) do + if id == slotId then found = true break end + end + if not found then return false, "slot not registered" end + local label = type(name) == "string" and name:match("^%s*(.-)%s*$") or nil + if label == "" then label = nil end + reg.names = reg.names or {} + reg.names[slotId] = label + if next(reg.names) == nil then reg.names = nil end + opts.saveSlots[version] = reg + SaveData.saveOptions(opts, fs) + return true +end + -- Point the active slot at slotId (registering it if new) and persist the -- choice to options.lua; also update the process-global cache so the very -- next save/load lands in the chosen slot. @@ -580,6 +613,7 @@ function SaveData.deleteSlot(version, slotId) remove(fs, tmp) table.remove(reg.list, idx) + if reg.names then reg.names[slotId] = nil end if reg.active == slotId then reg.active = reg.list[1] -- may be nil when the list is now empty end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 147ff030..42c48d1d 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1005,6 +1005,31 @@ local function printB(text, x, y) love.graphics.print(text, x + 0.6, y) end +-- UTF-8 helpers for the slot-rename field (#205). The `utf8` library only +-- exists inside LOVE (plain luajit, which loads this module in tests, has +-- none), so codepoint walking is done by hand -- the same lead-byte width +-- classes GenSave's encodeName uses. utf8Back drops the last codepoint; +-- utf8Cap truncates to maxChars whole codepoints. +local function utf8Back(t) + local i = #t + while i > 0 do + local b = t:byte(i) + i = i - 1 + if b < 0x80 or b >= 0xC0 then break end -- lead or ASCII: dropped, done + end + return t:sub(1, i) +end +local function utf8Cap(t, maxChars) + local count, i = 0, 1 + while i <= #t do + count = count + 1 + if count > maxChars then return t:sub(1, i - 1) end + local b = t:byte(i) + i = i + ((b < 0x80) and 1 or (b < 0xE0) and 2 or (b < 0xF0) and 3 or 4) + end + return t +end + -- One reusable unit quad, recoloured per call, for every vertical gradient -- fill (LOVE has no gradient primitive and a per-frame newMesh would churn -- the GPU). Callers set the blend mode; this only touches colour + geometry. @@ -1522,6 +1547,50 @@ function RomImporter:draw() -- events reach the launcher, so click-vs-drag is resolved here) self:_updateSlotDrag() + -- save-slot rename modal (#205), drawn over everything + if self._rename then + col(PAL.bgBot, 0.72) + love.graphics.rectangle("fill", 0, 0, width, height) + local dw = math.min(appW - 32 * s, 420 * s) + local dh = 128 * s + local dx = appX + (appW - dw) / 2 + local dy = (height - dh) / 2 + local rr = 12 * s + neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4) + fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85) + love.graphics.setLineWidth(math.max(1, 1.2 * s)) + col(PAL.green, 0.5) + love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr) + + love.graphics.setFont(self.slotNameFont) + col(PAL.white) + love.graphics.print(Strings("Name save slot"), dx + 16 * s, dy + 14 * s) + + -- the field: bordered strip, current text, blinking caret on the pulse + local fx, fy = dx + 16 * s, dy + 44 * s + local fw, fh = dw - 32 * s, 30 * s + col(PAL.bgBot, 0.9) + love.graphics.rectangle("fill", fx, fy, fw, fh, 8 * s, 8 * s) + love.graphics.setLineWidth(math.max(1, s)) + col(PAL.cardBorder, 0.45) + love.graphics.rectangle("line", fx, fy, fw, fh, 8 * s, 8 * s) + love.graphics.setFont(self.detailFont) + col(PAL.heading) + local shown = ellipsize(self.detailFont, self._rename.text, fw - 20 * s) + love.graphics.print(shown, fx + 10 * s, fy + (fh - self.detailFont:getHeight()) / 2) + if (self.pulse * 2 % 1) < 0.5 then + local cx = fx + 10 * s + self.detailFont:getWidth(shown) + 2 * s + col(PAL.green) + love.graphics.rectangle("fill", cx, fy + 6 * s, math.max(1, 1.5 * s), + fh - 12 * s) + end + + love.graphics.setFont(self.hintFont) + col(PAL.detail) + printfB(Strings("Enter to save - Esc to cancel - empty clears"), + dx + 16 * s, dy + dh - 30 * s, dw - 32 * s, "left") + end + -- pointer cursor over any interactive element (desktop only) if self._hoverEnabled and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then if self._anyHover then @@ -1538,6 +1607,20 @@ local function inside(r, x, y) end function RomImporter:mousepressed(x, y, button) + if self._rename then return end -- the rename modal swallows all clicks + -- right-click a save-slot row to rename it (#205); desktop only (touch + -- has no secondary button) + if button == 2 then + if not self.android and self.workState ~= "working" then + for _, r in ipairs(self.slotRects or {}) do + if inside(r, x, y) then + self:_beginRename(self.panelVersion, r.id) + return + end + end + end + return + end if button ~= 1 then return end if inside(self.bcgButton, x, y) or inside(self.linkUrlRect, x, y) then love.system.openURL(COMMUNITY_URL) @@ -1642,6 +1725,16 @@ function RomImporter:mousepressed(x, y, button) end function RomImporter:keypressed(key) + if self._rename then + if key == "backspace" then + self._rename.text = utf8Back(self._rename.text) + elseif key == "return" or key == "kpenter" then + self:_commitRename() + elseif key == "escape" then + self._rename = nil + end + return + end if self.workState == "working" then return end if key == "return" or key == "space" or key == "kpenter" then -- Enter acts on the visible game tab: Play if its ROM is ready, otherwise @@ -2069,6 +2162,33 @@ function RomImporter:_selectSlot(version, id) self.activeSlot[version] = id end +-- Inline slot rename (#205): right-click arms a modal text field; Enter +-- commits through SaveData.renameSlot (empty clears the label), Esc cancels. +-- While it is up, keypressed/textinput/mousepressed all route here first. +local MAX_SLOT_LABEL = 24 + +function RomImporter:_beginRename(version, id) + local label + for _, slot in ipairs(self.slots[version] or {}) do + if slot.id == id then label = slot.label break end + end + self._rename = { version = version, id = id, text = label or "" } + self._slotPress = nil -- cancel any armed click/drag on the list +end + +function RomImporter:_commitRename() + local r = self._rename + if not r then return end + self._rename = nil + require("src.core.SaveData").renameSlot(r.version, r.id, r.text) + self:_refreshSlots(r.version) +end + +function RomImporter:textinput(text) + if not self._rename then return end + self._rename.text = utf8Cap(self._rename.text .. text, MAX_SLOT_LABEL) +end + -- "+ New save slot": register an empty slot, make it active, relist, and pin the -- scroll to the bottom (clamped next draw) so the new row is on screen. function RomImporter:_newSlot(version) @@ -2242,7 +2362,8 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h) love.graphics.setFont(self.slotNameFont) col(PAL.white) - local name = slot.name or Strings("NEW GAME") + -- a custom label (#205) wins over the player name; both ellipsize + local name = slot.label or slot.name or Strings("NEW GAME") printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)), rx + 12 * s, ry + rowPadV) diff --git a/tests/engine/save_slots.lua b/tests/engine/save_slots.lua index b86dd1fa..89ff8a82 100644 --- a/tests/engine/save_slots.lua +++ b/tests/engine/save_slots.lua @@ -219,6 +219,50 @@ do T.check(loaded and loaded.player.name == "SLOT2", "load reads back from slot2") end +-- ---------------------------------------------- renameSlot (#205) + +do + local files = fresh() + local a = SaveData.createSlot("red") + local b = SaveData.createSlot("red") + local save = SaveData.newGame() + save.player.name = "ASH" + T.check(SaveData.writeSlot("red", a, save), "seed slot1 with a save") + + T.check(SaveData.renameSlot("red", a, "Nuzlocke"), + "renameSlot labels a registered slot") + local opts = SaveSerializer.decode(files["options.lua"]) + T.eq(opts.saveSlots.red.names[a], "Nuzlocke", + "the label persists in the options registry") + + local slots = SaveData.listSlots("red") + T.eq(slots[1].label, "Nuzlocke", "listSlots carries the custom label") + T.eq(slots[1].name, "ASH", "the player name still comes through separately") + T.eq(slots[2].label, nil, "an unlabeled slot has no label") + + T.check(SaveData.renameSlot("red", b, " "), "whitespace-only clears") + T.check(SaveData.renameSlot("red", a, ""), + "an empty name clears the label") + opts = SaveSerializer.decode(files["options.lua"]) + T.eq(opts.saveSlots.red.names and opts.saveSlots.red.names[a], nil, + "cleared labels leave the registry") + T.eq(SaveData.listSlots("red")[1].label, nil, "the row is unlabeled again") + + -- trimming + delete cleanup + T.check(SaveData.renameSlot("red", a, " Victory run "), + "renameSlot trims the label") + T.eq(SaveData.listSlots("red")[1].label, "Victory run", + "the stored label is trimmed") + T.check(SaveData.deleteSlot("red", a), "delete the labeled slot") + opts = SaveSerializer.decode(files["options.lua"]) + T.eq(opts.saveSlots.red.names[a], nil, "deleteSlot drops the label too") + + local bad, badErr = SaveData.renameSlot("red", "slot99", "x") + T.check(not bad, "renaming an unknown slot fails") + T.check(tostring(badErr):find("not registered", 1, true) ~= nil, + "unknown-slot rename error is user-presentable") +end + -- ---------------------------------------------- a version with no slots do