diff --git a/data/cards_cache.sqlite b/data/cards_cache.sqlite index 0aa1536..b8512e0 100644 Binary files a/data/cards_cache.sqlite and b/data/cards_cache.sqlite differ diff --git a/main.py b/main.py index 476098b..bb6dcf9 100644 --- a/main.py +++ b/main.py @@ -1,10 +1,13 @@ # main.py import os import io +import subprocess, shlex import requests import winsound +import webbrowser import tkinter as tk -from tkinter import ttk, messagebox, simpledialog +from tkinter import ttk, simpledialog, messagebox +import tkinter.font as tkfont from PIL import Image, ImageTk from mtg_api import init_cache_db, get_card_by_name, search_cards @@ -13,57 +16,114 @@ from collection_manager import load_collection, save_collection from battle_simulator import simulate_match, record_manual_result, load_match_history from models import Deck, Card -# ----------------------------------------------------------------------------- -# Helper to detect lands -# ----------------------------------------------------------------------------- -def is_land(card: Card) -> 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__