diff --git a/assets/text/TODO b/assets/text/TODO index 1cb4cb2..2a9fda0 100644 --- a/assets/text/TODO +++ b/assets/text/TODO @@ -20,7 +20,6 @@ Release roadmap: 1) {copy,cut,delete,insert from,paste}, edit asset in new canvas, import from {canvas,object} 2) operators: crop, scale, shift, slice -3) auto{load,save} & {backup,restore} -4) tools: unicode block elements +3) tools: unicode block elements vim:ff=dos tw=0 diff --git a/libroar/RoarCanvasCommands.py b/libroar/RoarCanvasCommands.py index df5ad17..0c87211 100644 --- a/libroar/RoarCanvasCommands.py +++ b/libroar/RoarCanvasCommands.py @@ -79,6 +79,9 @@ class RoarCanvasCommands(RoarCanvasCommandsFile, RoarCanvasCommandsEdit, RoarCan if "dirty" in self.lastPanelState \ and self.lastPanelState["dirty"]: textItems.append("*") + if "backupStatus" in self.lastPanelState \ + and self.lastPanelState["backupStatus"] == True: + textItems.append("Saving backup...") self.parentFrame.statusBar.SetStatusText(" | ".join(textItems)) if ("undoInhibit" in self.lastPanelState) \ and (self.lastPanelState["undoInhibit"]): diff --git a/libroar/RoarCanvasCommandsFile.py b/libroar/RoarCanvasCommandsFile.py index fa1029d..6b9a6fa 100644 --- a/libroar/RoarCanvasCommandsFile.py +++ b/libroar/RoarCanvasCommandsFile.py @@ -18,10 +18,10 @@ except ImportError: from GuiFrame import GuiCommandDecorator, GuiSubMenuDecorator, NID_MENU_SEP from RtlPlatform import getLocalConfPathName -import io, os, wx +import datetime, io, os, stat, wx class RoarCanvasCommandsFile(): - def _import(self, f, newDirty, pathName): + def _import(self, f, newDirty, pathName, emptyPathName=False): rc = False self.parentCanvas.SetCursor(wx.Cursor(wx.CURSOR_WAIT)) try: @@ -29,7 +29,11 @@ class RoarCanvasCommandsFile(): if rc: self.parentCanvas.update(newSize, False, newMap, dirty=newDirty) self.parentCanvas.dirty = newDirty - self.canvasPathName = newPathName + if not emptyPathName: + self.canvasPathName = newPathName + else: + self.canvasPathName = None + self.parentCanvas._snapshotsReset() self.update(dirty=self.parentCanvas.dirty, pathName=self.canvasPathName, undoLevel=-1) self.parentCanvas.canvas.journal.resetCursor() self.parentCanvas.canvas.journal.resetUndo() @@ -41,7 +45,7 @@ class RoarCanvasCommandsFile(): self.parentCanvas.SetCursor(wx.Cursor(wx.NullCursor)) return rc, newPathName - def _importFile(self, f, newDirty, wildcard): + def _importFile(self, f, newDirty, wildcard, emptyPathName=False): with wx.FileDialog(self.parentCanvas, "Open...", os.getcwd(), "", wildcard, wx.FD_OPEN) as dialog: if self.lastDir != None: dialog.SetDirectory(self.lastDir) @@ -49,7 +53,7 @@ class RoarCanvasCommandsFile(): return False, None elif self._promptSaveChanges(): pathName = dialog.GetPath(); self.lastDir = os.path.dirname(pathName); self._storeLastDir(self.lastDir); - return self._import(f, newDirty, pathName) + return self._import(f, newDirty, pathName, emptyPathName=emptyPathName) return False, None def _loadLastDir(self): @@ -65,6 +69,11 @@ class RoarCanvasCommandsFile(): for lastFile in inFile.readlines(): self._pushRecent(lastFile.rstrip("\r\n"), False) + def _popSnapshot(self, pathName): + if pathName in self.snapshots.keys(): + self.canvasRestore.attrDict["menu"].Delete(self.snapshots[pathName]["menuItemId"]) + del self.snapshots[pathName] + def _promptSaveChanges(self): if self.parentCanvas.dirty: message = "Do you want to save changes to {}?".format(self.canvasPathName if self.canvasPathName != None else "(Untitled)") @@ -88,7 +97,7 @@ class RoarCanvasCommandsFile(): if (numLastFiles + 1) > 8: self.canvasOpenRecent.attrDict["menu"].Delete(self.lastFiles[0]["menuItemId"]) del self.lastFiles[0] - menuItemWindow = self.canvasOpenRecent.attrDict["menu"].Insert(self.canvasOpenRecent.attrDict["menu"].GetMenuItemCount() - 2, menuItemId, "{}".format(pathName), pathName) + menuItemWindow = self.canvasOpenRecent.attrDict["menu"].Insert(self.canvasOpenRecent.attrDict["menu"].GetMenuItemCount() - 2, menuItemId, pathName, pathName) self.parentFrame.menuItemsById[self.canvasOpenRecent.attrDict["id"]].Enable(True) self.parentFrame.Bind(wx.EVT_MENU, lambda event: self.canvasOpenRecent(event, pathName), menuItemWindow) self.lastFiles += [{"menuItemId":menuItemId, "menuItemWindow":menuItemWindow, "pathName":pathName}] @@ -98,6 +107,21 @@ class RoarCanvasCommandsFile(): for lastFile in [l["pathName"] for l in self.lastFiles]: print(lastFile, file=outFile) + def _pushSnapshot(self, pathName): + menuItemId = wx.NewId() + if not (pathName in self.snapshots.keys()): + label = datetime.datetime.fromtimestamp(os.stat(pathName)[stat.ST_MTIME]).strftime("%c") + menuItemWindow = self.canvasRestore.attrDict["menu"].Insert(self.canvasRestore.attrDict["menu"].GetMenuItemCount() - 2, menuItemId, label) + self.parentFrame.menuItemsById[self.canvasRestore.attrDict["id"]].Enable(True) + self.parentFrame.Bind(wx.EVT_MENU, lambda event: self.canvasRestore(event, pathName), menuItemWindow) + self.snapshots[pathName] = {"menuItemId":menuItemId, "menuItemWindow":menuItemWindow} + + def _resetSnapshots(self): + for pathName in list(self.snapshots.keys()): + self._popSnapshot(pathName) + if self.canvasRestore.attrDict["id"] in self.parentFrame.menuItemsById: + self.parentFrame.menuItemsById[self.canvasRestore.attrDict["id"]].Enable(False) + def _storeLastDir(self, pathName): localConfFileName = getLocalConfPathName("RecentDir.txt") with open(localConfFileName, "w", encoding="utf-8") as outFile: @@ -194,7 +218,7 @@ class RoarCanvasCommandsFile(): def canvasImportAnsi_(pathName): rc, error = self.parentCanvas.canvas.importStore.importAnsiFile(pathName) return (rc, error, self.parentCanvas.canvas.importStore.outMap, pathName, self.parentCanvas.canvas.importStore.inSize) - self._importFile(canvasImportAnsi_, True, "ANSI files (*.ans;*.txt)|*.ans;*.txt|All Files (*.*)|*.*") + self._importFile(canvasImportAnsi_, True, "ANSI files (*.ans;*.txt)|*.ans;*.txt|All Files (*.*)|*.*", emptyPathName=True) @GuiCommandDecorator("Import from clipboard", "Import from &clipboard", None, None, None) def canvasImportFromClipboard(self, event): @@ -209,15 +233,14 @@ class RoarCanvasCommandsFile(): else: rc, error = False, "Clipboard does not contain text data and/or cannot be opened" return (rc, error, self.parentCanvas.canvas.importStore.outMap, None, self.parentCanvas.canvas.importStore.inSize) - if self._promptSaveChanges(): - self._import(canvasImportFromClipboard_, True, None) + self._import(canvasImportFromClipboard_, True, None, emptyPathName=True) @GuiCommandDecorator("Import SAUCE...", "Import &SAUCE...", None, None, None) def canvasImportSauce(self, event): def canvasImportSauce_(pathName): rc, error = self.parentCanvas.canvas.importStore.importSauceFile(pathName) return (rc, error, self.parentCanvas.canvas.importStore.outMap, pathName, self.parentCanvas.canvas.importStore.inSize) - self._importFile(canvasImportSauce_, True, "SAUCE files (*.ans;*.txt)|*.ans;*.txt|All Files (*.*)|*.*") + self._importFile(canvasImportSauce_, True, "SAUCE files (*.ans;*.txt)|*.ans;*.txt|All Files (*.*)|*.*", emptyPathName=True) @GuiCommandDecorator("New", "&New", ["", wx.ART_NEW], [wx.ACCEL_CTRL, ord("N")], None) def canvasNew(self, event, newCanvasSize=None): @@ -250,6 +273,22 @@ class RoarCanvasCommandsFile(): numLastFile = [i for i in range(len(self.lastFiles)) if self.lastFiles[i]["pathName"] == pathName][0] self.canvasOpenRecent.attrDict["menu"].Delete(self.lastFiles[numLastFile]["menuItemId"]); del self.lastFiles[numLastFile]; + @GuiSubMenuDecorator("Restore Snapshot", "Res&tore Snapshot", None, None, False) + def canvasRestore(self, event, pathName=None): + def canvasImportmIRC(pathName_): + rc, error = self.parentCanvas.canvas.importStore.importTextFile(pathName) + return (rc, error, self.parentCanvas.canvas.importStore.outMap, self.canvasPathName, self.parentCanvas.canvas.importStore.inSize) + if self._promptSaveChanges(): + rc, newPathName = self._import(canvasImportmIRC, False, self.canvasPathName) + + @GuiCommandDecorator("Restore from file", "Restore from &file", None, None, False) + def canvasRestoreFile(self, event): + def canvasImportmIRC(pathName): + rc, error = self.parentCanvas.canvas.importStore.importTextFile(pathName) + return (rc, error, self.parentCanvas.canvas.importStore.outMap, pathName, self.parentCanvas.canvas.importStore.inSize) + if self._promptSaveChanges(): + rc, newPathName = self._import(canvasImportmIRC, False, self.canvasPathName) + @GuiCommandDecorator("Save", "&Save", ["", wx.ART_FILE_SAVE], [wx.ACCEL_CTRL, ord("S")], None) def canvasSave(self, event, newDirty=False): if self.canvasPathName == None: @@ -284,11 +323,11 @@ class RoarCanvasCommandsFile(): return False def __init__(self): - self.imgurApiKey, self.lastFiles, self.lastDir = ImgurApiKey.imgurApiKey if haveImgurApiKey else None, [], None + self.imgurApiKey, self.lastFiles, self.lastDir, self.snapshots = ImgurApiKey.imgurApiKey if haveImgurApiKey else None, [], None, {} self.accels = () self.menus = ( ("&File", - self.canvasNew, self.canvasOpen, self.canvasOpenRecent, self.canvasSave, self.canvasSaveAs, NID_MENU_SEP, + self.canvasNew, self.canvasOpen, self.canvasOpenRecent, self.canvasRestore, self.canvasSave, self.canvasSaveAs, NID_MENU_SEP, ("&Export...", self.canvasExportAsAnsi, self.canvasExportToClipboard, self.canvasExportImgur, self.canvasExportPastebin, self.canvasExportAsPng,), ("&Import...", self.canvasImportAnsi, self.canvasImportFromClipboard, self.canvasImportSauce,), NID_MENU_SEP, diff --git a/libroar/RoarCanvasWindow.py b/libroar/RoarCanvasWindow.py index cea64cf..15cebc1 100644 --- a/libroar/RoarCanvasWindow.py +++ b/libroar/RoarCanvasWindow.py @@ -5,9 +5,11 @@ # from GuiWindow import GuiWindow +from Rtl import natural_sort +from RtlPlatform import getLocalConfPathName from ToolObject import ToolObject from ToolText import ToolText -import copy, json, wx, sys +import copy, hashlib, json, os, re, time, wx, sys class RoarCanvasWindowDropTarget(wx.TextDropTarget): def done(self): @@ -60,6 +62,50 @@ class RoarCanvasWindow(GuiWindow): eventDc.SetDeviceOrigin(*eventDcOrigin) self.commands.update(dirty=self.dirty, cellPos=self.brushPos, undoLevel=self.canvas.journal.patchesUndoLevel) + def _snapshotsReset(self): + self._snapshotFiles, self._snapshotsUpdateLast = [], time.time() + self.commands._resetSnapshots() + if self.commands.canvasPathName != None: + canvasPathName = os.path.abspath(self.commands.canvasPathName) + canvasFileName = os.path.basename(canvasPathName) + canvasPathNameHash = hashlib.sha1(canvasPathName.encode()).hexdigest() + self._snapshotsDirName = os.path.join(getLocalConfPathName(), "{}_{}".format(canvasFileName, canvasPathNameHash)) + if os.path.exists(self._snapshotsDirName): + for snapshotFile in natural_sort([f for f in os.listdir(self._snapshotsDirName) \ + if (re.match(r'snapshot\d+\.txt$', f)) and os.path.isfile(os.path.join(self._snapshotsDirName, f))]): + self.commands._pushSnapshot(os.path.join(self._snapshotsDirName, snapshotFile)) + else: + self._snapshotsDirName = None + + def _snapshotsUpdate(self): + if self._snapshotsDirName != None: + t = time.time() + if (t > self._snapshotsUpdateLast) and ((t - self._snapshotsUpdateLast) >= (5 * 60)): + try: + if not os.path.exists(self._snapshotsDirName): + os.makedirs(self._snapshotsDirName) + self._snapshotFiles = natural_sort([f for f in os.listdir(self._snapshotsDirName) + if (re.match(r'snapshot\d+\.txt$', f)) and os.path.isfile(os.path.join(self._snapshotsDirName, f))]) + if self._snapshotFiles != []: + snapshotsCount, snapshotIndex = len(self._snapshotFiles), abs(int(re.match(r'snapshot(\d+)\.txt$', self._snapshotFiles[-1])[1])) + 1 + else: + snapshotsCount, snapshotIndex = 0, 1 + snapshotPathName = os.path.join(self._snapshotsDirName, "snapshot{}.txt".format(snapshotIndex)); + self.commands.update(snapshotStatus=True) + with open(snapshotPathName, "w", encoding="utf-8") as outFile: + self.SetCursor(wx.Cursor(wx.CURSOR_WAIT)) + self.canvas.exportStore.exportTextFile(self.canvas.map, self.canvas.size, outFile) + self.SetCursor(wx.Cursor(wx.NullCursor)) + self.commands.update(snapshotStatus=False); self._snapshotsUpdateLast = time.time(); + self._snapshotFiles += [os.path.basename(snapshotPathName)]; + self.commands._pushSnapshot(snapshotPathName) + if len(self._snapshotFiles) > 72: + for snapshotFile in self._snapshotFiles[:len(self._snapshotFiles) - 8]: + self.commands._popSnapshot(os.path.join(self._snapshotsDirName, snapshotFile)) + os.remove(os.path.join(self._snapshotsDirName, snapshotFile)); snapshotsCount -= 1; + except: + print("Exception during _snapshotsUpdate(): {}".format(sys.exc_info()[1])) + def applyOperator(self, currentTool, mapPoint, mouseLeftDown, mousePoint, operator, viewRect): eventDc, patches, patchesCursor, rc = self.backend.getDeviceContext(self.GetClientSize(), self), None, None, True if (currentTool.__class__ == ToolObject) and (currentTool.toolState >= currentTool.TS_SELECT): @@ -81,16 +127,19 @@ class RoarCanvasWindow(GuiWindow): rc, patches, patchesCursor = currentTool.onSelectEvent(self.canvas, (0, 0), True, wx.MOD_NONE, None, currentTool.targetRect) patchesCursor = [] if patchesCursor == None else patchesCursor patchesCursor += currentTool._drawSelectRect(currentTool.targetRect) + self._applyPatches(eventDc, patches, patchesCursor, rc) else: patches = [] for numRow in range(len(region)): for numCol in range(len(region[numRow])): patches += [[numCol, numRow, *region[numRow][numCol]]] - self._applyPatches(eventDc, patches, patchesCursor, rc) + self._applyPatches(eventDc, patches, patchesCursor, rc) + if (patches != None) and (len(patches) > 0): + self._snapshotsUpdate() return rc def applyTool(self, eventDc, eventMouse, keyChar, keyCode, keyModifiers, mapPoint, mouseDragging, mouseLeftDown, mouseRightDown, tool, viewRect, force=False): - dirty, patches, patchesCursor, rc = False, None, None, False + patches, patchesCursor, rc = None, None, False if eventMouse: self.lastCellState = None if force else self.lastCellState if ((mapPoint[0] < self.canvas.size[0]) and (mapPoint[1] < self.canvas.size[1])) \ @@ -118,6 +167,8 @@ class RoarCanvasWindow(GuiWindow): self.commands.update(toolName=newToolName, undoInhibit=False) else: self.commands.update(undoInhibit=False) + if (patches != None) and (len(patches) > 0): + self._snapshotsUpdate() return rc def onKeyboardInput(self, event): @@ -237,6 +288,8 @@ class RoarCanvasWindow(GuiWindow): eventDc.SetDeviceOrigin(*eventDcOrigin) self.Scroll(*viewRect); self.dirty = dirty; self.commands.update(dirty=self.dirty, size=newSize, undoLevel=self.canvas.journal.patchesUndoLevel) + if commitUndo: + self._snapshotsUpdate() def undo(self, redo=False): deltaPatches = self.canvas.journal.popUndo() if not redo else self.canvas.journal.popRedo() @@ -276,5 +329,6 @@ class RoarCanvasWindow(GuiWindow): self.dropTarget = RoarCanvasWindowDropTarget(self) self.SetDropTarget(self.dropTarget) self.Bind(wx.EVT_MOUSEWHEEL, self.onMouseWheel) + self._snapshotsReset() # vim:expandtab foldmethod=marker sw=4 ts=4 tw=120 diff --git a/libroar/RoarClient.py b/libroar/RoarClient.py index 55d2ad4..af1ceac 100644 --- a/libroar/RoarClient.py +++ b/libroar/RoarClient.py @@ -62,10 +62,15 @@ class RoarClient(GuiFrame): for menuItem in self.canvasPanel.commands.menus[3][1:]: menuItemWindow = self.canvasPanel.operatorsMenu.Append(menuItem.attrDict["id"], menuItem.attrDict["label"], menuItem.attrDict["caption"]) self.Bind(wx.EVT_MENU, self.onMenu, menuItemWindow) - self.canvasPanel.commands.canvasOpenRecent.attrDict["menu"].AppendSeparator() + self.canvasPanel.commands.canvasClearRecent.attrDict["id"] = wx.NewId() + self.canvasPanel.commands.canvasOpenRecent.attrDict["menu"].AppendSeparator() + self.canvasPanel.commands.canvasRestore.attrDict["menu"].AppendSeparator() + self.canvasPanel.commands.canvasRestoreFile.attrDict["id"] = wx.NewId() menuItemWindow = self.canvasPanel.commands.canvasOpenRecent.attrDict["menu"].Append(self.canvasPanel.commands.canvasClearRecent.attrDict["id"], self.canvasPanel.commands.canvasClearRecent.attrDict["label"], self.canvasPanel.commands.canvasClearRecent.attrDict["caption"]) + menuItemWindow = self.canvasPanel.commands.canvasRestore.attrDict["menu"].Append(self.canvasPanel.commands.canvasRestoreFile.attrDict["id"], self.canvasPanel.commands.canvasRestoreFile.attrDict["label"], self.canvasPanel.commands.canvasRestoreFile.attrDict["caption"]) self.canvasPanel.commands.canvasOpenRecent.attrDict["menu"].Bind(wx.EVT_MENU, self.canvasPanel.commands.canvasClearRecent, menuItemWindow) + self.canvasPanel.commands.canvasRestore.attrDict["menu"].Bind(wx.EVT_MENU, self.canvasPanel.commands.canvasRestoreFile, menuItemWindow) self.Bind(wx.EVT_CLOSE, self.onClose); self.Bind(wx.EVT_SIZE, self.onSize); self.toolBarPanes[0].BestSize(0, 0).Right(); self.toolBarPanes[1].BestSize(0, 0).Right(); self.auiManager.Update(); diff --git a/librtl/Rtl.py b/librtl/Rtl.py index 63c548f..e7abbe2 100644 --- a/librtl/Rtl.py +++ b/librtl/Rtl.py @@ -5,13 +5,18 @@ # This project is licensed under the terms of the MIT licence. # -import statistics, time +import re, statistics, time timeState = [None, None, 0, []] def flatten(l): return [li for l_ in l for li in l_] +def natural_sort(l): + convert = lambda text: int(text) if text.isdigit() else text.lower() + alphanum_key = lambda key: [convert(c) for c in re.split('([0-9]+)', key)] + return sorted(l, key=alphanum_key) + def timePrint(pfx): timeState[0] = time.time() if timeState[0] == None else timeState[0] t1 = time.time(); td = t1 - timeState[0] diff --git a/roar.py b/roar.py index dd52e43..9fa1aaa 100755 --- a/roar.py +++ b/roar.py @@ -25,6 +25,7 @@ def main(*argv): if (len(argv) >= 2) and (argv[1].endswith(".lst")): roarClient.assetsWindow._load_list(argv[1]) roarClient.canvasPanel.commands.canvasPathName = argv[0] + roarClient.canvasPanel._snapshotsReset() rc, error = roarClient.canvasPanel.canvas.importStore.importTextFile(argv[0]) if rc: roarClient.canvasPanel.update(roarClient.canvasPanel.canvas.importStore.inSize, False, roarClient.canvasPanel.canvas.importStore.outMap, dirty=False)