ViewVC Help
View File | Revision Log | Show Annotations | View Changeset | Root Listing
root/ns_dev/Python/NinoCode/Active_prgs/Gromulus/Gromulus_UI.py
Revision: 990
Committed: Mon Mar 9 21:20:37 2026 UTC (2 weeks, 5 days ago) by nino.borges
Content type: text/x-python
File size: 35545 byte(s)
Log Message:
Gromulus v1.5: user metadata save workflow, schema expansion, and UI refactor

- Added user-editable game metadata workflow with explicit Save button (no autosave).
- Added dirty-state tracking and Save/Discard/Cancel prompts when navigating away or closing with unsaved changes.
- Added DB schema support for new metadata fields:
  - 
  - , 
  - , 
  - , 
  - , 
- Wired UI to user-first metadata precedence with  fallback for release date, genre, cooperative, max players, and description.
- Added release date display/storage conversion:
  - GUI display 
  - DB storage 
- Refactored main game info panel:
  - moved hash/file/No-Intro/TOSEC detail fields into  modal
  - added compact TOSEC/NoIntro match indicators
  - added Favorite, Release Date, Genre, Cooperative, Max Players, Description, and User Notes controls
- Enhanced artwork previews from prior update:
  - larger preview boxes
  - aspect-ratio-preserving scaling
  - click-to-open full-size modal viewer
- Updated schema/documentation files to stay aligned:
  - 
  - 
  - canonical DB utility schema/migration logic

File Contents

# User Rev Content
1 nino.borges 795 """
2     Created by Emanuel Borges
3     05.19.2023
4    
5     This is the main UI for Gromulus, which is a catalog inventory for roms, games and maybe one day applications.
6     Like most of my GUI programs, this will import a separate library with my methods for doing the actual work.
7    
8     """
9    
10    
11     import sys, os, wx
12 nino.borges 990 from datetime import datetime
13 nino.borges 795 #import wx.lib.buttons as buttons
14 nino.borges 979 import wx.lib.agw.pybusyinfo as PBI
15 nino.borges 795 import Gromulus_Lib
16    
17    
18    
19     class MyFrame(wx.Frame):
20     def __init__(self, parent, ID, title, pos=wx.DefaultPosition):
21 nino.borges 979 wx.Frame.__init__(self, parent, ID, title, pos, size = (1450,925))
22 nino.borges 909 self.db = Gromulus_Lib.DatabaseManager()
23 nino.borges 976 self.dbConnection = self.db.get_connection()
24 nino.borges 990 Gromulus_Lib.EnsureMetadataSchema(self.dbConnection)
25     self.currentGameData = None
26     self._isDirty = False
27     self._suspendFieldEvents = False
28 nino.borges 795 self.panel = wx.Panel(self,-1)
29 nino.borges 976 self.currentSystemKey = "SNES"
30     self.gamesListMatrix = Gromulus_Lib.GetGameListBySystem(self.currentSystemKey, self.dbConnection)
31 nino.borges 795 gamesList = list(self.gamesListMatrix.keys())
32     gamesList.sort()
33 nino.borges 806 self.gamesCount = len(gamesList)
34     unmatchedGamesList=[i for i in gamesList if "." in i]
35     self.unmatchedGamesCount = len(unmatchedGamesList)
36     print(f"{self.gamesCount} total games for this system. {self.gamesCount - self.unmatchedGamesCount} matched and {self.unmatchedGamesCount} unmatched.")
37 nino.borges 795
38     self.CreateSystemButtonSection()
39 nino.borges 979 self.gameSelectionListBox = wx.ListBox(self.panel, 60, (100, 50), (490, 500), gamesList , wx.LB_SINGLE|wx.LB_OWNERDRAW)
40 nino.borges 795 self.CreateFieldsFirstRow()
41 nino.borges 979 self.CreateArtworkPreviewSection()
42 nino.borges 795
43    
44    
45     mainSizer = wx.BoxSizer(wx.HORIZONTAL)
46     mainSizer.Add(self.buttonSizer, 0, wx.ALIGN_TOP|wx.LEFT|wx.TOP,25)
47     mainSizer.Add(self.gameSelectionListBox, 0, wx.ALIGN_TOP|wx.LEFT|wx.TOP,25)
48     mainSizer.Add(self.fieldsFirstRowSizer, 0, wx.ALIGN_TOP|wx.LEFT|wx.TOP,25)
49    
50 nino.borges 979 rootSizer = wx.BoxSizer(wx.VERTICAL)
51     rootSizer.Add(mainSizer, 0, wx.EXPAND)
52     rootSizer.AddStretchSpacer(1)
53     rootSizer.Add(self.artworkPreviewSizer, 0, wx.ALIGN_CENTER|wx.ALL, 15)
54     self.panel.SetSizer(rootSizer)
55 nino.borges 795
56 nino.borges 979
57 nino.borges 795 self.CreateStatusBar()
58     self.SetStatusText("Ready.")
59     self.CreateMenuBar()
60    
61     self.Bind(wx. EVT_TOGGLEBUTTON, self.OnSystemSelected, self.snesSystemButton)
62     self.Bind(wx. EVT_TOGGLEBUTTON, self.OnSystemSelected, self.nesSystemButton)
63     self.Bind(wx. EVT_TOGGLEBUTTON, self.OnSystemSelected, self.genesisSystemButton)
64     self.Bind(wx. EVT_TOGGLEBUTTON, self.OnSystemSelected, self.fz1SystemButton)
65     self.Bind(wx. EVT_TOGGLEBUTTON, self.OnSystemSelected, self.ps1SystemButton)
66    
67     self.Bind(wx.EVT_LISTBOX, self.OnGameSelected, self.gameSelectionListBox)
68 nino.borges 990 self.Bind(wx.EVT_CLOSE, self.OnCloseWindow)
69 nino.borges 795
70 nino.borges 976 def _set_text(self, text_ctrl, value):
71     text_ctrl.SetValue(value if value else "")
72 nino.borges 795
73 nino.borges 990 def _set_match_indicator(self, indicator_ctrl, is_match):
74     indicator_ctrl.SetValue(bool(is_match))
75    
76     def _is_yes(self, value):
77     return str(value).strip().lower() in {"1", "true", "yes", "y"}
78    
79     def _coalesce(self, primary, fallback):
80     return primary if primary else fallback
81    
82     def _format_date_for_display(self, value):
83     if not value:
84     return ""
85     try:
86     return datetime.strptime(value, "%Y-%m-%d").strftime("%m-%d-%Y")
87     except ValueError:
88     return value
89    
90     def _format_date_for_storage(self, value):
91     cleaned = (value or "").strip()
92     if not cleaned:
93     return ""
94     for fmt in ("%m-%d-%Y", "%Y-%m-%d"):
95     try:
96     return datetime.strptime(cleaned, fmt).strftime("%Y-%m-%d")
97     except ValueError:
98     continue
99     raise ValueError("Release Date must use MM-DD-YYYY.")
100    
101     def _set_dirty(self, is_dirty):
102     self._isDirty = is_dirty
103     self.saveGameButton.Enable(is_dirty)
104    
105     def _reset_main_game_fields(self):
106     self.currentGameData = None
107     self._suspendFieldEvents = True
108     try:
109     self._set_text(self.gameNameTextCtrl, "")
110     self.favoriteGameCheckBox.SetValue(False)
111     self._set_text(self.gameReleaseDateTextCtrl, "")
112     self._set_text(self.gameGenreTextCtrl, "")
113     self.cooperativeChoice.SetSelection(0)
114     self._set_text(self.maxPlayersTextCtrl, "")
115     self._set_text(self.gameDescriptionTextCtrl, "")
116     self._set_text(self.userNotesTextCtrl, "")
117     self._set_match_indicator(self.tosecMatchIndicator, False)
118     self._set_match_indicator(self.noIntroMatchIndicator, False)
119     finally:
120     self._suspendFieldEvents = False
121     self._set_dirty(False)
122    
123     def _populate_main_game_fields(self, game_data):
124     display_name = game_data.get("game_name") or game_data.get("no_intro_game") or game_data.get("tosec_game") or game_data.get("filename")
125     self._suspendFieldEvents = True
126     try:
127     self._set_text(self.gameNameTextCtrl, display_name)
128     self.favoriteGameCheckBox.SetValue(self._is_yes(game_data.get("favorite_game")))
129     self._set_text(self.gameReleaseDateTextCtrl, self._format_date_for_display(self._coalesce(game_data.get("release_date"), game_data.get("release_date_scraped"))))
130     self._set_text(self.gameGenreTextCtrl, self._coalesce(game_data.get("game_genre"), game_data.get("game_genre_scraped")))
131     cooperative_value = self._coalesce(game_data.get("cooperative"), game_data.get("cooperative_scraped"))
132     if cooperative_value in ("Yes", "No"):
133     self.cooperativeChoice.SetStringSelection(cooperative_value)
134     else:
135     self.cooperativeChoice.SetSelection(0)
136     self._set_text(self.maxPlayersTextCtrl, self._coalesce(game_data.get("max_players"), game_data.get("max_players_scraped")))
137     self._set_text(self.gameDescriptionTextCtrl, self._coalesce(game_data.get("description"), game_data.get("description_scraped")))
138     self._set_text(self.userNotesTextCtrl, game_data.get("user_notes"))
139     self._set_match_indicator(self.tosecMatchIndicator, bool(game_data.get("tosec_game")))
140     self._set_match_indicator(self.noIntroMatchIndicator, bool(game_data.get("no_intro_game")))
141     finally:
142     self._suspendFieldEvents = False
143     self._set_dirty(False)
144    
145     def _restore_selection_by_game_id(self, game_id):
146     if not game_id:
147     self.gameSelectionListBox.SetSelection(wx.NOT_FOUND)
148     return
149     for label, mapped_id in self.gamesListMatrix.items():
150     if mapped_id == game_id:
151     self.gameSelectionListBox.SetStringSelection(label)
152     return
153     self.gameSelectionListBox.SetSelection(wx.NOT_FOUND)
154    
155     def _confirm_discard_or_save_pending_changes(self):
156     if not self._isDirty:
157     return True
158     dlg = wx.MessageDialog(
159     self,
160     "You have unsaved changes for this game.\nDo you want to save before continuing?",
161     "Unsaved Changes",
162     wx.YES_NO | wx.CANCEL | wx.ICON_WARNING,
163     )
164     dlg.SetYesNoCancelLabels("Save", "Discard", "Cancel")
165     choice = dlg.ShowModal()
166     dlg.Destroy()
167     if choice == wx.ID_YES:
168     return self._save_current_game_changes(show_message=False)
169     if choice == wx.ID_NO:
170     self._set_dirty(False)
171     return True
172     return False
173    
174     def _save_current_game_changes(self, show_message=False):
175     if not self.currentGameData:
176     return False
177     try:
178     release_date_value = self._format_date_for_storage(self.gameReleaseDateTextCtrl.GetValue())
179     except ValueError as exc:
180     wx.MessageBox(str(exc), "Invalid Release Date", wx.OK | wx.ICON_ERROR)
181     return False
182    
183     cooperative_value = self.cooperativeChoice.GetStringSelection()
184     values = {
185     "favorite_game": 1 if self.favoriteGameCheckBox.GetValue() else 0,
186     "release_date": release_date_value,
187     "game_genre": self.gameGenreTextCtrl.GetValue().strip(),
188     "cooperative": cooperative_value if cooperative_value in ("Yes", "No") else "",
189     "max_players": self.maxPlayersTextCtrl.GetValue().strip(),
190     "description": self.gameDescriptionTextCtrl.GetValue().strip(),
191     "user_notes": self.userNotesTextCtrl.GetValue().strip(),
192     }
193     Gromulus_Lib.SaveGameUserProperties(self.currentGameData["id"], values, self.dbConnection)
194     self.currentGameData = Gromulus_Lib.GetSingleGameById(self.currentGameData["id"], self.dbConnection)
195     if self.currentGameData:
196     self._populate_main_game_fields(self.currentGameData)
197     if show_message:
198     wx.MessageBox("Game changes saved.", "Save Complete", wx.OK | wx.ICON_INFORMATION)
199     return True
200    
201     def OnMainFieldChanged(self, evt):
202     if self._suspendFieldEvents or not self.currentGameData:
203     evt.Skip()
204     return
205     self._set_dirty(True)
206     evt.Skip()
207    
208 nino.borges 979 def _load_scaled_bitmap(self, image_path, width, height):
209     if not image_path or not os.path.isfile(image_path):
210     return wx.Bitmap(width, height)
211     image = wx.Image(image_path, wx.BITMAP_TYPE_ANY)
212     if not image.IsOk():
213     return wx.Bitmap(width, height)
214    
215 nino.borges 987 src_w, src_h = image.GetSize()
216     if src_w <= 0 or src_h <= 0:
217     return wx.Bitmap(width, height)
218    
219     # Preserve aspect ratio and letterbox inside the preview area.
220     scale = min(width / src_w, height / src_h)
221     dst_w = max(1, int(src_w * scale))
222     dst_h = max(1, int(src_h * scale))
223     scaled_image = image.Scale(dst_w, dst_h, wx.IMAGE_QUALITY_HIGH)
224    
225     canvas = wx.Bitmap(width, height)
226     dc = wx.MemoryDC(canvas)
227     dc.SetBackground(wx.Brush(self.panel.GetBackgroundColour()))
228     dc.Clear()
229     x = (width - dst_w) // 2
230     y = (height - dst_h) // 2
231     dc.DrawBitmap(wx.Bitmap(scaled_image), x, y, True)
232     dc.SelectObject(wx.NullBitmap)
233     return canvas
234    
235 nino.borges 979 def _set_artwork_preview(self, bitmap_ctrl, image_path):
236 nino.borges 987 self.previewImagePathByCtrl[bitmap_ctrl] = image_path if image_path and os.path.isfile(image_path) else None
237 nino.borges 979 width, height = bitmap_ctrl.GetSize()
238     bitmap_ctrl.SetBitmap(self._load_scaled_bitmap(image_path, width, height))
239    
240     def _clear_artwork_previews(self):
241     self._set_artwork_preview(self.boxArtBitmap, None)
242     self._set_artwork_preview(self.titleArtBitmap, None)
243     self._set_artwork_preview(self.ingameArtBitmap, None)
244    
245 nino.borges 987 def _show_full_image_modal(self, title, image_path):
246     if not image_path or not os.path.isfile(image_path):
247     wx.MessageBox("No artwork file is available for this preview.", "Artwork Preview", wx.OK | wx.ICON_INFORMATION)
248     return
249    
250     image = wx.Image(image_path, wx.BITMAP_TYPE_ANY)
251     if not image.IsOk():
252     wx.MessageBox("Unable to load selected artwork file.", "Artwork Preview", wx.OK | wx.ICON_ERROR)
253     return
254    
255     bmp = wx.Bitmap(image)
256     img_w, img_h = bmp.GetSize()
257     display_w, display_h = wx.GetDisplaySize()
258     dialog_w = min(max(640, img_w + 60), int(display_w * 0.9))
259     dialog_h = min(max(480, img_h + 120), int(display_h * 0.9))
260    
261     dlg = wx.Dialog(self, title=title, size=(dialog_w, dialog_h), style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
262     panel = wx.Panel(dlg)
263     sizer = wx.BoxSizer(wx.VERTICAL)
264    
265     scroller = wx.ScrolledWindow(panel, style=wx.HSCROLL | wx.VSCROLL)
266     scroller.SetScrollRate(10, 10)
267     wx.StaticBitmap(scroller, -1, bmp, pos=(0, 0))
268     scroller.SetVirtualSize((img_w, img_h))
269    
270     close_btn = wx.Button(panel, wx.ID_OK, "Close")
271     sizer.Add(scroller, 1, wx.EXPAND | wx.ALL, 8)
272     sizer.Add(close_btn, 0, wx.ALIGN_RIGHT | wx.RIGHT | wx.BOTTOM, 8)
273     panel.SetSizer(sizer)
274    
275     dlg.ShowModal()
276     dlg.Destroy()
277    
278     def OnArtworkPreviewClicked(self, evt):
279     ctrl = evt.GetEventObject()
280     image_path = self.previewImagePathByCtrl.get(ctrl)
281     title = self.previewTitleByCtrl.get(ctrl, "Artwork Preview")
282     self._show_full_image_modal(title, image_path)
283    
284 nino.borges 979 def _run_with_busy(self, message, func, *args, **kwargs):
285     busy = PBI.PyBusyInfo(message, parent=self, title="System Busy: Please Wait.")
286     wx.YieldIfNeeded()
287     try:
288     return func(*args, **kwargs)
289     finally:
290     del busy
291    
292 nino.borges 976 def _load_games_for_system(self, system_key):
293     self.currentSystemKey = system_key
294     self.gamesListMatrix = Gromulus_Lib.GetGameListBySystem(system_key, self.dbConnection)
295     gamesList = list(self.gamesListMatrix.keys())
296     gamesList.sort()
297    
298     self.gameSelectionListBox.Clear()
299     self.gameSelectionListBox.AppendItems(gamesList)
300    
301 nino.borges 990 self.gameSelectionListBox.SetSelection(wx.NOT_FOUND)
302     self._reset_main_game_fields()
303 nino.borges 979 self._clear_artwork_previews()
304 nino.borges 976
305 nino.borges 979 def _choose_system(self, prompt_title, only_with_roms=False):
306     systemMatrix = (
307     Gromulus_Lib.GetSystemListWithRoms(self.dbConnection)
308     if only_with_roms
309     else Gromulus_Lib.GetSystemList(self.dbConnection)
310     )
311 nino.borges 978 systemsList = list(systemMatrix.keys())
312     if not systemsList:
313 nino.borges 979 wx.MessageBox("No eligible systems found.", "System Selection", wx.OK | wx.ICON_WARNING)
314 nino.borges 978 return None
315 nino.borges 976
316 nino.borges 979 dlg = wx.SingleChoiceDialog(self, "Select system", prompt_title, systemsList)
317 nino.borges 978 if dlg.ShowModal() != wx.ID_OK:
318     return None
319     selected_name = dlg.GetStringSelection()
320     return systemMatrix[selected_name][0]
321    
322    
323 nino.borges 795 def CreateSystemButtonSection(self):
324     #systemsList = ['SNES','NES','Genisys']
325     #for system in systemsList:
326     #self.snesSystemButton = buttons.GenToggleButton(self.panel, -1, "SNES")
327     #self.nesSystemButton = buttons.GenToggleButton(self.panel, -1, "NES")
328     #self.genesisSystemButton = buttons.GenToggleButton(self.panel, -1, "Genesis")
329     #self.ps1SystemButton = buttons.GenToggleButton(self.panel, -1, "Playstation")
330    
331     ## Create a dictionary that holds the button instances by label text, so that you can toggle the other ones off in the bind event.
332     self.systemButtonDict = {}
333 nino.borges 976 self.buttonSystemMap = {}
334 nino.borges 795 self.snesSystemButton = wx.ToggleButton(self.panel, -1, "SNES")
335     self.systemButtonDict[self.snesSystemButton.GetLabelText()] = self.snesSystemButton
336 nino.borges 976 self.buttonSystemMap[self.snesSystemButton.GetLabelText()] = "SNES"
337 nino.borges 795
338     self.nesSystemButton = wx.ToggleButton(self.panel, -1, "NES")
339     self.systemButtonDict[self.nesSystemButton.GetLabelText()] = self.nesSystemButton
340 nino.borges 976 self.buttonSystemMap[self.nesSystemButton.GetLabelText()] = "NES"
341 nino.borges 795
342     self.genesisSystemButton = wx.ToggleButton(self.panel, -1, "Genesis")
343     self.systemButtonDict[self.genesisSystemButton.GetLabelText()] = self.genesisSystemButton
344 nino.borges 976 self.buttonSystemMap[self.genesisSystemButton.GetLabelText()] = "Genesis"
345 nino.borges 795
346     self.fz1SystemButton = wx.ToggleButton(self.panel, -1, "3DO")
347     self.systemButtonDict[self.fz1SystemButton.GetLabelText()] = self.fz1SystemButton
348 nino.borges 976 self.buttonSystemMap[self.fz1SystemButton.GetLabelText()] = "3DO"
349 nino.borges 795
350     self.ps1SystemButton = wx.ToggleButton(self.panel, -1, "Playstation")
351     self.systemButtonDict[self.ps1SystemButton.GetLabelText()] = self.ps1SystemButton
352 nino.borges 976 self.buttonSystemMap[self.ps1SystemButton.GetLabelText()] = "Playstation"
353     self.snesSystemButton.SetValue(True)
354 nino.borges 795
355    
356     self.buttonSizer = wx.BoxSizer(wx.VERTICAL)
357     self.buttonSizer.Add(self.snesSystemButton, 0, wx.ALL,10)
358     self.buttonSizer.Add(self.nesSystemButton,0,wx.ALL,10)
359     self.buttonSizer.Add(self.genesisSystemButton,0,wx.ALL,10)
360     self.buttonSizer.Add(self.fz1SystemButton,0,wx.ALL,10)
361     self.buttonSizer.Add(self.ps1SystemButton,0,wx.ALL,10)
362    
363     def CreateFieldsFirstRow(self):
364     self.gameNameStaticText = wx.StaticText(self.panel, -1, "Game Name:")
365 nino.borges 990 self.gameNameTextCtrl = wx.TextCtrl(self.panel, -1, size=(325, -1), style=wx.TE_READONLY)
366 nino.borges 795
367 nino.borges 990 self.tosecMatchLabel = wx.StaticText(self.panel, -1, "TOSEC")
368     self.tosecMatchIndicator = wx.RadioButton(self.panel, -1, "", style=wx.RB_SINGLE)
369     self.tosecMatchIndicator.Disable()
370     self.noIntroMatchLabel = wx.StaticText(self.panel, -1, "NoIntro")
371     self.noIntroMatchIndicator = wx.RadioButton(self.panel, -1, "", style=wx.RB_SINGLE)
372     self.noIntroMatchIndicator.Disable()
373 nino.borges 795
374 nino.borges 990 compact_font = self.tosecMatchLabel.GetFont()
375     compact_font.SetPointSize(max(6, compact_font.GetPointSize() - 3))
376     self.tosecMatchLabel.SetFont(compact_font)
377     self.noIntroMatchLabel.SetFont(compact_font)
378 nino.borges 795
379 nino.borges 990 self.favoriteGameCheckBox = wx.CheckBox(self.panel, -1, "Favorite Game")
380 nino.borges 795
381 nino.borges 990 self.gameReleaseDateStaticText = wx.StaticText(self.panel, -1, "Release Date:")
382     self.gameReleaseDateTextCtrl = wx.TextCtrl(self.panel, -1, size=(110, -1))
383 nino.borges 795
384 nino.borges 990 self.gameGenreStaticText = wx.StaticText(self.panel, -1, "Game Genre:")
385     self.gameGenreTextCtrl = wx.TextCtrl(self.panel, -1, size=(325, -1))
386 nino.borges 795
387 nino.borges 990 self.cooperativeStaticText = wx.StaticText(self.panel, -1, "Cooperative:")
388     self.cooperativeChoice = wx.Choice(self.panel, -1, choices=["", "Yes", "No"], size=(120, -1))
389     self.cooperativeChoice.SetSelection(0)
390 nino.borges 976
391 nino.borges 990 self.maxPlayersStaticText = wx.StaticText(self.panel, -1, "Max Players:")
392     self.maxPlayersTextCtrl = wx.TextCtrl(self.panel, -1, size=(120, -1))
393    
394     self.gameDescriptionStaticText = wx.StaticText(self.panel, -1, "Description:")
395     self.gameDescriptionTextCtrl = wx.TextCtrl(self.panel, -1, size=(430, 80), style=wx.TE_MULTILINE)
396    
397     self.userNotesStaticText = wx.StaticText(self.panel, -1, "User Notes:")
398     self.userNotesTextCtrl = wx.TextCtrl(self.panel, -1, size=(430, 90), style=wx.TE_MULTILINE)
399     self.saveGameButton = wx.Button(self.panel, -1, "Save", size=(80, -1))
400     self.saveGameButton.Disable()
401    
402 nino.borges 795 self.gameNameSizer = wx.BoxSizer(wx.HORIZONTAL)
403     self.gameNameSizer.Add(self.gameNameStaticText,0,wx.ALL, 10)
404     self.gameNameSizer.Add(self.gameNameTextCtrl,0,wx.ALL, 10)
405    
406 nino.borges 990 self.matchIndicatorLineSizer = wx.BoxSizer(wx.HORIZONTAL)
407     self.matchIndicatorLineSizer.AddStretchSpacer(1)
408     self.matchIndicatorLineSizer.Add(self.tosecMatchLabel, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 4)
409     self.matchIndicatorLineSizer.Add(self.tosecMatchIndicator, 0, wx.ALIGN_CENTER_VERTICAL)
410     self.matchIndicatorLineSizer.AddSpacer(18) # ~3 spaces visual gap
411     self.matchIndicatorLineSizer.Add(self.noIntroMatchLabel, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 4)
412     self.matchIndicatorLineSizer.Add(self.noIntroMatchIndicator, 0, wx.ALIGN_CENTER_VERTICAL)
413 nino.borges 795
414 nino.borges 990 self.favoriteAndReleaseSizer = wx.BoxSizer(wx.HORIZONTAL)
415     self.favoriteAndReleaseSizer.Add(self.favoriteGameCheckBox, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 10)
416     self.favoriteAndReleaseSizer.AddStretchSpacer(1)
417     self.favoriteAndReleaseSizer.Add(self.gameReleaseDateStaticText, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 10)
418     self.favoriteAndReleaseSizer.Add(self.gameReleaseDateTextCtrl, 0, wx.ALL, 10)
419 nino.borges 795
420 nino.borges 990 self.genreSizer = wx.BoxSizer(wx.HORIZONTAL)
421     self.genreSizer.Add(self.gameGenreStaticText, 0, wx.ALL, 10)
422     self.genreSizer.Add(self.gameGenreTextCtrl, 0, wx.ALL, 10)
423 nino.borges 795
424 nino.borges 990 self.coopAndPlayersSizer = wx.BoxSizer(wx.HORIZONTAL)
425     self.coopAndPlayersSizer.Add(self.cooperativeStaticText, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 10)
426     self.coopAndPlayersSizer.Add(self.cooperativeChoice, 0, wx.ALL, 10)
427     self.coopAndPlayersSizer.Add(self.maxPlayersStaticText, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 10)
428     self.coopAndPlayersSizer.Add(self.maxPlayersTextCtrl, 0, wx.ALL, 10)
429 nino.borges 795
430 nino.borges 990 self.descriptionSizer = wx.BoxSizer(wx.HORIZONTAL)
431     self.descriptionSizer.Add(self.gameDescriptionStaticText, 0, wx.ALL, 10)
432     self.descriptionSizer.Add(self.gameDescriptionTextCtrl, 0, wx.ALL, 10)
433 nino.borges 795
434 nino.borges 990 self.userNotesSizer = wx.BoxSizer(wx.HORIZONTAL)
435     self.userNotesSizer.Add(self.userNotesStaticText, 0, wx.ALL, 10)
436     self.userNotesSizer.Add(self.userNotesTextCtrl, 0, wx.ALL, 10)
437 nino.borges 976
438 nino.borges 990 self.saveButtonSizer = wx.BoxSizer(wx.HORIZONTAL)
439     self.saveButtonSizer.AddStretchSpacer(1)
440     self.saveButtonSizer.Add(self.saveGameButton, 0, wx.RIGHT | wx.BOTTOM, 10)
441    
442 nino.borges 795 self.fieldsFirstRowSizer = wx.BoxSizer(wx.VERTICAL)
443 nino.borges 990 self.fieldsFirstRowSizer.Add(self.matchIndicatorLineSizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 10)
444     self.fieldsFirstRowSizer.Add(self.gameNameSizer, 0, wx.ALL, 10)
445     self.fieldsFirstRowSizer.Add(self.favoriteAndReleaseSizer, 0, wx.EXPAND)
446     self.fieldsFirstRowSizer.Add(self.genreSizer, 0, wx.ALL, 0)
447     self.fieldsFirstRowSizer.Add(self.coopAndPlayersSizer, 0, wx.ALL, 0)
448     self.fieldsFirstRowSizer.Add(self.descriptionSizer, 0, wx.ALL, 0)
449     self.fieldsFirstRowSizer.Add(self.userNotesSizer, 0, wx.ALL, 0)
450     self.fieldsFirstRowSizer.Add(self.saveButtonSizer, 0, wx.EXPAND)
451 nino.borges 795
452 nino.borges 990 self.favoriteGameCheckBox.Bind(wx.EVT_CHECKBOX, self.OnMainFieldChanged)
453     self.gameReleaseDateTextCtrl.Bind(wx.EVT_TEXT, self.OnMainFieldChanged)
454     self.gameGenreTextCtrl.Bind(wx.EVT_TEXT, self.OnMainFieldChanged)
455     self.cooperativeChoice.Bind(wx.EVT_CHOICE, self.OnMainFieldChanged)
456     self.maxPlayersTextCtrl.Bind(wx.EVT_TEXT, self.OnMainFieldChanged)
457     self.gameDescriptionTextCtrl.Bind(wx.EVT_TEXT, self.OnMainFieldChanged)
458     self.userNotesTextCtrl.Bind(wx.EVT_TEXT, self.OnMainFieldChanged)
459     self.saveGameButton.Bind(wx.EVT_BUTTON, self.OnSaveGame)
460     self._reset_main_game_fields()
461    
462 nino.borges 979 def CreateArtworkPreviewSection(self):
463 nino.borges 987 preview_size = (315, 189)
464 nino.borges 795
465 nino.borges 979 boxSizer = wx.StaticBoxSizer(wx.VERTICAL, self.panel, "Box Art")
466     self.boxArtBitmap = wx.StaticBitmap(self.panel, -1, wx.Bitmap(*preview_size), size=preview_size)
467     boxSizer.Add(self.boxArtBitmap, 0, wx.ALL, 8)
468    
469     titleSizer = wx.StaticBoxSizer(wx.VERTICAL, self.panel, "Title Art")
470     self.titleArtBitmap = wx.StaticBitmap(self.panel, -1, wx.Bitmap(*preview_size), size=preview_size)
471     titleSizer.Add(self.titleArtBitmap, 0, wx.ALL, 8)
472    
473     ingameSizer = wx.StaticBoxSizer(wx.VERTICAL, self.panel, "Ingame Art")
474     self.ingameArtBitmap = wx.StaticBitmap(self.panel, -1, wx.Bitmap(*preview_size), size=preview_size)
475     ingameSizer.Add(self.ingameArtBitmap, 0, wx.ALL, 8)
476    
477     self.artworkPreviewSizer = wx.BoxSizer(wx.HORIZONTAL)
478     self.artworkPreviewSizer.Add(boxSizer, 0, wx.ALL, 10)
479     self.artworkPreviewSizer.Add(titleSizer, 0, wx.ALL, 10)
480     self.artworkPreviewSizer.Add(ingameSizer, 0, wx.ALL, 10)
481 nino.borges 987
482     self.previewImagePathByCtrl = {}
483     self.previewTitleByCtrl = {
484     self.boxArtBitmap: "Box Art Preview",
485     self.titleArtBitmap: "Title Art Preview",
486     self.ingameArtBitmap: "Ingame Art Preview",
487     }
488     self.boxArtBitmap.SetCursor(wx.Cursor(wx.CURSOR_HAND))
489     self.titleArtBitmap.SetCursor(wx.Cursor(wx.CURSOR_HAND))
490     self.ingameArtBitmap.SetCursor(wx.Cursor(wx.CURSOR_HAND))
491     self.boxArtBitmap.Bind(wx.EVT_LEFT_UP, self.OnArtworkPreviewClicked)
492     self.titleArtBitmap.Bind(wx.EVT_LEFT_UP, self.OnArtworkPreviewClicked)
493     self.ingameArtBitmap.Bind(wx.EVT_LEFT_UP, self.OnArtworkPreviewClicked)
494    
495 nino.borges 979 self._clear_artwork_previews()
496    
497    
498 nino.borges 795 def MenuData(self):
499     return(("&Tools",
500 nino.borges 805 ("&Add Roms","Adds new Roms to a selected system.", self.OnAddNewRoms),
501 nino.borges 979 ("Add &Artwork","Adds artwork files to matched games for a selected system.", self.OnAddNewArtwork),
502 nino.borges 805 ("Import &NoIntro DAT", "Allows for the import of any of the No Intro DAT files.", self.OnImportNewNoIntroDat),
503 nino.borges 806 ("Import &TOSEC DAT", "Allows for the import of any of the TOSEC DAT files.", self.OnImportNewTosecDat)),
504 nino.borges 795 ("&Reports",
505 nino.borges 990 ("Game &Properties", "Opens a window showing detailed properties for the selected game.", self.OnOpenGameProperties),
506 nino.borges 795 ("&Duplicates Report","Generates a report detailing duplicates that exist in your collection.", self.NothingYet)),
507     ("&Help",
508     ("&About", "Displays the About Window", self.NothingYet)))
509    
510     def CreateMenuBar(self):
511     menuBar = wx.MenuBar()
512     for eachMenuData in self.MenuData():
513     menuLabel = eachMenuData[0]
514     menuItems = eachMenuData[1:]
515     menuBar.Append(self.CreateMenu(menuItems), menuLabel)
516     self.SetMenuBar(menuBar)
517    
518     def CreateMenu(self, menuData):
519     menu = wx.Menu()
520     for eachLabel, eachStatus, eachHandler in menuData:
521     if not eachLabel:
522     menu.AppendSeparator()
523     continue
524     menuItem = menu.Append(-1, eachLabel, eachStatus)
525     self.Bind(wx.EVT_MENU, eachHandler, menuItem)
526     return menu
527    
528     def OnSystemSelected(self, evt):
529 nino.borges 976 if not evt.GetEventObject().GetValue():
530     evt.GetEventObject().SetValue(True)
531     return
532    
533 nino.borges 990 target_system_key = self.buttonSystemMap.get(evt.GetEventObject().GetLabelText(), "SNES")
534     if target_system_key != self.currentSystemKey and not self._confirm_discard_or_save_pending_changes():
535     evt.GetEventObject().SetValue(False)
536     for label, btn in self.systemButtonDict.items():
537     btn.SetValue(self.buttonSystemMap.get(label) == self.currentSystemKey)
538     return
539    
540 nino.borges 795 systemsButtonNamesList = list(self.systemButtonDict.keys())
541     systemsButtonNamesList.remove(evt.GetEventObject().GetLabelText())
542     for i in systemsButtonNamesList:
543     self.systemButtonDict[i].SetValue(False)
544    
545 nino.borges 990 self._run_with_busy("Loading games for selected system...", self._load_games_for_system, target_system_key)
546 nino.borges 795
547    
548     def OnGameSelected(self, evt):
549     print(self.gameSelectionListBox.GetStringSelection())
550 nino.borges 976 selected = self.gameSelectionListBox.GetStringSelection()
551     if not selected:
552     return
553 nino.borges 795
554 nino.borges 990 next_game_id = self.gamesListMatrix[selected]
555     previous_game_id = self.currentGameData.get("id") if self.currentGameData else None
556     if previous_game_id and next_game_id != previous_game_id and not self._confirm_discard_or_save_pending_changes():
557     self._restore_selection_by_game_id(previous_game_id)
558     return
559    
560     gameData = Gromulus_Lib.GetSingleGameById(next_game_id, self.dbConnection)
561 nino.borges 976 if not gameData:
562     return
563 nino.borges 795
564 nino.borges 990 self.currentGameData = gameData
565     self._populate_main_game_fields(gameData)
566 nino.borges 979 self._set_artwork_preview(self.boxArtBitmap, gameData.get("box_art_path"))
567     self._set_artwork_preview(self.titleArtBitmap, gameData.get("title_art_path"))
568     self._set_artwork_preview(self.ingameArtBitmap, gameData.get("ingame_art_path"))
569 nino.borges 976
570 nino.borges 990 def OnSaveGame(self, _event):
571     self._save_current_game_changes(show_message=True)
572    
573     def OnOpenGameProperties(self, event):
574     selected = self.gameSelectionListBox.GetStringSelection()
575     if not selected:
576     wx.MessageBox("Select a game first to view properties.", "Game Properties", wx.OK | wx.ICON_INFORMATION)
577     return
578    
579     game_data = self.currentGameData
580     if not game_data or self.gamesListMatrix.get(selected) != game_data.get("id"):
581     game_data = Gromulus_Lib.GetSingleGameById(self.gamesListMatrix[selected], self.dbConnection)
582     self.currentGameData = game_data
583     if not game_data:
584     wx.MessageBox("Unable to load game properties.", "Game Properties", wx.OK | wx.ICON_ERROR)
585     return
586    
587     dlg = wx.Dialog(
588     self,
589     title="Game Properties",
590     size=(760, 420),
591     style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER,
592     )
593     panel = wx.Panel(dlg)
594     root = wx.BoxSizer(wx.VERTICAL)
595     grid = wx.FlexGridSizer(rows=6, cols=2, vgap=8, hgap=12)
596     grid.AddGrowableCol(1, 1)
597    
598     details = [
599     ("Game Hash:", game_data.get("hash")),
600     ("Game File Name:", game_data.get("filename")),
601     ("Game File Path:", game_data.get("path")),
602     ("No Intro Game Name:", game_data.get("no_intro_game")),
603     ("No Intro System:", game_data.get("no_intro_system")),
604     ("TOSEC Game Name:", game_data.get("tosec_game")),
605     ]
606     for label_text, value in details:
607     label = wx.StaticText(panel, -1, label_text)
608     field = wx.TextCtrl(panel, -1, value if value else "", style=wx.TE_READONLY)
609     grid.Add(label, 0, wx.ALIGN_CENTER_VERTICAL)
610     grid.Add(field, 1, wx.EXPAND)
611    
612     close_btn = wx.Button(panel, wx.ID_OK, "Close")
613     root.Add(grid, 1, wx.EXPAND | wx.ALL, 14)
614     root.Add(close_btn, 0, wx.ALIGN_RIGHT | wx.RIGHT | wx.BOTTOM, 10)
615     panel.SetSizer(root)
616    
617     dlg.ShowModal()
618     dlg.Destroy()
619    
620 nino.borges 795 def NothingYet(self, event):
621     diag = wx.MessageDialog(self, "Nothing here yet!", "Disabled...", wx.OK | wx.ICON_INFORMATION)
622     diag.ShowModal()
623     diag.Destroy()
624    
625 nino.borges 805 def OnImportNewNoIntroDat(self,event):
626 nino.borges 979 selected_system_id = self._choose_system("No-Intro DAT System Selection")
627 nino.borges 978 if not selected_system_id:
628     return
629    
630 nino.borges 805 dlg = wx.FileDialog(
631     self, message="Import New NoIntro DAT ...", defaultDir=os.getcwd(),
632     defaultFile="", wildcard="DAT files (*.dat)|*.dat", style=wx.FD_OPEN | wx.FD_CHANGE_DIR
633     )
634     if dlg.ShowModal() == wx.ID_OK:
635     datPath = dlg.GetPath()
636 nino.borges 978 Gromulus_Lib.ImportNewNoIntroDat(datPath, selected_system_id, self.dbConnection)
637 nino.borges 805 doneDiag = wx.MessageDialog(self, "NoIntro DAT imported","All Done!",wx.OK | wx.ICON_INFORMATION)
638     doneDiag.ShowModal()
639     doneDiag.Destroy()
640 nino.borges 795
641 nino.borges 806 def OnImportNewTosecDat(self,event):
642 nino.borges 979 selected_system_id = self._choose_system("TOSEC DAT System Selection")
643 nino.borges 978 if not selected_system_id:
644     return
645    
646 nino.borges 806 dlg = wx.FileDialog(
647     self, message="Import New Tosec DAT ...", defaultDir=os.getcwd(),
648     defaultFile="", wildcard="DAT files (*.dat)|*.dat", style=wx.FD_OPEN | wx.FD_CHANGE_DIR
649     )
650     if dlg.ShowModal() == wx.ID_OK:
651     datPath = dlg.GetPath()
652 nino.borges 978 Gromulus_Lib.ImportNewTosecDat(datPath, selected_system_id, self.dbConnection)
653 nino.borges 806 doneDiag = wx.MessageDialog(self, "Tosec DAT imported","All Done!",wx.OK | wx.ICON_INFORMATION)
654     doneDiag.ShowModal()
655     doneDiag.Destroy()
656    
657 nino.borges 979 def OnAddNewArtwork(self, event):
658     selected_system_id = self._choose_system("Artwork System Selection", only_with_roms=True)
659     if not selected_system_id:
660     return
661 nino.borges 806
662 nino.borges 979 artwork_type_map = {
663     "Game Box Art": "box",
664     "Game Title": "title",
665     "Game Ingame Screenshot": "ingame",
666     }
667     type_choices = list(artwork_type_map.keys())
668     type_dlg = wx.SingleChoiceDialog(
669     self,
670     "Select the type of artwork to ingest",
671     "Artwork Type",
672     type_choices,
673     )
674     if type_dlg.ShowModal() != wx.ID_OK:
675     return
676     selected_artwork_label = type_dlg.GetStringSelection()
677     artwork_type_key = artwork_type_map[selected_artwork_label]
678    
679     dry_run_dlg = wx.MessageDialog(
680     self,
681     "Run as dry run (preview only, no file copy and no DB updates)?",
682     "Artwork Import Mode",
683     wx.YES_NO | wx.ICON_QUESTION,
684     )
685     dry_run_mode = dry_run_dlg.ShowModal() == wx.ID_YES
686     dry_run_dlg.Destroy()
687    
688     dir_dlg = wx.DirDialog(
689     self,
690     f"Choose a directory containing {selected_artwork_label} artwork files:",
691     style=wx.DD_DEFAULT_STYLE,
692     )
693     if dir_dlg.ShowModal() != wx.ID_OK:
694     return
695     artwork_source_dir = dir_dlg.GetPath()
696    
697     matched_count, skipped_existing_count, unmatched_count, error_count = self._run_with_busy(
698     "Matching artwork files and updating game records...",
699     Gromulus_Lib.AddNewArtwork,
700     artwork_source_dir,
701     selected_system_id,
702     artwork_type_key,
703     self.dbConnection,
704     testOnly=dry_run_mode,
705     )
706     doneDiag = wx.MessageDialog(
707     self,
708     (
709     f"Mode: {'Dry Run (no files written)' if dry_run_mode else 'Import'}\n\n"
710     f"{matched_count} artwork files matched and imported.\n"
711     f"{skipped_existing_count} games skipped (artwork already set).\n"
712     f"{unmatched_count} games had no filename/title match.\n"
713     f"{error_count} files failed during copy/update."
714     ),
715     "Artwork import complete",
716     wx.OK | wx.ICON_INFORMATION,
717     )
718     doneDiag.ShowModal()
719     doneDiag.Destroy()
720    
721    
722 nino.borges 805 def OnAddNewRoms(self,event):
723 nino.borges 908 systemMatrix = Gromulus_Lib.GetSystemList(self.dbConnection)
724 nino.borges 805 systemsList = list(systemMatrix.keys())
725     dlg = wx.SingleChoiceDialog(self,
726     "Select system for new ROMs",
727     "List of systemss", systemsList)
728     if (dlg.ShowModal() == wx.ID_OK):
729     dlg2 = wx.DirDialog(self, "Choose a directory:",
730     style=wx.DD_DEFAULT_STYLE)
731     if (dlg2.ShowModal() == wx.ID_OK):
732     romsToImportPath = dlg2.GetPath()
733     systemID = systemMatrix[dlg.GetStringSelection()][0]
734 nino.borges 908 importedCount, alreadyExistsCount, errImportCount = Gromulus_Lib.AddNewRoms(romsToImportPath, systemID, self.dbConnection)#, testOnly=True)
735 nino.borges 805 #print(systemMatrix[dlg.GetStringSelection()])
736     #print(dlg2.GetPath())
737     doneDiag = wx.MessageDialog(self, f"{str(importedCount)} files imported.\n{str(alreadyExistsCount)} files not imported because you already have them.\n{str(errImportCount)} files imported because of errors.", "Rom import process complete",wx.OK | wx.ICON_INFORMATION)
738     doneDiag.ShowModal()
739     doneDiag.Destroy()
740    
741 nino.borges 990 def OnCloseWindow(self, evt):
742     if self._confirm_discard_or_save_pending_changes():
743     evt.Skip()
744     else:
745     evt.Veto()
746 nino.borges 805
747    
748 nino.borges 795 class MyApp(wx.App):
749     def OnInit(self):
750 nino.borges 990 self.frame = MyFrame(None, -1, "Gromulus v1.5")
751 nino.borges 795 self.frame.Show(True)
752     self.SetTopWindow(self.frame)
753     return True
754 nino.borges 908 def OnExit(self):
755 nino.borges 910 if hasattr(self, "frame") and hasattr(self.frame, "db"):
756     self.frame.db.close()
757 nino.borges 908 print("Closed DB")
758     return 0
759 nino.borges 795
760    
761     if __name__ == '__main__':
762     app = MyApp(0)
763 nino.borges 976 app.MainLoop()