diff --git a/aniphallow.lua b/aniphallow.lua index 88cf312..b7478df 100644 --- a/aniphallow.lua +++ b/aniphallow.lua @@ -23,7 +23,7 @@ local SOURCE_VIEWPORT_W = 300 local SOURCE_VIEWPORT_H = 400 local MAX_ANIMS = 20 -- max number of dynamic animations -local TABS = { "Animations", "Preview", "GB" } +local TABS = { "Animations", "GB" } local DEFAULT_GB_TILE_W = 8 -- Default Game Boy tile width local DEFAULT_GB_TILE_H = 8 -- Default Game Boy tile height local GB_COLS = 16 -- tiles per row in optimized image (128px = GB standard) @@ -117,11 +117,6 @@ local SETUP_IDS = { "btnFrameUp", "btnFrameDown", "btnFrameLeft", "btnFrameRight", "btnFrameReset", } --- Render tab elements -local RENDER_IDS = { - "sepRender", "btnSeparateWindow", "canvasRender", -} - -- GB tab elements (reordered: Flip, Offset, Silhouette, Compress) local GB_IDS = { "sepGb", "btnAnalyze", "gbFlipOpt", "gbOffsetOpt", "gbSilhouette", "gbCompress", @@ -132,6 +127,24 @@ local GB_IDS = { "btnGbSaveLayer", "btnGbCopyClipboard", "btnGbSave", } +-- GB canvas heights (taller to match Animations tab and prevent resize on tab switch) +local GB_SRC_H = 200 +local GB_OPT_H = 200 + +---------------------------------------------------------------------- +-- Module-level window/timer variables +---------------------------------------------------------------------- +local previewDlg = nil +local previewTimer = nil +local pvRefreshTimer = nil +local mainDlg = nil +local mainAnimTimer = nil +local mainRefreshTimer = nil + +-- Forward declarations +local openPreviewWindow +local openMainDialog + ---------------------------------------------------------------------- -- Preferences ---------------------------------------------------------------------- @@ -182,7 +195,8 @@ local function loadPrefs() if k == "currentTab" and v ~= "" then -- Migrate old tab names if v == "Setup" then v = "Animations" - elseif v == "Render" then v = "Preview" end + elseif v == "Render" then v = "Animations" + elseif v == "Preview" then v = "Animations" end S.currentTab = v end if k == "animNames" and v ~= "" then @@ -391,6 +405,35 @@ local function getDarkestPaletteColor() return Color(0, 0, 0) end +---------------------------------------------------------------------- +-- Module-level refreshSource +---------------------------------------------------------------------- +local function refreshSource() + if not app.sprite then return end + local sp = app.sprite + local rgbSpec = ImageSpec{ + width = sp.width, height = sp.height, + colorMode = ColorMode.RGB, transparentColor = 0 + } + local img = Image(rgbSpec) + img:clear() + img:drawSprite(sp, app.frame) + S.sourceImage = img +end + +---------------------------------------------------------------------- +-- Module-level getFrameImage +---------------------------------------------------------------------- +local function getFrameImageGlobal(animName, idx) + local frames = S.anims[animName] + if not frames then return nil end + local f = frames[idx] + 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 ---------------------------------------------------------------------- -- GB Tile Hashing & Deduplication (parameterized by tw, th) @@ -870,69 +913,152 @@ local function buildOptimizedImage(img, tiles, compress, silhouette, silhouetteC end ---------------------------------------------------------------------- --- Separate preview window (module-level to persist) +-- Preview Window (primary window) ---------------------------------------------------------------------- -local previewDlg = nil -local previewTimer = nil - ----------------------------------------------------------------------- --- Dialog ----------------------------------------------------------------------- -local function run() - if not app.sprite then - app.alert("No sprite is open.") - return +openPreviewWindow = function() + -- Close existing preview window if open + if previewDlg then + pcall(function() previewDlg:close() end) + end + if previewTimer then + pcall(function() previewTimer:stop() end) + previewTimer = nil + end + if pvRefreshTimer then + pcall(function() pvRefreshTimer:stop() end) + pvRefreshTimer = nil end - -- Force tab to Animations on start - S.currentTab = "Animations" + local pvAnimFrame = { value = 0 } + local PV_RENDER_MARGIN = 2 - local animTimer = nil - local refreshTimer = nil + -- Calculate content-based size + local numAnims = #S.animNames + local ptw = S.tileW * S.previewZoom + local pth = S.tileH * S.previewZoom + local cellW = ptw + PV_RENDER_MARGIN * 2 + local cellH = pth + PV_RENDER_MARGIN * 2 + local pvCols = 2 + local pvRows = math.max(1, math.ceil(numAnims / pvCols)) + local pvWidth = math.max(pvCols * cellW, 120) + local pvHeight = math.max(pvRows * cellH, 40) - local function refreshSource() - if not app.sprite then return end - local sp = app.sprite - local rgbSpec = ImageSpec{ - width = sp.width, - height = sp.height, - colorMode = ColorMode.RGB, - transparentColor = 0 - } - local img = Image(rgbSpec) - img:clear() - img:drawSprite(sp, app.frame) - S.sourceImage = img - end - - refreshSource() - - local contentW, contentH = getSourceContentSize() - local viewW = math.min(contentW, SOURCE_VIEWPORT_W) - local viewH = math.min(contentH, SOURCE_VIEWPORT_H) - - local dlg = Dialog{ - title = "AniPhallow - Animation Builder", + previewDlg = Dialog{ + title = "AniPhallow Preview", onclose = function() - if animTimer then animTimer:stop() end - if refreshTimer then refreshTimer:stop() end + if previewTimer then pcall(function() previewTimer:stop() end) end + if pvRefreshTimer then pcall(function() pvRefreshTimer:stop() end) end + previewTimer = nil + pvRefreshTimer = nil + previewDlg = nil + S.previewWindowOpen = false savePrefs() + -- Update main dialog button if open + if mainDlg then + pcall(function() mainDlg:modify{ id = "tabPreview", text = " Preview " } end) + end end } - -- Get a frame image for an animation at index (handles flipped frames) - local function getFrameImage(animName, idx) - local frames = S.anims[animName] - if not frames then return nil end - local f = frames[idx] - 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 + previewDlg:button{ + id = "pvSetup", + text = "Setup", + onclick = function() + openMainDialog() + end + } + + previewDlg:canvas{ + id = "pvCanvas", + width = pvWidth, + height = pvHeight, + autoscaling = false, + onpaint = function(ev) + local gc = ev.context + local na = #S.animNames + if na == 0 then return end + + local pw = S.tileW * S.previewZoom + local ph = S.tileH * S.previewZoom + local cW = pw + PV_RENDER_MARGIN * 2 + local cH = ph + PV_RENDER_MARGIN * 2 + local cols = 2 + local af = pvAnimFrame.value + + for i, name in ipairs(S.animNames) do + local col = (i - 1) % cols + local row = math.floor((i - 1) / cols) + local ox = col * cW + local oy = row * cH + + if S.useBgColor then + gc.color = S.bgColor + gc:fillRect(Rectangle(ox + PV_RENDER_MARGIN, oy + PV_RENDER_MARGIN, pw, ph)) + else + drawCheckerboard(gc, pw, ph, S.previewZoom, + ox + PV_RENDER_MARGIN, oy + PV_RENDER_MARGIN) + end + + local frames = S.anims[name] or {} + local totalFrames = #frames + if totalFrames > 0 then + local idx = (af % totalFrames) + 1 + local fimg = getFrameImageGlobal(name, idx) + if fimg then + local f = frames[idx] + local fOffX = (f.offX or 0) * S.previewZoom + local fOffY = (f.offY or 0) * S.previewZoom + gc:drawImage( + fimg, + Rectangle(0, 0, S.tileW, S.tileH), + Rectangle(ox + PV_RENDER_MARGIN + fOffX, oy + PV_RENDER_MARGIN + fOffY, pw, ph) + ) + end + end + 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)) + pcall(function() previewDlg:repaint() end) + end + } + + previewTimer = Timer{ + interval = S.animSpeed / 1000.0, + ontick = function() + pvAnimFrame.value = pvAnimFrame.value + 1 + pcall(function() previewDlg:repaint() end) + end + } + previewTimer:start() + + pvRefreshTimer = Timer{ + interval = 0.5, + ontick = function() + refreshSource() + pcall(function() previewDlg:repaint() end) + end + } + pvRefreshTimer:start() + + S.previewWindowOpen = true + savePrefs() + + previewDlg:show{ wait = false } +end + +---------------------------------------------------------------------- +-- Main Dialog (secondary window - setup) +---------------------------------------------------------------------- +openMainDialog = function() + -- If main dialog already open, just bring focus (close and reopen) + if mainDlg then + pcall(function() mainDlg:close() end) end - -- Get current animation name + local dlg + local function currentAnimName() if S.currentAnim >= 1 and S.currentAnim <= #S.animNames then return S.animNames[S.currentAnim] @@ -940,16 +1066,8 @@ local function run() return nil end - local function startAnimTimer() - if animTimer then animTimer:stop() end - animTimer = Timer{ - interval = S.animSpeed / 1000.0, - ontick = function() - S.animFrame = S.animFrame + 1 - dlg:repaint() - end - } - animTimer:start() + local function getFrameImage(animName, idx) + return getFrameImageGlobal(animName, idx) end local function captureCell(pixelX, pixelY, flipped) @@ -968,34 +1086,56 @@ local function run() dlg:repaint() end - -- Switch visible tab + -- Switch visible tab (only Animations and GB) local function switchTab(tab) S.currentTab = tab local isSetup = (tab == "Animations") - local isRender = (tab == "Preview") local isGB = (tab == "GB") for _, id in ipairs(SETUP_IDS) do pcall(function() dlg:modify{ id = id, visible = isSetup } end) end - for _, id in ipairs(RENDER_IDS) do - pcall(function() dlg:modify{ id = id, visible = isRender } end) - end for _, id in ipairs(GB_IDS) do pcall(function() dlg:modify{ id = id, visible = isGB } end) end - -- Update tab button labels + -- Update tab button labels for Animations and GB only for _, t in ipairs(TABS) do local label = (t == tab) and ("[" .. t .. "]") or (" " .. t .. " ") - dlg:modify{ id = "tab" .. t, text = label } + pcall(function() dlg:modify{ id = "tab" .. t, text = label } end) end dlg:repaint() end - -- Forward-declare openPreviewWindow so it can be used from button and auto-open - local openPreviewWindow + local function startAnimTimer() + if mainAnimTimer then mainAnimTimer:stop() end + mainAnimTimer = Timer{ + interval = S.animSpeed / 1000.0, + ontick = function() + S.animFrame = S.animFrame + 1 + pcall(function() dlg:repaint() end) + end + } + mainAnimTimer:start() + end + + local contentW, contentH = getSourceContentSize() + local viewW = math.min(contentW, SOURCE_VIEWPORT_W) + local viewH = math.min(contentH, SOURCE_VIEWPORT_H) + + dlg = Dialog{ + title = "AniPhallow - Animation Builder", + onclose = function() + if mainAnimTimer then pcall(function() mainAnimTimer:stop() end) end + if mainRefreshTimer then pcall(function() mainRefreshTimer:stop() end) end + mainAnimTimer = nil + mainRefreshTimer = nil + mainDlg = nil + savePrefs() + end + } + mainDlg = dlg ---------------------------------------------------------------- - -- TAB BUTTONS + -- TAB BUTTONS (Animations, GB, Preview toggle) ---------------------------------------------------------------- for _, tab in ipairs(TABS) do local label = (tab == S.currentTab) and ("[" .. tab .. "]") or (" " .. tab .. " ") @@ -1008,9 +1148,22 @@ local function run() } end - ---------------------------------------------------------------- - -- SETUP TAB - ---------------------------------------------------------------- + -- Preview toggle button + dlg:button{ + id = "tabPreview", + text = S.previewWindowOpen and "[Preview]" or " Preview ", + onclick = function() + if previewDlg then + -- Close it + pcall(function() previewDlg:close() end) + -- previewDlg onclose handler sets S.previewWindowOpen = false + dlg:modify{ id = "tabPreview", text = " Preview " } + else + openPreviewWindow() + dlg:modify{ id = "tabPreview", text = "[Preview]" } + end + end + } ---------------------------------------------------------------- -- Config button (opens sub-dialog) - visible on ALL tabs @@ -1046,7 +1199,8 @@ local function run() if th < 4 then th = 4 elseif th > 128 then th = 128 end S.tileH = th S.animSpeed = d.data.animSpeed - if animTimer then animTimer.interval = S.animSpeed / 1000.0 end + if mainAnimTimer then mainAnimTimer.interval = S.animSpeed / 1000.0 end + if previewTimer then previewTimer.interval = S.animSpeed / 1000.0 end S.useBgColor = d.data.useBgColor S.bgColor = d.data.bgColor local gtw = d.data.gbTileW @@ -1107,7 +1261,7 @@ local function run() end savePrefs() dlg:close() - run() + openMainDialog() end end end @@ -1131,7 +1285,7 @@ local function run() S.selectedFrame = 0 savePrefs() dlg:close() - run() + openMainDialog() end } @@ -1160,7 +1314,7 @@ local function run() end savePrefs() dlg:close() - run() + openMainDialog() end end end @@ -1199,7 +1353,7 @@ local function run() end savePrefs() dlg:close() - run() + openMainDialog() end end end @@ -1483,7 +1637,7 @@ local function run() ---------------------------------------------------------------- -- Frame action buttons ---------------------------------------------------------------- - dlg:label{ id = "lblFrames", text = "Frames: L-click=select, R-click=remove, drag=reorder" } + dlg:separator{ id = "lblFrames", text = "Frames (L-click=select, R-click=remove, drag=reorder)" } dlg:button{ id = "btnFlipX", @@ -1564,7 +1718,7 @@ local function run() S.selectedFrame = 0 savePrefs() dlg:close() - run() + openMainDialog() return end @@ -1649,186 +1803,6 @@ local function run() end } - ---------------------------------------------------------------- - -- RENDER TAB (Preview - all animations) - ---------------------------------------------------------------- - dlg:separator{ id = "sepRender", text = "All Animations Preview", visible = false } - - -- Define openPreviewWindow function - openPreviewWindow = function() - -- Close existing preview window if open - if previewDlg then - pcall(function() previewDlg:close() end) - end - if previewTimer then - pcall(function() previewTimer:stop() end) - end - - local pvAnimFrame = { value = 0 } - local PV_RENDER_MARGIN = 2 - - -- Calculate content-based size - local numAnims = #S.animNames - local ptw = S.tileW * S.previewZoom - local pth = S.tileH * S.previewZoom - local cellW = ptw + PV_RENDER_MARGIN * 2 - local cellH = pth + PV_RENDER_MARGIN * 2 - local pvCols = 2 - local pvRows = math.max(1, math.ceil(numAnims / pvCols)) - local pvWidth = pvCols * cellW - local pvHeight = pvRows * cellH - - previewDlg = Dialog{ - title = "AniPhallow Preview", - onclose = function() - if previewTimer then - pcall(function() previewTimer:stop() end) - end - previewTimer = nil - previewDlg = nil - S.previewWindowOpen = false - savePrefs() - end - } - - previewDlg:canvas{ - id = "pvCanvas", - width = pvWidth, - height = pvHeight, - autoscaling = false, - onpaint = function(ev) - local gc = ev.context - local na = #S.animNames - if na == 0 then return end - - local pw = S.tileW * S.previewZoom - local ph = S.tileH * S.previewZoom - local cW = pw + PV_RENDER_MARGIN * 2 - local cH = ph + PV_RENDER_MARGIN * 2 - local cols = 2 - local af = pvAnimFrame.value - - for i, name in ipairs(S.animNames) do - local col = (i - 1) % cols - local row = math.floor((i - 1) / cols) - local ox = col * cW - local oy = row * cH - - if S.useBgColor then - gc.color = S.bgColor - gc:fillRect(Rectangle(ox + PV_RENDER_MARGIN, oy + PV_RENDER_MARGIN, pw, ph)) - else - drawCheckerboard(gc, pw, ph, S.previewZoom, - ox + PV_RENDER_MARGIN, oy + PV_RENDER_MARGIN) - end - - local frames = S.anims[name] or {} - local totalFrames = #frames - if totalFrames > 0 then - local idx = (af % totalFrames) + 1 - local fimg = getFrameImage(name, idx) - if fimg then - local f = frames[idx] - local fOffX = (f.offX or 0) * S.previewZoom - local fOffY = (f.offY or 0) * S.previewZoom - gc:drawImage( - fimg, - Rectangle(0, 0, S.tileW, S.tileH), - Rectangle(ox + PV_RENDER_MARGIN + fOffX, oy + PV_RENDER_MARGIN + fOffY, pw, ph) - ) - end - end - 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)) - pcall(function() previewDlg:repaint() end) - end - } - - previewTimer = Timer{ - interval = S.animSpeed / 1000.0, - ontick = function() - pvAnimFrame.value = pvAnimFrame.value + 1 - pcall(function() previewDlg:repaint() end) - end - } - previewTimer:start() - - S.previewWindowOpen = true - savePrefs() - - previewDlg:show{ wait = false } - end - - dlg:button{ - id = "btnSeparateWindow", - text = "Separate Window", - visible = false, - onclick = function() - openPreviewWindow() - end - } - - local RENDER_MARGIN = 2 - - dlg:canvas{ - id = "canvasRender", - width = SOURCE_VIEWPORT_W, - height = SOURCE_VIEWPORT_H, - autoscaling = false, - visible = false, - onpaint = function(ev) - local gc = ev.context - local numAnims = #S.animNames - if numAnims == 0 then return end - - local ptw = S.tileW * S.previewZoom - local pth = S.tileH * S.previewZoom - local cellW = ptw + RENDER_MARGIN * 2 - local cellH = pth + RENDER_MARGIN * 2 - local cols = 2 - - for i, name in ipairs(S.animNames) do - local col = (i - 1) % cols - local row = math.floor((i - 1) / cols) - local ox = col * cellW - local oy = row * cellH - - if S.useBgColor then - gc.color = S.bgColor - gc:fillRect(Rectangle(ox + RENDER_MARGIN, oy + RENDER_MARGIN, ptw, pth)) - else - drawCheckerboard(gc, ptw, pth, S.previewZoom, - ox + RENDER_MARGIN, oy + RENDER_MARGIN) - end - - local frames = S.anims[name] or {} - local totalFrames = #frames - if totalFrames > 0 then - local idx = (S.animFrame % totalFrames) + 1 - local fimg = getFrameImage(name, idx) - if fimg then - local f = frames[idx] - local fOffX = (f.offX or 0) * S.previewZoom - local fOffY = (f.offY or 0) * S.previewZoom - gc:drawImage( - fimg, - Rectangle(0, 0, S.tileW, S.tileH), - Rectangle(ox + RENDER_MARGIN + fOffX, oy + RENDER_MARGIN + fOffY, ptw, pth) - ) - end - end - 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 - } - ---------------------------------------------------------------- -- GB TAB ---------------------------------------------------------------- @@ -1952,7 +1926,6 @@ local function run() dlg:separator{ id = "sepGbSource", text = "Occurrences in Source", visible = false } local GB_SRC_W = SOURCE_VIEWPORT_W - local GB_SRC_H = 150 -- Flip colors local FLIP_COLORS = { @@ -2118,7 +2091,6 @@ local function run() -- Optimized Spritesheet canvas (L-click=select) ---------------------------------------------------------------- local GB_OPT_W = SOURCE_VIEWPORT_W - local GB_OPT_H = 150 dlg:canvas{ id = "canvasGbOpt", @@ -2359,7 +2331,7 @@ local function run() end } - dlg:label{ id = "lblGbOpt", text = "Optimized Spritesheet (L-click=select)", visible = false } + dlg:separator{ id = "lblGbOpt", text = "Optimized Spritesheet (L-click=select)", visible = false } -- Save as Layer button dlg:button{ @@ -2495,19 +2467,19 @@ local function run() } ---------------------------------------------------------------- - -- Timers + -- Timers for main dialog ---------------------------------------------------------------- - animTimer = Timer{ + mainAnimTimer = Timer{ interval = S.animSpeed / 1000.0, ontick = function() S.animFrame = S.animFrame + 1 - dlg:repaint() + pcall(function() dlg:repaint() end) end } - animTimer:start() + mainAnimTimer:start() local gbRefreshCounter = 0 - refreshTimer = Timer{ + mainRefreshTimer = Timer{ interval = 0.5, ontick = function() local isDragging = S.dragging or S.gbDragging or S.gbSrcDragging @@ -2517,26 +2489,35 @@ local function run() gbRefreshCounter = 0 refreshSource() end - dlg:repaint() + pcall(function() dlg:repaint() end) else gbRefreshCounter = 0 if not isDragging then refreshSource() end - dlg:repaint() + pcall(function() dlg:repaint() end) end end } - refreshTimer:start() + mainRefreshTimer:start() startAnimTimer() switchTab(S.currentTab) dlg:show{ wait = false } +end - -- Auto-reopen separate preview window if it was open - if S.previewWindowOpen then - openPreviewWindow() +---------------------------------------------------------------------- +-- Entry point +---------------------------------------------------------------------- +local function run() + if not app.sprite then + app.alert("No sprite is open.") + return end + S.currentTab = "Animations" + refreshSource() + -- Always open preview window on launch + openPreviewWindow() end run()