Compare commits
7 Commits
master
...
pull_image
| Author | SHA1 | Date |
|---|---|---|
|
|
416e78ad4b | |
|
|
856a171c66 | |
|
|
364b3e46d5 | |
|
|
717ac72f36 | |
|
|
5bfdd83e90 | |
|
|
07983f292d | |
|
|
ff66aeb3c3 |
|
|
@ -13,11 +13,13 @@
|
||||||
- Theme toggle (light/dark) with rounded toolbar buttons and accent-aware highlights
|
- Theme toggle (light/dark) with rounded toolbar buttons and accent-aware highlights
|
||||||
- Folder support with wrap-around previous/next navigation
|
- Folder support with wrap-around previous/next navigation
|
||||||
- Quick overlay export (PNG) with configurable defaults and language settings via `config.toml`
|
- Quick overlay export (PNG) with configurable defaults and language settings via `config.toml`
|
||||||
|
- Integrated CS2 pattern fetcher to download weapon skin artwork for reference
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
- Python 3.11+ (3.10 works with `tomli`)
|
- Python 3.11+ (3.10 works with `tomli`)
|
||||||
- [uv](https://github.com/astral-sh/uv) for dependency management
|
- [uv](https://github.com/astral-sh/uv) for dependency management
|
||||||
- Tkinter (install separately on some Linux distros)
|
- Tkinter (install separately on some Linux distros)
|
||||||
|
- Internet access if you plan to use the CS2 pattern fetcher subtool
|
||||||
|
|
||||||
## Setup with uv (Windows PowerShell)
|
## Setup with uv (Windows PowerShell)
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -37,7 +39,8 @@ On macOS/Linux activate with `source .venv/bin/activate` instead.
|
||||||
3. Fine‑tune sliders; watch the overlay update on the right.
|
3. Fine‑tune sliders; watch the overlay update on the right.
|
||||||
4. Toggle freehand mode (`△`) or stick with rectangles and mark areas to exclude (right mouse drag).
|
4. Toggle freehand mode (`△`) or stick with rectangles and mark areas to exclude (right mouse drag).
|
||||||
5. Move through folder images with `⬅️` / `➡️`; exclusions stay put unless you opt into automatic resets.
|
5. Move through folder images with `⬅️` / `➡️`; exclusions stay put unless you opt into automatic resets.
|
||||||
6. Save an overlay (`💾`) when ready.
|
6. Fetch CS2 pattern images (`⭳`) whenever you need additional references.
|
||||||
|
7. Save an overlay (`💾`) when ready.
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
```
|
```
|
||||||
|
|
@ -45,6 +48,7 @@ app/
|
||||||
app.py # main app assembly
|
app.py # main app assembly
|
||||||
gui/ # UI, theme, picker mixins
|
gui/ # UI, theme, picker mixins
|
||||||
logic/ # image ops, defaults, config helpers
|
logic/ # image ops, defaults, config helpers
|
||||||
|
tools/ # auxiliary tools (e.g., CS2 pattern fetcher)
|
||||||
lang/ # localisation TOML files
|
lang/ # localisation TOML files
|
||||||
config.toml # optional defaults
|
config.toml # optional defaults
|
||||||
main.py # entry point
|
main.py # entry point
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ class ICRAApp(
|
||||||
self._exclude_canvas_ids: list[int] = []
|
self._exclude_canvas_ids: list[int] = []
|
||||||
self._current_stroke: list[tuple[int, int]] | None = None
|
self._current_stroke: list[tuple[int, int]] | None = None
|
||||||
self.free_draw_width = 14
|
self.free_draw_width = 14
|
||||||
|
self._cs2_tool_window = None
|
||||||
self.pick_mode = False
|
self.pick_mode = False
|
||||||
|
|
||||||
# Image references
|
# Image references
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||||
import colorsys
|
import colorsys
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
import tkinter.font as tkfont
|
import tkinter.font as tkfont
|
||||||
from tkinter import ttk
|
from tkinter import messagebox, ttk
|
||||||
|
|
||||||
|
|
||||||
class UIBuilderMixin:
|
class UIBuilderMixin:
|
||||||
|
|
@ -26,6 +26,7 @@ class UIBuilderMixin:
|
||||||
("🧹", self._t("toolbar.clear_excludes"), self.clear_excludes),
|
("🧹", self._t("toolbar.clear_excludes"), self.clear_excludes),
|
||||||
("↩", self._t("toolbar.undo_exclude"), self.undo_exclude),
|
("↩", self._t("toolbar.undo_exclude"), self.undo_exclude),
|
||||||
("🔄", self._t("toolbar.reset_sliders"), self.reset_sliders),
|
("🔄", self._t("toolbar.reset_sliders"), self.reset_sliders),
|
||||||
|
("⭳", self._t("toolbar.cs2_tool"), self.open_cs2_pattern_tool),
|
||||||
("🌓", self._t("toolbar.toggle_theme"), self.toggle_theme),
|
("🌓", self._t("toolbar.toggle_theme"), self.toggle_theme),
|
||||||
]
|
]
|
||||||
self._toolbar_buttons: list[dict[str, object]] = []
|
self._toolbar_buttons: list[dict[str, object]] = []
|
||||||
|
|
@ -522,6 +523,18 @@ class UIBuilderMixin:
|
||||||
activeforeground=palette["fg"],
|
activeforeground=palette["fg"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def open_cs2_pattern_tool(self) -> None:
|
||||||
|
try:
|
||||||
|
from app.tools import open_cs2_pattern_tool as _open_cs2_pattern_tool
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
messagebox.showerror(
|
||||||
|
self._t("dialog.error_title"),
|
||||||
|
self._t("cs2.launch_error").format(error=str(exc)),
|
||||||
|
parent=getattr(self, "root", None),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
_open_cs2_pattern_tool(self)
|
||||||
|
|
||||||
def _canvas_background_colour(self) -> str:
|
def _canvas_background_colour(self) -> str:
|
||||||
return "#0f0f10" if getattr(self, "theme", "light") == "dark" else "#ffffff"
|
return "#0f0f10" if getattr(self, "theme", "light") == "dark" else "#ffffff"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"toolbar.save_overlay" = "Overlay speichern"
|
"toolbar.save_overlay" = "Overlay speichern"
|
||||||
"toolbar.clear_excludes" = "Ausschlüsse löschen"
|
"toolbar.clear_excludes" = "Ausschlüsse löschen"
|
||||||
"toolbar.toggle_free_draw" = "Freihandmodus umschalten"
|
"toolbar.toggle_free_draw" = "Freihandmodus umschalten"
|
||||||
|
"toolbar.cs2_tool" = "Bilder abrufen"
|
||||||
"toolbar.undo_exclude" = "Letzten Ausschluss entfernen"
|
"toolbar.undo_exclude" = "Letzten Ausschluss entfernen"
|
||||||
"toolbar.reset_sliders" = "Slider zurücksetzen"
|
"toolbar.reset_sliders" = "Slider zurücksetzen"
|
||||||
"toolbar.toggle_theme" = "Theme umschalten"
|
"toolbar.toggle_theme" = "Theme umschalten"
|
||||||
|
|
@ -59,3 +60,20 @@
|
||||||
"dialog.no_image_loaded" = "Kein Bild geladen."
|
"dialog.no_image_loaded" = "Kein Bild geladen."
|
||||||
"dialog.no_preview_available" = "Keine Preview vorhanden."
|
"dialog.no_preview_available" = "Keine Preview vorhanden."
|
||||||
"dialog.overlay_saved" = "Overlay gespeichert: {path}"
|
"dialog.overlay_saved" = "Overlay gespeichert: {path}"
|
||||||
|
"cs2.title" = "CS2 Muster Downloader"
|
||||||
|
"cs2.status_loading" = "Waffendaten werden geladen..."
|
||||||
|
"cs2.status_ready" = "Daten geladen. Waffe und Muster auswählen."
|
||||||
|
"cs2.status_error" = "CS2-Daten konnten nicht geladen werden: {error}"
|
||||||
|
"cs2.status_empty" = "Keine Waffen in der Datenquelle verfügbar."
|
||||||
|
"cs2.weapon_label" = "Waffe"
|
||||||
|
"cs2.pattern_label" = "Muster"
|
||||||
|
"cs2.output_label" = "Speichern unter"
|
||||||
|
"cs2.browse_button" = "Durchsuchen..."
|
||||||
|
"cs2.refresh_button" = "Liste aktualisieren"
|
||||||
|
"cs2.download_button" = "Bild herunterladen"
|
||||||
|
"cs2.no_weapon" = "Bitte zuerst eine Waffe wählen."
|
||||||
|
"cs2.no_pattern" = "Bitte ein Muster zum Download auswählen."
|
||||||
|
"cs2.pattern_missing" = "Musterdaten fehlen. Bitte neu laden."
|
||||||
|
"cs2.download_error" = "Muster konnte nicht geladen werden: {error}"
|
||||||
|
"cs2.download_success" = "Bild gespeichert unter {path}"
|
||||||
|
"cs2.launch_error" = "Werkzeug konnte nicht geöffnet werden: {error}"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"toolbar.save_overlay" = "Save overlay"
|
"toolbar.save_overlay" = "Save overlay"
|
||||||
"toolbar.clear_excludes" = "Clear exclusions"
|
"toolbar.clear_excludes" = "Clear exclusions"
|
||||||
"toolbar.toggle_free_draw" = "Toggle free-draw"
|
"toolbar.toggle_free_draw" = "Toggle free-draw"
|
||||||
|
"toolbar.cs2_tool" = "Fetch Images"
|
||||||
"toolbar.undo_exclude" = "Undo last exclusion"
|
"toolbar.undo_exclude" = "Undo last exclusion"
|
||||||
"toolbar.reset_sliders" = "Reset sliders"
|
"toolbar.reset_sliders" = "Reset sliders"
|
||||||
"toolbar.toggle_theme" = "Toggle theme"
|
"toolbar.toggle_theme" = "Toggle theme"
|
||||||
|
|
@ -59,3 +60,20 @@
|
||||||
"dialog.no_image_loaded" = "No image loaded."
|
"dialog.no_image_loaded" = "No image loaded."
|
||||||
"dialog.no_preview_available" = "No preview available."
|
"dialog.no_preview_available" = "No preview available."
|
||||||
"dialog.overlay_saved" = "Overlay saved: {path}"
|
"dialog.overlay_saved" = "Overlay saved: {path}"
|
||||||
|
"cs2.title" = "CS2 Pattern Fetcher"
|
||||||
|
"cs2.status_loading" = "Loading weapon data..."
|
||||||
|
"cs2.status_ready" = "Weapon data loaded. Choose a weapon and pattern."
|
||||||
|
"cs2.status_error" = "Could not load CS2 data: {error}"
|
||||||
|
"cs2.status_empty" = "No weapons available from the data source."
|
||||||
|
"cs2.weapon_label" = "Weapon"
|
||||||
|
"cs2.pattern_label" = "Pattern"
|
||||||
|
"cs2.output_label" = "Download to"
|
||||||
|
"cs2.browse_button" = "Browse..."
|
||||||
|
"cs2.refresh_button" = "Refresh list"
|
||||||
|
"cs2.download_button" = "Download image"
|
||||||
|
"cs2.no_weapon" = "Select a weapon first."
|
||||||
|
"cs2.no_pattern" = "Select a pattern to download."
|
||||||
|
"cs2.pattern_missing" = "Pattern data is missing. Try refreshing."
|
||||||
|
"cs2.download_error" = "Unable to download pattern: {error}"
|
||||||
|
"cs2.download_success" = "Saved image to {path}"
|
||||||
|
"cs2.launch_error" = "Pattern tool could not be opened: {error}"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
"""Auxiliary tooling for the ICRA application."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .cs2_patterns import open_cs2_pattern_tool
|
||||||
|
|
||||||
|
__all__ = ["open_cs2_pattern_tool"]
|
||||||
|
|
||||||
|
|
@ -0,0 +1,742 @@
|
||||||
|
"""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://raw.githubusercontent.com/ByMykel/CSGO-API/main/public/api/en/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(540, 340)
|
||||||
|
self.resizable(True, True)
|
||||||
|
self._drag_offset: tuple[int, int] | None = None
|
||||||
|
self._setup_window()
|
||||||
|
self.body = ttk.Frame(self.container, style="CS2.TFrame")
|
||||||
|
self.body.pack(fill=tk.BOTH, expand=True, padx=18, pady=(14, 18))
|
||||||
|
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_styles()
|
||||||
|
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()
|
||||||
|
self._center_window()
|
||||||
|
|
||||||
|
# UI construction --------------------------------------------------
|
||||||
|
|
||||||
|
def _init_widgets(self) -> None:
|
||||||
|
frame = self.body
|
||||||
|
|
||||||
|
toolbar = tk.Frame(frame, bg=self._background_colour(), bd=0, highlightthickness=0)
|
||||||
|
toolbar.pack(fill=tk.X, pady=(0, 16))
|
||||||
|
self._toolbar_frame = toolbar
|
||||||
|
for icon, label, command in self._toolbar_button_defs():
|
||||||
|
self._add_toolbar_button(toolbar, icon, label, command)
|
||||||
|
|
||||||
|
top = ttk.Frame(frame, style="CS2.TFrame")
|
||||||
|
top.pack(fill=tk.X, pady=(0, 12))
|
||||||
|
ttk.Label(top, text=self._t("cs2.weapon_label"), style="CS2.TLabel").grid(row=0, column=0, sticky="w")
|
||||||
|
self.weapon_combo = ttk.Combobox(
|
||||||
|
top, textvariable=self.weapons_var, state="disabled", style="CS2.TCombobox"
|
||||||
|
)
|
||||||
|
self.weapon_combo.grid(row=1, column=0, sticky="we", padx=(0, 12))
|
||||||
|
self.weapon_combo.bind("<<ComboboxSelected>>", self._on_weapon_selected)
|
||||||
|
|
||||||
|
ttk.Label(top, text=self._t("cs2.pattern_label"), style="CS2.TLabel").grid(
|
||||||
|
row=0, column=1, sticky="w"
|
||||||
|
)
|
||||||
|
self.pattern_combo = ttk.Combobox(
|
||||||
|
top, textvariable=self.patterns_var, state="disabled", style="CS2.TCombobox"
|
||||||
|
)
|
||||||
|
self.pattern_combo.grid(row=1, column=1, sticky="we")
|
||||||
|
self.pattern_combo.bind("<<ComboboxSelected>>", self._on_pattern_selected)
|
||||||
|
top.columnconfigure(0, weight=1)
|
||||||
|
top.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
dir_frame = ttk.Frame(frame, style="CS2.TFrame")
|
||||||
|
dir_frame.pack(fill=tk.X, pady=(0, 12))
|
||||||
|
ttk.Label(dir_frame, text=self._t("cs2.output_label"), style="CS2.TLabel").grid(
|
||||||
|
row=0, column=0, sticky="w"
|
||||||
|
)
|
||||||
|
entry = ttk.Entry(dir_frame, textvariable=self.directory_var, style="CS2.TEntry")
|
||||||
|
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", style="CS2.TLabel")
|
||||||
|
status_label.pack(fill=tk.X)
|
||||||
|
self._refresh_toolbar_buttons_theme()
|
||||||
|
|
||||||
|
def _setup_window(self) -> None:
|
||||||
|
self.overrideredirect(True)
|
||||||
|
border_colour = "#27272b" if self.theme == "dark" else "#d0d0d8"
|
||||||
|
self.configure(bg=border_colour)
|
||||||
|
self.container = tk.Frame(self, bg=self._background_colour(), bd=0, highlightthickness=0)
|
||||||
|
self.container.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
|
||||||
|
self._create_titlebar()
|
||||||
|
|
||||||
|
def _create_titlebar(self) -> None:
|
||||||
|
bar_bg = "#1f1f1f" if self.theme == "dark" else "#2f2f35"
|
||||||
|
title_bar = tk.Frame(self.container, 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("<Enter>", lambda _e: close_btn.configure(bg="#cf212f"))
|
||||||
|
close_btn.bind("<Leave>", 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("<ButtonPress-1>", self._start_window_drag)
|
||||||
|
widget.bind("<B1-Motion>", 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
|
||||||
|
|
||||||
|
def _center_window(self) -> None:
|
||||||
|
try:
|
||||||
|
self.update_idletasks()
|
||||||
|
root = self.app.root
|
||||||
|
root.update_idletasks()
|
||||||
|
width = self.winfo_width()
|
||||||
|
height = self.winfo_height()
|
||||||
|
root_width = root.winfo_width()
|
||||||
|
root_height = root.winfo_height()
|
||||||
|
root_x = root.winfo_rootx()
|
||||||
|
root_y = root.winfo_rooty()
|
||||||
|
x = root_x + (root_width - width) // 2
|
||||||
|
y = root_y + (root_height - height) // 2
|
||||||
|
self.geometry(f"{width}x{height}+{x}+{y}")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _init_styles(self) -> None:
|
||||||
|
style = ttk.Style(self)
|
||||||
|
try:
|
||||||
|
style.theme_use("clam")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
base_bg = self._background_colour()
|
||||||
|
fg = "#f5f5f5" if self.theme == "dark" else "#202020"
|
||||||
|
field_bg = "#1f1f25" if self.theme == "dark" else "#f5f5f8"
|
||||||
|
border = "#4d4d50" if self.theme == "dark" else "#b8b8c0"
|
||||||
|
|
||||||
|
style.configure("CS2.TFrame", background=base_bg)
|
||||||
|
style.configure("CS2.TLabel", background=base_bg, foreground=fg, font=("Segoe UI", 10))
|
||||||
|
|
||||||
|
style.configure(
|
||||||
|
"CS2.TEntry",
|
||||||
|
fieldbackground=field_bg,
|
||||||
|
foreground=fg,
|
||||||
|
insertcolor=fg,
|
||||||
|
bordercolor=border,
|
||||||
|
lightcolor=border,
|
||||||
|
darkcolor=border,
|
||||||
|
relief="flat",
|
||||||
|
padding=6,
|
||||||
|
)
|
||||||
|
style.map("CS2.TEntry", fieldbackground=[("disabled", field_bg)], foreground=[("disabled", fg)])
|
||||||
|
|
||||||
|
style.configure(
|
||||||
|
"CS2.TCombobox",
|
||||||
|
fieldbackground=field_bg,
|
||||||
|
foreground=fg,
|
||||||
|
background=field_bg,
|
||||||
|
bordercolor=border,
|
||||||
|
arrowcolor=fg,
|
||||||
|
relief="flat",
|
||||||
|
padding=4,
|
||||||
|
)
|
||||||
|
style.map(
|
||||||
|
"CS2.TCombobox",
|
||||||
|
fieldbackground=[("readonly", field_bg)],
|
||||||
|
foreground=[("readonly", fg)],
|
||||||
|
background=[("readonly", field_bg)],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.container.configure(bg=base_bg)
|
||||||
|
self.body.configure(style="CS2.TFrame")
|
||||||
|
|
||||||
|
# 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 err=exc: self._on_load_failed(err))
|
||||||
|
return
|
||||||
|
self.after(0, lambda items=weapons: self._on_data_ready(items 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("<ButtonPress-1>", on_press)
|
||||||
|
canvas.bind("<ButtonRelease-1>", on_release)
|
||||||
|
canvas.bind("<Enter>", on_enter)
|
||||||
|
canvas.bind("<Leave>", on_leave)
|
||||||
|
canvas.bind("<FocusIn>", on_focus_in)
|
||||||
|
canvas.bind("<FocusOut>", on_focus_out)
|
||||||
|
canvas.bind("<space>", invoke_keyboard)
|
||||||
|
canvas.bind("<Return>", invoke_keyboard)
|
||||||
|
|
||||||
|
def _refresh_toolbar_buttons_theme(self) -> None:
|
||||||
|
if not self._toolbar_buttons:
|
||||||
|
return
|
||||||
|
palette = self._toolbar_palette()
|
||||||
|
bg = self._background_colour()
|
||||||
|
if hasattr(self, "_toolbar_frame") and self._toolbar_frame is not None:
|
||||||
|
try:
|
||||||
|
self._toolbar_frame.configure(bg=bg)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
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
|
||||||
|
|
@ -8,6 +8,7 @@ license = "MIT"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pillow>=10.0.0",
|
"pillow>=10.0.0",
|
||||||
|
"requests>=2.31.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue