diff --git a/mymcplusplus/gui/dirlist_control.py b/mymcplusplus/gui/dirlist_control.py index a81b4a5..37d4a8a 100644 --- a/mymcplusplus/gui/dirlist_control.py +++ b/mymcplusplus/gui/dirlist_control.py @@ -49,11 +49,13 @@ def __init__(self, type, dirent, icon_sys, size, title): self.size = size self.title = title - def __init__(self, parent, evt_focus, evt_select, config): self.config = config self.selected = set() self.dirtable = [] + # current sort state for column header clicks + self.sort_column = self.Column.DIRECTORY + self.sort_reverse = False self.evt_select = evt_select wx.ListCtrl.__init__(self, parent, wx.ID_ANY, @@ -63,7 +65,6 @@ def __init__(self, parent, evt_focus, evt_select, config): self.Bind(wx.EVT_LIST_ITEM_SELECTED, self.evt_item_selected) self.Bind(wx.EVT_LIST_ITEM_DESELECTED, self.evt_item_deselected) - def _update_dirtable(self, mc, dir): self.dirtable = table = [] enc = "unicode" @@ -91,7 +92,6 @@ def _update_dirtable(self, mc, dir): size = mc.dir_size(dirpath) table.append(self.TableEntry(type, ent, icon_sys, size, title)) - def update_dirtable(self, mc): self.dirtable = [] if mc is None: @@ -102,30 +102,97 @@ def update_dirtable(self, mc): finally: dir.close() + def update_column_headers(self): + """Update column texts with an arrow on the active sort column.""" - def cmp_dir_name(self, i1, i2): - return self.dirtable[i1].dirent[8] > self.dirtable[i2].dirent[8] + # Base titles for each column + columns = [ + (self.Column.DIRECTORY, "Directory"), + (self.Column.SIZE, "Size"), + (self.Column.MODIFIED, "Modified"), + (self.Column.DESCRIPTION, "Description"), + ] + # Unicode arrows for clarity; switch to " ^"/" v" if you prefer ASCII + arrow_up = " ▲" + arrow_down = " ▼" - def cmp_dir_title(self, i1, i2): - return self.dirtable[i1].title > self.dirtable[i2].title + for col_enum, base in columns: + text = base + if col_enum == self.sort_column: + text += arrow_down if self.sort_reverse else arrow_up + item = self.GetColumn(col_enum.value) + item.SetText(text) + self.SetColumn(col_enum.value, item) - def cmp_dir_size(self, i1, i2): - return self.dirtable[i1].size > self.dirtable[i2].size + def _apply_sort_direction(self, result): + """Flip the comparison result if we're in reverse-sort mode. + + wx.ListCtrl.SortItems expects a negative/zero/positive integer. + """ + if self.sort_reverse: + return -result + return result + + def cmp_dir_name(self, i1, i2): + v1 = self.dirtable[i1].dirent[8] + v2 = self.dirtable[i2].dirent[8] + if v1 < v2: + result = -1 + elif v1 > v2: + result = 1 + else: + result = 0 + return self._apply_sort_direction(result) + + def cmp_dir_title(self, i1, i2): + v1 = self.dirtable[i1].title + v2 = self.dirtable[i2].title + if v1 < v2: + result = -1 + elif v1 > v2: + result = 1 + else: + result = 0 + return self._apply_sort_direction(result) + def cmp_dir_size(self, i1, i2): + v1 = self.dirtable[i1].size + v2 = self.dirtable[i2].size + if v1 < v2: + result = -1 + elif v1 > v2: + result = 1 + else: + result = 0 + return self._apply_sort_direction(result) def cmp_dir_modified(self, i1, i2): m1 = list(self.dirtable[i1].dirent[6]) m2 = list(self.dirtable[i2].dirent[6]) + # dirent[6] is a tuple like (sec, min, hour, day, month, year) + # Reverse so that comparisons happen in chronological order. m1.reverse() m2.reverse() - return m1 > m2 - + if m1 < m2: + result = -1 + elif m1 > m2: + result = 1 + else: + result = 0 + return self._apply_sort_direction(result) def evt_col_click(self, event): col = self.Column(event.Column) + # Clicking the same column toggles ascending/descending. + if col == self.sort_column: + self.sort_reverse = not self.sort_reverse + else: + self.sort_column = col + self.sort_reverse = False + if col == self.Column.DIRECTORY: cmp = self.cmp_dir_name elif col == self.Column.SIZE: @@ -136,19 +203,21 @@ def evt_col_click(self, event): cmp = self.cmp_dir_title else: return - self.SortItems(cmp) + # Update header text to show the arrow for the active sort + self.update_column_headers() + + # Apply the sort + self.SortItems(cmp) def evt_item_selected(self, event): self.selected.add(event.GetData()) self.evt_select(event) - def evt_item_deselected(self, event): self.selected.discard(event.GetData()) self.evt_select(event) - def update(self, mc): """Update the ListCtrl according to the contents of the memory card image.""" @@ -171,6 +240,11 @@ def update(self, mc): column.SetAlign(align) self.InsertColumn(index, column) + # Default sort: directory name ascending + self.sort_column = self.Column.DIRECTORY + self.sort_reverse = False + self.update_column_headers() + self.update_dirtable(mc) empty = len(self.dirtable) == 0 @@ -192,4 +266,6 @@ def update(self, mc): for i in range(4): self.SetColumnWidth(i, wx.LIST_AUTOSIZE) self.SetColumnWidth(i, self.GetColumnWidth(i) + du) + + # Apply default sort (Directory, ascending) self.SortItems(self.cmp_dir_name) diff --git a/mymcplusplus/gui/gui.py b/mymcplusplus/gui/gui.py index ee7b903..af60f05 100644 --- a/mymcplusplus/gui/gui.py +++ b/mymcplusplus/gui/gui.py @@ -33,8 +33,11 @@ # Fix DPI awareness import ctypes - try: ctypes.windll.shcore.SetProcessDpiAwareness(True) - except: pass + try: + ctypes.windll.shcore.SetProcessDpiAwareness(True) + except Exception: + pass + import wx from .. import ps2mc, ps2iconsys @@ -51,28 +54,35 @@ class GuiConfig(wx.Config): memcard_dir = "Memory Card Directory" savefile_dir = "Save File Directory" ascii = "ASCII Descriptions" + force_import = "Force Import Overwrite" def __init__(self): - wx.Config.__init__(self, "mymc++", style = wx.CONFIG_USE_LOCAL_FILE) + wx.Config.__init__(self, "mymc++", style=wx.CONFIG_USE_LOCAL_FILE) - def get_memcard_dir(self, default = None): + def get_memcard_dir(self, default=None): return self.Read(GuiConfig.memcard_dir, default) def set_memcard_dir(self, value): return self.Write(GuiConfig.memcard_dir, value) - def get_savefile_dir(self, default = None): + def get_savefile_dir(self, default=None): return self.Read(GuiConfig.savefile_dir, default) def set_savefile_dir(self, value): return self.Write(GuiConfig.savefile_dir, value) - def get_ascii(self, default = False): + def get_ascii(self, default=False): return bool(self.ReadInt(GuiConfig.ascii, int(bool(default)))) def set_ascii(self, value): return self.WriteInt(GuiConfig.ascii, int(bool(value))) + def get_force_import(self, default=False): + return bool(self.ReadInt(GuiConfig.force_import, int(bool(default)))) + + def set_force_import(self, value): + return self.WriteInt(GuiConfig.force_import, int(bool(value))) + def add_tool(toolbar, id, label, standard_art, ico): bmp = wx.NullBitmap @@ -84,7 +94,7 @@ def add_tool(toolbar, id, label, standard_art, ico): tbsize = toolbar.GetToolBitmapSize() bmp = utils.get_png_resource_bmp(ico, tbsize) - return toolbar.AddTool(id, label, bmp, shortHelp = label) + return toolbar.AddTool(id, label, bmp, shortHelp=label) class GuiFrame(wx.Frame): @@ -95,33 +105,37 @@ class GuiFrame(wx.Frame): ID_CMD_EXPORT = 103 ID_CMD_IMPORT = 104 ID_CMD_DELETE = wx.ID_DELETE + ID_CMD_SELECT_ALL = 111 ID_CMD_ASCII = 106 ID_CMD_SAVEAS = 107 + ID_CMD_NEW_MC = 109 + ID_CMD_ECC_TOOL = 110 + ID_CMD_FORCE_IMPORT = 108 - def message_box(self, message, caption = "mymcplusplus", style = wx.OK, - x = -1, y = -1): + def message_box(self, message, caption="mymcplusplus", style=wx.OK, + x=-1, y=-1): return wx.MessageBox(message, caption, style, self, x, y) def error_box(self, msg): return self.message_box(msg, "Error", wx.OK | wx.ICON_ERROR) - def mc_error(self, value, filename = None): + def mc_error(self, value, filename=None): """Display a message box for EnvironmentError exeception.""" - if filename == None: + if filename is None: filename = getattr(value, "filename") - if filename == None: + if filename is None: filename = self.mcname - if filename == None: + if filename is None: filename = "???" strerror = getattr(value, "strerror", None) - if strerror == None: + if strerror is None: strerror = "unknown error" return self.error_box(filename + ": " + strerror) - def __init__(self, parent, title, mcname = None): + def __init__(self, parent, title, mcname=None): self.f = None self.mc = None self.mcname = None @@ -134,6 +148,14 @@ def __init__(self, parent, title, mcname = None): self.config = GuiConfig() self.title = title + # OS-specific shortcut labels for Save As and ECC Save As + is_mac = (wx.Platform == "__WXMAC__") + if is_mac: + self._saveas_shortcut = "⌘S" + self._ecc_shortcut = "⌘⌥S" + else: + self._saveas_shortcut = "Ctrl+S" + self._ecc_shortcut = "Ctrl+Shift+S" self.SetIcon(wx.Icon(utils.get_png_resource_bmp("icon.png"))) @@ -143,22 +165,66 @@ def __init__(self, parent, title, mcname = None): self.Bind(wx.EVT_MENU, self.evt_cmd_export, id=self.ID_CMD_EXPORT) self.Bind(wx.EVT_MENU, self.evt_cmd_import, id=self.ID_CMD_IMPORT) self.Bind(wx.EVT_MENU, self.evt_cmd_delete, id=self.ID_CMD_DELETE) + self.Bind(wx.EVT_MENU, self.evt_cmd_select_all, id=self.ID_CMD_SELECT_ALL) self.Bind(wx.EVT_MENU, self.evt_cmd_ascii, id=self.ID_CMD_ASCII) + self.Bind(wx.EVT_MENU, self.evt_cmd_force_import, id=self.ID_CMD_FORCE_IMPORT) + self.Bind(wx.EVT_MENU, self.evt_cmd_new_mc, id=self.ID_CMD_NEW_MC) + self.Bind(wx.EVT_MENU, self.evt_cmd_ecc_tool, id=self.ID_CMD_ECC_TOOL) filemenu = wx.Menu() - filemenu.Append(self.ID_CMD_OPEN, "&Open...", "Opens an existing PS2 memory card image.") + filemenu.Append( + self.ID_CMD_OPEN, + "&Open...\tCtrl+O", + "Opens an existing PS2 memory card image.", + ) + self.new_mc_menu_item = filemenu.Append( + self.ID_CMD_NEW_MC, + "&New Memory Card...\tCtrl+N", + "Create a new PS2 memory card image.", + ) + self.ecc_tool_menu_item = filemenu.Append( + self.ID_CMD_ECC_TOOL, + f"Remove ECC and Save As...\t{self._ecc_shortcut}", + "Add or remove ECC on the current memory card image.", + ) filemenu.AppendSeparator() - self.saveas_menu_item = filemenu.Append(self.ID_CMD_SAVEAS, "&Save As...") + self.saveas_menu_item = filemenu.Append( + self.ID_CMD_SAVEAS, + f"&Save As...\t{self._saveas_shortcut}", + ) + self.select_all_menu_item = filemenu.Append( + self.ID_CMD_SELECT_ALL, + "Select &All\tCtrl+A", + ) filemenu.AppendSeparator() - self.export_menu_item = filemenu.Append(self.ID_CMD_EXPORT, "&Export...", "Export a save file from this image.") - self.import_menu_item = filemenu.Append(self.ID_CMD_IMPORT, "&Import...", "Import a save file into this image.") - self.delete_menu_item = filemenu.Append(self.ID_CMD_DELETE, "&Delete") + self.export_menu_item = filemenu.Append( + self.ID_CMD_EXPORT, + "&Export...\tCtrl+E", + "Export a save file from this image.", + ) + self.import_menu_item = filemenu.Append( + self.ID_CMD_IMPORT, + "&Import...\tCtrl+I", + "Import a save file into this image.", + ) + self.delete_menu_item = filemenu.Append( + self.ID_CMD_DELETE, + "&Delete\tDel", + ) filemenu.AppendSeparator() - filemenu.Append(self.ID_CMD_EXIT, "E&xit") + filemenu.Append(self.ID_CMD_EXIT, "E&xit\tCtrl+Q") optionmenu = wx.Menu() - self.ascii_menu_item = optionmenu.AppendCheckItem(self.ID_CMD_ASCII, "&ASCII Descriptions", "Show descriptions in ASCII instead of Shift-JIS") - + self.ascii_menu_item = optionmenu.AppendCheckItem( + self.ID_CMD_ASCII, + "&ASCII Descriptions\tCtrl+Shift+A", + "Show descriptions in ASCII instead of Shift-JIS", + ) + self.force_import_menu_item = optionmenu.AppendCheckItem( + self.ID_CMD_FORCE_IMPORT, + "&Force Import\tCtrl+Shift+F", + "Force overwriting existing saves when importing", + ) self.Bind(wx.EVT_MENU_OPEN, self.evt_menu_open) @@ -174,6 +240,8 @@ def __init__(self, parent, title, mcname = None): self.statusbar = self.CreateStatusBar(2, style=wx.STB_SIZEGRIP) self.statusbar.SetStatusWidths([-2, -1]) + self._statusbar_default_colour = self.statusbar.GetForegroundColour() + self._statusbar_default_colour = self.statusbar.GetForegroundColour() panel = wx.Panel(self, wx.ID_ANY, (0, 0)) sizer = wx.BoxSizer(wx.HORIZONTAL) @@ -183,16 +251,21 @@ def __init__(self, parent, title, mcname = None): splitter_window = wx.SplitterWindow(panel, style=wx.SP_LIVE_UPDATE) splitter_window.SetSashGravity(0.5) - self.dirlist = DirListControl(splitter_window, - self.evt_dirlist_item_focused, - self.evt_dirlist_select, - self.config) + self.dirlist = DirListControl( + splitter_window, + self.evt_dirlist_item_focused, + self.evt_dirlist_select, + self.config, + ) if mcname is not None: self.open_mc(mcname) else: self.refresh() + # Ensure the force import status bar warning matches the current config + self._update_force_import_status() + panel_sizer = wx.BoxSizer(wx.HORIZONTAL) panel_sizer.Add(splitter_window, wx.EXPAND, wx.EXPAND) panel.SetSizer(panel_sizer) @@ -223,33 +296,65 @@ def __init__(self, parent, title, mcname = None): # self.info3 = wx.StaticText(panel, -1, "") info_sizer = wx.BoxSizer(wx.VERTICAL) - info_sizer.Add(self.info1, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=4) - info_sizer.Add(self.info2, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, border=4) + info_sizer.Add( + self.info1, + 0, + wx.EXPAND | wx.LEFT | wx.RIGHT, + border=4, + ) + info_sizer.Add( + self.info2, + 0, + wx.EXPAND | wx.LEFT | wx.RIGHT, + border=4, + ) # info_sizer.Add(self.info3, 0, wx.EXPAND) info_sizer.AddSpacer(5) info_sizer.Add(icon_win, 1, wx.EXPAND) info_win.SetSizer(info_sizer) - splitter_window.SplitVertically(self.dirlist, info_win, int(self.Size.Width * 0.7)) + splitter_window.SplitVertically( + self.dirlist, + info_win, + int(self.Size.Width * 0.7), + ) menubar = wx.MenuBar() menubar.Append(filemenu, "&File") menubar.Append(optionmenu, "&Options") self.SetMenuBar(menubar) + # Set up keyboard shortcuts (Cmd on macOS, Ctrl on others) for main actions. + accel_entries = [ + (wx.ACCEL_CMD, ord("O"), self.ID_CMD_OPEN), # Open + (wx.ACCEL_CMD, ord("N"), self.ID_CMD_NEW_MC), # New Memory Card + (wx.ACCEL_CMD, ord("R"), self.ID_CMD_ECC_TOOL), # Add/Remove ECC and Save As + (wx.ACCEL_CMD | wx.ACCEL_SHIFT, ord("S"), self.ID_CMD_SAVEAS), # Save As + (wx.ACCEL_CMD, ord("E"), self.ID_CMD_EXPORT), # Export + (wx.ACCEL_CMD, ord("I"), self.ID_CMD_IMPORT), # Import + (wx.ACCEL_NORMAL, wx.WXK_DELETE, self.ID_CMD_DELETE), # Delete + (wx.ACCEL_CMD, ord("Q"), self.ID_CMD_EXIT), # Exit + (wx.ACCEL_CMD | wx.ACCEL_SHIFT, ord("A"), self.ID_CMD_ASCII), # ASCII Descriptions + (wx.ACCEL_CMD | wx.ACCEL_SHIFT, ord("F"), self.ID_CMD_FORCE_IMPORT) # Force Import + ] + accel_table = wx.AcceleratorTable( + [(flags, key, cmd_id) for (flags, key, cmd_id) in accel_entries] + ) + self.SetAcceleratorTable(accel_table) + self.Show(True) - if self.mc == None: + if self.mc is None: self.evt_cmd_open() def _close_mc(self): - if self.mc != None: + if self.mc is not None: try: self.mc.close() except EnvironmentError as value: self.mc_error(value) self.mc = None - if self.f != None: + if self.f is not None: try: self.f.close() except EnvironmentError as value: @@ -267,10 +372,10 @@ def refresh(self): mc = self.mc - self.toolbar.EnableTool(self.ID_CMD_IMPORT, mc != None) + self.toolbar.EnableTool(self.ID_CMD_IMPORT, mc is not None) self.toolbar.EnableTool(self.ID_CMD_EXPORT, False) - if mc == None: + if mc is None: status = "No memory card image" else: free = mc.get_free_space() // 1024 @@ -281,7 +386,7 @@ def refresh(self): def open_mc(self, filename): self._close_mc() self.statusbar.SetStatusText("", 1) - if self.icon_win != None: + if self.icon_win is not None: self.icon_win.load_icon(None, None) f = None @@ -289,7 +394,7 @@ def open_mc(self, filename): f = open(filename, "r+b") mc = ps2mc.ps2mc(f) except EnvironmentError as value: - if f != None: + if f is not None: f.close() self.mc_error(value, filename) self.SetTitle(self.title) @@ -304,14 +409,16 @@ def open_mc(self, filename): def delete_selected(self): mc = self.mc - if mc == None: + if mc is None: return selected = self.dirlist.selected dirtable = self.dirlist.dirtable - dirnames = [dirtable[i].dirent[8].decode("ascii") - for i in selected] + dirnames = [ + dirtable[i].dirent[8].decode("ascii") + for i in selected + ] if len(selected) == 1: title = dirtable[list(selected)[0]].title s = dirnames[0] + " (" + utils.single_title(title) + ")" @@ -319,10 +426,11 @@ def delete_selected(self): s = ", ".join(dirnames) if len(s) > 200: s = s[:200] + "..." - r = self.message_box("Are you sure you want to delete " - + s + "?", - "Delete Save File Confirmation", - wx.YES_NO) + r = self.message_box( + "Are you sure you want to delete " + s + "?", + "Delete Save File Confirmation", + wx.YES_NO, + ) if r != wx.YES: return @@ -336,21 +444,141 @@ def delete_selected(self): self.refresh() def evt_key_pressed(self, event): + """Global key handler for shortcuts (Ctrl/Cmd + key).""" keycode = event.GetUnicodeKey() - if keycode == wx.WXK_DELETE: + ctrl_or_cmd = event.ControlDown() or event.CmdDown() + shift = event.ShiftDown() + alt = event.AltDown() + is_mac = (wx.Platform == "__WXMAC__") + + # Normalize letters to uppercase for comparison. + if isinstance(keycode, int) and 97 <= keycode <= 122: + key = chr(keycode - 32) + elif isinstance(keycode, int) and 65 <= keycode <= 90: + key = chr(keycode) + else: + key = None + + # Delete key (no modifiers). + if keycode == wx.WXK_DELETE and not ctrl_or_cmd and not shift and not alt: self.delete_selected() + return + + # Handle Save As / ECC Save As on S with modifiers: + # Windows/Linux: Ctrl+S (Save As), Ctrl+Shift+S (ECC Save As) + # macOS: Cmd+S (Save As), Cmd+Option+S (ECC Save As). + if ctrl_or_cmd and key == "S": + # Windows / Linux ECC: Ctrl+Shift+S (no Alt). + if (not is_mac) and shift and not alt: + self.evt_cmd_ecc_tool(event) + return + # macOS ECC: Cmd+Option+S (AltDown). + if is_mac and alt: + self.evt_cmd_ecc_tool(event) + return + # Plain Save As: primary modifier only, no Shift/Alt. + if not shift and not alt: + self.evt_cmd_saveas(event) + return + + # Shortcuts that use primary modifier (Ctrl on Windows/Linux, + # Cmd on macOS), no Shift/Alt. + if ctrl_or_cmd and not shift and not alt and key is not None: + if key == "O": + self.evt_cmd_open(event) + return + if key == "N": + self.evt_cmd_new_mc(event) + return + if key == "A": + # Select All + self.evt_cmd_select_all(event) + return + if key == "E": + self.evt_cmd_export(event) + return + if key == "I": + self.evt_cmd_import(event) + return + if key == "F": + # Force Import toggle + self.evt_cmd_force_import(event) + return + if key == "Q": + self.evt_cmd_exit(event) + return + + # Ctrl/Cmd+Shift+A -> ASCII Descriptions toggle (no Alt). + if ctrl_or_cmd and shift and not alt and key == "A": + self.evt_cmd_ascii(event) + return + + # Let other keys propagate normally. + event.Skip() + + def _update_force_import_status(self): + """Sync the force import checkbox and status bar with current config.""" + fi = self.config.get_force_import() + + # Keep the menu checkmark in sync with the config + if hasattr(self, "force_import_menu_item"): + self.force_import_menu_item.Check(fi) + + if fi: + # Show a red warning in the status bar when force import is active. + self.statusbar.SetStatusText( + "Force Import Mode Enabled - existing saves will be overwritten!", + 0, + ) + self.statusbar.SetForegroundColour(wx.RED) + else: + # Clear warning and restore default color. + self.statusbar.SetStatusText("", 0) + self.statusbar.SetForegroundColour(self._statusbar_default_colour) def evt_menu_open(self, event): + # Enable/disable basic actions based on current state self.import_menu_item.Enable(self.mc is not None) + selected = self.mc is not None and len(self.dirlist.selected) > 0 self.export_menu_item.Enable(selected) self.delete_menu_item.Enable(selected) + + self.select_all_menu_item.Enable( + self.mc is not None and len(self.dirlist.dirtable) > 0 + ) self.ascii_menu_item.Check(self.config.get_ascii()) + + # Enable and update the ECC tool menu item based on current card + has_mc = self.mc is not None + self.ecc_tool_menu_item.Enable(has_mc) + if has_mc: + # Detect ECC based on spare_size: if there is spare area, ECC bytes exist. + has_ecc = getattr(self.mc, "spare_size", 0) > 0 + + if has_ecc: + # Card currently has ECC → menu should show Remove ECC + self.ecc_tool_menu_item.SetItemLabel( + f"&Remove ECC and Save As...\t{self._ecc_shortcut}" + ) + else: + # Card currently has NO ECC → menu should show Add ECC + self.ecc_tool_menu_item.SetItemLabel( + f"&Add ECC and Save As...\t{self._ecc_shortcut}" + ) + else: + # No card open; default to "Add ECC" label + self.ecc_tool_menu_item.SetItemLabel( + f"&Add ECC and Save As...\t{self._ecc_shortcut}" + ) + + # Sync force import status (checkbox + warning text) with current config. + self._update_force_import_status() + if self.icon_win is not None: self.icon_win.update_menu(self.icon_menu) - def evt_dirlist_item_focused(self, event): if self.icon_win is None: return @@ -381,38 +609,201 @@ def evt_dirlist_item_focused(self, event): self.icon_win.load_icon(icon_sys, icon) - def evt_dirlist_select(self, event): - self.toolbar.EnableTool(self.ID_CMD_IMPORT, self.mc != None) - self.toolbar.EnableTool(self.ID_CMD_EXPORT, - len(self.dirlist.selected) > 0) - - def evt_cmd_open(self, event = None): - fn = wx.FileSelector("Open Memory Card Image", - self.config.get_memcard_dir(""), - "Mcd001.ps2", "ps2", "Memory Card Image (*.ps2;*.mc2;*.mcd)|*.ps2;*.mc2;*.mcd", - wx.FD_FILE_MUST_EXIST | wx.FD_OPEN, - self) + self.toolbar.EnableTool(self.ID_CMD_IMPORT, self.mc is not None) + self.toolbar.EnableTool( + self.ID_CMD_EXPORT, + len(self.dirlist.selected) > 0, + ) + + def evt_cmd_open(self, event=None): + fn = wx.FileSelector( + "Open Memory Card Image", + self.config.get_memcard_dir(""), + "Mcd001.ps2", + "ps2", + "Memory Card Image (*.ps2;*.mc2;*.mcd)|*.ps2;*.mc2;*.mcd", + wx.FD_FILE_MUST_EXIST | wx.FD_OPEN, + self, + ) if fn == "": return self.open_mc(fn) - if self.mc != None: + if self.mc is not None: dirname = os.path.dirname(fn) if os.path.isabs(dirname): self.config.set_memcard_dir(dirname) + def evt_cmd_new_mc(self, event): + """Create a new blank PS2 memory card image.""" + # Dialog to choose size and ECC settings + dlg = wx.Dialog(self, title="Create New Memory Card") + vbox = wx.BoxSizer(wx.VERTICAL) + + size_label = wx.StaticText(dlg, label="Card size:") + size_choices = ["8 MB", "16 MB", "32 MB", "64 MB"] + size_radio = wx.RadioBox( + dlg, + label="", + choices=size_choices, + majorDimension=1, + style=wx.RA_SPECIFY_ROWS, + ) + + ecc_checkbox = wx.CheckBox(dlg, label="Disable ECC (-e)") + ecc_checkbox.SetValue(False) + + vbox.Add(size_label, 0, wx.ALL, 5) + vbox.Add(size_radio, 0, wx.ALL, 5) + vbox.Add(ecc_checkbox, 0, wx.ALL, 5) + + btn_sizer = dlg.CreateStdDialogButtonSizer(wx.OK | wx.CANCEL) + vbox.Add(btn_sizer, 0, wx.ALL | wx.ALIGN_RIGHT, 5) + + dlg.SetSizer(vbox) + dlg.Fit() + + if dlg.ShowModal() != wx.ID_OK: + dlg.Destroy() + return + + size_idx = size_radio.GetSelection() + disable_ecc = ecc_checkbox.GetValue() + dlg.Destroy() + + size_factors = [1, 2, 4, 8] # 8MB, 16MB, 32MB, 64MB + factor = size_factors[size_idx] + with_ecc = not disable_ecc + + # Select filename for the new card + fn = wx.FileSelector( + "Create New Memory Card Image", + self.config.get_memcard_dir(""), + "Mcd001.ps2", + "ps2", + "PCSX2 Image|*.ps2" + "|Multipurpose Memory Card Emulator - Virtual Memory Card|*.mc2;*.mcd", + (wx.FD_OVERWRITE_PROMPT | wx.FD_SAVE), + self, + ) + if fn == "": + return + + try: + # Compute parameters for the new card. + page_size = ps2mc.PS2MC_STANDARD_PAGE_SIZE + pages_per_erase_block = ps2mc.PS2MC_STANDARD_PAGES_PER_ERASE_BLOCK + base_pages_per_card = ps2mc.PS2MC_STANDARD_PAGES_PER_CARD + pages_per_card = base_pages_per_card * factor + + params = ( + with_ecc, + page_size, + pages_per_erase_block, + pages_per_card, + ) + + with open(fn, "w+b") as f: + new_mc = ps2mc.ps2mc(f, True, params) + new_mc.flush() + new_mc.close() + + dirname = os.path.dirname(fn) + if os.path.isabs(dirname): + self.config.set_memcard_dir(dirname) + + # Open the newly created card + self.open_mc(fn) + + except EnvironmentError as value: + self.mc_error(value, fn) + return + + def evt_cmd_ecc_tool(self, event): + """Rebuild the current card as ECC or non-ECC and save as a new image.""" + mc = self.mc + if mc is None: + return + + # Determine current ECC state from the card object. + # We consider the card to "have ECC" if it has a non-zero spare area. + has_ecc = getattr(mc, "spare_size", 0) > 0 + target_with_ecc = not has_ecc + + from pathlib import Path as _Path + base_name = _Path(self.mcname).name if getattr(self, "mcname", None) else "Mcd001.ps2" + if has_ecc: + # Card has ECC now: we are removing ECC. + default_name = "NoECC_" + base_name + else: + # Card has no ECC now: we are adding ECC. + default_name = "ECC_" + base_name + + fn = wx.FileSelector( + "Remove ECC and Save As..." if has_ecc else "Add ECC and Save As...", + self.config.get_memcard_dir(""), + default_name, + "ps2", + "PCSX2 Image|*.ps2" + "|Multipurpose Memory Card Emulator - Virtual Memory Card|*.mc2;*.mcd", + (wx.FD_OVERWRITE_PROMPT | wx.FD_SAVE), + self, + ) + if fn == "": + return + + try: + # Use the current card geometry, but flip the ECC setting. + page_size = mc.page_size + pages_per_erase_block = mc.pages_per_erase_block + pages_per_card = mc.clusters_per_card * mc.pages_per_cluster + + params = ( + target_with_ecc, + page_size, + pages_per_erase_block, + pages_per_card, + ) + + with open(fn, "w+b") as f: + new_mc = ps2mc.ps2mc(f, True, params) + # Copy all saves from current card to new card. + for dir_tab_entry in self.dirlist.dirtable: + dirname = dir_tab_entry.dirent[8].decode("ascii") + new_mc.import_save_file( + mc.export_save_file("/" + dirname), + False, + ) + + new_mc.flush() + new_mc.close() + + dirname = os.path.dirname(fn) + if os.path.isabs(dirname): + self.config.set_memcard_dir(dirname) + + # Open the rebuilt card. + self.open_mc(fn) + + except EnvironmentError as value: + self.mc_error(value, fn) + return + def evt_cmd_saveas(self, event): mc = self.mc - if mc == None: + if mc is None: return - - fn = wx.FileSelector("Save As", - self.config.get_memcard_dir(""), Path(self.mcname).stem + '.ps2', "ps2", - "PCSX2 Image|*.ps2" - "|Multipurpose Memory Card Emulator - Virtual Memory Card|*.mc2;*.mcd", - (wx.FD_OVERWRITE_PROMPT - | wx.FD_SAVE), - self) + + fn = wx.FileSelector( + "Save As", + self.config.get_memcard_dir(""), + Path(self.mcname).stem + ".ps2", + "ps2", + "PCSX2 Image|*.ps2" + "|Multipurpose Memory Card Emulator - Virtual Memory Card|*.mc2;*.mcd", + (wx.FD_OVERWRITE_PROMPT | wx.FD_SAVE), + self, + ) if fn == "": return try: @@ -421,17 +812,21 @@ def evt_cmd_saveas(self, event): ecc = False else: ecc = True - params = (ecc, + params = ( + ecc, mc.page_size, mc.pages_per_erase_block, - mc.clusters_per_card * mc.pages_per_cluster + mc.clusters_per_card * mc.pages_per_cluster, ) with open(fn, "w+b") as f: new_mc = ps2mc.ps2mc(f, True, params) for dir_tab_entry in self.dirlist.dirtable: dirname = dir_tab_entry.dirent[8].decode("ascii") - new_mc.import_save_file(mc.export_save_file("/" + dirname), False) + new_mc.import_save_file( + mc.export_save_file("/" + dirname), + False, + ) new_mc.flush() new_mc.close() @@ -441,7 +836,7 @@ def evt_cmd_saveas(self, event): def evt_cmd_export(self, event): mc = self.mc - if mc == None: + if mc is None: return selected = self.dirlist.selected @@ -462,14 +857,17 @@ def evt_cmd_export(self, event): dir = self.config.get_savefile_dir("") if len(selected) == 1: (dirname, sf, longname) = sfiles[0] - fn = wx.FileSelector("Export " + dirname, - dir, longname, "psu", - "EMS save file (.psu)|*.psu" - "|MAXDrive save file (.max)" - "|*.max", - (wx.FD_OVERWRITE_PROMPT - | wx.FD_SAVE), - self) + fn = wx.FileSelector( + "Export " + dirname, + dir, + longname, + "psu", + "EMS save file (.psu)|*.psu" + "|MAXDrive save file (.max)" + "|*.max", + (wx.FD_OVERWRITE_PROMPT | wx.FD_SAVE), + self, + ) if fn == "": return try: @@ -490,7 +888,7 @@ def evt_cmd_export(self, event): self.message_box("Exported " + fn + " successfully.") return - dir = wx.DirSelector("Export Save Files", dir, parent = self) + dir = wx.DirSelector("Export Save Files", dir, parent=self) if dir == "": return count = 0 @@ -506,9 +904,7 @@ def evt_cmd_export(self, event): if count > 0: if os.path.isabs(dir): self.config.set_savefile_dir(dir) - self.message_box("Exported %d file(s) successfully." - % count) - + self.message_box("Exported %d file(s) successfully." % count) def _do_import(self, fn): sf = ps2save.PS2SaveFile() @@ -524,22 +920,54 @@ def _do_import(self, fn): finally: f.close() - if not self.mc.import_save_file(sf, True): + force_import = self.config.get_force_import() + mc = self.mc + + if force_import: + # Use the directory name embedded in the save file itself, not + # the filename, since a .psu/.max/etc can extract to any name. + dir_ent = sf.get_directory() + dir_name = dir_ent[8].decode("ascii") + dirname = "/" + dir_name + + # Attempt to delete any existing save at that directory. + try: + mc.rmdir(dirname) + except EnvironmentError as value: + import errno + err = getattr(value, "errno", None) + # Ignore "no such file or directory"; surface other errors. + if err not in (None, errno.ENOENT): + self.mc_error(value, dirname) + return + + # After optional deletion, import as usual. If a save with the same + # directory name still exists, this will fail in the normal way. + if not mc.import_save_file(sf, True): self.error_box(fn + ": Save file already present.") def evt_cmd_import(self, event): - if self.mc == None: + if self.mc is None: return dir = self.config.get_savefile_dir("") - fd = wx.FileDialog(self, "Import Save File", dir, - wildcard = ("PS2 save files" - " (.cbs;.psu;.psv;.max;.sps;.xps)" - "|*.cbs;*.psu;.psv;*.max;*.sps;*.xps" - "|All files|*.*"), - style = (wx.FD_OPEN | wx.FD_MULTIPLE - | wx.FD_FILE_MUST_EXIST)) - if fd == None: + fd = wx.FileDialog( + self, + "Import Save File", + dir, + wildcard=( + "PS2 save files" + " (.cbs;.psu;.psv;.max;.sps;.xps)" + "|*.cbs;*.psu;.psv;*.max;*.sps;*.xps" + "|All files|*.*" + ), + style=( + wx.FD_OPEN + | wx.FD_MULTIPLE + | wx.FD_FILE_MUST_EXIST + ), + ) + if fd is None: return r = fd.ShowModal() if r == wx.ID_CANCEL: @@ -553,7 +981,7 @@ def evt_cmd_import(self, event): except EnvironmentError as value: self.mc_error(value, fn) - if success != None: + if success is not None: dir = os.path.dirname(success) if os.path.isabs(dir): self.config.set_savefile_dir(dir) @@ -562,10 +990,25 @@ def evt_cmd_import(self, event): def evt_cmd_delete(self, event): self.delete_selected() + def evt_cmd_select_all(self, event): + """Select all save entries in the list.""" + if self.mc is None: + return + count = self.dirlist.GetItemCount() + for i in range(count): + self.dirlist.Select(i, on=1) + def evt_cmd_ascii(self, event): self.config.set_ascii(not self.config.get_ascii()) self.refresh() + def evt_cmd_force_import(self, event): + new_value = not self.config.get_force_import() + self.config.set_force_import(new_value) + + # Update the status bar and menu checkmark to reflect the new value. + self._update_force_import_status() + def evt_cmd_exit(self, event): self.Close(True) @@ -573,13 +1016,15 @@ def evt_close(self, event): self._close_mc() self.Destroy() -def run(filename = None): + +def run(filename=None): """Display a GUI for working with memory card images.""" wx_app = wx.App() frame = GuiFrame(None, "mymc++", filename) return wx_app.MainLoop() + if __name__ == "__main__": import gc gc.set_debug(gc.DEBUG_LEAK) diff --git a/mymcplusplus/gui/resources.py b/mymcplusplus/gui/resources.py index ce4be0f..e5df879 100644 --- a/mymcplusplus/gui/resources.py +++ b/mymcplusplus/gui/resources.py @@ -65,26 +65,21 @@ "delete.png": base64.b64decode( b"iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAYAAADimHc4AAAAAXNSR0IB2cksfwAAAARnQU1BAACx" b"jwv8YQUAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAlwSFlz" - b"AAA7DgAAOw4BzLahgwAAAAd0SU1FB+kLFwYAIpZsg4sAAAQiSURBVHja7ZxLixRnFIafnhk1SXuJ" - b"CaR1objQjW5ETRZCVokhCVHiwqURBMXLyj/gfwgS1NGEiNnlRi7kRhLyA0SEILoQL2G8j07r2MYZ" - b"Z6Zd1Fum0tRMV/V0d01XvQ8U0wxNDXOe75w63+muAmOMMcYYY0zRKCV4Ty3nMShn+cf7vAazxQIs" - b"wAKMBViAsQALyJphYCewA7hlAd3lG2Cjfv6k1595J9z5nfAwcEiBj+N94AiwzDvhzq766ch9NmSR" - b"Ac1WPV3OhkJlQJJVX6hs6FYGtLrqu5ENuc+A2az63GdD0T8PqAMLvQ/IjklvxDyKMBZgAcYCLMBY" - b"gAUYC7AAYwEWYCzAAowFWICxAAswFmABxgJyyoBD8JzXgM3ASmAUOAv8DUy14bzrgH7gDFBNe4Ja" - b"k6Pe40fI68CPCtAV4CSwYZZVogJ8TfDNwPs65wJnQDxl4GVgkeLyrlb/MeBcC5lQAT4BtgBPJbsf" - b"GPM1IJ7rCnQVmA8sBbYCB4D1KWMVBv8dBR+dcx7woktQfAnqV8k5qZLxVMdwynJUAb4CHgL3dDzU" - b"7yqtrIyiCAgrQijhDvBEJeM2cCKBhLYHv2gCohJOAEPqiEb1eiYJHQl+EQVEJQwq8FVgRK8HYyR0" - b"LPhFFRCXCSPAA+BmwzUhDP4DtZph8L9sR/CLLKBRwnXgsa4LN5QJb6vPrylLxtWu/pA0+O24QeOl" - b"Hm8/SwkkrAf2A9u0X5iIyKioxSzp+A3YrUzBGTC7DJiuHEVX+xjBjR7j7Sw7FjC9hC+0+qd01CXg" - b"d2C5p6GdY0plZZlGFfVI+RojeL7F8rQxtYB044UjwBvAv5H5zpR20Vt0nUg1trCAdLOdDxTwCZWd" - b"a2o/B4AlBPcv70sjwQLSDdYm1AXNB34B9hDc/zyiWC6WhMSZ4HF0+qlmCfgVOAjc1eZrAPhQYsrA" - b"e3rv0RZH2e6CIjvc0SbjhT6CD3NOa2g3EhlbJBngWcAMwR/XZut+k9lOH7BJI4qhdksomoAw+GOq" - b"+VVdaJsN1hoHeG2TUCQB0cFaTQJGU0w14wZ4UQkb1bJaQIyAV4BvFfyqLrrjLYyUZ5JwGHjVbWg8" - b"84A1klLWz+/V7dxOuWM+pw7oZy3SPu0TVtLwdBa3of+xmOAbEWW1mt+1EPw4CXV1SZPAZZW0to6j" - b"e5kJrcyQj4BVwAvAxy0Gv7HCrANWS8Al4GJ0X2AB/xcQ0k+XniXka0A8XXuQkwVkjAVYgAUYC7CA" - b"mXiU4///cS8IGMqxgKu9IOCPHAv4sxcEnGL290nN1V3w6V4QcB74NIcCBgnmMplSSvi+BQTTwTdz" - b"Evy/gO0E8/5MSfrpzCTB9x6Xkv5+qbnEJHAc2DsXgp8mA6KsBXYBbwEryPjx7wmoAf+omfgcuODd" - b"hzHGGGOMMSZLngE6JCElptQmEwAAAABJRU5ErkJggg==" + b"AAA7DgAAOw4BzLahgwAAAAd0SU1FB+kLFwY1H2w1DSwAAAMCSURBVHja7ZvBaxNBFIe/pAVLG1JC" + b"PBRBEY8eiuBZELx58+BRvHnv3+JJQVE8CoIXb1X/A0+ixxJUeuiyFJqCpUk8ZIPDsEm2zezuzM7v" + b"g5IUyszkffPezttNQQghhBBCCBEbrRLGHDY8ZlsuB2trD9aLBEiABAgJkAAhARIQGkfAE+AxcCgB" + b"1fIBuJu9fsrev1YnXH4nfATsZYHP4yHwHNhRJ1zurp9HcNkQQgYs2/VUnA1RZUCRXR90NviaAZfd" + b"9VVkQ+MzYJVdH1w26HnAxZgAHfUB9TFSI6ZbEUICJEBIgAQICZAAIQESICRAAoQESICQAAkQEiAB" + b"QgIkQEiABIgirNcw55bnMRlWGS9lQOQlaGz8bFY4731r7qhKkL0BEqAHnFSwKbaB1Pg9BfqxX4T7" + b"RjDMrLjh+HOOjTlSa+7oT0FtIzCz4Bxk2bG9wrgb2RjnOeN78dnr+Hb0slNQYrzvrbBbk5wxioyz" + b"6vqDugYsKklmADFKSL9A4HsLxo2+D1hFRG+BCDvwqc+B97kEFSlN8yS52PHRl6BFGWHv8on16sXR" + b"sqkCzF09trK3lUkIrrMPbcGJ1blOrN0/nlOOlAGOgs+cI2WS87d9CXAXePtYadf5vOtD0WOrBBQM" + b"/LJj5UWPrRJQoIM1A38N+FvwQr0LfLFEJD72Bb71AXm3D24Bx5dciy2iSHNWaR/gi4C8On8TGDha" + b"0w7wZ8l1pBYBdR9DO1mNNstNL1vXwOE8h9mYm0YWzK4Pu7GWoA3g1Aj8VWCN/7eOy/zM14Fvhvh2" + b"jBnwG0i73W4rKwWTCoI/a94GQD+bO62zeQvpZlxVRHUNiJ51D3eYzzgvkcqAmpEACZAAIQESICRA" + b"Alxx0uB4nYYg4FeDBRyEIGC/wQI+hyDgLTX/00NJnAPvQhDwHXjVQAEvgZ+uB22VtNgrwEfgXkOC" + b"/xV4BJy5HnitpAWPgPdMnzjdCfi4OwJeAM/KCH6ZGWByG3gKPGD6KLDjedCHTJ+Y7QNvgB/qVoQQ" + b"QgghhBAu+QetvagK8sI7vQAAAABJRU5ErkJggg==" ), "icon.png": base64.b64decode( b"iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAYAAADimHc4AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz"