class EditCommand: "undo/redo-able representation of an art edit (eg paint, erase) operation" def __init__(self, art): self.art = art self.start_time = art.app.get_elapsed_time() self.finish_time = None # nested dictionary with frame(layer(column(row))) structure - # this prevents multiple commands operating on the same tile # from stomping each other self.tile_commands = {} def get_number_of_commands(self): commands = 0 for frame in self.tile_commands.values(): for layer in frame.values(): for column in layer.values(): for _tile in column.values(): commands += 1 return commands def __str__(self): # get unique-ish ID from memory address addr = self.__repr__() addr = addr[addr.find("0") : -1] s = f"EditCommand_{addr}: {self.get_number_of_commands()} tiles, time {self.finish_time}" return s def add_command_tiles(self, new_command_tiles): for ct in new_command_tiles: # create new tables for frames/layers/columns if not present if ct.frame not in self.tile_commands: self.tile_commands[ct.frame] = {} if ct.layer not in self.tile_commands[ct.frame]: self.tile_commands[ct.frame][ct.layer] = {} if ct.y not in self.tile_commands[ct.frame][ct.layer]: self.tile_commands[ct.frame][ct.layer][ct.y] = {} # preserve "before" state of any command we overwrite if ct.x in self.tile_commands[ct.frame][ct.layer][ct.y]: old_ct = self.tile_commands[ct.frame][ct.layer][ct.y][ct.x] ct.set_before(old_ct.b_char, old_ct.b_fg, old_ct.b_bg, old_ct.b_xform) self.tile_commands[ct.frame][ct.layer][ct.y][ct.x] = ct def undo_commands_for_tile(self, frame, layer, x, y): # no commands at all yet, maybe if len(self.tile_commands) == 0: return # tile might not have undo commands, eg text entry beyond start region if ( y not in self.tile_commands[frame][layer] or x not in self.tile_commands[frame][layer][y] ): return self.tile_commands[frame][layer][y][x].undo() def undo(self): for frame in self.tile_commands.values(): for layer in frame.values(): for column in layer.values(): for tile_command in column.values(): tile_command.undo() def apply(self): for frame in self.tile_commands.values(): for layer in frame.values(): for column in layer.values(): for tile_command in column.values(): tile_command.apply() class EntireArtCommand: """ undo/redo-able representation of a whole-art operation, eg: resize/crop, run art script, add/remove layer, etc """ # art arrays to grab array_types = ["chars", "fg_colors", "bg_colors", "uv_mods"] def __init__(self, art, origin_x=0, origin_y=0): self.art = art # remember origin of resize command self.origin_x, self.origin_y = origin_x, origin_y self.before_frame = art.active_frame self.before_layer = art.active_layer self.start_time = self.finish_time = art.app.get_elapsed_time() def save_tiles(self, before=True): # save copies of tile data lists prefix = "b" if before else "a" for atype in self.array_types: # save list as eg "b_chars" for "character data before operation" src_data = getattr(self.art, atype) var_name = f"{prefix}_{atype}" # deep copy each frame's data, else before == after new_data = [] for frame in src_data: new_data.append(frame.copy()) setattr(self, var_name, new_data) if before: self.before_size = (self.art.width, self.art.height) else: self.after_size = (self.art.width, self.art.height) def undo(self): # undo might remove frames/layers that were added self.art.set_active_frame(self.before_frame) self.art.set_active_layer(self.before_layer) if self.before_size != self.after_size: x, y = self.before_size self.art.resize(x, y, self.origin_x, self.origin_y) for atype in self.array_types: new_data = getattr(self, "b_" + atype) setattr(self.art, atype, new_data[:]) if self.before_size != self.after_size: # Art.resize will set geo_changed and mark all frames changed self.art.app.ui.adjust_for_art_resize(self.art) self.art.mark_all_frames_changed() def apply(self): if self.before_size != self.after_size: x, y = self.after_size self.art.resize(x, y, self.origin_x, self.origin_y) for atype in self.array_types: new_data = getattr(self, "a_" + atype) setattr(self.art, atype, new_data[:]) if self.before_size != self.after_size: self.art.app.ui.adjust_for_art_resize(self.art) self.art.mark_all_frames_changed() class EditCommandTile: def __init__(self, art): self.art = art self.creation_time = self.art.app.get_elapsed_time() # initialize everything # previously did 'string list of serialized items' + setattr # which made prettier code but was slower self.frame = self.layer = self.x = self.y = None self.b_char = self.b_fg = self.b_bg = self.b_xform = None self.a_char = self.a_fg = self.a_bg = self.a_xform = None def __str__(self): s = "F{} L{} {},{} @ {:.2f}: ".format( self.frame, self.layer, str(self.x).rjust(2, "0"), str(self.y).rjust(2, "0"), self.creation_time, ) s += f"c{self.b_char} f{self.b_fg} b{self.b_bg} x{self.b_xform} -> " s += f"c{self.a_char} f{self.a_fg} b{self.a_bg} x{self.a_xform}" return s def __eq__(self, value): return ( self.frame == value.frame and self.layer == value.layer and self.x == value.x and self.y == value.y and self.b_char == value.b_char and self.b_fg == value.b_fg and self.b_bg == value.b_bg and self.b_xform == value.b_xform and self.a_char == value.a_char and self.a_fg == value.a_fg and self.a_bg == value.a_bg and self.a_xform == value.a_xform ) def copy(self): "returns a deep copy of this tile command" new_ect = EditCommandTile(self.art) # TODO: old or new timestamp? does it matter? # new_ect.creation_time = self.art.app.get_elapsed_time() new_ect.creation_time = self.creation_time # copy all properties new_ect.frame, new_ect.layer = self.frame, self.layer new_ect.x, new_ect.y = self.x, self.y new_ect.b_char, new_ect.b_xform = self.b_char, self.b_xform new_ect.b_fg, new_ect.b_bg = self.b_fg, self.b_bg new_ect.a_char, new_ect.a_xform = self.a_char, self.a_xform new_ect.a_fg, new_ect.a_bg = self.a_fg, self.a_bg return new_ect def set_tile(self, frame, layer, x, y): self.frame, self.layer = frame, layer self.x, self.y = x, y def set_before(self, char, fg, bg, xform): self.b_char, self.b_xform = char, xform self.b_fg, self.b_bg = fg, bg def set_after(self, char, fg, bg, xform): self.a_char, self.a_xform = char, xform self.a_fg, self.a_bg = fg, bg def is_null(self): return ( self.a_char == self.b_char and self.a_fg == self.b_fg and self.a_bg == self.b_bg and self.a_xform == self.b_xform ) def undo(self): # tile's frame or layer may have been deleted if self.layer > self.art.layers - 1 or self.frame > self.art.frames - 1: return if self.x >= self.art.width or self.y >= self.art.height: return tool = self.art.app.ui.selected_tool set_all = ( tool.affects_char and tool.affects_fg_color and tool.affects_fg_color and tool.affects_xform ) self.art.set_tile_at( self.frame, self.layer, self.x, self.y, self.b_char, self.b_fg, self.b_bg, self.b_xform, set_all, ) def apply(self): tool = self.art.app.ui.selected_tool set_all = ( tool.affects_char and tool.affects_fg_color and tool.affects_fg_color and tool.affects_xform ) self.art.set_tile_at( self.frame, self.layer, self.x, self.y, self.a_char, self.a_fg, self.a_bg, self.a_xform, set_all, ) class CommandStack: def __init__(self, art): self.art = art self.undo_commands, self.redo_commands = [], [] def __str__(self): s = f"stack for {self.art.filename}:\n" s += "===\nundo:\n" for cmd in self.undo_commands: s += str(cmd) + "\n" s += "\n===\nredo:\n" for cmd in self.redo_commands: s += str(cmd) + "\n" return s def commit_commands(self, new_commands): self.undo_commands += new_commands[:] self.clear_redo() def undo(self): if len(self.undo_commands) == 0: return command = self.undo_commands.pop() self.art.app.cursor.undo_preview_edits() command.undo() self.redo_commands.append(command) self.art.app.cursor.update_cursor_preview() def redo(self): if len(self.redo_commands) == 0: return command = self.redo_commands.pop() # un-apply cursor preview before applying redo, else preview edits # edits will "stick" self.art.app.cursor.undo_preview_edits() command.apply() # add to end of undo stack self.undo_commands.append(command) self.art.app.cursor.update_cursor_preview() def clear_redo(self): self.redo_commands = []