From 93f482614830785a12569709d3bc238fe49a213e Mon Sep 17 00:00:00 2001 From: Dani Date: Tue, 3 Jun 2025 18:20:03 -0400 Subject: [PATCH] Added update coding to the program, as well as fixed it so search lets you autocomplete. --- data/cards_cache.sqlite | Bin 65536 -> 135168 bytes main.py | 903 ++++++++++++++++++++++------------------ update_checker.py | 46 ++ versioning.py | 39 ++ 4 files changed, 578 insertions(+), 410 deletions(-) create mode 100644 update_checker.py create mode 100644 versioning.py diff --git a/data/cards_cache.sqlite b/data/cards_cache.sqlite index 0aa153694da087c3f68d4f6f955cfeeb81f410db..b8512e04c490a2dc86682d60be8b6cc1595ec612 100644 GIT binary patch literal 135168 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
    SF7=eOZ-vo3}g9byO(S?Sn zGq8iw_1BUNLNx_+`U!j{(7DK>BD<*k|4Xa_n5ItbSxEnPqX10!h(kmmpi~QFSp+dE z?itC31^r#rQlM40nf`Bju1hH%GnoL(`S~S0(B=Y^l`5WqpKm;aTmboXgRkGn@p?+V l{n;<8=<~~q_oSUR(ep=M-#R>hX$!rw#fJF*!Fuxl|9_Y&^`ig) delta 128 zcmZozz|qjaGC`V^n}LBrV4{LOBlpIHCH!2B{J(($47~imH}h?H%dZGz^RRINNiM#0 z2LAQ@Cj8v|v3!sCCh*_jOXqLn+qtpPn~z0 bool: - return "Land" in card.type_line +# ────────────────────────────────────────────────────────────────────────────── +# VERSIONING (build number from Git commits, fallback __version__) +# ────────────────────────────────────────────────────────────────────────────── +MAJOR = 1 +MINOR = 2 +__version__ = f"{MAJOR}.{MINOR}.0" # fallback if not in a Git repo +GITHUB_REPO = "YourUsername/YourRepo" # ← replace with your GitHub "owner/repo" -# ----------------------------------------------------------------------------- -# Play a custom WAV whenever you want (falls back if missing) -# ----------------------------------------------------------------------------- -def play_sound(sound_name: str): - path = os.path.join("assets", "sounds", f"{sound_name}.wav") - if os.path.isfile(path): - winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC) -# ----------------------------------------------------------------------------- -# Main Application Window -# ----------------------------------------------------------------------------- +def get_local_version() -> str: + """ + Return ".." by running: + git rev-list --count HEAD + Fallback to __version__ if Git fails. + """ + try: + p = subprocess.run( + shlex.split("git rev-list --count HEAD"), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=True, + text=True + ) + build = p.stdout.strip() + return f"{MAJOR}.{MINOR}.{build}" + except Exception: + return __version__ + + +def check_for_updates(local_version: str, repo: str) -> None: + """ + Fetch GitHub’s latest release (tag_name), compare to local_version. + If GitHub’s is newer, prompt to open the Releases page. + """ + api_url = f"https://api.github.com/repos/{repo}/releases/latest" + try: + resp = requests.get(api_url, timeout=5) + resp.raise_for_status() + data = resp.json() + tag = data.get("tag_name", "").lstrip("v") + except Exception: + return + + def to_tuple(v: str): + nums = [int(x) for x in v.split(".") if x.isdigit()] + return tuple(nums) + + try: + if to_tuple(tag) > to_tuple(local_version): + ans = messagebox.askyesno( + "Update Available", + f"A newer version ({tag}) is available on GitHub.\n" + f"You’re running {local_version}.\n\n" + "Would you like to view the Releases page?" + ) + if ans: + webbrowser.open( + data.get("html_url", f"https://github.com/{repo}/releases/latest") + ) + except Exception: + pass + + +# ────────────────────────────────────────────────────────────────────────────── +# MTGDeckBuilder GUI +# ────────────────────────────────────────────────────────────────────────────── class MTGDeckBuilder(tk.Tk): def __init__(self): super().__init__() self.title("MTG Deck Builder") self.geometry("1200x750") - # Ensure necessary folders/files exist + # Ensure data folders/files init_cache_db() - _ = load_collection() # ensure collection file - _ = load_match_history() # ensure match history file + _ = load_collection() + _ = load_match_history() - # Track theme: "dark" or "light" + # Theme tracking self.theme = tk.StringVar(value="dark") - # Currently loaded Deck + # Current deck self.current_deck: Deck | None = None - # In-memory cache: card_name → Card object - self.card_cache: dict[str, Card] = {} - - # Keep references to PhotoImage to avoid garbage-collection - self.thumbnail_images: dict[str, ImageTk.PhotoImage] = {} + # Caches + self.card_cache: dict[str, Card] = {} # name → Card + self.search_images: dict[str, ImageTk.PhotoImage] = {} + self.coll_images: dict[str, dict[str, ImageTk.PhotoImage]] = { + tab: {} for tab in ["All", "Black", "White", "Red", "Green", "Blue", "Unmarked", "Tokens"] + } + self.deck_images: dict[str, dict[str, ImageTk.PhotoImage]] = { + tab: {} for tab in ["All", "Black", "White", "Red", "Green", "Blue", "Unmarked", "Tokens"] + } self.preview_photo: ImageTk.PhotoImage | None = None self.color_icon_images: dict[str, ImageTk.PhotoImage] = {} - # Build & layout UI + # Build UI self._load_color_icons() self._load_sounds() self._build_widgets() self._layout_widgets() - self.apply_theme() # start in VSCode-style dark + self.apply_theme() + + # After 1 second, check for updates + local_ver = get_local_version() + self.after(1000, lambda: check_for_updates(local_ver, GITHUB_REPO)) # ----------------------------------------------------------------------------- - # Pre-load color icons (W/U/B/R/G) + # Preload W/U/B/R/G icons # ----------------------------------------------------------------------------- def _load_color_icons(self): icon_folder = os.path.join("assets", "icons") @@ -76,18 +136,17 @@ class MTGDeckBuilder(tk.Tk): self.color_icon_images[symbol] = None # ----------------------------------------------------------------------------- - # Ensure sound folder exists + # Ensure sounds folder # ----------------------------------------------------------------------------- def _load_sounds(self): sound_folder = os.path.join("assets", "sounds") os.makedirs(sound_folder, exist_ok=True) - # Place click.wav and error.wav there if you have them. # ----------------------------------------------------------------------------- - # Create all widgets + # Build all widgets (including Combobox for autocomplete) # ----------------------------------------------------------------------------- def _build_widgets(self): - # --- Top row: Deck controls + theme toggle --- + # Deck controls (top) self.deck_frame = ttk.LabelFrame(self, text="Deck Controls", padding=8) self.new_deck_btn = ttk.Button(self.deck_frame, text="New Deck", command=self._on_new_deck) self.load_deck_btn = ttk.Button(self.deck_frame, text="Load Deck", command=self._on_load_deck) @@ -105,7 +164,7 @@ class MTGDeckBuilder(tk.Tk): command=self.apply_theme ) - # --- Collection panel with tabs (left) --- + # Collection panel with tabs (left) self.coll_frame = ttk.LabelFrame(self, text="Your Collection", padding=8) self.coll_notebook = ttk.Notebook(self.coll_frame) self.coll_tabs = {} @@ -116,6 +175,7 @@ class MTGDeckBuilder(tk.Tk): tree = ttk.Treeview(frame, height=20, columns=("info",), show="tree") scroll = ttk.Scrollbar(frame, orient="vertical", command=tree.yview) tree.configure(yscrollcommand=scroll.set) + tree.bind("<>", self._on_coll_select) tree.pack(fill="both", expand=True, side="left", padx=(4,0), pady=4) scroll.pack(fill="y", side="left", padx=(0,4), pady=4) self.coll_notebook.add(frame, text=tab_name) @@ -123,14 +183,21 @@ class MTGDeckBuilder(tk.Tk): self.coll_trees[tab_name] = tree self.coll_scrolls[tab_name] = scroll self.remove_from_coll_btn = ttk.Button(self.coll_frame, text="Remove from Collection", command=self._on_remove_from_collection) + self.coll_qty_label = ttk.Label(self.coll_frame, text="Qty:") + self.coll_qty_spin = ttk.Spinbox(self.coll_frame, from_=1, to=1000, width=6) + self.coll_set_qty_btn = ttk.Button(self.coll_frame, text="Set Quantity", command=self._on_set_coll_qty) - # --- Right side: Search panel + Deck panel + Preview --- + # Right side: Search panel + Deck panel + Preview self.right_frame = ttk.Frame(self) + # Search / Add Cards self.search_frame = ttk.LabelFrame(self.right_frame, text="Search / Add Cards", padding=8) - self.search_entry = ttk.Entry(self.search_frame, width=30) + self.search_entry = ttk.Combobox(self.search_frame, width=30) + self.search_entry.set("") + self.search_entry.bind("<>", lambda e: self._on_autocomplete_select()) + self.search_entry.bind("", lambda e: self._update_autocomplete()) + self.search_entry.bind("", lambda e: self.search_entry.select_range(0, tk.END)) self.search_btn = ttk.Button(self.search_frame, text="Search", command=self._on_perform_search) - self.search_entry.bind("", lambda e: self._on_perform_search()) self.results_tree = ttk.Treeview(self.search_frame, height=12, columns=("info",), show="tree") self.results_scroll = ttk.Scrollbar(self.search_frame, orient="vertical", command=self.results_tree.yview) self.results_tree.configure(yscrollcommand=self.results_scroll.set) @@ -138,7 +205,6 @@ class MTGDeckBuilder(tk.Tk): self.add_qty_label = ttk.Label(self.search_frame, text="Qty:") self.add_qty_spin = ttk.Spinbox(self.search_frame, from_=1, to=20, width=5) self.add_qty_spin.set("1") - # Swap these two so that Add to Collection is on the left self.add_to_coll_btn = ttk.Button(self.search_frame, text="Add to Collection", command=self._on_add_to_collection) self.add_to_deck_btn = ttk.Button(self.search_frame, text="Add to Deck", command=self._on_add_to_deck) @@ -153,14 +219,17 @@ class MTGDeckBuilder(tk.Tk): tree = ttk.Treeview(frame, height=20, columns=("info",), show="tree") scroll = ttk.Scrollbar(frame, orient="vertical", command=tree.yview) tree.configure(yscrollcommand=scroll.set) + tree.bind("<>", self._on_deck_select) tree.pack(fill="both", expand=True, side="left", padx=(4,0), pady=4) scroll.pack(fill="y", side="left", padx=(0,4), pady=4) self.deck_notebook.add(frame, text=tab_name) self.deck_tabs[tab_name] = frame self.deck_trees[tab_name] = tree self.deck_scrolls[tab_name] = scroll - tree.bind("<>", self._on_deck_select) self.remove_card_btn = ttk.Button(self.deck_view_frame, text="Remove Selected", command=self._on_remove_selected) + self.deck_qty_label = ttk.Label(self.deck_view_frame, text="Qty:") + self.deck_qty_spin = ttk.Spinbox(self.deck_view_frame, from_=1, to=1000, width=6) + self.deck_set_qty_btn = ttk.Button(self.deck_view_frame, text="Set Quantity", command=self._on_set_deck_qty) # Card preview (bottom) self.preview_frame = ttk.LabelFrame(self, text="Card Preview", padding=8) @@ -168,11 +237,11 @@ class MTGDeckBuilder(tk.Tk): self.color_icons_frame = ttk.Frame(self.preview_frame) # ----------------------------------------------------------------------------- - # Arrange everything with pack() and grid() + # Layout everything with pack() and grid() # ----------------------------------------------------------------------------- def _layout_widgets(self): - # --- Deck controls (top) --- - self.deck_frame.pack(fill="x", padx=10, pady=(10, 5)) + # Deck controls + self.deck_frame.pack(fill="x", padx=10, pady=(10,5)) self.new_deck_btn.grid(row=0, column=0, padx=4, pady=4) self.load_deck_btn.grid(row=0, column=1, padx=4, pady=4) self.save_deck_btn.grid(row=0, column=2, padx=4, pady=4) @@ -182,13 +251,18 @@ class MTGDeckBuilder(tk.Tk): self.theme_toggle.grid(row=0, column=6, padx=20, pady=4) self.deck_name_label.grid(row=0, column=7, padx=10, pady=4, sticky="w") - # --- Collection panel (left) --- + # Collection panel self.coll_frame.pack(fill="y", side="left", padx=(10,5), pady=5) - self.coll_frame.configure(width=250) + self.coll_frame.configure(width=280) self.coll_notebook.pack(fill="both", expand=True, padx=4, pady=4) - self.remove_from_coll_btn.pack(fill="x", padx=4, pady=(4,10)) + self.remove_from_coll_btn.pack(fill="x", padx=4, pady=(4,4)) + qty_frame_c = ttk.Frame(self.coll_frame) + qty_frame_c.pack(fill="x", padx=4, pady=(0,10)) + self.coll_qty_label.pack(in_=qty_frame_c, side="left") + self.coll_qty_spin.pack(in_=qty_frame_c, side="left", padx=(4,10)) + self.coll_set_qty_btn.pack(in_=qty_frame_c, side="left") - # --- Right side: search + deck --- + # Right side: search + deck self.right_frame.pack(fill="both", expand=True, side="left", padx=(5,10), pady=5) self.right_frame.columnconfigure(0, weight=1) self.right_frame.columnconfigure(1, weight=1) @@ -216,49 +290,41 @@ class MTGDeckBuilder(tk.Tk): self.deck_view_frame.rowconfigure(0, weight=1) self.deck_notebook.pack(fill="both", expand=True, padx=4, pady=4) - self.remove_card_btn.pack(fill="x", padx=4, pady=(4,10)) + self.remove_card_btn.pack(fill="x", padx=4, pady=(4,4)) + qty_frame_d = ttk.Frame(self.deck_view_frame) + qty_frame_d.pack(fill="x", padx=4, pady=(0,10)) + self.deck_qty_label.pack(in_=qty_frame_d, side="left") + self.deck_qty_spin.pack(in_=qty_frame_d, side="left", padx=(4,10)) + self.deck_set_qty_btn.pack(in_=qty_frame_d, side="left") - # Preview panel (bottom) + # Preview panel self.preview_frame.pack(fill="x", padx=10, pady=(0,10)) self.preview_frame.columnconfigure(0, weight=1) self.preview_frame.rowconfigure(0, weight=1) self.card_image_label.grid(row=0, column=0, padx=4, pady=4) self.color_icons_frame.grid(row=1, column=0, padx=4, pady=(4,8)) - # Populate both Collection and Deck initially + # Populate collection + deck self._refresh_collection() self._refresh_deck() # ----------------------------------------------------------------------------- - # Apply either “VSCode Dark+” or Light theme + # Apply VSCode Dark+ or Light theme # ----------------------------------------------------------------------------- def apply_theme(self): - mode = self.theme.get() # "dark" or "light" + mode = self.theme.get() style = ttk.Style() style.theme_use("clam") if mode == "dark": - # VSCode Dark+ approximate palette - bg = "#1e1e1e" - fg = "#d4d4d4" - panel = "#252526" - entry_bg = "#3c3c3c" - entry_fg = "#d4d4d4" - select_bg = "#264f78" - btn_bg = "#0e639c" - btn_fg = "#ffffff" + bg = "#1e1e1e"; fg = "#d4d4d4"; panel = "#252526" + entry_bg = "#3c3c3c"; entry_fg = "#d4d4d4"; select_bg = "#264f78" + btn_bg = "#0e639c"; btn_fg = "#ffffff" else: - # Standard light - bg = "#ffffff" - fg = "#000000" - panel = "#f0f0f0" - entry_bg = "#ffffff" - entry_fg = "#000000" - select_bg = "#cce5ff" - btn_bg = "#007acc" - btn_fg = "#ffffff" + bg = "#ffffff"; fg = "#000000"; panel = "#f0f0f0" + entry_bg = "#ffffff"; entry_fg = "#000000"; select_bg = "#cce5ff" + btn_bg = "#007acc"; btn_fg = "#ffffff" - # Configure styles style.configure("TLabelframe", background=panel, foreground=fg) style.configure("TLabelframe.Label", background=panel, foreground=fg) style.configure("TLabel", background=bg, foreground=fg) @@ -269,269 +335,52 @@ class MTGDeckBuilder(tk.Tk): style.configure("Treeview", background=entry_bg, foreground=entry_fg, - fieldbackground=entry_bg, selectbackground=select_bg, rowheight=36) + fieldbackground=entry_bg, selectbackground=select_bg, rowheight=48) style.map("Treeview", background=[("selected", select_bg)]) style.configure("TEntry", fieldbackground=entry_bg, foreground=entry_fg) style.configure("TSpinbox", fieldbackground=entry_bg, foreground=entry_fg) + style.configure("TCombobox", fieldbackground=entry_bg, foreground=entry_fg) - # Re-color all frames self.configure(background=bg) for frame in [self.deck_frame, self.coll_frame, self.search_frame, self.deck_view_frame, self.preview_frame, self.right_frame]: frame.configure(style="TLabelframe") # ----------------------------------------------------------------------------- - # “New Deck” callback + # Autocomplete: update Combobox values as user types # ----------------------------------------------------------------------------- - def _on_new_deck(self): - play_sound("click") - name = simpledialog.askstring("New Deck", "Enter deck name:", parent=self) - if not name: + def _update_autocomplete(self): + text = self.search_entry.get().strip() + if len(text) < 2: return - self.current_deck = Deck(name=name) - self.deck_name_label.config(text=f"Deck: {name} (0 cards)") - self._refresh_deck() - self._clear_preview() - - # ----------------------------------------------------------------------------- - # “Load Deck” callback - # ----------------------------------------------------------------------------- - def _on_load_deck(self): - play_sound("click") - choices = list_saved_decks() - if not choices: - messagebox.showinfo("Load Deck", "No saved decks found.") + # Fetch up to 10 matching card names + try: + results = search_cards(text + "*",) # wildcard to get broader matches + except Exception: return + names = [c.name for c in results[:10]] + # Update the Combobox dropdown + self.search_entry["values"] = names + # If there are suggestions, open the dropdown + if names: + self.search_entry.event_generate("") - name = simpledialog.askstring( - "Load Deck", - f"Available: {', '.join(choices)}\nEnter deck name:", - parent=self - ) - if not name: + # ----------------------------------------------------------------------------- + # When the user selects from autocomplete dropdown + # ----------------------------------------------------------------------------- + def _on_autocomplete_select(self): + selected = self.search_entry.get().strip() + if not selected: return - deck = load_deck(name) - if deck: - self.current_deck = deck - self.deck_name_label.config(text=f"Deck: {deck.name} ({deck.total_cards()} cards)") - self._refresh_deck() - self._clear_preview() - else: - play_sound("error") - messagebox.showerror("Error", f"Deck '{name}' not found.") - - # ----------------------------------------------------------------------------- - # “Save Deck” callback - # ----------------------------------------------------------------------------- - def _on_save_deck(self): - play_sound("click") - if not self.current_deck: - messagebox.showwarning("Save Deck", "No deck loaded.") + # Fetch full Card and preview it + card = self.card_cache.get(selected) or get_card_by_name(selected) + if not card: return - dm_save_deck(self.current_deck) - messagebox.showinfo("Save Deck", f"Deck '{self.current_deck.name}' saved.") + self.card_cache[card.name] = card + self._show_preview(card) # ----------------------------------------------------------------------------- - # “Smart Build Deck” callback - # ----------------------------------------------------------------------------- - def _on_smart_build(self): - play_sound("click") - color_input = simpledialog.askstring( - "Smart Build: Colors", - "Enter 1–3 colors (e.g. R G) separated by spaces:", - parent=self - ) - if not color_input: - return - colors = [c.strip().upper() for c in color_input.split() if c.strip().upper() in {"W","U","B","R","G"}] - if not 1 <= len(colors) <= 3: - play_sound("error") - messagebox.showerror("Invalid Colors", "You must pick 1–3 of W, U, B, R, G.") - return - - history = load_match_history() - archetypes = ["Aggro", "Midrange", "Control"] - best_arch = None - best_rate = -1.0 - combo = "/".join(colors) - for arch in archetypes: - total = wins = 0 - for record in history: - dn = record.get("deck", "") - if dn.startswith(arch) and combo in dn: - res = record.get("result", "") - if res in ("W","L"): - total += 1 - if res == "W": - wins += 1 - if total > 0: - rate = wins / total - if rate > best_rate: - best_rate = rate - best_arch = arch - - if best_arch: - confirm = messagebox.askokcancel( - "Choose Archetype", - f"Based on history, {best_arch} {combo} has win rate {best_rate:.0%}.\nUse it?" - ) - if confirm: - archetype = best_arch.lower() - else: - archetype = None - else: - archetype = None - - if not archetype: - arch_input = simpledialog.askstring( - "Smart Build: Archetype", - "Enter archetype (Aggro, Control, Midrange):", - parent=self - ) - if not arch_input: - return - arch_input = arch_input.strip().lower() - if arch_input not in {"aggro", "control", "midrange"}: - play_sound("error") - messagebox.showerror("Invalid Archetype", "Must be 'Aggro', 'Control', or 'Midrange'.") - return - archetype = arch_input - - deck_name = f"{archetype.capitalize()} {combo} Auto" - deck = Deck(name=deck_name) - - land_count = 24 - if archetype == "aggro": - creature_target = 24; noncreature_target = 12 - elif archetype == "midrange": - creature_target = 18; noncreature_target = 18 - else: - creature_target = 12; noncreature_target = 24 - - per_color = land_count // len(colors) - extra = land_count % len(colors) - basic_map = {"W":"Plains","U":"Island","B":"Swamp","R":"Mountain","G":"Forest"} - for idx, col in enumerate(colors): - qty = per_color + (1 if idx < extra else 0) - deck.add_card(basic_map[col], qty) - - creature_query = f"c:{''.join(colors)} type:creature" - if archetype == "aggro": - creature_query += " cmc<=3" - elif archetype == "midrange": - creature_query += " cmc<=4" - else: - creature_query += " cmc<=5" - creatures = search_cards(creature_query) - creatures = [c for c in creatures if set(c.colors).issubset(set(colors))] - used = set(); added = 0 - for c in creatures: - if added >= creature_target: - break - if c.name not in used: - deck.add_card(c.name, 1) - used.add(c.name); added += 1 - - noncre_query = f"c:{''.join(colors)} (type:instant or type:sorcery)" - if archetype == "aggro": - noncre_query += " cmc<=3" - elif archetype == "midrange": - noncre_query += " cmc<=4" - else: - noncre_query += " cmc>=3" - noncre = search_cards(noncre_query) - noncre = [c for c in noncre if set(c.colors).issubset(set(colors))] - added_non = 0 - for c in noncre: - if added_non >= noncreature_target: - break - if c.name not in used: - deck.add_card(c.name, 1) - used.add(c.name); added_non += 1 - - total_cards = sum(deck.cards.values()) - if total_cards < 60: - fill_needed = 60 - total_cards - filler = search_cards("type:creature cmc<=3") - for c in filler: - if c.name not in used: - deck.add_card(c.name,1) - used.add(c.name) - fill_needed -=1 - if fill_needed==0: break - - self.current_deck = deck - self.deck_name_label.config(text=f"Deck: {deck.name} ({deck.total_cards()} cards)") - self._refresh_deck() - self._clear_preview() - messagebox.showinfo("Smart Build Complete", f"Created deck '{deck.name}' with {deck.total_cards()} cards.") - - # ----------------------------------------------------------------------------- - # Refresh the entire collection (all tabs) - # ----------------------------------------------------------------------------- - def _refresh_collection(self): - coll = load_collection() - # Prepare a dict: tab_name → list of (name, qty) - buckets = {tn: [] for tn in self.coll_trees} - for name, qty in coll.items(): - # Fetch Card object (cache or API) - card = self.card_cache.get(name) or get_card_by_name(name) - if card: - self.card_cache[card.name] = card - colors = card.colors - is_token = "Token" in card.type_line - else: - colors = [] - is_token = False - - # Every card goes into "All" - buckets["All"].append((name, qty)) - - # Color-specific tabs - for col, tab in [("B", "Black"), ("W", "White"), - ("R", "Red"), ("G", "Green"), ("U", "Blue")]: - if col in colors: - buckets[tab].append((name, qty)) - - # Unmarked = no colors - if not colors and not is_token: - buckets["Unmarked"].append((name, qty)) - - # Tokens tab - if is_token: - buckets["Tokens"].append((name, qty)) - - # Now update each Treeview - for tab_name, tree in self.coll_trees.items(): - tree.delete(*tree.get_children()) - for idx, (card_name, qty) in enumerate(sorted(buckets[tab_name], key=lambda x: x[0].lower())): - display = f"{qty}× {card_name}" - tree.insert("", "end", iid=str(idx), text=display) - - # ----------------------------------------------------------------------------- - # “Remove from Collection” callback - # ----------------------------------------------------------------------------- - def _on_remove_from_collection(self): - play_sound("click") - # Determine which tab is currently selected - current_tab = self.coll_notebook.tab(self.coll_notebook.select(), "text") - tree = self.coll_trees[current_tab] - sel = tree.selection() - if not sel: - return - iid = sel[0] - display = tree.item(iid, "text") - qty_str, name_part = display.split("×", 1) - card_name = name_part.strip() - - coll = load_collection() - if card_name in coll: - del coll[card_name] - save_collection(coll) - self._refresh_collection() - - # ----------------------------------------------------------------------------- - # “Search” callback + # Perform a normal “Search” (when user clicks Search) # ----------------------------------------------------------------------------- def _on_perform_search(self): play_sound("click") @@ -540,43 +389,39 @@ class MTGDeckBuilder(tk.Tk): return self.results_tree.delete(*self.results_tree.get_children()) - self.thumbnail_images.clear() + self.search_images.clear() - results = search_cards(query) + try: + results = search_cards(query) + except Exception: + results = [] if not results: - messagebox.showinfo("Search", "No cards found.") return for idx, card in enumerate(results): self.card_cache[card.name] = card - thumb = None - if card.thumbnail_url: + img = None + if card.image_url: try: - resp = requests.get(card.thumbnail_url, timeout=5) + resp = requests.get(card.image_url, timeout=5) resp.raise_for_status() - img = Image.open(io.BytesIO(resp.content)) - img.thumbnail((40, 60), Image.LANCZOS) - thumb = ImageTk.PhotoImage(img) + pil = Image.open(io.BytesIO(resp.content)) + pil.thumbnail((80,120), Image.LANCZOS) + img = ImageTk.PhotoImage(pil) + self.search_images[card.name] = img except Exception: - thumb = None + img = None - display_text = f"{card.name} ● {card.mana_cost or ''} ● {card.type_line} [{card.rarity}]" - if thumb: - self.thumbnail_images[card.name] = thumb - self.results_tree.insert( - "", "end", iid=str(idx), - text=display_text, image=thumb - ) + display = f"{card.name} ● {card.mana_cost or ''} ● {card.type_line} [{card.rarity}]" + if img: + self.results_tree.insert("", "end", iid=str(idx), text=display, image=img) else: - self.results_tree.insert( - "", "end", iid=str(idx), - text=display_text - ) + self.results_tree.insert("", "end", iid=str(idx), text=display) self._clear_preview() # ----------------------------------------------------------------------------- - # When a search result is selected → preview it + # When a search result is clicked → preview it # ----------------------------------------------------------------------------- def _on_result_select(self, event): sel = self.results_tree.selection() @@ -593,7 +438,7 @@ class MTGDeckBuilder(tk.Tk): self._show_preview(card) # ----------------------------------------------------------------------------- - # Show full image + color pips in preview + # Show full image + color icons in preview # ----------------------------------------------------------------------------- def _show_preview(self, card: Card): for w in self.color_icons_frame.winfo_children(): @@ -614,7 +459,7 @@ class MTGDeckBuilder(tk.Tk): resp.raise_for_status() img_data = resp.content image = Image.open(io.BytesIO(img_data)) - image.thumbnail((250, 350), Image.LANCZOS) + image.thumbnail((250,350), Image.LANCZOS) photo = ImageTk.PhotoImage(image) self.preview_photo = photo self.card_image_label.config(image=photo, text="") @@ -626,48 +471,13 @@ class MTGDeckBuilder(tk.Tk): self.preview_photo = None # ----------------------------------------------------------------------------- - # “Add to Deck” callback - # ----------------------------------------------------------------------------- - def _on_add_to_deck(self): - play_sound("click") - if not self.current_deck: - messagebox.showwarning("Add Card", "Create or load a deck first.") - return - sel = self.results_tree.selection() - if not sel: - messagebox.showwarning("Add Card", "Select a card from search results.") - return - iid = sel[0] - display = self.results_tree.item(iid, "text") - card_name = display.split(" ● ")[0].strip() - - try: - qty = int(self.add_qty_spin.get()) - if qty < 1: - raise ValueError - except Exception: - qty = 1 - - card = self.card_cache.get(card_name) or get_card_by_name(card_name) - if not card: - play_sound("error") - messagebox.showerror("Error", f"Card '{card_name}' not found.") - return - self.card_cache[card.name] = card - - self.current_deck.add_card(card.name, qty) - self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({self.current_deck.total_cards()} cards)") - self._refresh_deck() - - # ----------------------------------------------------------------------------- - # “Add to Collection” callback + # Add to Collection (silent, cache thumbnails automatically) # ----------------------------------------------------------------------------- def _on_add_to_collection(self): play_sound("click") coll = load_collection() sel = self.results_tree.selection() if not sel: - messagebox.showwarning("Add to Collection", "Select a card first.") return iid = sel[0] display = self.results_tree.item(iid, "text") @@ -683,13 +493,222 @@ class MTGDeckBuilder(tk.Tk): coll[card_name] = coll.get(card_name, 0) + qty save_collection(coll) self._refresh_collection() - messagebox.showinfo("Collection", f"Added {qty}× '{card_name}' to your collection.") + + # Clear the search box so user can type another name + self.search_entry.set("") + self.search_entry.focus_set() + self.results_tree.delete(*self.results_tree.get_children()) + self._clear_preview() # ----------------------------------------------------------------------------- - # “Deck” selection callback → preview + # Add to Deck (silent) + # ----------------------------------------------------------------------------- + def _on_add_to_deck(self): + play_sound("click") + if not self.current_deck: + return + sel = self.results_tree.selection() + if not sel: + return + iid = sel[0] + display = self.results_tree.item(iid, "text") + card_name = display.split(" ● ")[0].strip() + + try: + qty = int(self.add_qty_spin.get()) + if qty < 1: + raise ValueError + except Exception: + qty = 1 + + card = self.card_cache.get(card_name) or get_card_by_name(card_name) + if not card: + return + self.card_cache[card.name] = card + + self.current_deck.add_card(card.name, qty) + self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({self.current_deck.total_cards()} cards)") + self._refresh_deck() + + # ----------------------------------------------------------------------------- + # Remove selected from collection + # ----------------------------------------------------------------------------- + def _on_remove_from_collection(self): + play_sound("click") + current_tab = self.coll_notebook.tab(self.coll_notebook.select(), "text") + tree = self.coll_trees[current_tab] + sel = tree.selection() + if not sel: + return + iid = sel[0] + display = tree.item(iid, "text") + _, name_part = display.split("×", 1) + card_name = name_part.strip() + + coll = load_collection() + if card_name in coll: + del coll[card_name] + save_collection(coll) + self._refresh_collection() + + # ----------------------------------------------------------------------------- + # When a collection card is selected → populate spinbox + # ----------------------------------------------------------------------------- + def _on_coll_select(self, event): + current_tab = self.coll_notebook.tab(self.coll_notebook.select(), "text") + tree = self.coll_trees[current_tab] + sel = tree.selection() + if not sel: + return + iid = sel[0] + display = tree.item(iid, "text") + qty_str, _ = display.split("×", 1) + try: + self.coll_qty_spin.set(qty_str.strip()) + except Exception: + self.coll_qty_spin.set("1") + + # ----------------------------------------------------------------------------- + # Set quantity in collection (inline) + # ----------------------------------------------------------------------------- + def _on_set_coll_qty(self): + play_sound("click") + current_tab = self.coll_notebook.tab(self.coll_notebook.select(), "text") + tree = self.coll_trees[current_tab] + sel = tree.selection() + if not sel: + return + iid = sel[0] + display = tree.item(iid, "text") + _, name_part = display.split("×", 1) + card_name = name_part.strip() + + try: + new_qty = int(self.coll_qty_spin.get()) + if new_qty < 1: + raise ValueError + except Exception: + new_qty = 1 + + coll = load_collection() + coll[card_name] = new_qty + save_collection(coll) + self._refresh_collection() + + # ----------------------------------------------------------------------------- + # Refresh the entire collection (all tabs) + autofit columns + # ----------------------------------------------------------------------------- + def _refresh_collection(self): + coll = load_collection() + buckets = {tn: [] for tn in self.coll_trees} + for name, qty in coll.items(): + card = self.card_cache.get(name) or get_card_by_name(name) + if card: + self.card_cache[card.name] = card + colors = card.colors + is_token = "Token" in card.type_line + else: + colors = [] + is_token = False + + buckets["All"].append((name, qty)) + for col, tab in [("B", "Black"), ("W", "White"), + ("R", "Red"), ("G", "Green"), ("U", "Blue")]: + if col in colors: + buckets[tab].append((name, qty)) + if not colors and not is_token: + buckets["Unmarked"].append((name, qty)) + if is_token: + buckets["Tokens"].append((name, qty)) + + for tab_name, tree in self.coll_trees.items(): + tree.delete(*tree.get_children()) + # Do NOT clear self.coll_images[tab_name]; reuse cached thumbnails + fnt_spec = ttk.Style().lookup("Treeview", "font") + if fnt_spec: + fnt = tkfont.Font(font=fnt_spec) + else: + fnt = tkfont.nametofont("TkDefaultFont") + + max_width = 0 + for idx, (card_name, qty) in enumerate(sorted(buckets[tab_name], key=lambda x: x[0].lower())): + card = self.card_cache.get(card_name) + img = None + if card and card.thumbnail_url: + if card_name not in self.coll_images[tab_name]: + try: + resp = requests.get(card.thumbnail_url, timeout=5) + resp.raise_for_status() + pil = Image.open(io.BytesIO(resp.content)) + pil.thumbnail((24,36), Image.LANCZOS) + img_obj = ImageTk.PhotoImage(pil) + self.coll_images[tab_name][card_name] = img_obj + except Exception: + pass + img = self.coll_images[tab_name].get(card_name) + + display = f"{qty}× {card_name}" + if img: + tree.insert("", "end", iid=str(idx), text=display, image=img) + text_w = fnt.measure(display) + total_w = text_w + 24 + 10 + else: + tree.insert("", "end", iid=str(idx), text=display) + total_w = fnt.measure(display) + 10 + + if total_w > max_width: + max_width = total_w + + tree.column("#0", width=max_width) + + # ----------------------------------------------------------------------------- + # New Deck + # ----------------------------------------------------------------------------- + def _on_new_deck(self): + play_sound("click") + name = simpledialog.askstring("New Deck", "Enter deck name:", parent=self) + if not name: + return + self.current_deck = Deck(name=name) + self.deck_name_label.config(text=f"Deck: {name} (0 cards)") + self._refresh_deck() + self._clear_preview() + + # ----------------------------------------------------------------------------- + # Load Deck + # ----------------------------------------------------------------------------- + def _on_load_deck(self): + play_sound("click") + choices = list_saved_decks() + if not choices: + return + name = simpledialog.askstring( + "Load Deck", + f"Available: {', '.join(choices)}\nEnter deck name:", + parent=self + ) + if not name: + return + deck = load_deck(name) + if deck: + self.current_deck = deck + self.deck_name_label.config(text=f"Deck: {deck.name} ({deck.total_cards()} cards)") + self._refresh_deck() + self._clear_preview() + + # ----------------------------------------------------------------------------- + # Save Deck + # ----------------------------------------------------------------------------- + def _on_save_deck(self): + play_sound("click") + if not self.current_deck: + return + dm_save_deck(self.current_deck) + + # ----------------------------------------------------------------------------- + # When a deck card is selected → preview + set spinbox # ----------------------------------------------------------------------------- def _on_deck_select(self, event): - # Determine which tab is selected current_tab = self.deck_notebook.tab(self.deck_notebook.select(), "text") tree = self.deck_trees[current_tab] sel = tree.selection() @@ -700,10 +719,16 @@ class MTGDeckBuilder(tk.Tk): parts = display.split("×", 1) if len(parts) != 2: return - card_name = parts[1].strip() + qty_str, name_part = parts + card_name = name_part.strip() if card_name.endswith("⚠"): card_name = card_name[:-1].strip() + try: + self.deck_qty_spin.set(qty_str.strip()) + except Exception: + self.deck_qty_spin.set("1") + card = self.card_cache.get(card_name) or get_card_by_name(card_name) if not card: return @@ -711,35 +736,68 @@ class MTGDeckBuilder(tk.Tk): self._show_preview(card) # ----------------------------------------------------------------------------- - # “Remove Selected” from deck callback + # Set quantity in deck (inline) # ----------------------------------------------------------------------------- - def _on_remove_selected(self): + def _on_set_deck_qty(self): play_sound("click") + if not self.current_deck: + return current_tab = self.deck_notebook.tab(self.deck_notebook.select(), "text") tree = self.deck_trees[current_tab] sel = tree.selection() - if not sel or not self.current_deck: + if not sel: return iid = sel[0] display = tree.item(iid, "text") parts = display.split("×", 1) if len(parts) != 2: return - try: - qty = int(parts[0].strip()) - except ValueError: - return - card_name = parts[1].strip() + _, name_part = parts + card_name = name_part.strip() if card_name.endswith("⚠"): card_name = card_name[:-1].strip() - self.current_deck.remove_card(card_name, qty) + try: + new_qty = int(self.deck_qty_spin.get()) + if new_qty < 1: + raise ValueError + except Exception: + new_qty = 1 + + self.current_deck.cards[card_name] = new_qty + self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({self.current_deck.total_cards()} cards)") + self._refresh_deck() + + # ----------------------------------------------------------------------------- + # Remove selected from deck + # ----------------------------------------------------------------------------- + def _on_remove_selected(self): + play_sound("click") + if not self.current_deck: + return + current_tab = self.deck_notebook.tab(self.deck_notebook.select(), "text") + tree = self.deck_trees[current_tab] + sel = tree.selection() + if not sel: + return + iid = sel[0] + display = tree.item(iid, "text") + parts = display.split("×", 1) + if len(parts) != 2: + return + _, name_part = parts + card_name = name_part.strip() + if card_name.endswith("⚠"): + card_name = card_name[:-1].strip() + + if card_name in self.current_deck.cards: + del self.current_deck.cards[card_name] self.deck_name_label.config(text=f"Deck: {self.current_deck.name} ({self.current_deck.total_cards()} cards)") self._refresh_deck() self._clear_preview() # ----------------------------------------------------------------------------- - # Refresh deck tabs + # Refresh the deck tabs + autofit # ----------------------------------------------------------------------------- def _refresh_deck(self): if not self.current_deck: @@ -747,7 +805,6 @@ class MTGDeckBuilder(tk.Tk): tree.delete(*tree.get_children()) return - # Prepare buckets similar to collection buckets = {tn: [] for tn in self.deck_trees} for name, qty in self.current_deck.cards.items(): card = self.card_cache.get(name) or get_card_by_name(name) @@ -769,16 +826,48 @@ class MTGDeckBuilder(tk.Tk): if is_token: buckets["Tokens"].append((name, qty)) - # Update each deck Treeview for tab_name, tree in self.deck_trees.items(): tree.delete(*tree.get_children()) + self.deck_images[tab_name].clear() + fnt_spec = ttk.Style().lookup("Treeview", "font") + if fnt_spec: + fnt = tkfont.Font(font=fnt_spec) + else: + fnt = tkfont.nametofont("TkDefaultFont") + + max_width = 0 for idx, (card_name, qty) in enumerate(sorted(buckets[tab_name], key=lambda x: x[0].lower())): card = self.card_cache.get(card_name) + img = None + if card and card.thumbnail_url: + if card_name not in self.deck_images[tab_name]: + try: + resp = requests.get(card.thumbnail_url, timeout=5) + resp.raise_for_status() + pil = Image.open(io.BytesIO(resp.content)) + pil.thumbnail((24,36), Image.LANCZOS) + img_obj = ImageTk.PhotoImage(pil) + self.deck_images[tab_name][card_name] = img_obj + except Exception: + pass + img = self.deck_images[tab_name].get(card_name) + flag = "" if card and qty > 1 and not is_land(card): flag = " ⚠" display = f"{qty}× {card_name}{flag}" - tree.insert("", "end", iid=str(idx), text=display) + if img: + tree.insert("", "end", iid=str(idx), text=display, image=img) + text_w = fnt.measure(display) + total_w = text_w + 24 + 10 + else: + tree.insert("", "end", iid=str(idx), text=display) + total_w = fnt.measure(display) + 10 + + if total_w > max_width: + max_width = total_w + + tree.column("#0", width=max_width) # ----------------------------------------------------------------------------- # Clear card preview @@ -790,13 +879,12 @@ class MTGDeckBuilder(tk.Tk): self.preview_photo = None # ----------------------------------------------------------------------------- - # “Simulate Battle” callback + # Simulate Battle # ----------------------------------------------------------------------------- def _on_simulate_battle(self): play_sound("click") choices = list_saved_decks() if len(choices) < 2: - messagebox.showinfo("Simulate Battle", "Need at least two saved decks.") return d1 = simpledialog.askstring( @@ -808,8 +896,6 @@ class MTGDeckBuilder(tk.Tk): return deck1 = load_deck(d1) if not deck1: - play_sound("error") - messagebox.showerror("Error", f"Deck '{d1}' not found.") return d2 = simpledialog.askstring( @@ -821,25 +907,24 @@ class MTGDeckBuilder(tk.Tk): return deck2 = load_deck(d2) if not deck2: - play_sound("error") - messagebox.showerror("Error", f"Deck '{d2}' not found.") return wins1, wins2, ties = simulate_match(deck1, deck2, iterations=1000) - msg = (f"Simulation results (1000 games):\n\n" - f"{d1} wins: {wins1}\n" - f"{d2} wins: {wins2}\n" - f"Ties: {ties}") - messagebox.showinfo("Simulation Complete", msg) + messagebox.showinfo( + "Simulation Complete", + f"Results (1000 games):\n\n" + f"{d1} wins: {wins1}\n" + f"{d2} wins: {wins2}\n" + f"Ties: {ties}" + ) # ----------------------------------------------------------------------------- - # “Record Result” callback + # Record Result # ----------------------------------------------------------------------------- def _on_record_result(self): play_sound("click") choices = list_saved_decks() if not choices: - messagebox.showinfo("Record Result", "No saved decks to record.") return deck_name = simpledialog.askstring( @@ -864,16 +949,14 @@ class MTGDeckBuilder(tk.Tk): parent=self ) if not result or result.upper() not in {"W","L","T"}: - play_sound("error") - messagebox.showerror("Invalid Result", "Result must be W, L, or T.") return record_manual_result(deck_name, opponent, result.upper()) - messagebox.showinfo("Record Result", f"Recorded {result.upper()} for '{deck_name}' vs '{opponent}'.") -# ----------------------------------------------------------------------------- + +# ────────────────────────────────────────────────────────────────────────────── # Launch the app -# ----------------------------------------------------------------------------- +# ────────────────────────────────────────────────────────────────────────────── if __name__ == "__main__": missing_icons = [s for s in ["W","U","B","R","G"] if not os.path.isfile(os.path.join("assets","icons",f"{s}.png"))] diff --git a/update_checker.py b/update_checker.py new file mode 100644 index 0000000..a470610 --- /dev/null +++ b/update_checker.py @@ -0,0 +1,46 @@ +# ────────────────────────────────────────────────────────────────────────────── +# update_checker.py (fold this into main.py or import it) +# ────────────────────────────────────────────────────────────────────────────── + +import requests +import webbrowser +from tkinter import messagebox + +# Fill in your GitHub “owner/repo” here: +GITHUB_REPO = "YourUsername/YourRepo" + + +def check_for_updates(local_version: str, repo: str) -> None: + """ + 1. Hits GitHub’s API: /repos/{repo}/releases/latest + 2. Reads the "tag_name" of the latest release (e.g. "v1.2.60" or "1.2.60"). + 3. Strips any leading "v" and compares semver (major, minor, patch) tuples. + 4. If GitHub’s version > local_version, prompts user to open the Releases page. + """ + api_url = f"https://api.github.com/repos/{repo}/releases/latest" + try: + resp = requests.get(api_url, timeout=5) + resp.raise_for_status() + data = resp.json() + tag = data.get("tag_name", "").lstrip("v") + except Exception: + return # silently do nothing on network or JSON errors + + def to_tuple(v: str): + parts = [int(x) for x in v.split(".") if x.isdigit()] + return tuple(parts) + + try: + if to_tuple(tag) > to_tuple(local_version): + answer = messagebox.askyesno( + "Update Available", + f"A newer release ({tag}) is available on GitHub.\n" + f"You’re currently on {local_version}.\n\n" + "Would you like to open the Releases page?" + ) + if answer: + webbrowser.open( + data.get("html_url", f"https://github.com/{repo}/releases/latest") + ) + except Exception: + pass diff --git a/versioning.py b/versioning.py new file mode 100644 index 0000000..9f8c9f3 --- /dev/null +++ b/versioning.py @@ -0,0 +1,39 @@ +# ────────────────────────────────────────────────────────────────────────────── +# versioning.py (you could put this at the top of main.py or in its own file) +# ────────────────────────────────────────────────────────────────────────────── + +import subprocess +import shlex + +# Only bump these when you deliberately want to release a new major/minor: +MAJOR = 0 +MINOR = 1 + +# Fallback “build” if not in a Git repo (e.g. when you zip up or PyInstaller‐bundle). +# In that scenario, commit‐count detection will fail and we’ll use this. +__version__ = f"{MAJOR}.{MINOR}.0" + + +def get_local_version() -> str: + """ + Try to get the current Git‐based build number via: + git rev-list --count HEAD + This returns an integer count of commits on HEAD. We build a version string: + ".." + If anything fails (no Git, or not in a repo), we fall back to __version__. + """ + try: + # This returns something like "57\n" if there have been 57 commits. + p = subprocess.run( + shlex.split("git rev-list --count HEAD"), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=True, + text=True + ) + build = p.stdout.strip() + # Construct "MAJOR.MINOR.build" + return f"{MAJOR}.{MINOR}.{build}" + except Exception: + # Either git isn’t installed or this isn’t a Git checkout. + return __version__