"""CS2 skin pattern fetching subtool.""" from __future__ import annotations import json import threading from importlib import resources from pathlib import Path from typing import Any, Iterable, Optional import tkinter as tk import tkinter.font as tkfont from tkinter import filedialog, messagebox, ttk import requests from PIL import Image, ImageTk from urllib.parse import urlparse from app.logic import IMAGES_DIR class CS2PatternFetcher: """Fetch CS2 skin metadata and download pattern images.""" DATA_URL = "https://bymykel.github.io/CSGO-API/api/skins.json" def __init__(self, cache_dir: Path | None = None): self.cache_dir = cache_dir or Path.home() / ".icra" self.cache_path = self.cache_dir / "cs2_skins.json" self._data: list[dict[str, Any]] | None = None self._session: requests.Session | None = None def ensure_data(self, *, force_refresh: bool = False) -> list[dict[str, Any]]: if not force_refresh and self._data is not None: return self._data if not force_refresh: cached = self._load_cache() if cached is not None: self._data = cached return cached data = self._download() self._data = data self._write_cache(data) return data def list_weapons(self) -> list[str]: data = self.ensure_data() weapons = {self._weapon_name(item) for item in data} return sorted(filter(None, weapons)) def list_patterns(self, weapon: str) -> list[str]: data = self.ensure_data() patterns = { self._pattern_name(item) for item in data if self._weapon_name(item) == weapon } return sorted(filter(None, patterns)) def find_entry(self, weapon: str, pattern: str) -> Optional[dict[str, Any]]: data = self.ensure_data() for item in data: if self._weapon_name(item) == weapon and self._pattern_name(item) == pattern: return item return None def download_pattern_image( self, entry: dict[str, Any], target_dir: Path, ) -> Path: image_url = self._image_url(entry) if not image_url: raise ValueError("Selected pattern does not provide an image URL.") target_dir.mkdir(parents=True, exist_ok=True) weapon_slug = self._slugify(self._weapon_name(entry)) pattern_slug = self._slugify(self._pattern_name(entry)) suffix = self._infer_suffix(image_url) filename = f"{weapon_slug}__{pattern_slug}{suffix}" destination = target_dir / filename counter = 1 while destination.exists(): destination = target_dir / f"{weapon_slug}__{pattern_slug}_{counter}{suffix}" counter += 1 session = self._session or requests.Session() response = session.get(image_url, timeout=30) response.raise_for_status() destination.write_bytes(response.content) return destination # Internal helpers ------------------------------------------------- def _load_cache(self) -> list[dict[str, Any]] | None: try: if self.cache_path.exists(): return json.loads(self.cache_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None return None def _write_cache(self, data: Iterable[dict[str, Any]]) -> None: try: self.cache_dir.mkdir(parents=True, exist_ok=True) self.cache_path.write_text(json.dumps(list(data)), encoding="utf-8") except OSError: pass def _download(self) -> list[dict[str, Any]]: self._session = self._session or requests.Session() response = self._session.get(self.DATA_URL, timeout=30) response.raise_for_status() payload = response.json() if isinstance(payload, dict): # some mirrors wrap the payload in a top-level dict payload = payload.get("skins") or payload.get("data") or [] if not isinstance(payload, list): raise ValueError("Unexpected CS2 skin data format.") return payload @staticmethod def _weapon_name(entry: dict[str, Any]) -> str: weapon = entry.get("weapon") if isinstance(weapon, dict): for key in ("name", "value", "english", "label"): if weapon.get(key): return str(weapon[key]) if weapon: return str(weapon) return str(entry.get("weapon_name") or entry.get("weaponId") or "Unknown") @staticmethod def _pattern_name(entry: dict[str, Any]) -> str: pattern = entry.get("pattern") if isinstance(pattern, dict): for key in ("name", "value", "english", "label"): if pattern.get(key): return str(pattern[key]) if pattern: return str(pattern) return str(entry.get("name") or entry.get("skin") or entry.get("title") or "Pattern") @staticmethod def _image_url(entry: dict[str, Any]) -> Optional[str]: for key in ("image", "image_url", "url"): value = entry.get(key) if isinstance(value, str) and value.startswith("http"): return value media = entry.get("media") if isinstance(media, dict): for key in ("image", "large", "icon"): value = media.get(key) if isinstance(value, str) and value.startswith("http"): return value return None @staticmethod def _slugify(text: str | None) -> str: if not text: return "item" cleaned = "".join(ch.lower() if ch.isalnum() else "-" for ch in text) cleaned = "-".join(filter(None, cleaned.split("-"))) return cleaned or "item" @staticmethod def _infer_suffix(url: str) -> str: parsed = urlparse(url) path = Path(parsed.path) suffix = path.suffix.lower() if suffix in {".png", ".jpg", ".jpeg", ".webp"}: return suffix return ".png" class CS2PatternTool(tk.Toplevel): """Tkinter UI wrapper around CS2PatternFetcher.""" def __init__(self, app) -> None: super().__init__(app.root) self.app = app self.fetcher = CS2PatternFetcher() self.theme = getattr(self.app, "theme", "light") self.title(self._t("cs2.title")) self.geometry("560x380") self.minsize(520, 320) self.resizable(True, True) self._drag_offset: tuple[int, int] | None = None self._setup_window() self.body = ttk.Frame(self) self.body.pack(fill=tk.BOTH, expand=True, padx=16, pady=(12, 16)) self._toolbar_buttons: list[dict[str, Any]] = [] self.weapons_var = tk.StringVar() self.patterns_var = tk.StringVar() self.directory_var = tk.StringVar(value=str(IMAGES_DIR.resolve())) self.status_var = tk.StringVar(value=self._t("cs2.status_loading")) self._init_widgets() self._data_loaded = False self._load_thread: Optional[threading.Thread] = None self._start_loading() self.protocol("WM_DELETE_WINDOW", self._on_close) self._bring_to_front() # UI construction -------------------------------------------------- def _init_widgets(self) -> None: frame = self.body toolbar = ttk.Frame(frame) toolbar.pack(fill=tk.X, pady=(0, 16)) for icon, label, command in self._toolbar_button_defs(): self._add_toolbar_button(toolbar, icon, label, command) top = ttk.Frame(frame) top.pack(fill=tk.X, pady=(0, 12)) ttk.Label(top, text=self._t("cs2.weapon_label")).grid(row=0, column=0, sticky="w") self.weapon_combo = ttk.Combobox( top, textvariable=self.weapons_var, state="disabled" ) self.weapon_combo.grid(row=1, column=0, sticky="we", padx=(0, 12)) self.weapon_combo.bind("<>", self._on_weapon_selected) ttk.Label(top, text=self._t("cs2.pattern_label")).grid( row=0, column=1, sticky="w" ) self.pattern_combo = ttk.Combobox( top, textvariable=self.patterns_var, state="disabled" ) self.pattern_combo.grid(row=1, column=1, sticky="we") self.pattern_combo.bind("<>", self._on_pattern_selected) top.columnconfigure(0, weight=1) top.columnconfigure(1, weight=1) dir_frame = ttk.Frame(frame) dir_frame.pack(fill=tk.X, pady=(0, 12)) ttk.Label(dir_frame, text=self._t("cs2.output_label")).grid( row=0, column=0, sticky="w" ) entry = ttk.Entry(dir_frame, textvariable=self.directory_var) entry.grid(row=1, column=0, sticky="we", padx=(0, 8)) dir_frame.columnconfigure(0, weight=1) status_label = ttk.Label( frame, textvariable=self.status_var, anchor="w", justify="left" ) status_label.pack(fill=tk.X) self._refresh_toolbar_buttons_theme() def _setup_window(self) -> None: self.overrideredirect(True) self.configure(bg=self._background_colour()) self._create_titlebar() def _create_titlebar(self) -> None: bar_bg = "#1f1f1f" title_bar = tk.Frame(self, bg=bar_bg, relief="flat", height=34) title_bar.pack(fill=tk.X, side=tk.TOP) title_bar.pack_propagate(False) logo = None try: logo_resource = resources.files("app.assets").joinpath("logo.png") with resources.as_file(logo_resource) as logo_path: image = Image.open(logo_path).convert("RGBA") image.thumbnail((26, 26)) logo = ImageTk.PhotoImage(image) except Exception: # noqa: BLE001 logo = None if logo is not None: logo_label = tk.Label(title_bar, image=logo, bg=bar_bg) logo_label.image = logo logo_label.pack(side=tk.LEFT, padx=(10, 6), pady=4) else: logo_label = None title_label = tk.Label( title_bar, text=self._t("cs2.title"), bg=bar_bg, fg="#f5f5f5", font=("Segoe UI", 11, "bold"), anchor="w", ) title_label.pack(side=tk.LEFT, padx=6) close_btn = tk.Button( title_bar, text="✕", command=self._on_close, bg=bar_bg, fg="#f5f5f5", activebackground="#ff3b30", activeforeground="#ffffff", borderwidth=0, highlightthickness=0, relief="flat", font=("Segoe UI", 10, "bold"), cursor="hand2", width=3, ) close_btn.pack(side=tk.RIGHT, padx=8, pady=4) close_btn.bind("", lambda _e: close_btn.configure(bg="#cf212f")) close_btn.bind("", lambda _e: close_btn.configure(bg=bar_bg)) bind_targets = [title_bar, title_label] if logo_label is not None: bind_targets.append(logo_label) for widget in bind_targets: widget.bind("", self._start_window_drag) widget.bind("", self._perform_window_drag) def _bring_to_front(self) -> None: try: self.transient(self.app.root) self.lift() self.focus_force() except Exception: # noqa: BLE001 pass # Data loading ----------------------------------------------------- def _start_loading(self) -> None: if self._load_thread and self._load_thread.is_alive(): return self.status_var.set(self._t("cs2.status_loading")) self.weapon_combo.configure(state="disabled", values=[]) self.pattern_combo.configure(state="disabled", values=[]) self._load_thread = threading.Thread(target=self._load_data, daemon=True) self._load_thread.start() def _load_data(self, force_refresh: bool = False) -> None: try: weapons = self.fetcher.list_weapons() if not force_refresh else None if force_refresh: self.fetcher.ensure_data(force_refresh=True) weapons = self.fetcher.list_weapons() except Exception as exc: # noqa: BLE001 self.after(0, lambda: self._on_load_failed(exc)) return self.after(0, lambda: self._on_data_ready(weapons or [])) def _on_data_ready(self, weapons: list[str]) -> None: if not weapons: self.status_var.set(self._t("cs2.status_empty")) return self.weapon_combo.configure(state="readonly", values=weapons) self.weapon_combo.set(weapons[0]) self._populate_patterns(weapons[0]) self.status_var.set(self._t("cs2.status_ready")) self._data_loaded = True def _on_load_failed(self, exc: Exception) -> None: self.status_var.set( self._t("cs2.status_error").format(error=str(exc)) ) messagebox.showerror( self._t("dialog.error_title"), self._t("cs2.status_error").format(error=str(exc)), parent=self, ) def _refresh_data(self) -> None: self._start_loading() thread = threading.Thread( target=self._load_data, kwargs={"force_refresh": True}, daemon=True ) thread.start() def _populate_patterns(self, weapon: str) -> None: try: patterns = self.fetcher.list_patterns(weapon) except Exception as exc: # noqa: BLE001 self.status_var.set( self._t("cs2.status_error").format(error=str(exc)) ) return self.pattern_combo.configure(state="readonly", values=patterns) if patterns: self.pattern_combo.set(patterns[0]) else: self.pattern_combo.set("") # Event handlers --------------------------------------------------- def _on_weapon_selected(self, event=None) -> None: # noqa: ANN001 weapon = self.weapons_var.get() if weapon: self._populate_patterns(weapon) def _on_pattern_selected(self, event=None) -> None: # noqa: ANN001 return def _browse_directory(self) -> None: directory = filedialog.askdirectory(parent=self, mustexist=True) if directory: self.directory_var.set(directory) def _download_selected(self) -> None: weapon = self.weapons_var.get() pattern = self.patterns_var.get() if not weapon: messagebox.showinfo( self._t("dialog.info_title"), self._t("cs2.no_weapon"), parent=self ) return if not pattern: messagebox.showinfo( self._t("dialog.info_title"), self._t("cs2.no_pattern"), parent=self ) return target_dir = Path(self.directory_var.get()).expanduser() entry = self.fetcher.find_entry(weapon, pattern) if entry is None: messagebox.showerror( self._t("dialog.error_title"), self._t("cs2.pattern_missing"), parent=self, ) return try: path = self.fetcher.download_pattern_image(entry, target_dir) except requests.RequestException as exc: messagebox.showerror( self._t("dialog.error_title"), self._t("cs2.download_error").format(error=str(exc)), parent=self, ) return except Exception as exc: # noqa: BLE001 messagebox.showerror( self._t("dialog.error_title"), self._t("cs2.download_error").format(error=str(exc)), parent=self, ) return messagebox.showinfo( self._t("dialog.saved_title"), self._t("cs2.download_success").format(path=path), parent=self, ) def _start_window_drag(self, event) -> None: # noqa: ANN001 self._drag_offset = ( event.x_root - self.winfo_rootx(), event.y_root - self.winfo_rooty(), ) def _perform_window_drag(self, event) -> None: # noqa: ANN001 offset = getattr(self, "_drag_offset", None) if offset is None: return x = event.x_root - offset[0] y = event.y_root - offset[1] self.geometry(f"+{x}+{y}") def _toolbar_button_defs(self) -> list[tuple[str, str, Any]]: return [ ("🔄", self._t("cs2.refresh_button"), self._refresh_data), ("⭳", self._t("cs2.download_button"), self._download_selected), ("📁", self._t("cs2.browse_button"), self._browse_directory), ] def _toolbar_palette(self) -> dict[str, str]: if getattr(self, "theme", "light") == "dark": return { "normal": "#2f2f35", "hover": "#3a3a40", "active": "#1f1f25", "outline": "#4d4d50", "outline_focus": "#7c7c88", "text": "#f1f1f5", } return { "normal": "#ffffff", "hover": "#ededf4", "active": "#dcdce6", "outline": "#d0d0d8", "outline_focus": "#a9a9b2", "text": "#1f1f1f", } def _add_toolbar_button(self, parent, icon: str, label: str, command) -> None: font = tkfont.Font(root=self, family="Segoe UI", size=9) padding_x = 12 gap = font.measure(" ") icon_width = font.measure(icon) or font.measure(" ") label_width = font.measure(label) width = padding_x * 2 + icon_width + gap + label_width height = 28 radius = 9 bg = self._background_colour() canvas = tk.Canvas( parent, width=width, height=height, bd=0, highlightthickness=0, bg=bg, relief="flat", cursor="hand2", takefocus=1, ) canvas.pack(side=tk.LEFT, padx=4, pady=1) palette = self._toolbar_palette() rect_id = self._create_round_rect( canvas, 1, 1, width - 1, height - 1, radius, fill=palette["normal"], outline=palette["outline"], width=1, ) icon_id = canvas.create_text( padding_x, height / 2, text=icon, font=font, fill=palette["text"], anchor="w", ) label_id = canvas.create_text( padding_x + icon_width + gap, height / 2, text=label, font=font, fill=palette["text"], anchor="w", ) button_data = { "canvas": canvas, "rect": rect_id, "text_ids": (icon_id, label_id), "command": command, "palette": palette.copy(), } self._toolbar_buttons.append(button_data) def set_fill(state: str) -> None: pal = button_data["palette"] canvas.itemconfigure(rect_id, fill=pal[state]) def execute(): command() def on_press(_event=None): set_fill("active") def on_release(event=None): if event is not None and ( event.x < 0 or event.y < 0 or event.x > width or event.y > height ): set_fill("normal") return set_fill("hover") canvas.after_idle(execute) def on_enter(_event): set_fill("hover") def on_leave(_event): set_fill("normal") def on_focus_in(_event): pal = button_data["palette"] canvas.itemconfigure(rect_id, outline=pal["outline_focus"]) def on_focus_out(_event): pal = button_data["palette"] canvas.itemconfigure(rect_id, outline=pal["outline"]) def invoke_keyboard(_event=None): set_fill("active") canvas.after(120, lambda: set_fill("hover")) canvas.after_idle(execute) canvas.bind("", on_press) canvas.bind("", on_release) canvas.bind("", on_enter) canvas.bind("", on_leave) canvas.bind("", on_focus_in) canvas.bind("", on_focus_out) canvas.bind("", invoke_keyboard) canvas.bind("", invoke_keyboard) def _refresh_toolbar_buttons_theme(self) -> None: if not self._toolbar_buttons: return palette = self._toolbar_palette() bg = self._background_colour() for data in self._toolbar_buttons: canvas = data["canvas"] rect = data["rect"] text_ids = data["text_ids"] data["palette"] = palette.copy() canvas.configure(bg=bg) canvas.itemconfigure(rect, fill=palette["normal"], outline=palette["outline"]) for text_id in text_ids: canvas.itemconfigure(text_id, fill=palette["text"]) @staticmethod def _create_round_rect(canvas: tk.Canvas, x1, y1, x2, y2, radius, **kwargs): points = [ x1 + radius, y1, x2 - radius, y1, x2, y1, x2, y1 + radius, x2, y2 - radius, x2, y2, x2 - radius, y2, x1 + radius, y2, x1, y2, x1, y2 - radius, x1, y1 + radius, x1, y1, ] return canvas.create_polygon(points, smooth=True, splinesteps=24, **kwargs) def _background_colour(self) -> str: return "#0f0f10" if getattr(self, "theme", "light") == "dark" else "#ffffff" def _t(self, key: str) -> str: translator = getattr(self.app, "translator", None) if translator is not None: return translator.translate(key) if hasattr(self.app, "_t"): return self.app._t(key) # type: ignore[attr-defined] return key def _on_close(self) -> None: if hasattr(self.app, "_cs2_tool_window"): self.app._cs2_tool_window = None # type: ignore[attr-defined] self.destroy() def open_cs2_pattern_tool(app) -> CS2PatternTool: """Launch (or focus) the CS2 pattern tool.""" existing = getattr(app, "_cs2_tool_window", None) if existing is not None and isinstance(existing, tk.Toplevel): try: if existing.winfo_exists(): existing.lift() existing.focus_force() return existing except Exception: # noqa: BLE001 pass window = CS2PatternTool(app) app._cs2_tool_window = window # type: ignore[attr-defined] return window