Add nickname editing to the save editor's mon inspector

The mon inspector now has a NICKNAME section: a text field with Set / Clear, commit-on-Enter and discard-on-Escape.

Input is gated at the field instead of refused at commit. Only glyphs the game
font can actually draw AND that round-trip through a .sav are accepted, capped
at the naming screen's 10 glyphs, so a bad keystroke or paste never appears and
Set always succeeds. Chars like "@" (the Gen1 string terminator), "#" and the
dakuten kana have codec entries but no font tile so they are blocked rather than silently mangled.
This commit is contained in:
kikimanjaro
2026-08-06 18:51:27 +02:00
parent aa6217e581
commit 9cc72f951b
6 changed files with 401 additions and 2 deletions
+18
View File
@@ -842,6 +842,24 @@ function App.keypressed(key)
return
end
end
-- The inspector's nickname field is a commit-on-Enter field, unlike the
-- search fields, which are live view state. Enter commits the draft through
-- Ops and blurs; Escape discards it and blurs. Both must run before
-- Kit.keypressed, which maps return/escape to the same "\r" edit and cannot
-- tell "commit" from "cancel".
if Kit.focus == "mon-nickname" then
if key == "return" or key == "kpenter" then
if S.editingMon and Ops.setNickname(S, S.editingMon, S.nicknameDraft) then
S.nicknameDraft = S.editingMon.nickname or ""
end
Kit.blur()
return
elseif key == "escape" then
Kit.blur()
if S.editingMon then S.nicknameDraft = S.editingMon.nickname or "" end
return
end
end
-- A focused text field eats the keys it cares about (typing "s" into the
-- map filter must not trigger Save).
if Kit.keypressed(key) then return end
+9 -1
View File
@@ -426,7 +426,12 @@ end
-- state because Kit had no input widget; this replaces that hack, and App
-- routes love.textinput / love.keypressed in through Kit.textinput /
-- Kit.keypressed. Returns the (possibly edited) value; the caller stores it.
function Kit.textfield(id, x, y, w, h, value, placeholder)
--
-- `opts.sanitize(value)` (optional) is a post-filter run on the merged text
-- right after this frame's edits and BEFORE it draws, so a keystroke or paste
-- the filter rejects never even flashes on screen. It gets the whole value
-- because a paste arrives as one textinput chunk alongside existing text.
function Kit.textfield(id, x, y, w, h, value, placeholder, opts)
audit("control", x, y, w, h, id)
value = tostring(value or "")
if Kit.press(x, y, w, h) then Kit.focus = id end
@@ -444,6 +449,9 @@ function Kit.textfield(id, x, y, w, h, value, placeholder)
value = value .. e
end
end
if opts and opts.sanitize then
value = opts.sanitize(value)
end
end
if G then
local r = 8 * Kit.scale
+153
View File
@@ -16,12 +16,17 @@ local PartyMod = require("src.pokemon.Party")
local BoxesMod = require("src.pokemon.Boxes")
local Bag = require("src.inventory.Bag")
local MonOps = require("MonOps")
local Charmap = require("src.save_convert.data.charmap")
local Ops = {}
Ops.MONEY_MAX = 999999
Ops.STACK_MAX = 99
Ops.ARM_SECONDS = 2.5
-- The in-game naming screen caps a nickname at 10 glyphs
-- (BattleState:askNicknameUI / src/ui/NamingScreen.lua maxLen = 10); the
-- editor mirrors that cap instead of inventing its own.
Ops.NICKNAME_MAX = 10
local function clamp(n, lo, hi)
if n < lo then return lo end
@@ -400,6 +405,154 @@ function Ops.healMon(S, mon)
return Ops.mark(S, ("Healed %s to %d/%d HP"):format(mon.species, mon.hp, mon.stats.hp))
end
-- ----------------------------------------------------------------- nicknames
-- Gen1 has no "is nicknamed" bit: an un-nicknamed mon is mon.nickname == nil,
-- and every display site reads `mon.nickname or def.name`
-- (src/save_convert/GenSave.lua). The editor edits that field directly.
-- The byte length of the UTF-8 glyph starting at lead byte `b`. Self-contained
-- so this (and eachGlyph) also runs headless under luajit, which has no `utf8`
-- standard library.
local function glyphByteLen(b)
if b < 0x80 then return 1 end
if b < 0xE0 then return 2 end
if b < 0xF0 then return 3 end
return 4
end
-- Walk `name` one UTF-8 glyph at a time; fn(glyph) returning false stops the
-- walk early and eachGlyph returns false. Returns true when every glyph was
-- visited. The single place that walks a name, so the count / validate /
-- sanitize paths cannot drift apart (a glyph is "é" or "♂", not one of its
-- bytes, exactly as the naming screen counts its grid cells).
local function eachGlyph(name, fn)
local i, n = 1, #name
while i <= n do
local b = name:byte(i)
local ch = name:sub(i, i + glyphByteLen(b) - 1)
if fn(ch) == false then return false end
i = i + #ch
end
return true
end
-- Glyph count, not byte count: "é" or "♂" is ONE game character, exactly as
-- the naming screen counts its grid cells and GenSave.encodeName counts a
-- charmap sequence.
function Ops.nicknameLength(name)
local n = 0
eachGlyph(tostring(name or ""), function() n = n + 1 end)
return n
end
-- The set of glyphs a nickname may hold: present in BOTH the Gen1 text codec
-- charmap (so the name round-trips through a .sav) and the game's font
-- charmap (so it actually draws). The codec alone is not enough: "@" is the
-- string-terminator byte, and "#" plus the dakuten kana have codec entries
-- but no font tile, so Font.encode (src/render/Font.lua) draws them as a
-- space -- an invisible nickname. Only single-codepoint entries qualify:
-- multi-character macros ("<PK>", the 'd ligature) cannot be typed one
-- character at a time, so they have no place in the input gate.
-- Built once per loaded font table (a mod replacing the font rebuilds it);
-- falls back to the codec-only set when no font data is loaded (headless
-- suites that never call Data:load).
local glyphCache, glyphCacheFont
local function nameGlyphSet(S)
local font = S and S.data and S.data.font
if not (font and font.charmap) then return Charmap.byToken end
if glyphCache and glyphCacheFont == font then return glyphCache end
local set = {}
for _, e in ipairs(font.charmap) do
local s = e.seq
if type(s) == "string" and s ~= "" and Charmap.byToken[s]
and #s == glyphByteLen(s:byte(1)) then
set[s] = true
end
end
glyphCache, glyphCacheFont = set, font
return set
end
-- True when every glyph is a legal nickname glyph (see nameGlyphSet): the
-- name can be stored in a .sav AND draws in the game. Anything else either
-- encodes as "?" (GenSave.encodeName) or renders as a space (Font.encode),
-- which the user did not ask for, so it is refused rather than mangled.
function Ops.nicknameUsable(S, name)
local set = nameGlyphSet(S)
return eachGlyph(tostring(name or ""), function(ch)
return set[ch] ~= nil
end)
end
-- The species' display name, what an un-nicknamed mon reads as.
local function speciesName(S, species)
local def = species and S.data.pokemon[species]
return (def and def.name) or tostring(species or "")
end
-- The input gate for the inspector's nickname field. Given the whole draft
-- (existing text plus this frame's keystrokes and any paste), return the
-- version the game can actually hold: every glyph kept draws in the game
-- (see nameGlyphSet) and the result never exceeds the naming screen's
-- 10-glyph cap. Unrenderable glyphs are skipped, not used to abort the rest
-- of the string, so a paste of "PIKA€CHU" lands as "PIKACHU". The field runs
-- this through Kit.textfield's opts.sanitize, so a blocked character never
-- appears at all.
function Ops.nicknameSanitize(S, name)
local set = nameGlyphSet(S)
local out, count = {}, 0
eachGlyph(tostring(name or ""), function(ch)
if count < Ops.NICKNAME_MAX and set[ch] then
out[#out + 1] = ch
count = count + 1
end
end)
return table.concat(out)
end
-- One verb for both writing and clearing. An empty field means "no nickname",
-- exactly like an empty confirm on the in-game naming screen (which falls
-- through to the species' standard name). A name that equals the species'
-- standard name is the un-nicknamed state in this save format
-- (importedNickname in GenSave.lua maps exactly that to nil), so it is
-- normalized to nil rather than stored as a literal copy of the default.
function Ops.setNickname(S, mon, name)
if not mon then return Ops.say(S, "Pick a slot first") end
name = tostring(name or "")
if name == "" then
return Ops.clearNickname(S, mon)
end
if name == mon.nickname then
return Ops.say(S, ("Already nicknamed %s"):format(name))
end
if name == speciesName(S, mon.species) then
if mon.nickname == nil then
return Ops.say(S, ("%s is already un-nicknamed"):format(mon.species))
end
mon.nickname = nil
return Ops.mark(S, ("%s matches its standard name; nickname cleared")
:format(name))
end
if Ops.nicknameLength(name) > Ops.NICKNAME_MAX then
return Ops.say(S, ("Nicknames are capped at %d characters"):format(Ops.NICKNAME_MAX))
end
if not Ops.nicknameUsable(S, name) then
return Ops.say(S,
"That name has characters the game cannot render or export cleanly")
end
mon.nickname = name
return Ops.mark(S, ("Nicknamed %s \"%s\""):format(mon.species, name))
end
function Ops.clearNickname(S, mon)
if not mon then return Ops.say(S, "Pick a slot first") end
if mon.nickname == nil then
return Ops.say(S, ("%s has no nickname to clear"):format(mon.species))
end
mon.nickname = nil
return Ops.mark(S, ("Cleared %s's nickname"):format(mon.species))
end
-- ------------------------------------------------------------------ boxes
function Ops.boxes(S)
return BoxesMod.ensure(S.save)
+2
View File
@@ -43,6 +43,8 @@ function State.new()
partyOffset = 0, -- roster scroll position (#715)
inspectorScroll = 0, -- MonEditor body pixel scroll (#715)
editingMon = nil, -- reference into party or a box
nicknameDraft = nil, -- text being typed in the inspector's nickname field
nicknameMon = nil, -- the mon the draft belongs to (nil for none)
-- species picker overlay: nil when closed, otherwise { query, offset }
-- plus mode = "box-add" when it is adding to a box instead of changing a
-- species (Ops.openBoxAddPicker). Modal in the literal sense -- App
+42 -1
View File
@@ -218,7 +218,11 @@ function MonEditor.draw(S, Kit, x, y, w, h)
else
colsH = capH + 10 * s + colRowsH + 12 * s + actH
end
-- the nickname section: a caption line (with the Clear button on it) plus
-- the field + Set row
local nickFieldH = 30 * s
local contentH = pad + headerH + 18 * s
+ capH + 10 * s + nickFieldH + 18 * s
+ capH + 10 * s + cellH + 18 * s
+ colsH + pad
@@ -266,8 +270,45 @@ function MonEditor.draw(S, Kit, x, y, w, h)
drawLevelRow(S, Kit, mon, cx, cy + math.max(sprite, titleH) + 12 * s)
end
-- ---------------------------------------------------------- nickname
-- Editing the field is a draft (S.nicknameDraft) held on the mon it belongs
-- to; Set / Enter commit it through Ops.setNickname, which clears on an
-- empty value, and Clear goes through Ops.clearNickname. The draft resets
-- when the selection moves so one mon's typing can never leak onto another.
local nickY = cy + headerH + 18 * s
Kit.caption(cx, nickY, "NICKNAME")
local clearW = 74 * s
local clearH = 24 * s
if Kit.button(cx + inner - clearW, nickY + (capH - clearH) / 2, clearW, clearH,
"Clear", { kind = "danger", font = "micro", radius = 6 * s }) then
Ops.clearNickname(S, mon)
S.nicknameDraft = ""
end
local fieldY = nickY + capH + 10 * s
local setW = 64 * s
local fieldW = inner - setW - 10 * s
if S.nicknameMon ~= mon then
local switching = S.nicknameMon ~= nil
S.nicknameMon = mon
S.nicknameDraft = mon.nickname or ""
-- a still-focused field would keep appending keystrokes to the newly
-- selected mon; the selection move counts as leaving the field. The
-- first sync (nicknameMon starts nil) never blurs: the species picker
-- owns focus when it opens, and blurring there drops the player's typing.
if switching and Kit.focus == "mon-nickname" then Kit.blur() end
end
S.nicknameDraft = Kit.textfield("mon-nickname", cx, fieldY, fieldW, nickFieldH,
S.nicknameDraft or "", "no nickname",
{ sanitize = function(value) return Ops.nicknameSanitize(S, value) end })
if Kit.button(cx + fieldW + 10 * s, fieldY, setW, nickFieldH, "Set",
{ kind = "accent", font = "small", radius = 8 * s }) then
if Ops.setNickname(S, mon, S.nicknameDraft) then
S.nicknameDraft = mon.nickname or ""
end
end
-- ------------------------------------------------------- derived stats
local statsY = cy + headerH + 18 * s
local statsY = nickY + capH + 10 * s + nickFieldH + 18 * s
Kit.caption(cx, statsY, "STATS . recalculated from level + DVs")
statsY = statsY + capH + 10 * s
local gap = 12 * s