diff --git a/lib/src/lua.rs b/lib/src/lua.rs index 7c7b51e..e54ef24 100644 --- a/lib/src/lua.rs +++ b/lib/src/lua.rs @@ -62,12 +62,16 @@ fn save_file(_: &Lua, (dmi, filename): (LuaTable, String)) -> LuaResult LuaResult { +fn new_state( + lua: &Lua, + (width, height, temp, name): (u32, u32, String, Option), +) -> LuaResult { if !Path::new(&temp).exists() { Err("Temp directory does not exist".to_string()).into_lua_err()? } - let state = State::new_blank(String::new(), width, height).to_serialized(temp)?; + let state_name = name.unwrap_or(String::new()); + let state = State::new_blank(state_name, width, height).to_serialized(temp)?; let table = state.into_lua_table(lua)?; Ok(table) diff --git a/package.json b/package.json index a680086..2b8aa47 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ { "path": "./scripts/classes/preferences.lua" }, { "path": "./scripts/classes/editor/_editor.lua" }, { "path": "./scripts/classes/editor/rendering.lua" }, + { "path": "./scripts/classes/editor/state_operations.lua" }, { "path": "./scripts/classes/editor/state.lua" }, { "path": "./scripts/classes/statesprite.lua" }, { "path": "./scripts/classes/widget.lua" }, diff --git a/scripts/classes/editor/_editor.lua b/scripts/classes/editor/_editor.lua index 3c8237b..3c0025b 100644 --- a/scripts/classes/editor/_editor.lua +++ b/scripts/classes/editor/_editor.lua @@ -13,6 +13,7 @@ --- @field dmi Dmi The currently opened DMI file. --- @field open_sprites (StateSprite)[] A table containing all open sprites. --- @field widgets (AnyWidget)[] A table containing all state widgets. +--- @field selected_states State[] Selected icon widgets to possibly combine. --- @field context_widget ContextWidget|nil The state that is currently being right clicked --- @field beforecommand number The event object for the "beforecommand" event. --- @field aftercommand number The event object for the "aftercommand" event. @@ -28,8 +29,6 @@ Editor.__index = Editor --- @class Editor.Mouse --- @field position Point The current mouse position. ---- @field leftClick boolean Whether the left mouse button is pressed. ---- @field rightClick boolean Whether the right mouse button is pressed. --- Creates a new instance of the Editor class. --- @param title string The title of the editor. @@ -44,14 +43,19 @@ function Editor.new(title, dmi) self.focused_widget = nil self.hovering_widgets = {} self.scroll = 0 - self.mouse = { position = Point(0, 0), leftClick = false, rightClick = false } + self.mouse = { position = Point(0, 0) } self.dmi = nil self.open_sprites = {} self.widgets = {} + self.selected_states = {} self.context_widget = nil self.save_path = nil self.open_path = is_filename and dmi --[[@as string]] or nil + self.dragging = false + self.drag_start_time = math.huge + self.drop_index = nil + self.canvas_width = 185 self.canvas_height = 215 self.max_in_a_row = 1 @@ -220,7 +224,10 @@ end --- Shows the editor dialog. function Editor:show() - self.dialog:show { wait = false } + self.dialog:show { + wait = false, + autoscrollbars=true, + } end --- Opens a DMI file and displays it in the editor. @@ -239,6 +246,7 @@ function Editor:open_file(dmi) self.scroll = 0 self.dmi = nil self.widgets = {} + self.selected_states = {} self.open_sprites = {} self.save_path = nil diff --git a/scripts/classes/editor/rendering.lua b/scripts/classes/editor/rendering.lua index 17676fc..a926b64 100644 --- a/scripts/classes/editor/rendering.lua +++ b/scripts/classes/editor/rendering.lua @@ -44,31 +44,28 @@ function Editor:onpaint(ctx) local hovers = {} --[[ @as (string)[] ]] for _, widget in ipairs(self.widgets) do - local state = COMMON_STATE.normal + -- Styling + local stateStyle = COMMON_STATE.normal if widget == self.focused_widget then - state = COMMON_STATE.focused or state + stateStyle = COMMON_STATE.focused or stateStyle end - local is_mouse_over = not self.context_widget and widget.bounds:contains(self.mouse.position) - if is_mouse_over then - state = COMMON_STATE.hot or state - - if self.mouse.leftClick then - state = COMMON_STATE.selected or state - end + stateStyle = COMMON_STATE.hot or stateStyle + end + if widget.type == "IconWidget" and table.index_of(self.selected_states, widget.iconstate) > 0 then + stateStyle = COMMON_STATE.selected or stateStyle end if widget.type == "IconWidget" then - local widget = widget --[[ @as IconWidget ]] - - ctx:drawThemeRect(state.part, widget.bounds) + ctx:drawThemeRect(stateStyle.part, widget.bounds) ctx:drawImage( widget.icon, widget.icon.bounds, Rectangle(widget.bounds.x + (widget.bounds.width - self.dmi.width) / 2, - widget.bounds.y + (widget.bounds.height - self.dmi.height) / 2, widget.icon.bounds.width, + widget.bounds.y + (widget.bounds.height - self.dmi.height) / 2, + widget.icon.bounds.width, widget.icon.bounds.height) ) elseif widget.type == "TextWidget" then @@ -77,7 +74,8 @@ function Editor:onpaint(ctx) local text = self.fit_text(widget.text, ctx, widget.bounds.width) local size = ctx:measureText(text) - ctx.color = widget.text_color or app.theme.color[state.color] + -- fallback text color + ctx.color = widget.text_color or app.theme.color.text ctx:fillText( text, widget.bounds.x + (widget.bounds.width - size.width) / 2, @@ -90,7 +88,7 @@ function Editor:onpaint(ctx) elseif widget.type == "ThemeWidget" then local widget = widget --[[ @as ThemeWidget ]] - ctx:drawThemeRect(state.part, widget.bounds) + ctx:drawThemeRect(stateStyle.part, widget.bounds) if widget.partId then ctx:drawThemeImage(widget.partId, @@ -205,6 +203,10 @@ function Editor:repaint_states() local min_index = (self.max_in_a_row * self.scroll) local max_index = min_index + self.max_in_a_row * (self.max_in_a_column + 1) for index, state in ipairs(self.dmi.states) do +-- TODO: fix bug here and in box_bounds below where you can have +-- index = 7, min_index = 9, max_index = 10, max_in_a_row = 9 +-- and we don't render anything despite there being no more +-- states past 7, need to render at least max_in_a_row if index > min_index and index <= max_index then local bounds = self:box_bounds(index) local text_color = nil @@ -224,8 +226,6 @@ function Editor:repaint_states() end end - local name = #state.name > 0 and state.name or "no name" - local icon = self.image_cache:get(state.frame_key) local bytes = string.char(libdmi.overlay_color(app.theme.color.face.red, app.theme.color.face.green, app.theme.color.face.blue, icon.width, icon.height, string.byte(icon.bytes, 1, #icon.bytes)) --[[@as number]]) @@ -233,13 +233,16 @@ function Editor:repaint_states() local icon = Image(icon.width, icon.height) icon.bytes = bytes - table.insert(self.widgets, IconWidget.new( + -- Create IconWidget and store its state for selection logic + local iconWidget = IconWidget.new( self, bounds, icon, function() self:open_state(state) end, - function(ev) self:state_context(state, ev) end - )) + function(ev) self:state_context(state, ev) end, + state + ) + table.insert(self.widgets, iconWidget) table.insert(self.widgets, TextWidget.new( self, @@ -249,9 +252,9 @@ function Editor:repaint_states() bounds.width, TEXT_HEIGHT ), - name, + #state.name > 0 and state.name or "no name", text_color, - name, + state.name, function() self:state_properties(state) end, function(ev) self:state_context(state, ev) end )) @@ -286,11 +289,10 @@ end function Editor:box_bounds(index) local row_index = index - self.max_in_a_row * self.scroll - return Rectangle( (self.dmi.width + BOX_PADDING) * ((row_index - 1) % self.max_in_a_row), - (self.dmi.height + BOX_BORDER + BOX_PADDING * 2 + TEXT_HEIGHT) * math.floor((row_index - 1) / self.max_in_a_row) + - BOX_PADDING, + (self.dmi.height + BOX_BORDER + BOX_PADDING * 2 + TEXT_HEIGHT) * + math.floor((row_index - 1) / self.max_in_a_row) + BOX_PADDING, self.dmi.width + BOX_BORDER, self.dmi.height + BOX_BORDER ) @@ -300,22 +302,53 @@ end --- @param ev MouseEvent The mouse event object. function Editor:onmousedown(ev) if ev.button == MouseButton.LEFT then - self.mouse.leftClick = true + -- Don't set this until after selection behaivor is handled self.focused_widget = nil - -- Only start drag if we're not clicking on a context menu + -- Don't do anything fancy if we're clicking on a context menu if not self.context_widget then - -- Start potential drag + + local clickedWidget = nil --[[ @type AnyWidget ]] for _, widget in ipairs(self.widgets) do if widget.type == "IconWidget" and widget.bounds:contains(Point(ev.x, ev.y)) then - self.drag_widget = widget - self.drag_start_time = os.clock() + clickedWidget = widget break end end + if clickedWidget then + -- Handle range selection with Shift + Ctrl + if ev.shiftKey and ev.ctrlKey then + local iconWidgets = {} + for _, widget in ipairs(self.widgets) do + if widget.type == "IconWidget" then + table.insert(iconWidgets, widget) + end + end + local clickedWidgetIdx = table.index_of(iconWidgets, clickedWidget) + local closestSelWidgetIdx = self:find_closest_selected_state_index(clickedWidgetIdx) + if not closestSelWidgetIdx then return end + local low = math.min(clickedWidgetIdx, closestSelWidgetIdx) + local high = math.max(clickedWidgetIdx, closestSelWidgetIdx) + self:select_states_range(low, high) + self:repaint() + return + end + + -- Selection mode with just Ctrl + if ev.ctrlKey then + self:toggle_state_selection(clickedWidget.iconstate) + self:repaint() + return + end + + -- Setup Dragging + self.drag_widget = clickedWidget + self.drag_start_time = os.clock() + else -- No clicked widget, handle deselection + self.selected_states = {} + end end elseif ev.button == MouseButton.RIGHT then - self.mouse.rightClick = true self.focused_widget = nil self.context_widget = nil end @@ -358,15 +391,22 @@ function Editor:onmouseup(ev) end if not triggered then if ev.button == MouseButton.RIGHT then + -- If multiple states are selected, show combine option. + local buttonsToAdd = { + { text = "Paste", onclick = function() self:clipboard_paste_state() end }, + } + if #self.selected_states > 1 then + table.insert(buttonsToAdd, { text = "Combine", onclick = function() self:combine_selected_states() end }) + table.insert(buttonsToAdd, { text = "Deselect", onclick = function() self.selected_states = {}; self:repaint() end }) + end self.context_widget = ContextWidget.new( Rectangle(ev.x, ev.y, 0, 0), - { - { text = "Paste", onclick = function() self:clipboard_paste_state() end }, - } + buttonsToAdd ) end end end + -- Add logic to finalize drag-and-drop or click actions if ev.button == MouseButton.LEFT then if self.dragging and self.drag_widget and self.drop_index then -- Find source state index using widget index and scroll offset @@ -402,6 +442,8 @@ function Editor:onmouseup(ev) self.scroll = target_row - visible_rows + 1 end + -- Reset selected states since we've moved around states (and thus idx) + self.selected_states = {} self:repaint_states() end end @@ -417,9 +459,6 @@ function Editor:onmouseup(ev) self.drag_widget = nil self.drag_start_time = nil self.drop_index = nil - self.mouse.leftClick = false - elseif ev.button == MouseButton.RIGHT then - self.mouse.rightClick = false end end if repaint then @@ -428,7 +467,7 @@ function Editor:onmouseup(ev) end --- Updates the mouse position and triggers a repaint. ---- @param ev table The mouse event containing the x and y coordinates. +--- @param ev MouseEvent The mouse event containing the x and y coordinates. function Editor:onmousemove(ev) local mouse_position = Point(ev.x, ev.y) local should_repaint = false @@ -446,7 +485,7 @@ function Editor:onmousemove(ev) end -- Handle dragging - if self.mouse.leftClick and self.drag_widget and not self.dragging then + if (ev.button == MouseButton.LEFT) and self.drag_widget and not self.dragging then -- Start drag after small delay/movement if os.clock() - self.drag_start_time > 0.1 then self.dragging = true @@ -561,3 +600,80 @@ function Editor.fit_text(text, ctx, maxWidth) end return text end + +--- Helper method to toggle state selection +--- @param state State The state to toggle selection for. +function Editor:toggle_state_selection(state) + local idx = table.index_of(self.selected_states, state) + if idx ~= 0 then + table.remove(self.selected_states, idx) + else + table.insert(self.selected_states, state) + end +end + +--- Helper method to select a range of states +--- @param lowIndex number The lower index of the range. +--- @param highIndex number The higher index of the range. +function Editor:select_states_range(lowIndex, highIndex) + local iconWidgets = {} + for _, widget in ipairs(self.widgets) do + if widget.type == "IconWidget" then + table.insert(iconWidgets, widget) + end + end + + -- Determine if all states in the range are already selected. + local allSelected = true + for i = lowIndex, highIndex do + local widget = iconWidgets[i] + if table.index_of(self.selected_states, widget.iconstate) == 0 then + allSelected = false + break + end + end + + -- (De)select each state in the range. + for i = lowIndex, highIndex do + local widget = iconWidgets[i] + local idx = table.index_of(self.selected_states, widget.iconstate) + if allSelected then + -- Could be better but I'm not sure what the logic would be for + -- determining which neighboring states to deselect. + if idx and idx > 0 then + table.remove(self.selected_states, idx) + end + else + if idx == 0 then + table.insert(self.selected_states, widget.iconstate) + end + end + end +end + +--- Finds the closest selected state index to the clicked widget index. +--- @param clickedWidgetIndex number The index of the clicked widget. +--- @return number|nil closestIndex The index of the closest selected state. +function Editor:find_closest_selected_state_index(clickedWidgetIndex) + local closestIndex = nil + local closestDistance = math.huge + + local iconWidgets = {} --[[ @type IconWidget[] ]] + for _, widget in ipairs(self.widgets) do + if widget.type == "IconWidget" then + table.insert(iconWidgets, widget) + end + end + + for i, widget in ipairs(iconWidgets) do + if widget.type == "IconWidget" and table.index_of(self.selected_states, widget.iconstate) > 0 then + local distance = math.abs(i - clickedWidgetIndex) + if distance < closestDistance then + closestDistance = distance + closestIndex = i + end + end + end + + return closestIndex or nil +end diff --git a/scripts/classes/editor/state.lua b/scripts/classes/editor/state.lua index b6636a8..36be9cc 100644 --- a/scripts/classes/editor/state.lua +++ b/scripts/classes/editor/state.lua @@ -127,15 +127,38 @@ end --- @param state State The state to be opened. --- @param ev MouseEvent The mouse event object. function Editor:state_context(state, ev) + local buttons = { + { text = "Properties", onclick = function() self:state_properties(state) end }, + { text = "Open", onclick = function() self:open_state(state) end }, + { text = "Copy", onclick = function() self:clipboard_copy_state(state) end }, + { text = "Remove", onclick = function() self:remove_state(state) end }, + { text = "Split", onclick = function() self:split_state(state) end }, + { text = "Select", + onclick = function() + local i = table.index_of(self.selected_states, state) + if i == 0 then + table.insert(self.selected_states, state) + end + self:repaint() + end + }, + } + if #self.selected_states > 1 then + table.insert(buttons, { text = "Combine", onclick = function() self:combine_selected_states() end }) + table.insert(buttons, { + text = "Deselect", + onclick = function() + local i = table.index_of(self.selected_states, state) + if i ~= 0 then + table.remove(self.selected_states, i) + end + self:repaint() + end + }) + end self.context_widget = ContextWidget.new( Rectangle(ev.x, ev.y, 0, 0), - { - { text = "Properties", onclick = function() self:state_properties(state) end }, - { text = "Open", onclick = function() self:open_state(state) end }, - { text = "Copy", onclick = function() self:clipboard_copy_state(state) end }, - { text = "Remove", onclick = function() self:remove_state(state) end }, - { text = "Split", onclick = function() self:split_state(state) end }, - } + buttons ) self:repaint() end @@ -1019,82 +1042,3 @@ function Editor:reload_open_states() self:open_state(state) end end - ---- Splits a multi-directional state into individual states, one for each direction. ---- @param state State The state to be split. -function Editor:split_state(state) - if not self.dmi then return end - if state.dirs == 1 then - app.alert { title = "Warning", text = "Cannot split a state with only one direction" } - return - end - - -- Check if state is open and modified - for _, state_sprite in ipairs(self.open_sprites) do - if state_sprite.state == state then - if state_sprite.sprite.isModified then - app.alert { title = self.title, text = "Save the open sprite first" } - return - end - break - end - end - - local original_name = state.name - local direction_names = { - [4] = { "S", "N", "E", "W" }, - [8] = { "S", "N", "E", "W", "SE", "SW", "NE", "NW" } - } - - -- Create a new state for each direction - for i = 1, state.dirs do - local new_state, error = libdmi.new_state(self.dmi.width, self.dmi.height, self.dmi.temp) - if error then - app.alert { title = "Error", text = { "Failed to create new state", error } } - return - end - - if not new_state then - app.alert { title = "Error", text = "Failed to create new state" } - return - end - - -- Set the new state properties - new_state.name = original_name .. " - " .. direction_names[state.dirs][i] - new_state.dirs = 1 - new_state.loop = state.loop - new_state.rewind = state.rewind - new_state.movement = state.movement - new_state.delays = table.clone(state.delays) - - -- Copy the image data for this direction - local frames_per_dir = state.frame_count - local start_frame = (i - 1) * frames_per_dir - - for frame = 1, frames_per_dir do - local src_path = app.fs.joinPath(self.dmi.temp, state.frame_key .. "." .. tostring(start_frame + frame - 1) .. ".bytes") - local dst_path = app.fs.joinPath(self.dmi.temp, new_state.frame_key .. "." .. tostring(frame - 1) .. ".bytes") - - -- Copy the image file - local src_file = io.open(src_path, "rb") - if src_file then - local content = src_file:read("*all") - src_file:close() - - local dst_file = io.open(dst_path, "wb") - if dst_file then - dst_file:write(content) - dst_file:close() - end - end - end - - table.insert(self.dmi.states, new_state) - self.image_cache:load_state(self.dmi, new_state) - end - - -- Mark as modified and remove the original state - self.modified = true - self:remove_state(state) - self:repaint_states() -end diff --git a/scripts/classes/editor/state_operations.lua b/scripts/classes/editor/state_operations.lua new file mode 100644 index 0000000..5cd2cb5 --- /dev/null +++ b/scripts/classes/editor/state_operations.lua @@ -0,0 +1,261 @@ +--- Splits a multi-directional state into individual states, one for each direction. +--- @param state State The state to be split. +function Editor:split_state(state) + if not self.dmi then return end + if state.dirs == 1 then + app.alert { title = "Warning", text = "Cannot split a state with only one direction" } + return + end + + -- Check if state is open and modified + for _, state_sprite in ipairs(self.open_sprites) do + if state_sprite.state == state then + if state_sprite.sprite.isModified then + app.alert { title = self.title, text = "Save the open sprite first" } + return + end + break + end + end + + local original_name = state.name + local direction_names = { + [4] = { "S", "N", "E", "W" }, + [8] = { "S", "N", "E", "W", "SE", "SW", "NE", "NW" } + } + + -- Create a new state for each direction + for i = 1, state.dirs do + local new_state, error = libdmi.new_state(self.dmi.width, self.dmi.height, self.dmi.temp) + if error then + app.alert { title = "Error", text = { "Failed to create new state", error } } + return + end + + if not new_state then + app.alert { title = "Error", text = "Failed to create new state" } + return + end + + -- Set the new state properties + new_state.name = original_name .. " - " .. direction_names[state.dirs][i] + new_state.dirs = 1 + new_state.loop = state.loop + new_state.rewind = state.rewind + new_state.movement = state.movement + new_state.delays = table.clone(state.delays) + + -- Copy the image data for this direction + local frames_per_dir = state.frame_count + local start_frame = (i - 1) * frames_per_dir + + for frame = 1, frames_per_dir do + local src_path = app.fs.joinPath(self.dmi.temp, state.frame_key .. "." .. tostring(start_frame + frame - 1) .. ".bytes") + local dst_path = app.fs.joinPath(self.dmi.temp, new_state.frame_key .. "." .. tostring(frame - 1) .. ".bytes") + + self:copyImageBytes(src_path, dst_path) + end + + table.insert(self.dmi.states, new_state) + self.image_cache:load_state(self.dmi, new_state) + end + + -- Mark as modified and remove the original state + self.modified = true + self:remove_state(state) + self:repaint_states() +end + +--- Combines multiple selected states into one state. +function Editor:combine_selected_states() + if not self.dmi or #self.selected_states < 2 then + app.alert { title = self.title, text = "Select at least two states to combine." } + return + end + local dialog = Dialog { title = "Combine States" } + dialog:entry { + id = "combined_name", + label = "Combined Name:", + text = self.getCombinedDefaultName(self), + } + dialog:combobox { + id = "combine_type", + label = "Used Directions:", + option = COMBINE_TYPES.alldirs, + options = { COMBINE_TYPES.alldirs, COMBINE_TYPES.onedir }, + } + dialog:combobox { + id = "frame_sel_type", + label = "Frame Selection:", + option = FRAME_SEL_TYPES.all_seq, + options = { FRAME_SEL_TYPES.all_seq, FRAME_SEL_TYPES.first_only, }, + } + dialog:button { + text = "&OK", + focus = true, + onclick = function() + local combinedName = dialog.data.combined_name or "Combined" + local combineType = dialog.data.combine_type or COMBINE_TYPES.onedir --[[@as CombineType]] + local frameSelType = dialog.data.frame_sel_type or FRAME_SEL_TYPES.all_seq --[[@as FrameSelType]] + dialog:close() + self:performCombineStates(combinedName, combineType, frameSelType) + end + } + dialog:button { + text = "&Cancel", + onclick = function() + dialog:close() + end + } + dialog:show() +end + +--- Gets the default name for the combined state based on the selected states. +function Editor:getCombinedDefaultName() + local function normalize(s) -- Remove _ and - and numbers + return s:gsub("[-_%d]", "") + end + local function commonPrefix(a, b) + local i = 1 + while i <= #a and i <= #b and a:sub(i, i) == b:sub(i, i) do + i = i + 1 + end + return a:sub(1, i - 1) + end + + local commonBase = normalize(self.selected_states[1].name) + for i = 2, #self.selected_states do + commonBase = commonPrefix(commonBase, normalize(self.selected_states[i].name)) + if commonBase == "" then break end + end + if commonBase and #commonBase > 2 then -- < 3 char names are not useful + return commonBase + else + return "Combined" + end +end + +--- Combines the selected states into one state, based off of the selected combination type. +--- @param combinedName string The name for the combined state. +--- @param combineType CombineType The type of combination to perform. +--- @param frameSelType FrameSelType The frame selection type. +function Editor:performCombineStates(combinedName, combineType, frameSelType) + local combined_state, error = libdmi.new_state(self.dmi.width, self.dmi.height, self.dmi.temp, combinedName) + if error or not combined_state then + app.alert { title = "Error", text = { "Failed to create combined state", error } } + return + end + + -- Selected iconstates based on DMI order + local sortedStates = {} + for _, state in ipairs(self.dmi.states) do + for _, selState in ipairs(self.selected_states) do + if state == selState then + table.insert(sortedStates, state) + break + end + end + end + + combined_state.name = combinedName + if combineType == COMBINE_TYPES.onedir then + if not self:combine1direction(combined_state, sortedStates, frameSelType) then + return + end + elseif combineType == COMBINE_TYPES.alldirs then + if not self:combineAllDirections(combined_state, sortedStates, frameSelType) then + return + end + end + table.insert(self.dmi.states, combined_state) + self.image_cache:load_state(self.dmi, combined_state) + self.modified = true + self.selected_states = {} + self:repaint_states() +end + +--- Combines the selected states into one new 1-dir iconstate, so each frame is a different state. +--- @param combined_state State The combined state inject all the parts into. +--- @param sortedStates State[] The iconstates to combine. +function Editor:combine1direction(combined_state, sortedStates, frameSelType) + combined_state.dirs = 1 + local framesToUseList, total_frames = self:getFrameUsage(sortedStates, frameSelType) + combined_state.frame_count = total_frames + + local frameIndex = 0 + for idx, st in ipairs(sortedStates) do + local framesToUse = framesToUseList[idx] + for i = 0, framesToUse - 1 do + local srcPath = app.fs.joinPath(self.dmi.temp, st.frame_key .. "." .. i .. ".bytes") + local dstPath = app.fs.joinPath(self.dmi.temp, combined_state.frame_key .. "." .. frameIndex .. ".bytes") + self:copyImageBytes(srcPath, dstPath) + frameIndex = frameIndex + 1 + end + end + return true +end + +--- Combines the selected states into one new multi-dir iconstate, so each frame is a different state. +--- For example, if 2 selected states are 4-dir with 2 frames, the combined state will be 4 dir with 4 frames. +--- @param combined_state State The combined state inject all the parts into. +--- @param sortedStates State[] The iconstates to combine. +--- @param frameSelType FrameSelType The frame selection type. +function Editor:combineAllDirections(combined_state, sortedStates, frameSelType) + local dirs = sortedStates[1].dirs + for _, st in ipairs(sortedStates) do + if st.dirs ~= dirs then + app.alert { title = "Error", text = "All selected states must have the same number of directions." } + return false + end + end + combined_state.dirs = dirs + + local framesToUseList, totalFrames = self:getFrameUsage(sortedStates, frameSelType) + combined_state.frame_count = totalFrames + + local frameOffset = 0 + for idx, st in ipairs(sortedStates) do + local framesToUse = framesToUseList[idx] + for frame = 0, framesToUse - 1 do + for d = 0, dirs - 1 do + local srcIndex = (frame * dirs) + d + local dstIndex = ((frameOffset + frame) * dirs) + d + local srcPath = app.fs.joinPath(self.dmi.temp, st.frame_key .. "." .. srcIndex .. ".bytes") + local dstPath = app.fs.joinPath(self.dmi.temp, combined_state.frame_key .. "." .. dstIndex .. ".bytes") + self:copyImageBytes(srcPath, dstPath) + end + end + frameOffset = frameOffset + framesToUse + end + return true +end + +--- Gets the number of frames to use for each state based on the selected frame selection type. +--- @param sortedStates State[] The states to use. +--- @param frameSelType FrameSelType The frame selection type. +function Editor:getFrameUsage(sortedStates, frameSelType) + local framesToUseList = {} + local totalFrames = 0 + for _, st in ipairs(sortedStates) do + local useCount = ((frameSelType == FRAME_SEL_TYPES.first_only) and 1) or st.frame_count + table.insert(framesToUseList, useCount) + totalFrames = totalFrames + useCount + end + return framesToUseList, totalFrames +end + +--- Copies the image bytes from the source path to the destination path. +--- @param srcPath string The source path +--- @param dstPath string The destination path +function Editor:copyImageBytes(srcPath, dstPath) + local src_file = io.open(srcPath, "rb") + if src_file then + local content = src_file:read("*all") + src_file:close() + local dst_file = io.open(dstPath, "wb") + if dst_file then + dst_file:write(content) + dst_file:close() + end + end +end diff --git a/scripts/classes/statesprite.lua b/scripts/classes/statesprite.lua index f6c0c22..5ea3c1e 100644 --- a/scripts/classes/statesprite.lua +++ b/scripts/classes/statesprite.lua @@ -95,8 +95,16 @@ function StateSprite:save() end -- Store original layers if we need to auto-flatten + -- And only if there's layers that aren't the magic directional names local original_layers = nil - if Preferences.getAutoFlatten() then + local hasNonDirectional = false + for _, layer in ipairs(self.sprite.layers) do + if table.index_of(DIRECTION_NAMES, layer.name) == 0 then + hasNonDirectional = true + break + end + end + if Preferences.getAutoFlatten() and hasNonDirectional then original_layers = {} for _, layer in ipairs(self.sprite.layers) do table.insert(original_layers, layer) @@ -169,7 +177,7 @@ function StateSprite:save() self.editor.modified = true -- Restore original layers if we flattened by undoing the transaction from above - if Preferences.getAutoFlatten() then + if original_layers then app.command.Undo() end diff --git a/scripts/classes/widget.lua b/scripts/classes/widget.lua index 37b6bad..3e629b8 100644 --- a/scripts/classes/widget.lua +++ b/scripts/classes/widget.lua @@ -19,6 +19,7 @@ --- @field icon Image The icon of the widget. --- @field onleftclick MouseFunction|nil The onleftclick function of the widget. --- @field onrightclick MouseFunction|nil The onrightclick function of the widget. +--- @field iconstate State|nil The iconstate of the widget (optional). IconWidget = { type = "IconWidget" } IconWidget.__index = IconWidget @@ -28,8 +29,9 @@ IconWidget.__index = IconWidget --- @param icon Image The icon of the widget. --- @param onleftclick MouseFunction|nil The function to be called when the widget is clicked (optional). --- @param onrightclick MouseFunction|nil The function to be called when the widget is right clicked (optional). +--- @param iconstate State an associated iconstate (optional) --- @return IconWidget widget The newly created widget. -function IconWidget.new(editor, bounds, icon, onleftclick, onrightclick) +function IconWidget.new(editor, bounds, icon, onleftclick, onrightclick, iconstate) local self = setmetatable({}, IconWidget) self.editor = editor @@ -37,6 +39,7 @@ function IconWidget.new(editor, bounds, icon, onleftclick, onrightclick) self.icon = icon self.onleftclick = onleftclick or nil self.onrightclick = onrightclick or nil + self.iconstate = iconstate or nil return self end diff --git a/scripts/constants.lua b/scripts/constants.lua index e28bead..5132135 100644 --- a/scripts/constants.lua +++ b/scripts/constants.lua @@ -10,4 +10,18 @@ TEMP_DIR = app.fs.joinPath(app.fs.tempPath, TEMP_NAME) COMMON_STATE = { normal = { part = "sunken_normal", color = "button_normal_text" }, hot = { part = "sunken_focused", color = "button_hot_text" }, + focused = { part = "sunken_focused", color = "button_normal_text" }, + selected = { part = "sunken_mini_focused", color = "button_normal_text" }, } --[[@as WidgetState]] + +--- @alias CombineType "1 direction"|"All directions" +COMBINE_TYPES = { + onedir = "1 direction", + alldirs = "All directions", +} + +--- @alias FrameSelType "First frame only"|"All frames" +FRAME_SEL_TYPES = { + first_only = "First frame only", + all_seq = "All frames", +} diff --git a/scripts/types.lua b/scripts/types.lua index c637141..32c656f 100644 --- a/scripts/types.lua +++ b/scripts/types.lua @@ -198,6 +198,7 @@ end ------------------- ENUMS ------------------- +--- @enum ColorMode ColorMode = { RGB = 1, GRAY = 2, @@ -205,7 +206,9 @@ ColorMode = { TILEMAP = 4, } +--- @enum MouseButton MouseButton = { + NONE = 0, LEFT = 1, MIDDLE = 2, RIGHT = 3, @@ -213,6 +216,7 @@ MouseButton = { X2 = 5, } +--- @enum BlendMode BlendMode = { NORMAL = 0, SRC = 1, @@ -236,6 +240,7 @@ BlendMode = { DIVIDE = 19, } +--- @enum WebSocketMessageType WebSocketMessageType = { TEXT = "TEXT", BINARY = "BINARY", @@ -746,61 +751,17 @@ WebSocketMessageType = { --- @alias PixelColor number ---- @class MouseButton ---- @field LEFT number ---- @field MIDDLE number ---- @field RIGHT number ---- @field X1 number ---- @field X2 number - ---- @alias ColorMode ----| 0 ----| 1 ----| 2 ----| 3 - ---- @alias BlendMode ----| 0 ----| 1 ----| 2 ----| 3 ----| 4 ----| 5 ----| 6 ----| 7 ----| 8 ----| 9 ----| 10 ----| 11 ----| 12 ----| 13 ----| 14 ----| 15 ----| 16 ----| 17 ----| 18 ----| 19 - --- @alias AniDir ---| 0 ---| 1 ---| 2 ---| 3 ---- @alias WebSocketMessageType ----| 'TEXT' ----| 'BINARY' ----| 'OPEN' ----| 'CLOSE' ----| 'PING' ----| 'PONG' ----| 'FRAGMENT' - --- @class LibDmi: table --- @field new_file fun(name: string, width: number, height: number, temp: string): Dmi?, string? Creates a new DMI file. If fails, returns nil and an error message. --- @field open_file fun(path: string, temp: string): Dmi?, string? Opens a DMI file. If fails, returns nil and an error message. --- @field save_file fun(dmi: Dmi, filename: string): nil, string? Saves the DMI file. If fails, returns an error message. ---- @field new_state fun(width: number, height: number, temp: string): State?, string? Creates a new state. If fails, returns nil and an error message. +--- @field new_state fun(width: number, height: number, temp: string, name?: string): State?, string? Creates a new state. If fails, returns nil and an error message. --- @field copy_state fun(state: State, temp: string): nil, string? Copies the state to the clipboard. If fails, returns an error message. --- @field paste_state fun(width: number, height: number, temp: string): State?, string? Pastes the state from the clipboard. If fails, returns nil and an error message. --- @field resize fun(dmi: Dmi, width: number, height: number, medhod: string): nil, string? Resizes the DMI file. If fails, returns an error message.