Source code for yasuki_gui.ui.deck_builder.deck_builder

import tkinter as tk
from tkinter import filedialog, messagebox
from collections.abc import Callable

from yasuki_core.card_art import CustomPrint, classify
from yasuki_gui.ui.deck_builder.art_swap import BorrowArtDialog
from yasuki_gui.ui.deck_builder.components import CardStatsPanel, PrintSelector
from yasuki_gui.ui.deck_builder.card_preview import (
    CardPreviewController,
    back_image_source,
    front_image_source,
)
from yasuki_gui.ui.deck_builder.deck_data import DeckBuilderRepository, DeckState
from yasuki_gui.ui.deck_builder.deck_components import FilteredCardList, DeckCardList
from yasuki_gui.ui.deck_builder.deck_io import serialize_deck, import_deck_yaml
from yasuki_gui.ui.deck_builder.filter_dialog import FilterDialog, FilterOptions
from yasuki_gui.ui.deck_builder.search_help import show_search_help
import logging

logger = logging.getLogger(__name__)


[docs] class DeckBuilderWindow: """ Deck builder UI with three-column layout. Columns ------- Left: Search box and filtered card list Middle: Deck composition (Fate and Dynasty decks) Right: Card preview with image, stats, and rules text """
[docs] def __init__(self, master: tk.Misc, on_close: Callable | None = None): self.master = master self.on_close = on_close self.win = tk.Toplevel(master) self.win.title("Deck Builder") self.win.geometry("1600x900") self.win.protocol("WM_DELETE_WINDOW", self._close) logger.info("Initializing Deck Builder window") self._repository = DeckBuilderRepository() self._deck_state = DeckState() self._deck_name = tk.StringVar(value="") self._deck_author = tk.StringVar(value="") self._updating_lists = False self._filter_options = FilterOptions() self._search_debounce_id = None self._setup_layout() self._setup_event_bindings() logger.info(f"Loaded {len(self._repository.all_cards)} cards from database") self.card_list.refresh()
def _setup_layout(self) -> None: """Create the three-column layout with all UI components.""" # Use grid for better control over column widths self.win.grid_columnconfigure(0, weight=1, minsize=300) # Left: card list self.win.grid_columnconfigure(1, weight=2, minsize=400) # Middle: deck (wider) self.win.grid_columnconfigure(2, weight=1, minsize=300) # Right: preview (narrower) self.win.grid_rowconfigure(0, weight=1) col1 = self._create_card_list_column(self.win) col2 = self._create_deck_composition_column(self.win) col3 = self._create_preview_column(self.win) col1.grid(row=0, column=0, sticky="nsew") col2.grid(row=0, column=1, sticky="nsew") col3.grid(row=0, column=2, sticky="nsew") def _create_card_list_column(self, parent: tk.Widget) -> tk.Frame: """Create left column with search and filtered card list.""" col = tk.Frame(parent, padx=8, pady=8) search_frame = tk.Frame(col) search_frame.pack(fill="x") tk.Label(search_frame, text="Search:").pack(side="left") self.search_var = tk.StringVar() search_entry = tk.Entry(search_frame, textvariable=self.search_var) search_entry.pack(side="left", fill="x", expand=True, padx=(6, 4)) search_entry.bind("<KeyRelease>", lambda e: self._on_search_changed()) tk.Button(search_frame, text="?", width=2, command=self._show_search_help).pack(side="left") self.card_list = FilteredCardList(col, self._repository) self.card_list.pack(fill="both", expand=True, pady=(8, 0)) btn_frame = tk.Frame(col) btn_frame.pack(fill="x", pady=(8, 0)) tk.Button(btn_frame, text="Filters", command=self._open_filter_dialog).pack(side="left") tk.Button(btn_frame, text="Clear Filters", command=self._clear_filters).pack( side="left", padx=(4, 0) ) return col def _create_deck_composition_column(self, parent: tk.Widget) -> tk.Frame: """Create middle column with Fate, Dynasty, and Setup deck lists.""" col = tk.Frame(parent, padx=8, pady=8) name_frame = tk.Frame(col) name_frame.pack(fill="x", pady=(0, 8)) tk.Label(name_frame, text="Deck:").pack(side="left") self._name_entry = tk.Entry(name_frame, textvariable=self._deck_name) self._name_entry.pack(side="left", fill="x", expand=True, padx=(6, 4)) tk.Label(name_frame, text="By:").pack(side="left") tk.Entry(name_frame, textvariable=self._deck_author, width=14).pack( side="left", padx=(6, 4) ) tk.Button(name_frame, text="Import", command=self._import_deck).pack(side="left", padx=2) tk.Button(name_frame, text="Export", command=self._export_deck).pack(side="left") self.fate_label = tk.Label(col, text="Fate Deck (0)", font=("TkDefaultFont", 11, "bold")) self.fate_label.pack(anchor="w") self.fate_list = DeckCardList(col, self._repository, "FATE") self.fate_list.pack(fill="both", expand=True, pady=(4, 0)) self.dynasty_label = tk.Label( col, text="Dynasty Deck (0)", font=("TkDefaultFont", 11, "bold") ) self.dynasty_label.pack(anchor="w", pady=(12, 0)) self.dynasty_list = DeckCardList(col, self._repository, "DYNASTY") self.dynasty_list.pack(fill="both", expand=True, pady=(4, 0)) tk.Label(col, text="Setup Cards", font=("TkDefaultFont", 11, "bold")).pack( anchor="w", pady=(12, 0) ) self.setup_list = DeckCardList(col, self._repository, "SETUP") self.setup_list.frame.configure(height=150) self.setup_list.pack(fill="x", pady=(4, 0)) actions = tk.Frame(col) actions.pack(fill="x", pady=(12, 0)) tk.Button(actions, text="Clear Deck", command=self._clear_deck).pack(side="left") tk.Button(actions, text="Print Deck…", command=self._print_deck).pack( side="left", padx=(4, 0) ) return col def _create_preview_column(self, parent: tk.Widget) -> tk.Frame: """Create right column with card preview.""" col = tk.Frame(parent, padx=8, pady=8) tk.Label(col, text="Card Preview", font=("TkDefaultFont", 11, "bold")).pack(anchor="w") preview_img_lbl = tk.Label(col) preview_img_lbl.pack(anchor="n", pady=(4, 0), expand=True, fill="both") preview_img_lbl.bind("<Double-Button-1>", lambda e: self._add_from_preview()) print_row = tk.Frame(col) print_row.pack(fill="x", pady=(4, 0)) print_selector = PrintSelector(print_row, self._on_prev_print, self._on_next_print) print_selector.pack(side="left") tk.Button( print_row, text="⇄ art", command=self._borrow_art, relief="flat", borderwidth=0, fg="#4a90d9", cursor="hand2", padx=4, ).pack(side="right") stats_panel = CardStatsPanel(col) stats_panel.pack(fill="x", pady=(4, 0)) tk.Label(col, text="Flavor Text", font=("TkDefaultFont", 9, "bold")).pack( anchor="w", pady=(4, 2) ) flavor_holder = tk.Frame(col) flavor_holder.pack(fill="x", pady=(0, 4)) flavor_text = tk.Text(flavor_holder, wrap="word", height=3, font=("TkDefaultFont", 9)) flavor_text.pack(side="left", fill="x", expand=True) flavor_scroll = tk.Scrollbar(flavor_holder, orient="vertical", command=flavor_text.yview) flavor_text.configure(yscrollcommand=flavor_scroll.set, state="disabled") flavor_scroll.pack(side="left", fill="y") tk.Label(col, text="Rules Text", font=("TkDefaultFont", 9, "bold")).pack( anchor="w", pady=(4, 2) ) text_holder = tk.Frame(col) text_holder.pack(fill="x", pady=(0, 8)) preview_text = tk.Text(text_holder, wrap="word", height=6, font=("TkDefaultFont", 9)) preview_text.pack(side="left", fill="x", expand=True) tscroll = tk.Scrollbar(text_holder, orient="vertical", command=preview_text.yview) preview_text.configure(yscrollcommand=tscroll.set, state="disabled") tscroll.pack(side="left", fill="y") close_frame = tk.Frame(col) close_frame.pack(side="bottom", fill="x") tk.Button(close_frame, text="Close", command=self._close).pack(side="right") self.preview_controller = CardPreviewController( preview_img_lbl, stats_panel, preview_text, flavor_text, print_selector, self.win, self._repository, ) # Store flavor text widget for later use self.flavor_text = flavor_text return col def _setup_event_bindings(self) -> None: """Bind event handlers for user interactions.""" self.card_list.bind("<Double-Button-1>", lambda e: self._add_selected()) self.card_list.bind("<<ListboxSelect>>", lambda e: self._on_card_list_select()) self.fate_list.bind("<Double-Button-1>", lambda e: self._remove_from_fate()) self.fate_list.bind( "<<ListboxSelect>>", lambda e: self._on_deck_list_select(self.fate_list) ) self.dynasty_list.bind("<Double-Button-1>", lambda e: self._remove_from_dynasty()) self.dynasty_list.bind( "<<ListboxSelect>>", lambda e: self._on_deck_list_select(self.dynasty_list) ) self.setup_list.bind("<Double-Button-1>", lambda e: self._remove_from_setup()) self.setup_list.bind( "<<ListboxSelect>>", lambda e: self._on_deck_list_select(self.setup_list) ) def _close(self) -> None: if callable(self.on_close): try: self.on_close() except Exception: pass try: self.win.destroy() except Exception: pass def _on_search_changed(self) -> None: """Handle search box changes with debouncing to improve performance.""" # Cancel any pending search if self._search_debounce_id is not None: self.win.after_cancel(self._search_debounce_id) # Schedule new search after delay (150ms is responsive but reduces queries) self._search_debounce_id = self.win.after(150, self._execute_search) def _execute_search(self) -> None: """Execute the actual search (called after debounce delay).""" self._search_debounce_id = None query = self.search_var.get() self.card_list.set_filter(query) def _show_search_help(self) -> None: """Show search syntax help dialog.""" show_search_help(self.win) def _open_filter_dialog(self) -> None: """Open the filter dialog and apply selected filters.""" dialog = FilterDialog(self.win, self._filter_options) result = dialog.show() if result is not None: self._filter_options = result self.card_list.set_filter_options(self._filter_options) def _clear_filters(self) -> None: """Clear all filters and search text.""" self._filter_options = FilterOptions() self.search_var.set("") self.card_list.set_filter("") self.card_list.set_filter_options(self._filter_options) def _clear_deck(self) -> None: self._deck_state = self._deck_state.clear() self._refresh_deck_lists() _SIDE_ORDER = {"SETUP": 0, "DYNASTY": 1, "FATE": 2} def _collect_deck_images(self) -> list: """Printable faces per card copy (both sides for a double-sided print), ordered setup -> dynasty -> fate, then by name.""" cards = self._repository.cards_by_id def order_key(card_id: str) -> tuple[int, str]: card = cards.get(card_id) or {} decks = set(card.get("decks") or []) side = "DYNASTY" if "Dynasty" in decks else "FATE" if "Fate" in decks else "SETUP" return (self._SIDE_ORDER[side], card.get("name", "")) images = [] for card_id in sorted(self._deck_state.cards, key=order_key): card = cards.get(card_id) or {} prints = self._repository.get_prints(card_id) for print_id, count in self._deck_state.cards[card_id]: info = next((p for p in prints if p["print_id"] == print_id), None) source = front_image_source(card, info, self._repository) if info else None if source is not None: images.extend([source] * count) back = back_image_source(info) if info else None if back is not None: # double-sided: include both faces images.extend([back] * count) return images def _print_deck(self) -> None: images = self._collect_deck_images() if not images: messagebox.showinfo("Print Deck", "Add some cards to the deck first.", parent=self.win) return name = self._deck_name.get().strip() or "deck" default_filename = "".join(c if c.isalnum() else "_" for c in name.lower()) path = filedialog.asksaveasfilename( parent=self.win, title="Print Deck", defaultextension=".pdf", initialfile=f"{default_filename}.pdf", filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")], ) if not path: return from yasuki_core.deck_pdf import render_deck_pdf pages = render_deck_pdf(images, path) messagebox.showinfo( "Print Deck", f"Wrote {len(images)} cards across {pages} page(s).", parent=self.win ) def _export_deck(self) -> None: name = self._deck_name.get().strip() if not name: self._flash_name_entry() return yaml = serialize_deck( self._deck_state, self._repository, deck_name=name, deck_author=self._deck_author.get().strip(), ) default_filename = name.lower() default_filename = "".join(c if c.isalnum() else "_" for c in default_filename) path = filedialog.asksaveasfilename( parent=self.win, title="Export Deck", defaultextension=".yaml", initialfile=f"{default_filename}.yaml", filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")], ) if not path: return with open(path, "w", encoding="utf-8") as f: f.write(yaml) def _flash_name_entry(self) -> None: entry = self._name_entry entry.focus_set() original_bg = entry.cget("background") entry.configure(background="#ffcccc") def restore(): try: entry.configure(background=original_bg) except tk.TclError: pass self.win.after(800, restore) def _import_deck(self) -> None: path = filedialog.askopenfilename( parent=self.win, title="Import Deck", filetypes=[("YAML files", "*.yaml *.yml"), ("All files", "*.*")], ) if not path: return with open(path, encoding="utf-8") as f: text = f.read() new_state, deck_name, deck_author, unresolved = import_deck_yaml(text, self._repository) self._deck_state = new_state self._deck_name.set(deck_name) self._deck_author.set(deck_author) self._refresh_deck_lists() if unresolved: messagebox.showwarning( "Import Incomplete", f"Could not find {len(unresolved)} card(s):\n\n" + "\n".join(unresolved[:20]) + ("\n..." if len(unresolved) > 20 else ""), parent=self.win, ) def _add_selected(self) -> None: card_id = self.card_list.get_selected_card_id() if not card_id: return print_id = self._get_print_id_for_card(card_id) if print_id is None: logger.warning(f"No prints found for card {card_id}") return self._deck_state = self._deck_state.add_card(card_id, print_id) self._refresh_deck_lists() def _get_print_id_for_card(self, card_id: str) -> int | None: """Get print ID for card, preferring currently viewed print.""" if ( self.preview_controller.get_current_card_id() == card_id and self.preview_controller.get_current_print_id() ): return self.preview_controller.get_current_print_id() prints = self._repository.get_prints(card_id) return prints[0]["print_id"] if prints else None def _add_from_preview(self) -> None: card_id = self.preview_controller.get_current_card_id() print_id = self.preview_controller.get_current_print_id() if not card_id or not print_id: return self._deck_state = self._deck_state.add_card(card_id, print_id) self._refresh_deck_lists() def _borrow_art(self) -> None: base = self.preview_controller.current_recipient() if not base: messagebox.showinfo("Borrow Art", "Select a card with an image first.", parent=self.win) return card = self._repository.get_card(base["card_id"]) or {} recipient_key = classify(card, base["set_name"]) result = BorrowArtDialog( self.win, self._repository, base["image_path"], recipient_key ).show() if not result: return recipe = CustomPrint( base["card_id"], base["print_id"], result["donor_card_id"], result["donor_print_id"] ) custom_id = self._repository.register_custom_print(recipe) self.preview_controller.load_card(base["card_id"], preferred_print_id=custom_id) def _remove_from_fate(self) -> None: ids = self.fate_list.get_selected_ids() if not ids: return print_id, card_id = ids self._deck_state = self._deck_state.remove_card(card_id, print_id) self._refresh_deck_lists() def _remove_from_dynasty(self) -> None: ids = self.dynasty_list.get_selected_ids() if not ids: return print_id, card_id = ids self._deck_state = self._deck_state.remove_card(card_id, print_id) self._refresh_deck_lists() def _remove_from_setup(self) -> None: ids = self.setup_list.get_selected_ids() if not ids: return print_id, card_id = ids self._deck_state = self._deck_state.remove_card(card_id, print_id) self._refresh_deck_lists() def _refresh_deck_lists(self) -> None: self._updating_lists = True try: self.fate_list.refresh(self._deck_state) self.dynasty_list.refresh(self._deck_state) self.setup_list.refresh(self._deck_state) fate_count = self._deck_state.get_card_count("FATE", self._repository.cards_by_id) dynasty_count = self._deck_state.get_card_count("DYNASTY", self._repository.cards_by_id) setup_count = self._deck_state.get_card_count("SETUP", self._repository.cards_by_id) self.fate_label.config(text=f"Fate Deck ({fate_count})") self.dynasty_label.config(text=f"Dynasty Deck ({dynasty_count})") self.win.title( f"Deck Builder - Fate:{fate_count} Dynasty:{dynasty_count} Setup:{setup_count}" ) finally: self._updating_lists = False def _on_card_list_select(self) -> None: if self._updating_lists: return card_id = self.card_list.get_selected_card_id() if card_id: self.preview_controller.load_card(card_id) # Don't clear if no card selected - might be mid-click def _on_deck_list_select(self, deck_list: DeckCardList) -> None: if self._updating_lists: return ids = deck_list.get_selected_ids() if ids: print_id, card_id = ids self.preview_controller.load_card(card_id, print_id) # Don't clear if no selection - might be clicking on type header or mid-transition def _on_prev_print(self) -> None: self.preview_controller.prev_print() def _on_next_print(self) -> None: self.preview_controller.next_print()
[docs] def open_deck_builder(master: tk.Misc) -> DeckBuilderWindow: """ Open the deck builder window. Parameters ---------- master : tk.Misc Parent Tkinter widget Returns ------- window : DeckBuilderWindow The deck builder window instance """ return DeckBuilderWindow(master)