Files
UmaCardApplication/gui/card_view.py
2025-12-28 17:11:39 +00:00

560 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Card List View - Browse and search support cards with ownership management
"""
import tkinter as tk
from tkinter import ttk
import sys
import os
from PIL import Image, ImageTk
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from db.db_queries import get_all_cards, get_card_by_id, get_effects_at_level, set_card_owned, is_card_owned, update_owned_card_level
from utils import resolve_image_path
from gui.theme import (
BG_DARK, BG_MEDIUM, BG_LIGHT, BG_HIGHLIGHT,
ACCENT_PRIMARY, ACCENT_SECONDARY, ACCENT_SUCCESS,
TEXT_PRIMARY, TEXT_SECONDARY, TEXT_MUTED,
FONT_HEADER, FONT_SUBHEADER, FONT_BODY, FONT_BODY_BOLD, FONT_SMALL, FONT_MONO,
RARITY_COLORS, TYPE_COLORS, TYPE_ICONS,
create_styled_button, create_styled_text, create_card_frame,
get_rarity_color, get_type_color, get_type_icon,
EFFECT_DESCRIPTIONS, Tooltip
)
class CardListFrame(ttk.Frame):
"""Frame containing card list with search/filter, ownership, and details panel"""
def __init__(self, parent, on_card_selected_callback=None):
super().__init__(parent)
self.on_card_selected = on_card_selected_callback
self.cards = []
self.current_card_id = None
self.card_image = None # Keep reference to prevent garbage collection
self.icon_cache = {} # Cache for list icons
# Create main layout
self.create_widgets()
self.load_cards()
def create_widgets(self):
"""Create the card list interface"""
# Main horizontal layout
main_pane = ttk.PanedWindow(self, orient=tk.HORIZONTAL)
main_pane.pack(fill=tk.BOTH, expand=True)
# Left panel - Card list with filters
left_frame = ttk.Frame(main_pane, width=420)
main_pane.add(left_frame, weight=1)
# Right panel - Card details
self.details_frame = ttk.Frame(main_pane)
main_pane.add(self.details_frame, weight=2)
# === Left Panel Contents ===
# Initialize filter variables FIRST (before search trace can trigger filter_cards)
self.rarity_var = tk.StringVar(value="All")
self.type_var = tk.StringVar(value="All")
self.owned_only_var = tk.BooleanVar(value=False)
# Search bar with modern styling
search_frame = tk.Frame(left_frame, bg=BG_DARK)
search_frame.pack(fill=tk.X, padx=10, pady=10)
search_icon = tk.Label(search_frame, text="🔍", font=FONT_BODY, bg=BG_DARK, fg=TEXT_MUTED)
search_icon.pack(side=tk.LEFT, padx=(0, 5))
self.search_var = tk.StringVar()
self.search_entry = ttk.Entry(search_frame, textvariable=self.search_var, width=35)
self.search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Set placeholder BEFORE adding the trace (so it doesn't trigger filter)
self.search_entry.insert(0, "Search cards...")
self.search_entry.config(foreground=TEXT_MUTED)
self.search_entry.bind('<FocusIn>', self._on_search_focus_in)
self.search_entry.bind('<FocusOut>', self._on_search_focus_out)
# NOW add the trace (after placeholder is set)
self.search_var.trace('w', lambda *args: self.filter_cards())
# Filter dropdowns
filter_frame = tk.Frame(left_frame, bg=BG_DARK)
filter_frame.pack(fill=tk.X, padx=10, pady=(0, 10))
# Rarity filter
tk.Label(filter_frame, text="Rarity:", font=FONT_SMALL, bg=BG_DARK, fg=TEXT_MUTED).pack(side=tk.LEFT)
rarity_combo = ttk.Combobox(filter_frame, textvariable=self.rarity_var,
values=["All", "SSR", "SR", "R"], width=7, state='readonly')
rarity_combo.pack(side=tk.LEFT, padx=(5, 15))
rarity_combo.bind('<<ComboboxSelected>>', lambda e: self.filter_cards())
# Type filter
tk.Label(filter_frame, text="Type:", font=FONT_SMALL, bg=BG_DARK, fg=TEXT_MUTED).pack(side=tk.LEFT)
type_combo = ttk.Combobox(filter_frame, textvariable=self.type_var,
values=["All", "Speed", "Stamina", "Power", "Guts", "Wisdom", "Friend", "Group"],
width=10, state='readonly')
type_combo.pack(side=tk.LEFT, padx=5)
type_combo.bind('<<ComboboxSelected>>', lambda e: self.filter_cards())
# Owned only filter
owned_check = ttk.Checkbutton(filter_frame, text="Owned Only",
variable=self.owned_only_var, command=self.filter_cards)
owned_check.pack(side=tk.LEFT, padx=15)
# Reset Button
ttk.Button(filter_frame, text="Reset", command=self.reset_filters,
style='Small.TButton', width=7).pack(side=tk.LEFT, padx=5)
# Shortcuts
self.bind_all('<Control-f>', lambda e: self.search_entry.focus_set())
# Card count label
self.count_label = tk.Label(left_frame, text="0 cards", font=FONT_SMALL,
bg=BG_DARK, fg=ACCENT_PRIMARY)
self.count_label.pack(pady=5)
# Card list (Treeview)
list_frame = tk.Frame(left_frame, bg=BG_DARK)
list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
self.tree = ttk.Treeview(list_frame, columns=('owned', 'name', 'rarity', 'type'),
show='tree headings', selectmode='browse',
style="CardList.Treeview")
self.tree.heading('#0', text='')
self.tree.column('#0', width=45, anchor='center')
self.tree.heading('owned', text='', command=lambda: self.sort_column('owned', False))
self.tree.heading('name', text='Name', anchor='w', command=lambda: self.sort_column('name', False))
self.tree.heading('rarity', text='Rarity', command=lambda: self.sort_column('rarity', False))
self.tree.heading('type', text='Type', command=lambda: self.sort_column('type', False))
self.tree.column('owned', width=30, anchor='center')
self.tree.column('name', width=180, minwidth=150)
self.tree.column('rarity', width=55, anchor='center')
self.tree.column('type', width=90, anchor='center')
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.tree.yview)
self.tree.configure(yscrollcommand=scrollbar.set)
self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.tree.bind('<<TreeviewSelect>>', self.on_select)
# Tag for owned cards
self.tree.tag_configure('owned', background='#1a3a2e')
# === Right Panel Contents (Details) ===
self.create_details_panel()
def create_details_panel(self):
"""Create the card details panel"""
# Container with card-like appearance
details_container = tk.Frame(self.details_frame, bg=BG_DARK)
details_container.pack(fill=tk.BOTH, expand=True, padx=15, pady=10)
# Image area with card frame
image_frame = create_card_frame(details_container, padx=10, pady=10)
image_frame.pack(pady=10)
self.image_label = tk.Label(image_frame, text="", bg=BG_MEDIUM)
self.image_label.pack(padx=5, pady=5)
# Header with card name
self.detail_name = tk.Label(details_container, text="Select a card",
font=FONT_HEADER, bg=BG_DARK, fg=ACCENT_PRIMARY)
self.detail_name.pack(pady=(10, 5))
self.detail_info = tk.Label(details_container, text="",
font=FONT_SMALL, bg=BG_DARK, fg=TEXT_MUTED)
self.detail_info.pack()
# Owned checkbox with emphasis
owned_frame = tk.Frame(details_container, bg=BG_DARK)
owned_frame.pack(pady=15)
self.owned_var = tk.BooleanVar(value=False)
self.owned_checkbox = ttk.Checkbutton(owned_frame, text="✨ I Own This Card",
variable=self.owned_var,
command=self.toggle_owned,
style='Large.TCheckbutton')
self.owned_checkbox.pack(side=tk.LEFT)
# Level selector with button-based control (no slider)
level_frame = tk.Frame(details_container, bg=BG_DARK)
level_frame.pack(fill=tk.X, padx=30, pady=10)
tk.Label(level_frame, text="Card Level:", font=FONT_BODY,
bg=BG_DARK, fg=TEXT_SECONDARY).pack(side=tk.LEFT)
# Level display with increment/decrement
level_ctrl = tk.Frame(level_frame, bg=BG_DARK)
level_ctrl.pack(side=tk.LEFT, padx=15)
self.level_var = tk.IntVar(value=50)
self.max_level = 50
self.valid_levels = [30, 35, 40, 45, 50] # Default SSR
# Decrement button
dec_btn = tk.Button(level_ctrl, text="", font=FONT_HEADER,
bg=BG_LIGHT, fg=TEXT_PRIMARY, bd=0, width=2,
activebackground=BG_HIGHLIGHT, cursor='hand2',
command=self.decrement_level)
dec_btn.pack(side=tk.LEFT)
self.level_label = tk.Label(level_ctrl, text="50", width=4, font=FONT_HEADER,
bg=BG_MEDIUM, fg=ACCENT_PRIMARY, padx=10)
self.level_label.pack(side=tk.LEFT, padx=2)
# Increment button
inc_btn = tk.Button(level_ctrl, text="+", font=FONT_HEADER,
bg=BG_LIGHT, fg=TEXT_PRIMARY, bd=0, width=2,
activebackground=BG_HIGHLIGHT, cursor='hand2',
command=self.increment_level)
inc_btn.pack(side=tk.LEFT)
# Quick level buttons container
self.level_btn_frame = tk.Frame(level_frame, bg=BG_DARK)
self.level_btn_frame.pack(side=tk.LEFT, padx=20)
self.level_buttons = {}
# Initial population
self.update_level_buttons('SSR', 50)
# Effects display header
effects_header = tk.Frame(details_container, bg=BG_DARK)
effects_header.pack(fill=tk.X, padx=20, pady=(20, 10))
tk.Label(effects_header, text="📊 Effects at Current Level",
font=FONT_SUBHEADER, bg=BG_DARK, fg=TEXT_PRIMARY).pack(side=tk.LEFT)
# Effects text area with modern styling
effects_frame = create_card_frame(details_container)
effects_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=(0, 15))
self.effects_text = create_styled_text(effects_frame, height=10)
self.effects_text.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
self.effects_text.config(state=tk.DISABLED)
def load_cards(self):
"""Load all cards from database"""
self.cards = get_all_cards()
self.populate_tree(self.cards)
def reset_filters(self):
"""Reset all filters to default"""
self.search_var.set("")
self.rarity_var.set("All")
self.type_var.set("All")
self.owned_only_var.set(False)
# Reset placeholder
self.search_entry.delete(0, tk.END)
self.search_entry.insert(0, "Search cards...")
self.search_entry.config(foreground=TEXT_MUTED)
self.filter_cards()
def _on_search_focus_in(self, event):
"""Clear placeholder on focus"""
if self.search_entry.get() == "Search cards...":
self.search_entry.delete(0, tk.END)
self.search_entry.config(foreground=TEXT_PRIMARY)
def _on_search_focus_out(self, event):
"""Show placeholder if empty"""
if not self.search_entry.get():
self.search_entry.insert(0, "Search cards...")
self.search_entry.config(foreground=TEXT_MUTED)
def filter_cards(self):
"""Filter cards based on search and dropdown values"""
rarity = self.rarity_var.get() if self.rarity_var.get() != "All" else None
card_type = self.type_var.get() if self.type_var.get() != "All" else None
# Ignore placeholder text
search_text = self.search_var.get().strip()
search = search_text if search_text and search_text != "Search cards..." else None
owned_only = self.owned_only_var.get()
self.cards = get_all_cards(rarity_filter=rarity, type_filter=card_type,
search_term=search, owned_only=owned_only)
self.populate_tree(self.cards)
def sort_column(self, col, reverse):
"""Sort treeview by column"""
l = [(self.tree.set(k, col), k) for k in self.tree.get_children('')]
# Custom sort logic
if col == 'owned':
# Sort by star/empty
l.sort(key=lambda t: t[0] if t[0] else "", reverse=reverse)
elif col == 'rarity':
# Sort by rarity rank (SSR > SR > R)
rarity_map = {'SSR': 3, 'SR': 2, 'R': 1}
l.sort(key=lambda t: rarity_map.get(t[0], 0), reverse=reverse)
else:
# Default string sort
l.sort(reverse=reverse)
# Rearrange items
for index, (val, k) in enumerate(l):
self.tree.move(k, '', index)
# Reverse sort next time
self.tree.heading(col, command=lambda: self.sort_column(col, not reverse))
def populate_tree(self, cards):
"""Populate treeview with cards"""
self.tree.delete(*self.tree.get_children())
for card in cards:
card_id, name, rarity, card_type, max_level, image_path, is_owned, owned_level = card
type_icon = get_type_icon(card_type)
owned_mark = "" if is_owned else ""
tag = 'owned' if is_owned else ''
# Show level for owned cards
display_name = name
if is_owned and owned_level:
display_name = f"{name} (Lv{owned_level})"
# Load Icon
img = self.icon_cache.get(card_id)
if not img:
resolved_path = resolve_image_path(image_path)
if resolved_path and os.path.exists(resolved_path):
try:
pil_img = Image.open(resolved_path)
pil_img.thumbnail((32, 32), Image.Resampling.LANCZOS)
img = ImageTk.PhotoImage(pil_img)
self.icon_cache[card_id] = img
except:
pass
if img:
self.tree.insert('', tk.END, iid=card_id, text='', image=img,
values=(owned_mark, display_name, rarity, f"{type_icon} {card_type}"),
tags=(tag,))
else:
self.tree.insert('', tk.END, iid=card_id, text='',
values=(owned_mark, display_name, rarity, f"{type_icon} {card_type}"),
tags=(tag,))
self.count_label.config(text=f"{len(cards)} cards")
def on_select(self, event):
"""Handle card selection"""
selection = self.tree.selection()
if not selection:
return
card_id = int(selection[0])
card = get_card_by_id(card_id)
if card:
card_id, name, rarity, card_type, max_level, url, image_path, is_owned, owned_level = card
# Update owned checkbox
self.owned_var.set(bool(is_owned))
# Load card image if available
self.load_card_image(image_path)
# Use owned level if owned, otherwise max level or default 50
initial_level = owned_level if is_owned and owned_level else max_level
# Update level controls
self.max_level = max_level
self.update_level_buttons(rarity, max_level)
# Snap initial level to valid levels
if initial_level not in self.valid_levels:
# Find closest or default to max
initial_level = max_level
self.level_var.set(initial_level)
self.level_label.config(text=str(initial_level))
# Update details display with colors
type_icon = get_type_icon(card_type)
type_color = get_type_color(card_type)
rarity_color = get_rarity_color(rarity)
self.detail_name.config(text=f"{type_icon} {name}", fg=ACCENT_PRIMARY)
self.detail_info.config(text=f"{rarity}{card_type} │ Max Level: {max_level}")
# Load effects
self.current_card_id = card_id
self.update_effects_display()
# Notify parent window
if self.on_card_selected:
self.on_card_selected(card_id, name)
def load_card_image(self, image_path):
"""Load and display card image"""
resolved_path = resolve_image_path(image_path)
if resolved_path and os.path.exists(resolved_path):
try:
img = Image.open(resolved_path)
img.thumbnail((130, 130)) # Slightly larger
self.card_image = ImageTk.PhotoImage(img)
self.image_label.config(image=self.card_image)
except Exception as e:
self.image_label.config(image='', text="[Image not found]")
else:
self.image_label.config(image='', text="")
def toggle_owned(self):
"""Toggle owned status for current card"""
if self.current_card_id:
owned = self.owned_var.get()
level = int(self.level_var.get())
set_card_owned(self.current_card_id, owned, level)
self.filter_cards() # Refresh list to update owned markers
def update_level_buttons(self, rarity, max_level):
"""Update quick level buttons based on rarity/max level"""
# Determine valid levels
if max_level == 50: # SSR
self.valid_levels = [30, 35, 40, 45, 50]
elif max_level == 45: # SR
self.valid_levels = [25, 30, 35, 40, 45]
else: # R (max 40)
self.valid_levels = [20, 25, 30, 35, 40]
# Clear existing buttons
for widget in self.level_btn_frame.winfo_children():
widget.destroy()
self.level_buttons = {}
# Create new buttons
for lvl in self.valid_levels:
btn = create_styled_button(self.level_btn_frame, text=f"Lv{lvl}",
command=lambda l=lvl: self.set_level(l),
style_type='default')
btn.config(width=5, padx=6, pady=3, font=FONT_SMALL)
btn.pack(side=tk.LEFT, padx=2)
self.level_buttons[lvl] = btn
def set_level(self, level):
"""Set level from quick button"""
self.level_var.set(level)
self.level_label.config(text=str(level))
self.update_effects_display()
# Save level if owned
if self.current_card_id and self.owned_var.get():
update_owned_card_level(self.current_card_id, level)
self.update_tree_item_level(self.current_card_id, level)
def increment_level(self):
"""Increase level to next valid step"""
current = self.level_var.get()
# Find next level in valid_levels
for lvl in self.valid_levels:
if lvl > current:
self.set_level(lvl)
return
def decrement_level(self):
"""Decrease level to previous valid step"""
current = self.level_var.get()
# Find previous level in valid_levels
for lvl in reversed(self.valid_levels):
if lvl < current:
self.set_level(lvl)
return
def update_tree_item_level(self, card_id, level):
"""Update visible name in tree without full reload"""
if self.tree.exists(card_id):
current_values = self.tree.item(card_id, 'values')
if current_values:
# current_values is a tuple: (owned_mark, name, rarity, type)
# We need to strip existing " (LvXX)" from name if present
name = current_values[1]
base_name = name.split(" (Lv")[0]
new_name = f"{base_name} (Lv{level})"
# Make new values tuple preserving other columns
new_values = (current_values[0], new_name, current_values[2], current_values[3])
self.tree.item(card_id, values=new_values)
def update_effects_display(self):
"""Update the effects display for current card and level"""
if not self.current_card_id:
return
level = int(self.level_var.get())
effects = get_effects_at_level(self.current_card_id, level)
self.effects_text.config(state=tk.NORMAL)
self.effects_text.delete('1.0', tk.END)
# Configure tags for styling
self.effects_text.tag_configure('header', font=FONT_SUBHEADER, foreground=ACCENT_PRIMARY)
self.effects_text.tag_configure('highlight', foreground=ACCENT_SUCCESS)
self.effects_text.tag_configure('effect_name', foreground=TEXT_SECONDARY)
self.effects_text.tag_configure('effect_value', foreground=TEXT_PRIMARY)
self.effects_text.tag_configure('effect_tooltip', underline=False)
if effects:
self.effects_text.insert(tk.END, f"━━━ Level {level} ━━━\n\n", 'header')
for name, value in effects:
# Highlight high values
prefix = ""
if '%' in str(value):
try:
num = int(str(value).replace('%', '').replace('+', ''))
if num >= 20:
prefix = ""
except:
pass
if prefix:
self.effects_text.insert(tk.END, prefix, 'highlight')
# Insert effect name with tooltip tag
tag_name = f"tooltip_{name.replace(' ', '_')}"
self.effects_text.insert(tk.END, f"{name}: ", ('effect_name', tag_name))
# Bind tooltip events
self.effects_text.tag_bind(tag_name, "<Enter>", lambda e, n=name: self.show_effect_tooltip(e, n))
self.effects_text.tag_bind(tag_name, "<Leave>", self.hide_effect_tooltip)
self.effects_text.insert(tk.END, f"{value}\n", 'effect_value')
else:
self.effects_text.insert(tk.END, f"No effects data for Level {level}\n\n")
self.effects_text.insert(tk.END, "Available levels: 1, 25, 40, 50\n", 'effect_name')
self.effects_text.config(state=tk.DISABLED)
def show_effect_tooltip(self, event, effect_name):
"""Show tooltip for effect"""
if effect_name in EFFECT_DESCRIPTIONS:
text = EFFECT_DESCRIPTIONS[effect_name]
x = event.x_root + 15
y = event.y_root + 10
# Close existing if any
self.hide_effect_tooltip(None)
self.tooltip_window = tk.Toplevel(self)
self.tooltip_window.wm_overrideredirect(True)
self.tooltip_window.wm_geometry(f"+{x}+{y}")
label = tk.Label(self.tooltip_window, text=text, justify=tk.LEFT,
background=BG_LIGHT, foreground=TEXT_PRIMARY,
relief=tk.SOLID, borderwidth=1, font=FONT_SMALL,
padx=10, pady=5, wraplength=250)
label.pack()
def hide_effect_tooltip(self, event):
"""Hide tooltip"""
if hasattr(self, 'tooltip_window') and self.tooltip_window:
self.tooltip_window.destroy()
self.tooltip_window = None