Compare commits

..

15 Commits

Author SHA1 Message Date
lm f09da5018f Simplify borderless setup to fix input handling 2025-10-19 18:53:47 +02:00
lm 8053dc297a Keep custom title bar while preserving responsiveness 2025-10-19 18:51:58 +02:00
lm f65c37407c Reinstate always-borderless window setup 2025-10-19 18:48:49 +02:00
lm 3e6650eb2e Ensure Windows borderless window registers as taskbar app 2025-10-19 18:47:46 +02:00
lm 2bf9776076 Reapply Windows borderless styling without hiding taskbar entry 2025-10-19 18:44:49 +02:00
lm 1984145043 Force borderless window to appear in taskbar 2025-10-19 18:40:38 +02:00
lm 8ed1acc32d Restore custom title bar without native chrome 2025-10-19 18:38:13 +02:00
lm 183b769000 Revert "Restore native title bar with theme-aware styling"
This reverts commit 07d7679889.
2025-10-19 18:36:27 +02:00
lm 07d7679889 Restore native title bar with theme-aware styling 2025-10-19 18:34:28 +02:00
lm 01a9b2707c Implement native borderless window integration 2025-10-19 18:26:36 +02:00
lm f2db62036b Redo borderless window styling to keep taskbar entry 2025-10-19 18:21:17 +02:00
lm fb37da7e40 Ensure taskbar entry for borderless window 2025-10-19 18:18:45 +02:00
lm 50b9aa723c Improve maximize behaviour on multi-monitor setups 2025-10-19 18:14:40 +02:00
lm 27c0e55711 Upscale small previews to fill canvas 2025-10-19 18:10:24 +02:00
lm f467e0b2e5 Enhance window controls
Add minimize/maximize buttons, double-click maximize behaviour, and proper window state handling for the custom title bar.
2025-10-19 18:03:07 +02:00
9 changed files with 256 additions and 846 deletions

View File

@ -13,13 +13,11 @@
- 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
@ -39,8 +37,7 @@ On macOS/Linux activate with `source .venv/bin/activate` instead.
3. Finetune sliders; watch the overlay update on the right. 3. Finetune 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. Fetch CS2 pattern images (`⭳`) whenever you need additional references. 6. Save an overlay (`💾`) when ready.
7. Save an overlay (`💾`) when ready.
## Project Layout ## Project Layout
``` ```
@ -48,7 +45,6 @@ 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

View File

@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import ctypes
import platform
import tkinter as tk import tkinter as tk
from importlib import resources
from .gui import ColorPickerMixin, ExclusionMixin, ThemeMixin, UIBuilderMixin from .gui import ColorPickerMixin, ExclusionMixin, ThemeMixin, UIBuilderMixin
from .i18n import I18nMixin from .i18n import I18nMixin
@ -56,7 +59,6 @@ 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
@ -74,11 +76,94 @@ class ICRAApp(
self.bring_to_front() self.bring_to_front()
def _setup_window(self) -> None: def _setup_window(self) -> None:
self.root.overrideredirect(True)
screen_width = self.root.winfo_screenwidth() screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight() screen_height = self.root.winfo_screenheight()
default_width = int(screen_width * 0.8)
default_height = int(screen_height * 0.8)
default_x = (screen_width - default_width) // 2
default_y = (screen_height - default_height) // 4
self._window_geometry = f"{default_width}x{default_height}+{default_x}+{default_y}"
self._is_maximized = True
self._use_overrideredirect = True
self.root.geometry(f"{screen_width}x{screen_height}+0+0") self.root.geometry(f"{screen_width}x{screen_height}+0+0")
self.root.configure(bg="#f2f2f7") self.root.configure(bg="#f2f2f7")
try:
self.root.overrideredirect(True)
except Exception:
try:
self.root.attributes("-type", "splash")
except Exception:
pass
self._window_icon_ref = None
self._apply_window_icon()
self._init_window_chrome()
def _ensure_taskbar_entry(self) -> None:
"""Force the borderless window to show up in the Windows taskbar."""
try:
if platform.system() != "Windows":
return
hwnd = self.root.winfo_id()
if not hwnd:
self.root.after(50, self._ensure_taskbar_entry)
return
GWL_EXSTYLE = -20
WS_EX_TOOLWINDOW = 0x00000080
WS_EX_APPWINDOW = 0x00040000
SWP_NOSIZE = 0x0001
SWP_NOMOVE = 0x0002
SWP_NOZORDER = 0x0004
SWP_FRAMECHANGED = 0x0020
user32 = ctypes.windll.user32 # type: ignore[attr-defined]
shell32 = ctypes.windll.shell32 # type: ignore[attr-defined]
style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
new_style = (style & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW
if new_style != style:
user32.SetWindowLongW(hwnd, GWL_EXSTYLE, new_style)
user32.SetWindowPos(
hwnd,
0,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED,
)
app_id = ctypes.c_wchar_p("ICRA.App")
shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
except Exception:
pass
def _apply_window_icon(self) -> None:
try:
icon_resource = resources.files("app.assets").joinpath("logo.png")
with resources.as_file(icon_resource) as icon_path:
icon = tk.PhotoImage(file=str(icon_path))
self.root.iconphoto(False, icon)
self._window_icon_ref = icon
except Exception:
self._window_icon_ref = None
def _init_window_chrome(self) -> None:
"""Configure a borderless window while retaining a taskbar entry."""
try:
self.root.bind("<Map>", self._restore_borderless)
self.root.after(0, self._restore_borderless)
self.root.after(0, self._ensure_taskbar_entry)
except Exception:
pass
def _restore_borderless(self, _event=None) -> None:
try:
if self._use_overrideredirect:
self.root.overrideredirect(True)
self._ensure_taskbar_entry()
except Exception:
pass
def start_app() -> None: def start_app() -> None:

View File

@ -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 messagebox, ttk from tkinter import ttk
class UIBuilderMixin: class UIBuilderMixin:
@ -26,7 +26,6 @@ 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]] = []
@ -423,45 +422,172 @@ class UIBuilderMixin:
) )
title_label.pack(side=tk.LEFT, padx=6) title_label.pack(side=tk.LEFT, padx=6)
close_btn = tk.Button( btn_kwargs = {
title_bar, "bg": bar_bg,
text="", "fg": "#f5f5f5",
command=self._close_app, "activebackground": "#3a3a40",
bg=bar_bg, "activeforeground": "#ffffff",
fg="#f5f5f5", "borderwidth": 0,
activebackground="#ff3b30", "highlightthickness": 0,
activeforeground="#ffffff", "relief": "flat",
borderwidth=0, "font": ("Segoe UI", 10, "bold"),
highlightthickness=0, "cursor": "hand2",
relief="flat", "width": 3,
font=("Segoe UI", 10, "bold"), }
cursor="hand2",
width=3, close_btn = tk.Button(title_bar, text="", command=self._close_app, **btn_kwargs)
) close_btn.pack(side=tk.RIGHT, padx=6, pady=4)
close_btn.pack(side=tk.RIGHT, padx=8, pady=4)
close_btn.bind("<Enter>", lambda _e: close_btn.configure(bg="#cf212f")) close_btn.bind("<Enter>", lambda _e: close_btn.configure(bg="#cf212f"))
close_btn.bind("<Leave>", lambda _e: close_btn.configure(bg=bar_bg)) close_btn.bind("<Leave>", lambda _e: close_btn.configure(bg=bar_bg))
for widget in (title_bar, title_label): max_btn = tk.Button(title_bar, text="", command=self._toggle_maximize_window, **btn_kwargs)
widget.bind("<ButtonPress-1>", self._start_window_drag) max_btn.pack(side=tk.RIGHT, padx=0, pady=4)
widget.bind("<B1-Motion>", self._perform_window_drag) max_btn.bind("<Enter>", lambda _e: max_btn.configure(bg="#2c2c32"))
max_btn.bind("<Leave>", lambda _e: max_btn.configure(bg=bar_bg))
def _close_app(self) -> None: self._max_button = max_btn
try:
self.root.destroy() min_btn = tk.Button(title_bar, text="", command=self._minimize_window, **btn_kwargs)
except Exception: min_btn.pack(side=tk.RIGHT, padx=0, pady=4)
pass min_btn.bind("<Enter>", lambda _e: min_btn.configure(bg="#2c2c32"))
min_btn.bind("<Leave>", lambda _e: min_btn.configure(bg=bar_bg))
def _start_window_drag(self, event) -> None:
self._drag_offset = (event.x_root - self.root.winfo_rootx(), event.y_root - self.root.winfo_rooty()) for widget in (title_bar, title_label):
widget.bind("<ButtonPress-1>", self._start_window_drag)
def _perform_window_drag(self, event) -> None: widget.bind("<B1-Motion>", self._perform_window_drag)
offset = getattr(self, "_drag_offset", None) widget.bind("<Double-Button-1>", lambda _e: self._toggle_maximize_window())
if offset is None:
return self._update_maximize_button()
x = event.x_root - offset[0]
y = event.y_root - offset[1] def _close_app(self) -> None:
self.root.geometry(f"+{x}+{y}") try:
self.root.destroy()
except Exception:
pass
def _start_window_drag(self, event) -> None:
if getattr(self, "_is_maximized", False):
cursor_x, cursor_y = event.x_root, event.y_root
self._toggle_maximize_window(force_state=False)
self.root.update_idletasks()
new_x = self.root.winfo_rootx()
new_y = self.root.winfo_rooty()
self._drag_offset = (cursor_x - new_x, cursor_y - new_y)
return
self._drag_offset = (event.x_root - self.root.winfo_rootx(), event.y_root - self.root.winfo_rooty())
def _perform_window_drag(self, event) -> None:
offset = getattr(self, "_drag_offset", None)
if offset is None:
return
x = event.x_root - offset[0]
y = event.y_root - offset[1]
self.root.geometry(f"+{x}+{y}")
if not getattr(self, "_is_maximized", False):
self._remember_window_geometry()
def _remember_window_geometry(self) -> None:
try:
self._window_geometry = self.root.geometry()
except Exception:
pass
def _monitor_work_area(self) -> tuple[int, int, int, int] | None:
try:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32 # type: ignore[attr-defined]
root_x = self.root.winfo_rootx()
root_y = self.root.winfo_rooty()
width = max(self.root.winfo_width(), 1)
height = max(self.root.winfo_height(), 1)
center_x = root_x + width // 2
center_y = root_y + height // 2
class MONITORINFO(ctypes.Structure):
_fields_ = [
("cbSize", wintypes.DWORD),
("rcMonitor", wintypes.RECT),
("rcWork", wintypes.RECT),
("dwFlags", wintypes.DWORD),
]
monitor = user32.MonitorFromPoint(
wintypes.POINT(center_x, center_y), 2 # MONITOR_DEFAULTTONEAREST
)
info = MONITORINFO()
info.cbSize = ctypes.sizeof(MONITORINFO)
if not user32.GetMonitorInfoW(monitor, ctypes.byref(info)):
return None
work = info.rcWork
return work.left, work.top, work.right, work.bottom
except Exception:
return None
def _maximize_window(self) -> None:
self._remember_window_geometry()
work_area = self._monitor_work_area()
if work_area is None:
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
left = 0
top = 0
width = screen_width
height = screen_height
else:
left, top, right, bottom = work_area
width = max(1, right - left)
height = max(1, bottom - top)
self.root.geometry(f"{width}x{height}+{left}+{top}")
self._is_maximized = True
self._update_maximize_button()
def _restore_window(self) -> None:
geometry = getattr(self, "_window_geometry", None)
if not geometry:
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
width = int(screen_width * 0.8)
height = int(screen_height * 0.8)
x = (screen_width - width) // 2
y = (screen_height - height) // 4
geometry = f"{width}x{height}+{x}+{y}"
self.root.geometry(geometry)
self._is_maximized = False
self._update_maximize_button()
def _toggle_maximize_window(self, force_state: bool | None = None) -> None:
desired = force_state if force_state is not None else not getattr(self, "_is_maximized", False)
if desired:
self._maximize_window()
else:
self._restore_window()
def _minimize_window(self) -> None:
try:
self._remember_window_geometry()
use_or = getattr(self, "_use_overrideredirect", False)
if use_or and hasattr(self.root, "overrideredirect"):
try:
self.root.overrideredirect(False)
except Exception:
pass
self.root.iconify()
if use_or:
restorer = getattr(self, "_restore_borderless", None)
if callable(restorer):
self.root.after(120, restorer)
elif hasattr(self.root, "overrideredirect"):
self.root.after(120, lambda: self.root.overrideredirect(True)) # type: ignore[arg-type]
except Exception:
pass
def _update_maximize_button(self) -> None:
button = getattr(self, "_max_button", None)
if button is None:
return
symbol = "" if getattr(self, "_is_maximized", False) else ""
button.configure(text=symbol)
def _maybe_focus_window(self, _event) -> None: def _maybe_focus_window(self, _event) -> None:
try: try:
@ -523,18 +649,6 @@ 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"

View File

@ -7,7 +7,6 @@
"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"
@ -60,20 +59,3 @@
"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}"

View File

@ -7,7 +7,6 @@
"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"
@ -60,20 +59,3 @@
"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}"

View File

@ -194,7 +194,9 @@ class ImageProcessingMixin:
return return
width, height = self.orig_img.size width, height = self.orig_img.size
max_w, max_h = PREVIEW_MAX_SIZE max_w, max_h = PREVIEW_MAX_SIZE
scale = min(max_w / width, max_h / height, 1.0) scale = min(max_w / width, max_h / height)
if scale <= 0:
scale = 1.0
size = (max(1, int(width * scale)), max(1, int(height * scale))) size = (max(1, int(width * scale)), max(1, int(height * scale)))
self.preview_img = self.orig_img.resize(size, Image.LANCZOS) self.preview_img = self.orig_img.resize(size, Image.LANCZOS)
self.preview_tk = ImageTk.PhotoImage(self.preview_img) self.preview_tk = ImageTk.PhotoImage(self.preview_img)

View File

@ -1,8 +0,0 @@
"""Auxiliary tooling for the ICRA application."""
from __future__ import annotations
from .cs2_patterns import open_cs2_pattern_tool
__all__ = ["open_cs2_pattern_tool"]

View File

@ -1,742 +0,0 @@
"""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

View File

@ -8,7 +8,6 @@ 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]