Major UI overhaul and bug fixes

- Fix Save Layer creating empty layers (clone image before creating cel)
- Fix Copy to Clipboard (use MaskAll instead of SelectAll)
- Remove Layer saved alert dialog
- Rename tabs: Setup to Animations, Render to Preview
- Config button now visible on all tabs
- Animation selection: Add/Remove buttons, brackets on selected, Selected label
- Delete animation now asks for confirmation
- Source canvas: left-click add or scroll drag, mouse wheel zoom
- Frames strip: click to select, drag and drop to reorder frames
- New frame actions: Flip X, Flip Y, Move Left, Move Right, Clear Anim
- Added vertical flip support for frames
- Removed destructive Clear All button
- Added Preview canvas in Animations tab with wheel zoom
- Updated all hint labels to be more descriptive
- Backward-compatible prefs migration for old tab names

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cidwel Highwind 2026-04-03 13:14:10 +02:00
parent 23d74a6f2a
commit 534fc09da3
1 changed files with 282 additions and 65 deletions

View File

@ -23,7 +23,7 @@ local SOURCE_VIEWPORT_W = 300
local SOURCE_VIEWPORT_H = 250
local MAX_ANIMS = 20 -- max number of dynamic animations
local TABS = { "Setup", "Render", "GB" }
local TABS = { "Animations", "Preview", "GB" }
local GB_TILE = 8 -- Game Boy tile size (fixed 8x8)
local GB_COLS = 16 -- tiles per row in optimized image (128px = GB standard)
@ -40,7 +40,7 @@ local S = {
sourceZoom = DEFAULT_SOURCE_ZOOM,
animFrame = 0,
currentAnim = 1, -- index into animNames
currentTab = "Setup",
currentTab = "Animations",
sourceImage = nil,
scrollX = 0,
scrollY = 0,
@ -80,9 +80,18 @@ local S = {
gbLayerName = "auto-optimized-tiles", -- default layer name
gbAlwaysOverwrite = false, -- overwrite layer checkbox
gbTileSize = GB_TILE, -- current tile size used (8 for pixel, from tileset for tile)
-- Dynamic animations: each frame is {x, y, flipped}
-- Dynamic animations: each frame is {x, y, flipped, flippedV}
animNames = {}, -- ordered list of animation names
anims = {}, -- name -> list of {x, y, flipped}
anims = {}, -- name -> list of {x, y, flipped, flippedV}
-- Frame selection & drag
selectedFrame = 0,
frameDragging = false,
frameDragFrom = 0,
frameDragTo = 0,
-- Source canvas click vs drag
srcClickX = 0,
srcClickY = 0,
srcIsDrag = false,
}
----------------------------------------------------------------------
@ -90,11 +99,12 @@ local S = {
----------------------------------------------------------------------
-- Setup tab elements (static IDs, dynamic ones added at runtime)
local SETUP_IDS = {
"btnConfig",
"sepAnims", "lblCurrentAnim", "btnNewAnim", "btnDelAnim",
"sepAnims", "btnNewAnim", "btnDelAnim",
"lblSelectedAnim",
"sepSource", "canvasSource",
"sepFrames", "canvasStrips",
"sepActions", "btnClearAnim", "btnClearAll",
"sepActions", "btnFlipX", "btnFlipY", "btnMoveLeft", "btnMoveRight", "btnClearAnim",
"sepPreviewAnim", "canvasPreviewAnim",
}
-- Render tab elements (static, dynamic anim canvases handled separately)
@ -121,7 +131,7 @@ local PREFS_FILE = app.fs.joinPath(app.fs.userConfigPath, "aniphallow_prefs.ini"
local function serializeFrames(frames)
local parts = {}
for _, f in ipairs(frames) do
table.insert(parts, f.x .. "," .. f.y .. "," .. (f.flipped and "1" or "0"))
table.insert(parts, f.x .. "," .. f.y .. "," .. (f.flipped and "1" or "0") .. "," .. (f.flippedV and "1" or "0"))
end
return table.concat(parts, ";")
end
@ -131,11 +141,12 @@ local function deserializeFrames(str)
local frames = {}
if not str or str == "" then return frames end
for part in string.gmatch(str, "([^;]+)") do
local x, y, fl = string.match(part, "([%d-]+),([%d-]+),?(%d?)")
local x, y, fh, fv = string.match(part, "([%d-]+),([%d-]+),?(%d?),?(%d?)")
if x and y then
table.insert(frames, {
x = tonumber(x), y = tonumber(y),
flipped = (fl == "1")
flipped = (fh == "1"),
flippedV = (fv == "1")
})
end
end
@ -153,7 +164,12 @@ local function loadPrefs()
if k == "previewZoom" then S.previewZoom = tonumber(v) or DEFAULT_PREVIEW_ZOOM end
if k == "sourceZoom" then S.sourceZoom = tonumber(v) or DEFAULT_SOURCE_ZOOM end
if k == "currentAnim" then S.currentAnim = tonumber(v) or 1 end
if k == "currentTab" and v ~= "" then S.currentTab = v end
if k == "currentTab" and v ~= "" then
-- Migrate old tab names
if v == "Setup" then v = "Animations"
elseif v == "Render" then v = "Preview" end
S.currentTab = v
end
if k == "animNames" and v ~= "" then
S.animNames = {}
for name in string.gmatch(v, "([^|]+)") do
@ -261,6 +277,17 @@ local function flipImageH(src)
return flipped
end
local function flipImageV(src)
local flipped = Image(src.width, src.height, ColorMode.RGB)
flipped:clear(pc.rgba(0, 0, 0, 0))
for y = 0, src.height - 1 do
for x = 0, src.width - 1 do
flipped:putPixel(x, src.height - 1 - y, src:getPixel(x, y))
end
end
return flipped
end
local function drawCheckerboard(gc, w, h, zoom, offsetX, offsetY)
local ox = offsetX or 0
local oy = offsetY or 0
@ -869,6 +896,7 @@ local function run()
if not f or not S.sourceImage then return nil end
local img = extractCell(S.sourceImage, f.x, f.y, S.tileW, S.tileH)
if f.flipped then img = flipImageH(img) end
if f.flippedV then img = flipImageV(img) end
return img
end
@ -882,7 +910,13 @@ local function run()
local function updateAnimLabel()
local name = currentAnimName() or "(none)"
dlg:modify{ id = "lblCurrentAnim", text = "-> " .. name }
dlg:modify{ id = "lblSelectedAnim", text = "Selected -> " .. name }
for i = 1, #S.animNames do
pcall(function()
local label = (i == S.currentAnim) and ("[" .. S.animNames[i] .. "]") or S.animNames[i]
dlg:modify{ id = "btnAnim" .. i, text = label }
end)
end
end
local function startAnimTimer()
@ -916,8 +950,8 @@ local function run()
-- Switch visible tab
local function switchTab(tab)
S.currentTab = tab
local isSetup = (tab == "Setup")
local isRender = (tab == "Render")
local isSetup = (tab == "Animations")
local isRender = (tab == "Preview")
local isGB = (tab == "GB")
for _, id in ipairs(SETUP_IDS) do
dlg:modify{ id = id, visible = isSetup }
@ -1012,14 +1046,9 @@ local function run()
----------------------------------------------------------------
dlg:separator{ id = "sepAnims", text = "Animations" }
dlg:label{
id = "lblCurrentAnim",
text = "-> " .. (currentAnimName() or "(none)"),
}
dlg:button{
id = "btnNewAnim",
text = "+",
text = "Add Animation",
onclick = function()
local d = Dialog{ title = "New Animation" }
d:entry{ id = "name", label = "Name:", text = "" }
@ -1042,13 +1071,20 @@ local function run()
dlg:button{
id = "btnDelAnim",
text = "-",
text = "Remove Animation",
onclick = function()
local name = currentAnimName()
if not name then return end
local result = app.alert{
title = "Delete Animation",
text = "Delete animation '" .. name .. "'?",
buttons = { "Delete", "Cancel" }
}
if result ~= 1 then return end
S.anims[name] = nil
table.remove(S.animNames, S.currentAnim)
S.currentAnim = math.min(S.currentAnim, math.max(1, #S.animNames))
S.selectedFrame = 0
savePrefs()
dlg:close()
run()
@ -1057,13 +1093,22 @@ local function run()
dlg:newrow()
-- Create buttons only for existing animations
dlg:label{
id = "lblSelectedAnim",
text = "Selected -> " .. (currentAnimName() or "(none)"),
}
dlg:newrow()
-- Create buttons for existing animations (selected one gets [brackets])
for i = 1, #S.animNames do
local label = (i == S.currentAnim) and ("[" .. S.animNames[i] .. "]") or S.animNames[i]
dlg:button{
id = "btnAnim" .. i,
text = S.animNames[i],
text = label,
onclick = function()
S.currentAnim = i
S.selectedFrame = 0
updateAnimLabel()
dlg:repaint()
end
@ -1073,7 +1118,7 @@ local function run()
----------------------------------------------------------------
-- Source canvas (L-click=add, R-click=add flipped, M-click=scroll)
----------------------------------------------------------------
dlg:separator{ id = "sepSource", text = "Source (L=add, R=add flipped)" }
dlg:separator{ id = "sepSource", text = "Source (left-click=add frame, right-click=add flipped)" }
dlg:canvas{
id = "canvasSource",
@ -1125,7 +1170,6 @@ local function run()
local rw = S.tileW * S.sourceZoom
local rh = S.tileH * S.sourceZoom
if rx + rw > 0 and rx < vw and ry + rh > 0 and ry < vh then
-- Blue for normal, orange for flipped
gc.color = f.flipped
and Color(255, 160, 0, 160)
or Color(100, 200, 255, 160)
@ -1140,37 +1184,55 @@ local function run()
end,
onmousedown = function(ev)
if ev.button == MouseButton.LEFT then
local pixelX = math.floor((ev.x + S.scrollX) / S.sourceZoom)
local pixelY = math.floor((ev.y + S.scrollY) / S.sourceZoom)
captureCell(pixelX, pixelY, false)
S.srcClickX = ev.x
S.srcClickY = ev.y
S.srcIsDrag = false
S.dragging = true
S.dragLastX = ev.x
S.dragLastY = ev.y
elseif ev.button == MouseButton.RIGHT then
local pixelX = math.floor((ev.x + S.scrollX) / S.sourceZoom)
local pixelY = math.floor((ev.y + S.scrollY) / S.sourceZoom)
captureCell(pixelX, pixelY, true)
else
S.dragging = true
S.dragLastX = ev.x
S.dragLastY = ev.y
end
end,
onmousemove = function(ev)
if S.dragging then
S.scrollX = S.scrollX + (S.dragLastX - ev.x)
S.scrollY = S.scrollY + (S.dragLastY - ev.y)
clampScroll()
local dx = math.abs(ev.x - S.srcClickX)
local dy = math.abs(ev.y - S.srcClickY)
if dx > 3 or dy > 3 then
S.srcIsDrag = true
end
if S.srcIsDrag then
S.scrollX = S.scrollX + (S.dragLastX - ev.x)
S.scrollY = S.scrollY + (S.dragLastY - ev.y)
clampScroll()
end
S.dragLastX = ev.x
S.dragLastY = ev.y
end
end,
onmouseup = function(ev)
if S.dragging and not S.srcIsDrag then
local pixelX = math.floor((S.srcClickX + S.scrollX) / S.sourceZoom)
local pixelY = math.floor((S.srcClickY + S.scrollY) / S.sourceZoom)
captureCell(pixelX, pixelY, false)
end
S.dragging = false
S.srcIsDrag = false
end,
onwheel = function(ev)
local dz = ev.deltaY < 0 and 1 or -1
S.sourceZoom = math.max(1, math.min(10, S.sourceZoom + dz))
clampScroll()
dlg:repaint()
end
}
----------------------------------------------------------------
-- Frame strip for current animation
----------------------------------------------------------------
dlg:separator{ id = "sepFrames", text = "Frames (right-click to remove)" }
dlg:separator{ id = "sepFrames", text = "Frames (click to select, drag to reorder)" }
local STRIP_TOTAL_W = SOURCE_VIEWPORT_W
local STRIP_CELL = THUMB_SIZE + 1
@ -1228,11 +1290,37 @@ local function run()
gc:fillRect(Rectangle(tx + THUMB_SIZE - 4, ty, 4, 4))
end
-- Current frame indicator
-- Flip V indicator (green dot)
if frames[i].flippedV then
gc.color = Color(0, 200, 0, 220)
gc:fillRect(Rectangle(tx, ty, 4, 4))
end
-- Current playback frame indicator
if cnt > 0 and ((S.animFrame % cnt)) == (i - 1) then
gc.color = Color(100, 200, 255, 200)
gc:fillRect(Rectangle(tx, ty - 1, THUMB_SIZE, 2))
end
-- Selected frame highlight (yellow border)
if S.selectedFrame == i then
gc.color = Color(255, 255, 0, 220)
gc:fillRect(Rectangle(tx, ty, THUMB_SIZE, 2))
gc:fillRect(Rectangle(tx, ty + THUMB_SIZE - 2, THUMB_SIZE, 2))
gc:fillRect(Rectangle(tx, ty, 2, THUMB_SIZE))
gc:fillRect(Rectangle(tx + THUMB_SIZE - 2, ty, 2, THUMB_SIZE))
end
end
-- Drag insertion indicator
if S.frameDragging and S.frameDragTo >= 1 and S.frameDragFrom ~= S.frameDragTo then
local insertIdx = S.frameDragTo
local col = (insertIdx - 1) % thumbsPerRow
local row = math.floor((insertIdx - 1) / thumbsPerRow)
local lx = col * STRIP_CELL
local ly = 2 + row * STRIP_CELL
gc.color = Color(255, 100, 100, 220)
gc:fillRect(Rectangle(lx - 1, ly, 2, THUMB_SIZE))
end
end,
onmousedown = function(ev)
@ -1245,36 +1333,174 @@ local function run()
local row = math.floor((ev.y - 2) / STRIP_CELL)
local idx = row * thumbsPerRow + col + 1
if idx >= 1 and idx <= #frames then
if ev.button == MouseButton.RIGHT then
table.remove(frames, idx)
if ev.button == MouseButton.LEFT then
S.selectedFrame = idx
S.frameDragging = true
S.frameDragFrom = idx
S.frameDragTo = idx
dlg:repaint()
end
end
end,
onmousemove = function(ev)
if S.frameDragging then
local name = currentAnimName()
local frames = name and S.anims[name] or {}
local thumbsPerRow = math.max(1, math.floor(STRIP_TOTAL_W / STRIP_CELL))
local col = math.floor(ev.x / STRIP_CELL)
local row = math.floor((ev.y - 2) / STRIP_CELL)
local idx = row * thumbsPerRow + col + 1
S.frameDragTo = clamp(idx, 1, #frames)
dlg:repaint()
end
end,
onmouseup = function(ev)
if S.frameDragging then
local from = S.frameDragFrom
local to = S.frameDragTo
if from ~= to then
local name = currentAnimName()
if name and S.anims[name] then
local frames = S.anims[name]
local frame = table.remove(frames, from)
table.insert(frames, to, frame)
S.selectedFrame = to
end
end
S.frameDragging = false
dlg:repaint()
end
end
}
----------------------------------------------------------------
-- Clear buttons
-- Frame action buttons
----------------------------------------------------------------
dlg:separator{ id = "sepActions" }
dlg:button{
id = "btnFlipX",
text = "Flip X",
onclick = function()
local name = currentAnimName()
if not name or S.selectedFrame < 1 then return end
local frames = S.anims[name]
if not frames or S.selectedFrame > #frames then return end
frames[S.selectedFrame].flipped = not frames[S.selectedFrame].flipped
dlg:repaint()
end
}
dlg:button{
id = "btnFlipY",
text = "Flip Y",
onclick = function()
local name = currentAnimName()
if not name or S.selectedFrame < 1 then return end
local frames = S.anims[name]
if not frames or S.selectedFrame > #frames then return end
frames[S.selectedFrame].flippedV = not frames[S.selectedFrame].flippedV
dlg:repaint()
end
}
dlg:button{
id = "btnMoveLeft",
text = "Move Left",
onclick = function()
local name = currentAnimName()
if not name or S.selectedFrame < 2 then return end
local frames = S.anims[name]
if not frames then return end
local idx = S.selectedFrame
frames[idx], frames[idx - 1] = frames[idx - 1], frames[idx]
S.selectedFrame = idx - 1
dlg:repaint()
end
}
dlg:button{
id = "btnMoveRight",
text = "Move Right",
onclick = function()
local name = currentAnimName()
if not name or S.selectedFrame < 1 then return end
local frames = S.anims[name]
if not frames or S.selectedFrame >= #frames then return end
local idx = S.selectedFrame
frames[idx], frames[idx + 1] = frames[idx + 1], frames[idx]
S.selectedFrame = idx + 1
dlg:repaint()
end
}
dlg:button{
id = "btnClearAnim",
text = "Clear Anim",
onclick = function()
local name = currentAnimName()
if name then S.anims[name] = {} end
if not name then return end
local result = app.alert{
title = "Clear Animation",
text = "Clear all frames from '" .. name .. "'?",
buttons = { "Clear", "Cancel" }
}
if result ~= 1 then return end
S.anims[name] = {}
S.selectedFrame = 0
dlg:repaint()
end
}
dlg:button{
id = "btnClearAll",
text = "Clear All",
onclick = function()
for _, name in ipairs(S.animNames) do
S.anims[name] = {}
----------------------------------------------------------------
-- Preview canvas (animation playback in Animations tab)
----------------------------------------------------------------
dlg:separator{ id = "sepPreviewAnim", text = "Preview" }
local PREVIEW_ANIM_H = 60
dlg:canvas{
id = "canvasPreviewAnim",
width = SOURCE_VIEWPORT_W,
height = PREVIEW_ANIM_H,
autoscaling = false,
onpaint = function(ev)
local gc = ev.context
local name = currentAnimName()
gc.color = Color(30, 30, 30)
gc:fillRect(Rectangle(0, 0, SOURCE_VIEWPORT_W, PREVIEW_ANIM_H))
if not name then return end
local frames = S.anims[name] or {}
local totalFrames = #frames
if totalFrames == 0 then return end
local tw = S.tileW * S.previewZoom
local th = S.tileH * S.previewZoom
local ox = math.max(0, math.floor((SOURCE_VIEWPORT_W - tw) / 2))
local oy = math.max(0, math.floor((PREVIEW_ANIM_H - th) / 2))
if S.useBgColor then
gc.color = S.bgColor
gc:fillRect(Rectangle(ox, oy, tw, th))
else
drawCheckerboard(gc, tw, th, S.previewZoom, ox, oy)
end
local idx = (S.animFrame % totalFrames) + 1
local fimg = getFrameImage(name, idx)
if fimg then
gc:drawImage(
fimg,
Rectangle(0, 0, S.tileW, S.tileH),
Rectangle(ox, oy, tw, th)
)
end
end,
onwheel = function(ev)
local dz = ev.deltaY < 0 and 1 or -1
S.previewZoom = math.max(1, math.min(MAX_PREVIEW_ZOOM, S.previewZoom + dz))
dlg:repaint()
end
}
@ -1282,7 +1508,7 @@ local function run()
----------------------------------------------------------------
-- RENDER TAB (single canvas with all animations in 2 columns)
----------------------------------------------------------------
dlg:separator{ id = "sepRender", text = "Animations", visible = false }
dlg:separator{ id = "sepRender", text = "All Animations Preview", visible = false }
local RENDER_MARGIN = 2
@ -1767,18 +1993,16 @@ local function run()
end
end
local imgCopy = S.gbOptImage:clone()
app.transaction("Save Optimized Layer", function()
if existingLayer then
if S.gbAlwaysOverwrite then
-- Overwrite: delete old cels, add new cel with image
existingLayer.isEditable = true
for _, cel in ipairs(existingLayer.cels) do
sp:deleteCel(cel)
end
existingLayer.isEditable = true
sp:newCel(existingLayer, app.frame, S.gbOptImage, Point(0, 0))
existingLayer.isEditable = false
sp:newCel(existingLayer, app.frame, imgCopy, Point(0, 0))
else
-- Create new layer with incremented name
local suffix = 2
local newName = layerName .. "-" .. suffix
local nameExists = true
@ -1795,21 +2019,14 @@ local function run()
end
local newLayer = sp:newLayer()
newLayer.name = newName
-- Move just above existing layer
if existingIdx then
sp:newCel(newLayer, app.frame, S.gbOptImage, Point(0, 0))
newLayer.isEditable = false
end
sp:newCel(newLayer, app.frame, imgCopy, Point(0, 0))
end
else
-- Create at top of layer stack
local newLayer = sp:newLayer()
newLayer.name = layerName
sp:newCel(newLayer, app.frame, S.gbOptImage, Point(0, 0))
newLayer.isEditable = false
sp:newCel(newLayer, app.frame, imgCopy, Point(0, 0))
end
end)
app.alert("Layer saved: " .. layerName)
end
}
@ -1823,15 +2040,15 @@ local function run()
app.alert("Run Analyze first.")
return
end
local origSprite = app.sprite
local optW = S.gbOptImage.width
local optH = S.gbOptImage.height
local tmpSprite = Sprite(optW, optH, ColorMode.RGB)
local cel = tmpSprite.cels[1]
cel.image:drawImage(S.gbOptImage)
app.command.SelectAll()
cel.image = S.gbOptImage:clone()
app.command.MaskAll()
app.command.CopyMerged()
tmpSprite:close()
app.alert("Copied!")
end
}