diff --git a/installer.iss b/installer.iss index 36941d5..5263881 100644 --- a/installer.iss +++ b/installer.iss @@ -1,11 +1,11 @@ ; PixelLauncher — Inno Setup 6 script ; Сборка: python make_installer.py (или: ISCC.exe installer.iss) -; Результат: PixelLauncherSetup2.1.2.exe в папке проекта. +; Результат: PixelLauncherSetup2.1.3.exe в папке проекта. ; AppId должен оставаться неизменным, иначе обновление появится ; как отдельная программа. #define MyAppName "PixelLauncher" -#define MyAppVersion "2.1.2" +#define MyAppVersion "2.1.3" #define MyAppPublisher "SuperBoys" #define MyAppURL "https://pixellauncher.ru" #define StageDir "build\stage" @@ -32,7 +32,7 @@ ArchitecturesAllowed=x64compatible SetupIconFile={#MyIcon} UninstallDisplayIcon={app}\launcher.ico UninstallDisplayName={#MyAppName} -VersionInfoVersion=2.1.2.0 +VersionInfoVersion=2.1.3.0 VersionInfoProductVersion={#MyAppVersion} LZMANumBlockThreads=2 diff --git a/launcher/__init__.py b/launcher/__init__.py index 9562f83..09df65e 100644 --- a/launcher/__init__.py +++ b/launcher/__init__.py @@ -1,4 +1,4 @@ """PixelLauncher — lightweight Minecraft launcher (offline mode).""" LAUNCHER_NAME = "PixelLauncher" -LAUNCHER_VERSION = "2.1.2" +LAUNCHER_VERSION = "2.1.3" diff --git a/launcher/cfpack.py b/launcher/cfpack.py index d07da1e..254eda6 100644 --- a/launcher/cfpack.py +++ b/launcher/cfpack.py @@ -32,11 +32,13 @@ def _split_loader(lid): def read_manifest(path): """Читает manifest.json. Возвращает (данные, mc, loader, loader_ver).""" with zipfile.ZipFile(path) as z: - names = set(z.namelist()) - if "manifest.json" not in names: - raise RuntimeError("в архиве нет manifest.json") - with z.open("manifest.json") as f: - data = json.loads(f.read().decode("utf-8")) + return _read_manifest(z) + + +def _read_manifest(zf): + """То же, но из уже открытого архива.""" + with zf.open("manifest.json") as f: + data = json.loads(f.read().decode("utf-8")) minecraft = data.get("minecraft") or {} mc = minecraft.get("version") or "" loader, loader_ver = "", "" @@ -123,7 +125,7 @@ def install(game_dir, pack_path, log=None, progress=None, cancel=None): with zipfile.ZipFile(pack_path) as zf: names = set(zf.namelist()) if "manifest.json" in names: - data, mc, loader, loader_ver = read_manifest(pack_path) + data, mc, loader, loader_ver = _read_manifest(zf) mod_list = [] for entry in (data.get("files") or []): if entry.get("required") is False: diff --git a/launcher/config.py b/launcher/config.py index 797d3b7..f14a197 100644 --- a/launcher/config.py +++ b/launcher/config.py @@ -38,6 +38,8 @@ DEFAULTS = { # зеркала скачивания (пустые списки = официальные серверы Mojang) "asset_mirrors": [], "lib_mirrors": [], + # ключ CurseForge API (необязателен: есть зеркало без ключа) + "cf_api_key": "", # третья волна "fav_versions": [], # закреплённые версии (id) "profiles": [], # профили запуска (см. ui._open_profiles) @@ -87,6 +89,8 @@ def _migrate(s): s[key] = [] if not isinstance(s.get("accent"), str) or not s["accent"].startswith("#"): s["accent"] = DEFAULTS["accent"] + if not isinstance(s.get("cf_api_key"), str): + s["cf_api_key"] = "" if s.get("theme") not in ("dark", "light"): s["theme"] = "dark" diff --git a/launcher/http.py b/launcher/http.py index 1cb9ffa..1d5fcc9 100644 --- a/launcher/http.py +++ b/launcher/http.py @@ -13,7 +13,9 @@ import struct import time import urllib.request -USER_AGENT = "PixelLauncher/2.1.2 (lightweight Minecraft launcher)" +from . import LAUNCHER_VERSION + +USER_AGENT = "PixelLauncher/%s (lightweight Minecraft launcher)" % LAUNCHER_VERSION ATTEMPTS = 4 _opener = None # настроенный opener (прокси) или None — напрямую в сеть diff --git a/launcher/i18n.py b/launcher/i18n.py index 3ff3ad1..d4a6a2e 100644 --- a/launcher/i18n.py +++ b/launcher/i18n.py @@ -492,6 +492,9 @@ _TABLE = { "Загрузка…": "Loading…", "КБ": "KB", "Б": "B", + "Ключ CurseForge API (необязательно):": "CurseForge API key (optional):", + "Без ключа сборки берутся с зеркала CurseForge.": + "Without a key, modpacks come from the CurseForge mirror.", } diff --git a/launcher/instances.py b/launcher/instances.py new file mode 100644 index 0000000..c0c9288 --- /dev/null +++ b/launcher/instances.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- +"""Инстансы (как Prism Launcher): отдельная папка на каждый профиль. + +Инстанс = папка instances// со своим .minecraft-подобным содержимым +(mods, saves, resourcepacks, shaderpacks, config, options.txt и пр.) +плюс instance.json с метаданными (имя, версия, лоадер, Java, ОЗУ, иконка). + +Общие ресурсы (versions, assets, libraries) хранятся в общем .minecraft +и переиспользуются всеми инстансами — экономия места. +""" + +import json +import os +import shutil +import time + +from . import config + +INSTANCES_DIR = os.path.join(config.APP_DIR, "instances") + +# Подпапки инстанса (как в Prism) +INSTANCE_DIRS = ("mods", "saves", "resourcepacks", "shaderpacks", "config", + "screenshots", "datapacks") + +# Метаданные по умолчанию +META_DEFAULTS = { + "name": "", + "version": "", # id версии MC (например "1.21.4") + "loader": "", # "vanilla" | "fabric" | "forge" | "neoforge" | "quilt" + "java_path": "", # пусто = авто + "ram": 2048, # 0 = авто + "jvm_args": "", + "win_w": 0, + "win_h": 0, + "fullscreen": False, + "icon": "", # путь к PNG-иконке (пусто = дефолт) + "last_played": 0, # timestamp + "created": 0, + "notes": "", +} + + +def _meta_path(inst_dir): + return os.path.join(inst_dir, "instance.json") + + +def _game_dir(inst_dir): + return os.path.join(inst_dir, ".minecraft") + + +def _ensure_dirs(inst_dir): + """Создаёт структуру папок инстанса.""" + os.makedirs(inst_dir, exist_ok=True) + gd = _game_dir(inst_dir) + os.makedirs(gd, exist_ok=True) + for sub in INSTANCE_DIRS: + os.makedirs(os.path.join(gd, sub), exist_ok=True) + + +def _sanitize_name(name): + """Делает имя безопасным для пути.""" + bad = '<>:"/\\|?*' + out = "".join("_" if c in bad else c for c in name).strip() + out = out.rstrip(". ") + return out or "Instance" + + +def create(name, version="", loader="vanilla", **overrides): + """Создаёт новый инстанс. Возвращает путь к его папке.""" + name = _sanitize_name(name) + inst_dir = os.path.join(INSTANCES_DIR, name) + # если уже есть — добавляем суффикс + n = 1 + base = name + while os.path.exists(inst_dir): + n += 1 + name = f"{base} ({n})" + inst_dir = os.path.join(INSTANCES_DIR, name) + _ensure_dirs(inst_dir) + + meta = dict(META_DEFAULTS) + meta["name"] = name + meta["version"] = version + meta["loader"] = loader + meta["created"] = int(time.time()) + meta.update(overrides) + save_meta(inst_dir, meta) + return inst_dir + + +def load_meta(inst_dir): + """Читает instance.json (или дефолт, если нет/битый).""" + try: + with open(_meta_path(inst_dir), encoding="utf-8") as f: + data = json.load(f) + except Exception: + data = {} + meta = dict(META_DEFAULTS) + if isinstance(data, dict): + meta.update(data) + if not meta.get("name"): + meta["name"] = os.path.basename(inst_dir) + return meta + + +def save_meta(inst_dir, meta): + """Записывает instance.json.""" + try: + with open(_meta_path(inst_dir), "w", encoding="utf-8") as f: + json.dump(meta, f, indent=2, ensure_ascii=False) + except OSError: + pass + + +def list_instances(): + """Список всех инстансов: [{dir, meta}, ...] отсортированный по имени.""" + if not os.path.isdir(INSTANCES_DIR): + return [] + out = [] + for name in sorted(os.listdir(INSTANCES_DIR)): + d = os.path.join(INSTANCES_DIR, name) + if not os.path.isdir(d): + continue + if not os.path.isfile(_meta_path(d)): + # возможно, это старый .minecraft без instance.json — пропускаем + continue + out.append({"dir": d, "meta": load_meta(d)}) + return out + + +def game_dir(inst_dir): + """Путь к .minecraft внутри инстанса.""" + return _game_dir(inst_dir) + + +def shared_dir(): + """Общий каталог (versions/assets/libraries) — тот же, что и раньше.""" + return config.load_settings().get("game_dir", config.DEFAULT_GAME_DIR) + + +def delete(inst_dir, keep_saves=False): + """Удаляет инстанс. keep_saves=True — сохраняет папку saves.""" + if not os.path.isdir(inst_dir): + return False + if keep_saves: + # перемещаем saves во временное место + saves = os.path.join(_game_dir(inst_dir), "saves") + tmp = inst_dir + ".deleted" + try: + os.makedirs(tmp, exist_ok=True) + if os.path.isdir(saves): + shutil.move(saves, os.path.join(tmp, "saves")) + except OSError: + pass + shutil.rmtree(inst_dir, ignore_errors=True) + return True + + +def rename(inst_dir, new_name): + """Переименовывает инстанс (папку и meta.name).""" + new_name = _sanitize_name(new_name) + parent = os.path.dirname(inst_dir) + new_dir = os.path.join(parent, new_name) + if os.path.exists(new_dir): + return None + os.rename(inst_dir, new_dir) + meta = load_meta(new_dir) + meta["name"] = new_name + save_meta(new_dir, meta) + return new_dir + + +def duplicate(inst_dir): + """Дублирует инстанс (без saves).""" + meta = load_meta(inst_dir) + name = meta.get("name") + " (copy)" + new_dir = create(name, meta.get("version", ""), meta.get("loader", "vanilla")) + # копируем mods, resourcepacks, config + src_gd = _game_dir(inst_dir) + dst_gd = _game_dir(new_dir) + for sub in ("mods", "resourcepacks", "shaderpacks", "config"): + s = os.path.join(src_gd, sub) + d = os.path.join(dst_gd, sub) + if os.path.isdir(s): + try: + shutil.copytree(s, d, dirs_exist_ok=True) + except OSError: + pass + return new_dir + + +def export_zip(inst_dir, dest_path): + """Экспорт инстанса в zip (без saves). Возвращает путь.""" + import zipfile + meta = load_meta(inst_dir) + name = _sanitize_name(meta.get("name", "instance")) + if not dest_path: + dest_path = os.path.join(os.path.expanduser("~"), "Desktop", + name + ".pixelinst.zip") + src_gd = _game_dir(inst_dir) + with zipfile.ZipFile(dest_path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("instance.json", json.dumps(meta, indent=2, + ensure_ascii=False)) + for root, _dirs, files in os.walk(src_gd): + rel_root = os.path.relpath(root, src_gd) + # пропускаем saves + if rel_root == "saves" or rel_root.startswith("saves" + os.sep): + continue + for fn in files: + full = os.path.join(root, fn) + arc = os.path.join(".minecraft", rel_root, fn) + zf.write(full, arc) + return dest_path + + +def import_zip(zip_path): + """Импорт инстанса из .zip/.mrpack. Возвращает путь к новому инстансу.""" + import zipfile + with zipfile.ZipFile(zip_path, "r") as zf: + names = zf.namelist() + # ищем instance.json + meta_raw = None + if "instance.json" in names: + meta_raw = json.loads(zf.read("instance.json").decode("utf-8")) + # mrpack: modrinth.index.json + elif "modrinth.index.json" in names: + idx = json.loads(zf.read("modrinth.index.json").decode("utf-8")) + meta_raw = { + "name": idx.get("name", "Modrinth Pack"), + "version": idx.get("versionId", ""), + "loader": _detect_loader(idx), + } + if not meta_raw: + raise ValueError("неизвестный формат архива") + + name = _sanitize_name(meta_raw.get("name", "Imported")) + inst_dir = create(name, meta_raw.get("version", ""), + meta_raw.get("loader", "vanilla")) + dst_gd = _game_dir(inst_dir) + # распаковываем + for n in names: + if n.endswith("/"): + continue + if n == "instance.json" or n == "modrinth.index.json": + continue + target = n + if n.startswith(".minecraft/"): + target = n[len(".minecraft/"):] + # пропускаем saves при импорте + if target == "saves" or target.startswith("saves/"): + continue + out = os.path.join(dst_gd, target) + os.makedirs(os.path.dirname(out), exist_ok=True) + with zf.open(n) as src, open(out, "wb") as dst: + shutil.copyfileobj(src, dst) + # обновляем meta + meta = load_meta(inst_dir) + meta.update(meta_raw) + save_meta(inst_dir, meta) + return inst_dir + + +def _detect_loader(idx): + """Определяет лоадер из modrinth.index.json.""" + for dep in (idx.get("dependencies") or {}): + if dep in ("fabric-loader", "quilt-loader"): + return dep.split("-")[0] + if dep == "forge": + return "forge" + if dep == "neoforge": + return "neoforge" + return "vanilla" + + +def mark_played(inst_dir): + """Обновляет last_played.""" + meta = load_meta(inst_dir) + meta["last_played"] = int(time.time()) + save_meta(inst_dir, meta) + + +def fmt_last_played(ts): + """Человекочитаемое время последнего запуска.""" + if not ts: + return "—" + delta = int(time.time()) - ts + if delta < 3600: + return f"{max(1, delta // 60)} мин назад" + if delta < 86400: + return f"{delta // 3600} ч назад" + if delta < 86400 * 7: + return f"{delta // 86400} дн назад" + return time.strftime("%d.%m.%Y", time.localtime(ts)) diff --git a/launcher/packs.py b/launcher/packs.py index 38e0111..0c3ba22 100644 --- a/launcher/packs.py +++ b/launcher/packs.py @@ -1,12 +1,8 @@ # -*- coding: utf-8 -*- """Утилиты для работы с модпаками Modrinth/CurseForge.""" import os -import re -import tempfile -from . import forge, http, mods -from .java_fetch import _safe_join -from . import modrinth, curseforge +from . import curseforge, modrinth def install_pack_mrpack_url(game_dir, url, log=None, progress=None, cancel=None): diff --git a/launcher/selfupdate.py b/launcher/selfupdate.py index f277153..9db8265 100644 --- a/launcher/selfupdate.py +++ b/launcher/selfupdate.py @@ -2,7 +2,7 @@ """Автообновление лаунчера: проверка манифеста, скачивание установщика. Манифест на сервере — /update.json: - {"version": "2.1.2", "url": "/download/PixelLauncherSetup2.1.2.exe", + {"version": "2.1.3", "url": "/download/PixelLauncherSetup2.1.3.exe", "sha256": "…", "size": 15418378, "notes": "…"} url относительный (база — UPDATE_BASE), sha256/size необязательны, но sha256 проверяется, если указан. diff --git a/launcher/ui.py b/launcher/ui.py index 08db1f9..9d966bf 100644 --- a/launcher/ui.py +++ b/launcher/ui.py @@ -23,8 +23,17 @@ import webbrowser from ctypes import wintypes from tkinter import filedialog, messagebox, ttk +# Pillow (если доступна) — сглаженные иконки сборок; без неё — через Tk +try: + import io as _pil_io + from PIL import Image as _PILImage, ImageDraw as _PILDraw + from PIL import ImageTk as _PILImageTk +except Exception: # noqa: BLE001 + _PILImage = _PILDraw = _PILImageTk = _pil_io = None + from . import config, crash as crash_mod, downloader, forge as forge_mod, http from . import curseforge as curseforge_mod +from . import instances as inst_mod from . import java as java_mod, java_fetch from . import launch as launch_mod from . import manifest as manifest_mod @@ -102,7 +111,10 @@ def set_theme(name): TYPE_RU = {"release": "релиз", "snapshot": "снапшот", "old_alpha": "old alpha", "old_beta": "old beta"} -WIN_W, WIN_H = 660, 640 +# Высота — естественный размер контента (баннер, кнопка запуска, новости +# и панель быстрых настроек в две строки): сжимать окно ниже нельзя, +# иначе нижние чипы обрезаются. +WIN_W, WIN_H = 660, 684 BANNER_W, BANNER_H = 560, 300 @@ -851,6 +863,8 @@ class App(tk.Tk): self._close_after_menu = False self._pending_restore = False self._saved_geom = None + self._maximized = False # окно развёрнуто на весь экран + self._restore_geom = None # геометрия окна до разворота self._cancel = None # threading.Event отмены загрузки self._banner_after = None self._log_txt_widget = None @@ -869,17 +883,24 @@ class App(tk.Tk): # статистика игры и прокси — рядом с папкой игры stats_mod.set_path(os.path.join(self._game_dir(), ".launcher")) http.configure(self.settings.get("proxy")) + curseforge_mod.set_api_key(self.settings.get("cf_api_key")) self._scene = self._pick_scene() self._night = self._scene == "night" self._apply_ui_scale() self._build() + self.bind("", self._on_win_configure) # мин. высота self._set_icon() self._enable_dnd() self._load_settings_to_ui() + self._refresh_instances() + _act_inst = self._current_instance() + if _act_inst: + self._apply_instance(_act_inst) if select_profile: self._apply_profile_at_start(select_profile) self._center() + self._maximize() # лаунчер всегда стартует развёрнутым на экран self.after(60, self._apply_taskbar_style) self.after(100, self._poll_ui) self.after(700, self._start_tray) @@ -907,6 +928,10 @@ class App(tk.Tk): self._logo(left, 22).pack(side="left", padx=(0, 8)) tk.Label(left, text="PixelLauncher", bg=BAR, fg=TEXT, font=("Segoe UI", 11, "bold")).pack(side="left") + # Заголовок текущего инстанса (как Prism) + self.lbl_inst = tk.Label(left, text="", bg=BAR, fg=FAINT, + font=("Segoe UI", 9)) + self.lbl_inst.pack(side="left", padx=(10, 0)) right = tk.Frame(bar, bg=BAR) right.pack(side="right", padx=(0, 4)) self.lbl_nick = tk.Label(right, text=self.settings.get("username", "Player"), @@ -921,6 +946,8 @@ class App(tk.Tk): Icon_label(right, "☾", lambda: self._set_night(True)).pack(side="left") Icon_label(right, "☀", lambda: self._set_night(False)).pack(side="left") Icon_label(right, "⚙", self._open_menu).pack(side="left") + self.btn_max = Icon_label(right, "□", self._toggle_maximize) + self.btn_max.pack(side="left") Icon_label(right, "—", self._minimize).pack(side="left") Icon_label(right, "✕", self._on_close, danger=True).pack(side="left") tk.Frame(self, bg=BAR_BD, height=1).pack(fill="x") @@ -929,24 +956,74 @@ class App(tk.Tk): self._make_draggable(left) bar.bind("", self._drag_start) bar.bind("", self._drag_move) + # двойной клик по строке — разворот/возврат, как у обычного окна + bar.bind("", self._on_title_double) + self._bind_double(left) - # --- Основной контент + # --- Основной контент: панель инстансов слева + контент справа main = tk.Frame(self, bg=BG) main.pack(fill="both", expand=True, pady=(26, 0)) - self.banner = tk.Canvas(main, width=BANNER_W, height=BANNER_H, + # Панель инстансов (как Prism: список слева, контент справа) + self.inst_panel = tk.Frame(main, bg=PANEL, width=200) + self.inst_panel.pack(side="left", fill="y") + self.inst_panel.pack_propagate(False) + + inst_head = tk.Frame(self.inst_panel, bg=PANEL) + inst_head.pack(fill="x", padx=8, pady=(8, 4)) + tk.Label(inst_head, text=T("Инстансы"), bg=PANEL, fg=FAINT, + font=("Segoe UI", 7, "bold")).pack(side="left") + Icon_label(inst_head, "+", self._open_new_instance).pack(side="right") + + inst_wrap = tk.Frame(self.inst_panel, bg=PANEL) + inst_wrap.pack(fill="both", expand=True, padx=4) + inst_cv = tk.Canvas(inst_wrap, bg=PANEL, highlightthickness=0, bd=0) + inst_sb = tk.Scrollbar(inst_wrap, orient="vertical", + command=inst_cv.yview) + # тёмный скроллбар (не белая полоса). ПОРЯДОК PACK ВАЖЕН: sb первым, + # иначе Tk не отдаёт ему места и он вообще не покажется. + # width=0 — «спрятан», 17 — виден; переключает _toggle_inst_scroll. + inst_sb.configure(troughcolor=PANEL, bg=PILL, activebackground=HOVER, + highlightthickness=0, bd=0, width=0) + inst_cv.configure(yscrollcommand=self._on_inst_scroll) + self._inst_cv, self._inst_sb = inst_cv, inst_sb + inst_sb.pack(side="right", fill="y") + inst_cv.pack(side="left", fill="both", expand=True) + self.inst_rows = tk.Frame(inst_cv, bg=PANEL) + inst_win = inst_cv.create_window((0, 0), window=self.inst_rows, + anchor="nw") + self.inst_rows.bind("", + lambda e: inst_cv.configure( + scrollregion=inst_cv.bbox("all"))) + inst_cv.bind("", + lambda e: (inst_cv.itemconfigure(inst_win, width=e.width), + self._toggle_inst_scroll())) + inst_cv.bind("", + lambda e: inst_cv.yview_scroll( + -1 if e.delta > 0 else 1, "units")) + + # Контекстное меню по правой кнопке на инстансе + self.inst_menu = tk.Menu(self, tearoff=0, bg=PANEL, fg=TEXT, + activebackground=GREEN, activeforeground="#0d1a0d", + bd=0, relief="flat", font=("Segoe UI", 9)) + + # правая панель — контент + content = tk.Frame(main, bg=BG) + content.pack(side="left", fill="both", expand=True) + + self.banner = tk.Canvas(content, width=BANNER_W, height=BANNER_H, bg=BG, highlightthickness=0) self.banner.pack(fill="x") self.banner.bind("", self._on_banner_configure) - self.btn_play = PlayButton(main, self._on_play) + self.btn_play = PlayButton(content, self._on_play) self.btn_play.pack(pady=(26, 0)) - self.pill = VersionPill(main, self._open_versions) + self.pill = VersionPill(content, self._open_versions) self.pill.pack(pady=(14, 0)) # --- Лента новостей: последние версии из манифеста Mojang - self.news = tk.Frame(main, bg=BG) + self.news = tk.Frame(content, bg=BG) self.news.pack(pady=(16, 0)) tk.Label(self.news, text=T("ПОСЛЕДНИЕ ВЕРСИИ"), bg=BG, fg=FAINT, font=("Segoe UI", 7, "bold")).pack() @@ -956,16 +1033,16 @@ class App(tk.Tk): # --- Быстрые настройки прямо на главном экране: частые переключатели # не требуют ни меню ⚙, ни диалога «Настройки». - self.quick_bar = tk.Frame(main, bg=BG) - self.quick_bar.pack(pady=(10, 0)) + self.quick_bar = tk.Frame(content, bg=BG) + self.quick_bar.pack(fill="x", pady=(10, 0)) self._build_quick_bar(self.quick_bar) - self.lbl_status = tk.Label(main, text=T("Готово к запуску"), bg=BG, fg=DIM, + self.lbl_status = tk.Label(content, text=T("Готово к запуску"), bg=BG, fg=DIM, font=("Segoe UI", 8)) self.lbl_status.pack(pady=(12, 0)) # Кнопка отмены — видна только пока идёт загрузка - self.btn_cancel = tk.Button(main, text="■ " + T("ОТМЕНА"), + self.btn_cancel = tk.Button(content, text="■ " + T("ОТМЕНА"), command=self._on_cancel, bg=PANEL, fg=ERR, activebackground=HOVER, activeforeground=ERR, relief="flat", bd=0, padx=12, pady=2, @@ -981,6 +1058,7 @@ class App(tk.Tk): grip = tk.Label(self, text="◢", bg=BAR, fg=FAINT, cursor="sizing", width=2, font=("Segoe UI", 7)) grip.place(relx=1.0, rely=1.0, anchor="se") + self.grip = grip def grip_start(e): self._grip = (e.x_root, e.y_root, self.winfo_width(), @@ -988,12 +1066,56 @@ class App(tk.Tk): def grip_move(e): w = max(WIN_W, self._grip[2] + e.x_root - self._grip[0]) - h = max(WIN_H, self._grip[3] + e.y_root - self._grip[1]) + # ниже естественной высоты контента окно не сжимаем + h = max(self._min_height(), + self._grip[3] + e.y_root - self._grip[1]) self.geometry(f"{w}x{h}") grip.bind("", grip_start) grip.bind("", grip_move) + # ------------------------------------------------- мин. высота окна + def _min_height(self): + """Минимальная высота окна: естественный размер контента. + + Ниже не сжимаем — иначе панель быстрых настроек внизу уходит + за край окна и чипы обрезаются. Ограничена рабочей областью + экрана, чтобы окно не вылезало за панель задач. + """ + try: + need = self.winfo_reqheight() + limit = max(WIN_H, self.winfo_screenheight() - 72) + except Exception: # noqa: BLE001 — окно уже уничтожено + return WIN_H + return min(max(WIN_H, need), limit) + + def _fit_window_height(self): + """Растягивает окно, если оно ниже естественного размера.""" + try: + if not self.winfo_ismapped(): + return + need = self._min_height() + cur = self.winfo_height() + if need > cur + 1: + self.geometry("%dx%d" % (self.winfo_width(), need)) + except Exception: # noqa: BLE001 — окно уже уничтожено + pass + + def _on_win_configure(self, _event): + """Проверяет высоту окна при каждом изменении его размера.""" + if _event.widget is not self: + return # события детей не трогаем + if getattr(self, "_fit_after", None): + try: + self.after_cancel(self._fit_after) + except Exception: # noqa: BLE001 — уже отменён/выполнен + pass + self._fit_after = self.after(80, self._fit_tick) + + def _fit_tick(self): + self._fit_after = None + self._fit_window_height() + def _on_banner_configure(self, _event=None): """Отложенная перерисовка баннера при изменении размера окна.""" if self._banner_after is not None: @@ -1051,14 +1173,98 @@ class App(tk.Tk): for c in w.winfo_children(): self._make_draggable(c) + def _bind_double(self, w): + """Двойной клик по строке окна — разворот/возврат.""" + w.bind("", self._on_title_double) + for c in w.winfo_children(): + self._bind_double(c) + def _drag_start(self, e): self._drag_dx = e.x_root - self.winfo_rootx() self._drag_dy = e.y_root - self.winfo_rooty() def _drag_move(self, e): + if self._maximized: + # тянем развёрнутое окно — возвращаем прежний размер + self._unmaximize_under_cursor(e) self.geometry("+%d+%d" % (e.x_root - self._drag_dx, e.y_root - self._drag_dy)) + def _on_title_double(self, _event=None): + self._toggle_maximize() + + # ------------------------------------------------ разворот на экран + def _work_area(self): + """Рабочая область экрана под окном (без панели задач).""" + try: + u32 = ctypes.windll.user32 + hwnd = u32.GetAncestor(self.winfo_id(), 2) # GA_ROOT + MONITOR_DEFAULTTONEAREST = 2 + + class MONITORINFO(ctypes.Structure): + _fields_ = [("cbSize", wintypes.DWORD), + ("rcMonitor", wintypes.RECT), + ("rcWork", wintypes.RECT), + ("dwFlags", wintypes.DWORD)] + + mi = MONITORINFO() + mi.cbSize = ctypes.sizeof(MONITORINFO) + hmon = u32.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) + if hmon and u32.GetMonitorInfoW(hmon, ctypes.byref(mi)): + r = mi.rcWork + return r.left, r.top, r.right - r.left, r.bottom - r.top + except Exception: # noqa: BLE001 — не Windows / нет монитора + pass + return (0, 0, self.winfo_screenwidth(), + max(self.winfo_screenheight() - 40, WIN_H)) + + def _toggle_maximize(self): + if self._maximized: + self._restore_from_max() + else: + self._maximize() + + def _maximize(self): + """Разворачивает окно на рабочую область текущего монитора.""" + if self._maximized: + return + self._restore_geom = self.geometry() + self._maximized = True + x, y, w, h = self._work_area() + self.geometry("%dx%d+%d+%d" % (w, h, x, y)) + self.btn_max.configure(text="❐") + self.grip.place_forget() # размер при развороте не меняется + + def _restore_from_max(self): + """Возвращает окно к размеру до разворота.""" + if not self._maximized: + return + self._maximized = False + if self._restore_geom: + try: + self.geometry(self._restore_geom) + except tk.TclError: + pass + self.btn_max.configure(text="□") + self.grip.place(relx=1.0, rely=1.0, anchor="se") + + def _unmaximize_under_cursor(self, e): + """Перетаскивание развёрнутого окна: сначала вернуть размер.""" + try: + frac = ((e.x_root - self.winfo_rootx()) + / max(self.winfo_width(), 1)) + except Exception: # noqa: BLE001 + frac = 0.5 + self._restore_from_max() + self.update_idletasks() + # окно «прилипает» к курсору в той же точке строки + nx = int(e.x_root - frac * self.winfo_width()) + ny = e.y_root - 22 + self.geometry("+%d+%d" % (nx, ny)) + self.update_idletasks() # иначе rootx/rooty ещё старые + self._drag_dx = e.x_root - self.winfo_rootx() + self._drag_dy = e.y_root - self.winfo_rooty() + def _apply_taskbar_style(self): """Окно без рамки должно иметь кнопку в панели задач.""" if os.name != "nt": @@ -1693,13 +1899,13 @@ class App(tk.Tk): command=self._make_shortcut) m.add_separator() m.add_command(label=T("Открыть папку игры"), - command=lambda: self._open_explorer(self._game_dir())) + command=lambda: self._open_explorer(self._play_dir())) m.add_command(label=T("Открыть папку миров"), command=lambda: self._open_explorer( - os.path.join(self._game_dir(), "saves"))) + os.path.join(self._play_dir(), "saves"))) m.add_command(label=T("Открыть скриншоты"), command=lambda: self._open_explorer( - os.path.join(self._game_dir(), "screenshots"))) + os.path.join(self._play_dir(), "screenshots"))) m.add_command(label=T("Галерея скриншотов…"), command=self._open_gallery) m.add_separator() @@ -1945,6 +2151,307 @@ class App(tk.Tk): d.bind("", lambda e: save()) self._finish_dialog(d) + # ------------------------------------------------------------ инстансы + def _on_inst_scroll(self, first, last): + """yscrollcommand панели инстансов: обновляет скроллбар.""" + try: + self._inst_sb.set(first, last) + except (AttributeError, tk.TclError): + pass + self._toggle_inst_scroll() + + def _toggle_inst_scroll(self): + """Показывает/прячет скроллбар панели инстансов. + + Скрывать нельзя pack_forget/pack — при повторной упаковке после + Canvas Tk не даёт Scrollbar места и он не появится. Поэтому + переключаем width: 0 (фактически 1px, незаметен) <-> 17. + """ + cv = getattr(self, "_inst_cv", None) + sb = getattr(self, "_inst_sb", None) + if cv is None or sb is None: + return + try: + region = cv.bbox("all") + if not region: + return + need = region[3] > cv.winfo_height() + 2 + want = 17 if need else 0 + if int(sb.cget("width") or 0) != want: + sb.configure(width=want) + except (tk.TclError, ValueError, TypeError): + pass + + def _refresh_instances(self): + """Перерисовывает панель инстансов слева.""" + for w in self.inst_rows.winfo_children(): + w.destroy() + items = inst_mod.list_instances() + active = self.settings.get("active_instance") or "" + if not items: + tk.Label(self.inst_rows, text=T("Нет инстансов"), + bg=PANEL, fg=FAINT, font=("Segoe UI", 8), + anchor="w").pack(fill="x", padx=6, pady=6) + for it in items: + meta = it["meta"] + name = meta.get("name", "?") + sel = (name == active) + row = tk.Frame(self.inst_rows, + bg=HOVER if sel else PANEL, cursor="hand2") + row.pack(fill="x", padx=2, pady=1) + # иконка (эмодзи или кастомная) + icon = meta.get("icon") or "" + txt = icon if icon and not icon.endswith(".png") else "📦" + tk.Label(row, text=txt, bg=row["bg"], fg=TEXT, + font=("Segoe UI", 12)).pack(side="left", padx=(6, 4), + pady=4) + info = tk.Frame(row, bg=row["bg"]) + info.pack(side="left", fill="x", expand=True, pady=3) + tk.Label(info, text=name, bg=row["bg"], fg=TEXT, + font=("Segoe UI", 9, "bold"), anchor="w").pack(fill="x") + ver = meta.get("version") or "?" + loader = meta.get("loader") or "vanilla" + if loader and loader != "vanilla": + ver += " · " + loader + tk.Label(info, text=ver, bg=row["bg"], fg=FAINT, + font=("Segoe UI", 7), anchor="w").pack(fill="x") + + def make_select(n=name): + return lambda e: self._select_instance(n) + def make_ctx(n=name): + return lambda e: self._instance_menu(e, n) + row.bind("", make_select()) + row.bind("", make_ctx()) + for ch in row.winfo_children(): + ch.bind("", make_select()) + ch.bind("", make_ctx()) + for g in ch.winfo_children(): + g.bind("", make_select()) + g.bind("", make_ctx()) + # после перерисовки — спрятать скроллбар, если всё видно + try: + self.after(1, self._toggle_inst_scroll) + except (AttributeError, tk.TclError): + self._toggle_inst_scroll() + + def _select_instance(self, name): + """Выбирает активный инстанс и применяет его настройки.""" + items = inst_mod.list_instances() + for it in items: + if it["meta"].get("name") == name: + self.settings["active_instance"] = name + config.save_settings(self.settings) + self._apply_instance(it) + break + self._refresh_instances() + + def _apply_instance(self, it): + """Применяет настройки инстанса к UI и settings.""" + meta = it["meta"] + ver = meta.get("version") or "" + if ver: + self.settings["version"] = ver + self.var_ver.set(ver) + # пилюля и чип «Версия: …» внизу — одним вызовом + try: + self._update_pill() + except (AttributeError, tk.TclError): + pass + ram = meta.get("ram") or 0 + if ram: + self.settings["ram"] = ram + if meta.get("jvm_args") is not None: + self.settings["jvm_args"] = meta.get("jvm_args") or "" + if hasattr(self, "lbl_inst"): + self.lbl_inst.configure(text="· " + meta.get("name", "")) + config.save_settings(self.settings) + + def _current_instance(self): + """Возвращает dict {dir, meta} текущего инстанса или None.""" + name = self.settings.get("active_instance") or "" + if not name: + return None + for it in inst_mod.list_instances(): + if it["meta"].get("name") == name: + return it + return None + + def _open_new_instance(self): + """Диалог создания нового инстанса.""" + d = self._dialog(T("Новый инстанс"), width=420) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=16, pady=14) + + tk.Label(body, text=T("Название"), bg=BG, fg=TEXT, anchor="w", + font=("Segoe UI", 9)).pack(fill="x") + e_name = tk.Entry(body, bg=INPUT, fg=TEXT, insertbackground=TEXT, + relief="flat", bd=4, font=("Segoe UI", 10)) + e_name.pack(fill="x", pady=(2, 10)) + e_name.insert(0, "Новый мир") + + tk.Label(body, text=T("Версия"), bg=BG, fg=TEXT, anchor="w", + font=("Segoe UI", 9)).pack(fill="x") + cb_ver = ttk.Combobox(body, state="readonly", font=("Segoe UI", 9)) + vers = [v for v in (self.settings.get("versions_list") or [])] + if not vers: + vers = ["latest-release", "latest-snapshot"] + cb_ver["values"] = vers + cb_ver.set(self.settings.get("version", "latest-release")) + cb_ver.pack(fill="x", pady=(2, 10)) + + tk.Label(body, text=T("Загрузчик"), bg=BG, fg=TEXT, anchor="w", + font=("Segoe UI", 9)).pack(fill="x") + cb_loader = ttk.Combobox(body, state="readonly", font=("Segoe UI", 9)) + cb_loader["values"] = ["vanilla", "fabric", "forge", "neoforge"] + cb_loader.set("vanilla") + cb_loader.pack(fill="x", pady=(2, 14)) + + def create(): + nm = e_name.get().strip() or "Новый мир" + d2 = inst_mod.create(nm, cb_ver.get(), cb_loader.get()) + self.settings["active_instance"] = inst_mod.load_meta(d2)["name"] + config.save_settings(self.settings) + d.destroy() + self._refresh_instances() + it = self._current_instance() + if it: + self._apply_instance(it) + self._log(T("Инстанс создан: ") + nm) + + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x") + self._btn(btns, T("Создать"), create, primary=True).pack(side="right") + self._btn(btns, T("Отмена"), d.destroy).pack(side="right", padx=(0, 6)) + d.bind("", lambda e: create()) + self._finish_dialog(d) + + def _instance_menu(self, event, name): + """Контекстное меню инстанса (ПКМ).""" + m = self.inst_menu + m.delete(0, "end") + + def find(): + for it in inst_mod.list_instances(): + if it["meta"].get("name") == name: + return it + return None + + m.add_command(label=T("Запустить"), + command=lambda: self._play_instance(name)) + m.add_command(label=T("Настройки…"), + command=lambda: self._open_instance_settings(name)) + m.add_separator() + m.add_command(label=T("Открыть папку"), + command=lambda: self._open_instance_dir(name)) + m.add_command(label=T("Дублировать"), + command=lambda: self._dup_instance(name)) + m.add_command(label=T("Экспорт…"), + command=lambda: self._export_instance(name)) + m.add_separator() + m.add_command(label=T("Удалить"), + command=lambda: self._del_instance(name)) + try: + m.tk_popup(event.x_root, event.y_root) + finally: + m.grab_release() + + def _play_instance(self, name): + """Выбирает инстанс и запускает игру.""" + self._select_instance(name) + self._on_play() + + def _open_instance_dir(self, name): + for it in inst_mod.list_instances(): + if it["meta"].get("name") == name: + self._open_explorer(inst_mod.game_dir(it["dir"])) + break + + def _dup_instance(self, name): + for it in inst_mod.list_instances(): + if it["meta"].get("name") == name: + inst_mod.duplicate(it["dir"]) + self._refresh_instances() + break + + def _export_instance(self, name): + for it in inst_mod.list_instances(): + if it["meta"].get("name") == name: + try: + path = inst_mod.export_zip(it["dir"], None) + self._log(T("Экспортировано: ") + path) + except Exception as ex: + self._log(T("Ошибка экспорта: ") + str(ex), "err") + break + + def _del_instance(self, name): + if not messagebox.askyesno( + "PixelLauncher", + T("Удалить инстанс «") + name + T("»?") + "\n" + + T("Миры будут удалены безвозвратно."), parent=self): + return + for it in inst_mod.list_instances(): + if it["meta"].get("name") == name: + inst_mod.delete(it["dir"]) + if self.settings.get("active_instance") == name: + self.settings["active_instance"] = "" + config.save_settings(self.settings) + break + self._refresh_instances() + + def _open_instance_settings(self, name): + """Диалог настроек инстанса (ОЗУ, Java, окно).""" + for it in inst_mod.list_instances(): + if it["meta"].get("name") == name: + break + else: + return + meta = it["meta"] + d = self._dialog(T("Инстанс: ") + name, width=460) + body = tk.Frame(d, bg=BG) + body.pack(fill="both", expand=True, padx=16, pady=14) + + def row_label(txt): + tk.Label(body, text=txt, bg=BG, fg=TEXT, anchor="w", + font=("Segoe UI", 9)).pack(fill="x", pady=(8, 2)) + + row_label(T("ОЗУ (МБ, 0 = авто)")) + e_ram = tk.Entry(body, bg=INPUT, fg=TEXT, insertbackground=TEXT, + relief="flat", bd=4, font=("Segoe UI", 10)) + e_ram.pack(fill="x") + e_ram.insert(0, str(meta.get("ram") or 0)) + + row_label(T("JVM аргументы")) + e_jvm = tk.Entry(body, bg=INPUT, fg=TEXT, insertbackground=TEXT, + relief="flat", bd=4, font=("Segoe UI", 10)) + e_jvm.pack(fill="x") + e_jvm.insert(0, meta.get("jvm_args") or "") + + row_label(T("Заметка")) + e_notes = tk.Entry(body, bg=INPUT, fg=TEXT, insertbackground=TEXT, + relief="flat", bd=4, font=("Segoe UI", 10)) + e_notes.pack(fill="x") + e_notes.insert(0, meta.get("notes") or "") + + def save(): + try: + meta["ram"] = int(e_ram.get() or 0) + except ValueError: + meta["ram"] = 0 + meta["jvm_args"] = e_jvm.get() + meta["notes"] = e_notes.get() + inst_mod.save_meta(it["dir"], meta) + d.destroy() + if self.settings.get("active_instance") == name: + self._apply_instance(it) + self._log(T("Настройки инстанса сохранены")) + + btns = tk.Frame(body, bg=BG) + btns.pack(fill="x", pady=(14, 0)) + self._btn(btns, T("Сохранить"), save, primary=True).pack(side="right") + self._btn(btns, T("Отмена"), d.destroy).pack(side="right", padx=(0, 6)) + d.bind("", lambda e: save()) + self._finish_dialog(d) + def _open_profiles(self): """Список профилей запуска: применить, создать, изменить, удалить.""" d = self._dialog(T("Профили"), width=660) @@ -2118,7 +2625,7 @@ class App(tk.Tk): return mods = None if var_mods.get(): - mods = [m["file"] for m in mods_mod.list_mods(self._game_dir()) + mods = [m["file"] for m in mods_mod.list_mods(self._play_dir()) if m["enabled"]] prof = {"name": name, "username": nick, "version": ver, "ram": ram, "fullscreen": bool(var_fs.get()), "mods": mods, @@ -2177,10 +2684,10 @@ class App(tk.Tk): low = low[:-4] return low want = {base_of(x) for x in mods} - for m in mods_mod.list_mods(self._game_dir()): + for m in mods_mod.list_mods(self._play_dir()): on = base_of(m["file"]) in want if m["enabled"] != on: - mods_mod.set_enabled(self._game_dir(), m["file"], on) + mods_mod.set_enabled(self._play_dir(), m["file"], on) new_prof = str(p.get("name") or "") self.settings["active_profile"] = new_prof config.save_settings(self.settings) @@ -2263,7 +2770,7 @@ class App(tk.Tk): def refresh_worlds(): lb_w.delete(0, "end") - worlds[:] = worlds_mod.list_worlds(self._game_dir()) + worlds[:] = worlds_mod.list_worlds(self._play_dir()) for w in worlds: lb_w.insert("end", f"{w['name']} " f"({mods_mod.fmt_size(w['size'])}, " @@ -2276,7 +2783,7 @@ class App(tk.Tk): backups[:] = [] w = sel_world() if w: - backups[:] = worlds_mod.list_backups(self._game_dir(), w["name"]) + backups[:] = worlds_mod.list_backups(self._play_dir(), w["name"]) for b in backups: lb_b.insert("end", f"{b['name']} " f"({mods_mod.fmt_size(b['size'])})") @@ -2301,7 +2808,7 @@ class App(tk.Tk): try: cap = int(self.settings.get("world_backups", 5)) path = worlds_mod.backup_world( - self._game_dir(), w["name"], cap, + self._play_dir(), w["name"], cap, log=lambda m: lq.put(("log", m))) lq.put(("log", T("Копия мира {w} создана: ").format( w=w["name"]) + os.path.basename(path), "ok")) @@ -2320,7 +2827,7 @@ class App(tk.Tk): T("Удалить копию {b}?").format(b=b["name"]), parent=d): return - if worlds_mod.remove_backup(self._game_dir(), w["name"], + if worlds_mod.remove_backup(self._play_dir(), w["name"], b["name"]): self._log(T("Копия удалена: ") + b["name"], "ok") refresh_backups() @@ -2329,7 +2836,7 @@ class App(tk.Tk): w = sel_world() if not w: return - path = worlds_mod.backups_dir(self._game_dir(), w["name"]) + path = worlds_mod.backups_dir(self._play_dir(), w["name"]) os.makedirs(path, exist_ok=True) self._open_explorer(path) @@ -2342,7 +2849,7 @@ class App(tk.Tk): T("Удалить мир {w}? Это нельзя отменить.").format( w=w["name"]), parent=d): return - if worlds_mod.remove_world(self._game_dir(), w["name"]): + if worlds_mod.remove_world(self._play_dir(), w["name"]): self._log(T("Мир удалён: ") + w["name"], "ok") refresh_all() @@ -2484,47 +2991,124 @@ class App(tk.Tk): Дублируют подменю ⚙ → «Быстрые настройки»: язык, масштаб, автозагрузка, трей и авто-бэкап меняются одним кликом. + При узком окне чипы переносятся на следующие строки, а не + обрезаются правым краем. """ - def chip(text, command, active=False): - bg = GREEN_BD if active else PILL - fg = TEXT if active else DIM - lbl = tk.Label(parent, text=text, bg=bg, fg=fg, - font=("Segoe UI", 8), padx=9, pady=3, - cursor="hand2") - lbl.pack(side="left", padx=3) - lbl.bind("", lambda e: command()) - lbl.bind("", lambda e: lbl.configure(bg=HOVER, fg=TEXT)) - lbl.bind("", lambda e: lbl.configure(bg=bg, fg=fg)) - return lbl + def _avail(): + """Ширина под чипы с запасом по краям панели. - s = self.settings - on, off = T("вкл"), T("выкл") - cur_lang = s.get("lang", "ru") + 0 — ширина ещё неизвестна (окно не отрисовано): построим + чипы позже, когда придёт первый . + """ + w = parent.winfo_width() - 12 + if w <= 40 and parent.master is not None: + w = parent.master.winfo_width() - 12 + return w if w > 40 else 0 - ram_txt = (T("авто") if s.get("auto_ram") - else self._fmt_ram(s.get("ram", 2048))) - chip(T("ОЗУ") + ": " + ram_txt, self._cycle_ram, - active=bool(s.get("auto_ram"))) + state = {"w": 0} # ширина, под которую чипы сейчас разложены - self.quick_ver_chip = chip(self._ver_chip_text(), - self._open_versions) + def build(_event=None): + """Пересоздаёт чипы строками ровно по ширине панели.""" + avail = _avail() + if avail <= 0: + state["w"] = 0 # ждём первого с реальной шириной + return + state["w"] = avail + for old in list(parent.winfo_children()): + old.destroy() # прежние строки и чипы + chips = [] - chip("RU" if cur_lang == "ru" else "EN", - lambda: self._quick_lang("en" if cur_lang == "ru" else "ru")) + def chip(text, command, active=False): + bg = GREEN_BD if active else PILL + fg = TEXT if active else DIM + lbl = tk.Label(parent, text=text, bg=bg, fg=fg, + font=("Segoe UI", 8), padx=9, pady=3, + cursor="hand2") + chips.append(lbl) + lbl.bind("", lambda e: command()) + lbl.bind("", lambda e: lbl.configure(bg=HOVER, fg=TEXT)) + lbl.bind("", lambda e: lbl.configure(bg=bg, fg=fg)) + return lbl - chip("%d%%" % int(round(float(s.get("ui_scale", 1.0)) * 100)), - self._cycle_scale) + s = self.settings + on, off = T("вкл"), T("выкл") + cur_lang = s.get("lang", "ru") - chip(T("Автозагрузка") + ": " + (on if s.get("autostart") else off), - self._toggle_autostart, active=bool(s.get("autostart"))) + ram_txt = (T("авто") if s.get("auto_ram") + else self._fmt_ram(s.get("ram", 2048))) + chip(T("ОЗУ") + ": " + ram_txt, self._cycle_ram, + active=bool(s.get("auto_ram"))) - chip(T("Трей") + ": " + (on if s.get("minimize_to_tray_on_launch") - else off), - self._toggle_tray, - active=bool(s.get("minimize_to_tray_on_launch"))) + self.quick_ver_chip = chip(self._ver_chip_text(), + self._open_versions) - chip(T("Бэкап") + ": " + (on if s.get("auto_backup") else off), - self._toggle_backup, active=bool(s.get("auto_backup"))) + chip("RU" if cur_lang == "ru" else "EN", + lambda: self._quick_lang("en" if cur_lang == "ru" else "ru")) + + chip("%d%%" % int(round(float(s.get("ui_scale", 1.0)) * 100)), + self._cycle_scale) + + chip(T("Автозагрузка") + ": " + (on if s.get("autostart") else off), + self._toggle_autostart, active=bool(s.get("autostart"))) + + chip(T("Трей") + ": " + (on if s.get("minimize_to_tray_on_launch") + else off), + self._toggle_tray, + active=bool(s.get("minimize_to_tray_on_launch"))) + + chip(T("Бэкап") + ": " + (on if s.get("auto_backup") else off), + self._toggle_backup, active=bool(s.get("auto_backup"))) + + # дробим на строки: всё, что не влезло в ширину, — на новую + row = mid = None + x = 0 + for lbl in chips: + w = lbl.winfo_reqwidth() + 6 # межчиповый отступ + if row is None or (x and x + w > avail): + row = tk.Frame(parent, bg=BG) + # колонки-пустышки с весом центрируют строку сами + row.columnconfigure(0, weight=1) + row.columnconfigure(2, weight=1) + tk.Frame(row, bg=BG).grid(row=0, column=0) + tk.Frame(row, bg=BG).grid(row=0, column=2) + mid = tk.Frame(row, bg=BG) + mid.grid(row=0, column=1) + row.pack(side="top", fill="x") + x = 0 + lbl.pack(in_=mid, side="left") + x += w + + # строки создаются позже чипов и в X-порядке их перекрывают + # (чипы — дети bar, строки — тоже): поднимаем чипы наверх + for lbl in chips: + lbl.lift() + + # перенос строк мог увеличить естественную высоту окна + self.after_idle(self._fit_window_height) + + def on_resize(_event=None): + """Разбивает чипы заново, только если ширина реально изменилась.""" + if _avail() == state["w"]: + return + if getattr(self, "_quick_after", None): + try: + self.after_cancel(self._quick_after) + except Exception: # noqa: BLE001 — уже отменён/выполнен + pass + + def later(): + self._quick_after = None + try: + alive = parent.winfo_exists() + except Exception: # noqa: BLE001 — окно уже пересобрано + alive = False + if alive: + build() + + self._quick_after = self.after(40, later) + + parent.bind("", on_resize) + build() @staticmethod def _fmt_ram(mb): @@ -2697,14 +3281,14 @@ class App(tk.Tk): row = tk.Frame(body, bg=BG) row.pack(fill="x", pady=(10, 0)) self._btn(row, T("Открыть папку игры"), - lambda: self._open_explorer(self._game_dir())).pack(side="left") + lambda: self._open_explorer(self._play_dir())).pack(side="left") self._btn(row, T("Открыть папку миров"), lambda: self._open_explorer( - os.path.join(self._game_dir(), "saves"))).pack(side="left", + os.path.join(self._play_dir(), "saves"))).pack(side="left", padx=(6, 0)) self._btn(row, T("Открыть скриншоты"), lambda: self._open_explorer( - os.path.join(self._game_dir(), "screenshots"))).pack( + os.path.join(self._play_dir(), "screenshots"))).pack( side="left", padx=(6, 0)) self._btn(row, T("Галерея"), self._open_gallery).pack(side="left", padx=(6, 0)) @@ -2833,6 +3417,16 @@ class App(tk.Tk): var_px_pass)).pack(side="left") self._hint(body, T("HTTP-прокси и SOCKS5. Пустой хост = выключен.")) + # --- Ключ CurseForge API (необязателен) + tk.Label(body, text=T("Ключ CurseForge API (необязательно):"), bg=BG, + fg=DIM, anchor="w", font=("Segoe UI", 9)).pack(anchor="w", + pady=(12, 2)) + var_cf_key = tk.StringVar(value=self.settings.get("cf_api_key") or "") + tk.Entry(body, textvariable=var_cf_key, width=40, font=("Segoe UI", 9), + bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid", bd=1, + highlightthickness=0).pack(fill="x") + self._hint(body, T("Без ключа сборки берутся с зеркала CurseForge.")) + # --- Масштаб интерфейса sc_row = tk.Frame(body, bg=BG) sc_row.pack(fill="x", pady=(10, 0)) @@ -2892,6 +3486,7 @@ class App(tk.Tk): self.settings["theme"] = var_theme.get() self.settings["asset_mirrors"] = lines(txt_assets) self.settings["lib_mirrors"] = lines(txt_libs) + self.settings["cf_api_key"] = var_cf_key.get().strip() try: px_port = int(var_px_port.get().strip() or 1080) except ValueError: @@ -2922,6 +3517,7 @@ class App(tk.Tk): set_theme(self.settings["theme"]) set_accent(self.settings["accent"]) http.configure(self.settings.get("proxy")) + curseforge_mod.set_api_key(self.settings.get("cf_api_key")) self._apply_ui_scale() self._rebuild_ui() d.destroy() @@ -2964,11 +3560,163 @@ class App(tk.Tk): font=("Segoe UI", 8)) status.pack(fill="x", pady=(8, 0)) - lb = tk.Listbox(body, height=9, bg=INPUT, fg=TEXT, selectbackground=GREEN, - selectforeground="#0d1a0d", relief="solid", bd=1, - highlightthickness=0, font=("Segoe UI", 9), - exportselection=False) - lb.pack(fill="both", expand=True, pady=(4, 0)) + # --- список сборок: строки с иконками (canvas + скролл) --- + listwrap = tk.Frame(body, bg=INPUT, highlightthickness=1, + highlightbackground=INPUT_BD, relief="solid") + listwrap.pack(fill="both", expand=True, pady=(4, 0)) + cv = tk.Canvas(listwrap, bg=INPUT, highlightthickness=0, bd=0) + sb = tk.Scrollbar(listwrap, orient="vertical", command=cv.yview) + cv.configure(yscrollcommand=sb.set) + sb.pack(side="right", fill="y") + cv.pack(side="left", fill="both", expand=True) + rows_holder = tk.Frame(cv, bg=INPUT) + cv_win = cv.create_window((0, 0), window=rows_holder, anchor="nw") + rows_holder.bind("", + lambda e: cv.configure(scrollregion=cv.bbox("all"))) + cv.bind("", + lambda e: cv.itemconfigure(cv_win, width=e.width)) + + ICON_PX = 34 + sel_idx = {"i": None} + row_widgets = [] # (фрейм, метка иконки, метка текста) + photos = [] # ссылки на PhotoImage, чтобы Tk не освободил + + def _prep_icon(raw, box=ICON_PX): + """Фоновый поток: подготовка иконки через Pillow (или None).""" + if _PILImage is None: + return None + try: + im = _PILImage.open(_pil_io.BytesIO(raw)).convert("RGBA") + # логотипы часто с прозрачными полями — обрезаем их + bbox = im.getbbox() + if bbox: + im = im.crop(bbox) + w0, h0 = im.size + if w0 and h0: + k = box / float(max(w0, h0)) + if k != 1.0: + im = im.resize((max(1, int(round(w0 * k))), + max(1, int(round(h0 * k)))), + _PILImage.LANCZOS) + canvas = _PILImage.new("RGBA", (box, box), (0, 0, 0, 0)) + canvas.paste(im, ((box - im.width) // 2, + (box - im.height) // 2)) + mask = _PILImage.new("L", (box, box), 0) + _PILDraw.Draw(mask).rounded_rectangle( + (0, 0, box - 1, box - 1), radius=max(4, box // 7), + fill=255) + canvas.putalpha(mask) + return canvas + except Exception: # noqa: BLE001 + return None + + def _fit_icon_tk(raw, box=ICON_PX): + """Запасной путь без Pillow: Tk subsample + квадратный фон, + чтобы неквадратные иконки не выглядели «осколком».""" + img = tk.PhotoImage(data=raw) + w, h = img.width(), img.height() + k = max(w, h) / float(box) + if k > 1.0: + img = img.subsample(max(1, int(k)), max(1, int(k))) + w, h = img.width(), img.height() + if w != box or h != box: + sq = tk.PhotoImage(width=box, height=box) + sq.tk.call(str(sq), "copy", str(img), "-to", + max(0, (box - w) // 2), max(0, (box - h) // 2)) + return sq + return img + + def _scroll_wheel(e): + cv.yview_scroll(-1 if e.delta > 0 else 1, "units") + + def _icon_worker(url, lab): + """Скачивает иконку в фоне, отдаёт в GUI-поток через after.""" + # Modrinth отдаёт WEBP с суффиксом _NN — конвертируем в PNG + orig_url = url + if "cdn.modrinth.com" in url and url.endswith(".webp"): + fname = url.rsplit("/", 1)[-1].rsplit("_", 1)[0] + url = url.rsplit("/", 1)[0] + "/" + fname + ".png" + raw = None + for u in (url, orig_url): + try: + raw = http.get_bytes(u, timeout=20) + break + except Exception: # noqa: BLE001 + continue + if raw is None: + return + + prepared = _prep_icon(raw) # в фоне: сглаживание Pillow + + def put(): + try: + if not lab.winfo_exists(): + return + img = None + if prepared is not None and _PILImageTk is not None: + try: + img = _PILImageTk.PhotoImage(prepared) + except Exception: # noqa: BLE001 + img = None + if img is None: + img = _fit_icon_tk(raw) + photos.append(img) + lab.configure(image=img, text="") + except Exception: # noqa: BLE001 — битая картинка + pass + try: + if d.winfo_exists(): + d.after(0, put) + except Exception: # noqa: BLE001 — окно уже закрыли + pass + + def _select(i): + sel_idx["i"] = i + for j, (fr, ic, tx) in enumerate(row_widgets): + try: + bgc = GREEN if j == i else INPUT + fr.configure(bg=bgc) + ic.configure(bg=bgc, fg="#0d1a0d" if j == i else DIM) + tx.configure(bg=bgc, fg="#0d1a0d" if j == i else TEXT) + except tk.TclError: + pass + load_files() + + def _fill_rows(items): + for fr, _ic, _tx in row_widgets: + fr.destroy() + row_widgets.clear() + del photos[:] + sel_idx["i"] = None + for i, it in enumerate(items): + fr = tk.Frame(rows_holder, bg=INPUT) + fr.pack(fill="x", padx=4, pady=2) + icon = tk.Label(fr, text="📦", width=3, bg=INPUT, fg=DIM, + font=("Segoe UI", 12)) + icon.pack(side="left", padx=(8, 8), pady=4) + title = it.get("title") or it.get("name") or "?" + desc = it.get("desc") or it.get("summary") or "" + bits = [] + if it.get("downloads"): + bits.append(str(it["downloads"])) + if it.get("loaders"): + bits.append(", ".join(it["loaders"][:2])) + gv = [g for g in (it.get("game_versions") or []) if g] + if gv: + bits.append(str(gv[0])) + tail = (" [" + " · ".join(bits) + "]") if bits else "" + txt = tk.Label(fr, text="%s — %s%s" % (title, desc, tail), + bg=INPUT, fg=TEXT, font=("Segoe UI", 9), + anchor="w", justify="left", wraplength=540) + txt.pack(side="left", fill="x", expand=True, pady=4) + row_widgets.append((fr, icon, txt)) + for w in (fr, icon, txt): + w.bind("", lambda e, i=i: _select(i)) + w.bind("", _scroll_wheel) + url = it.get("icon") or "" + if url: + threading.Thread(target=_icon_worker, args=(url, icon), + daemon=True).start() lbl_files = tk.Label(body, text=T("Файлы сборки"), bg=BG, fg=DIM, anchor="w", font=("Segoe UI", 8)) @@ -3029,17 +3777,9 @@ class App(tk.Tk): nonlocal packs out, total = res packs = out - lb.delete(0, "end") - for it in out: - title = it.get("title") or it.get("name") or "?" - desc = it.get("desc") or it.get("summary") or "" - bits = [] - if it.get("downloads"): - bits.append(str(it["downloads"])) - if it.get("loaders"): - bits.append(", ".join(it["loaders"][:2])) - tail = (" [" + " · ".join(bits) + "]") if bits else "" - lb.insert("end", "%s — %s%s" % (title, desc, tail)) + files.clear() + lb_f.delete(0, "end") + _fill_rows(out) status.configure(text=T("Найдено: ") + str(total), fg=DIM) def err(e): @@ -3060,13 +3800,13 @@ class App(tk.Tk): timer["id"] = d.after(400, search_packs) def load_files(*_a): - sel = lb.curselection() + sel = sel_idx["i"] lb_f.delete(0, "end") files.clear() - if not sel or sel[0] >= len(packs): + if sel is None or sel >= len(packs): lbl_files.configure(text=T("Файлы сборки"), fg=DIM) return - p = packs[sel[0]] + p = packs[sel] status.configure(text=T("Загрузка…"), fg=DIM) def ok(res): @@ -3103,11 +3843,15 @@ class App(tk.Tk): run(api, ok, err) def install_sel(): - sel = lb.curselection() - if not sel or sel[0] >= len(packs): + sel = sel_idx["i"] + if sel is None or sel >= len(packs): status.configure(text=T("Выберите сборку"), fg=ERR) return - p = packs[sel[0]] + if self.busy: + status.configure( + text=T("Дождитесь окончания текущей загрузки"), fg=ERR) + return + p = packs[sel] fsel = lb_f.curselection() f = files[fsel[0]] if (fsel and fsel[0] < len(files)) else None title = p.get("title") or p.get("name") or "?" @@ -3118,7 +3862,7 @@ class App(tk.Tk): def worker(log, prog, cancel): return packs_mod.install_pack_modrinth( - pid, game_dir=self._game_dir(), version=ver, log=log, + pid, game_dir=self._play_dir(), version=ver, log=log, progress=prog, cancel=cancel) else: mid = p.get("id") @@ -3126,15 +3870,13 @@ class App(tk.Tk): def worker(log, prog, cancel): return packs_mod.install_pack_curseforge( - mid, fid, game_dir=self._game_dir(), log=log, + mid, fid, game_dir=self._play_dir(), log=log, progress=prog, cancel=cancel) self._install_pack_async( worker, T("Установка сборки: ") + str(title)) var_q.trace_add("write", schedule_search) - lb.bind("<>", load_files) lb_f.bind("", lambda e: install_sel()) - lb.bind("", lambda e: install_sel()) btns = tk.Frame(body, bg=BG) btns.pack(fill="x", pady=(10, 0)) @@ -3424,9 +4166,9 @@ class App(tk.Tk): mc, loader = self._version_context() mc_f = mc if re.match(r"\d", mc) else "" q_loader = loader if loader in ("fabric", "quilt") else "" - if not q_loader or not mods_mod.list_mods(self._game_dir()): + if not q_loader or not mods_mod.list_mods(self._play_dir()): return - ups = mods_mod.check_updates(self._game_dir(), mc_f, q_loader) + ups = mods_mod.check_updates(self._play_dir(), mc_f, q_loader) if ups and not self.busy: self.ui_q.put(("log", T("Есть обновления модов: {n}. " @@ -3537,7 +4279,7 @@ class App(tk.Tk): lq.put(("log", T("Ничего не найдено"), "warn")) return v = vs[0] - dest = os.path.join(mods_mod.mods_dir(self._game_dir()), + dest = os.path.join(mods_mod.mods_dir(self._play_dir()), v["filename"]) lq.put(("log", T("Установка: ") + v["filename"])) ok = modrinth_mod.download_mod(v["url"], dest, @@ -3564,7 +4306,7 @@ class App(tk.Tk): def refresh_local(): loc_lb.delete(0, "end") - state["local"] = mods_mod.list_mods(self._game_dir()) + state["local"] = mods_mod.list_mods(self._play_dir()) for m in state["local"]: loc_lb.insert("end", ("● " if m["enabled"] else "○ ") + m["name"] + " (" + mods_mod.fmt_size( @@ -3581,7 +4323,7 @@ class App(tk.Tk): m = selected_mod() if not m: return - new = mods_mod.set_enabled(self._game_dir(), m["file"], + new = mods_mod.set_enabled(self._play_dir(), m["file"], not m["enabled"]) if new: self._log((T("Мод включён: ") if not m["enabled"] @@ -3596,7 +4338,7 @@ class App(tk.Tk): "PixelLauncher", T("Удалить") + " " + m["name"] + "?", parent=d): return - if mods_mod.remove_mod(self._game_dir(), m["file"]): + if mods_mod.remove_mod(self._play_dir(), m["file"]): self._log(T("Мод удалён: ") + m["name"], "ok") refresh_local() @@ -3666,7 +4408,7 @@ class App(tk.Tk): ok_n = 0 for u in picked: lq.put(("log", T("Обновление: ") + u["new_file"])) - if mods_mod.apply_update(self._game_dir(), u): + if mods_mod.apply_update(self._play_dir(), u): ok_n += 1 lq.put(("log", T("Мод обновлён: ") + u["name"], "ok")) @@ -3707,7 +4449,7 @@ class App(tk.Tk): self._btn(brow, T("Каталог модпаков…"), self._open_packs).pack(side="left", padx=(6, 0)) self._btn(brow, T("Папка mods"), lambda: self._open_explorer( - mods_mod.mods_dir(self._game_dir()))).pack(side="right") + mods_mod.mods_dir(self._play_dir()))).pack(side="right") self._finish_dialog(d) return d @@ -3733,7 +4475,7 @@ class App(tk.Tk): try: log(T("Установка модпака: ") + os.path.basename(path)) vid, got = mrpack_mod.install( - self._game_dir(), path, log=log, + self._play_dir(), path, log=log, progress=lambda f: self.ui_q.put( ("progress", f * 100, 100, T("Модпак"))), cancel=self._cancel) @@ -4880,6 +5622,17 @@ class App(tk.Tk): def _game_dir(self): return self.settings.get("game_dir", config.DEFAULT_GAME_DIR) + def _play_dir(self): + """Каталог игры для запуска/контента: активный инстанс или общий. + + Общие ресурсы (versions/assets/libraries/.launcher) всегда в + _game_dir(); сюда попадают mods/saves/config выбранного инстанса. + """ + it = self._current_instance() + if it: + return inst_mod.game_dir(it["dir"]) + return self._game_dir() + def _ram_max(self): try: if os.name == "nt": @@ -4917,7 +5670,7 @@ class App(tk.Tk): d = self._dialog(T("Галерея скриншотов"), width=640) body = tk.Frame(d, bg=BG) body.pack(fill="both", expand=True, padx=12, pady=10) - shot_dir = os.path.join(self._game_dir(), "screenshots") + shot_dir = os.path.join(self._play_dir(), "screenshots") cols, tw, th = 4, 136, 84 cv = tk.Canvas(body, bg=BG, highlightthickness=0) @@ -5145,7 +5898,7 @@ class App(tk.Tk): def _add_mod_file(self, path): try: shutil.copy2(path, os.path.join( - mods_mod.mods_dir(self._game_dir()), os.path.basename(path))) + mods_mod.mods_dir(self._play_dir()), os.path.basename(path))) self._log(T("Мод добавлен: ") + os.path.basename(path), "ok") except OSError as e: # noqa: BLE001 self._log(T("Не удалось добавить мод") + ": " + str(e), "err") @@ -5332,6 +6085,10 @@ class App(tk.Tk): try: gd = self._game_dir() os.makedirs(gd, exist_ok=True) + # каталог игры выбранного инстанса (mods/saves), может отличаться + play_dir = self._play_dir() + if play_dir != gd: + os.makedirs(play_dir, exist_ok=True) log(T("Получение манифеста версий...")) manifest_mod.get_manifest(gd, net=True) @@ -5341,7 +6098,7 @@ class App(tk.Tk): # авто-бэкап мира перед запуском (по настройке) if do_launch and self.settings.get("auto_backup"): try: - self._backup_worlds(gd, log) + self._backup_worlds(play_dir, log) except Exception as be: # noqa: BLE001 log(T("Ошибка: ") + str(be), "warn") @@ -5395,7 +6152,8 @@ class App(tk.Tk): ram = (self._auto_ram() if self.settings.get("auto_ram") else int(self.settings.get("ram", 2048))) cmd = ver.build_command(jpath, nick, uuid_hex, ram, - self.settings.get("jvm_args", "")) + self.settings.get("jvm_args", ""), + game_directory=play_dir) # размер окна/полный экран из профиля try: ww = int(self.settings.get("win_w") or 0) @@ -5427,7 +6185,7 @@ class App(tk.Tk): t0 = time.time() stats_mod.record_start(vid) proc = launch_mod.launch( - gd, cmd, + play_dir, cmd, log=lambda m: self.ui_q.put( ("log", m, "ok" if m.startswith(T("Игра запущена, PID ")) else None)), diff --git a/launcher/version.py b/launcher/version.py index 68e000c..a1893a2 100644 --- a/launcher/version.py +++ b/launcher/version.py @@ -183,12 +183,16 @@ class Version: return os.pathsep.join(entries) # ------------------------------------------------------------------ - def build_command(self, java, username, uuid_hex, ram_mb, extra_jvm=""): + def build_command(self, java, username, uuid_hex, ram_mb, extra_jvm="", + game_directory=None): + # Общие ресурсы (libraries/assets/versions) берём из self.game_dir, + # а каталог игры (mods/saves/config) можно переопределить — так + # работают инстансы: общий движок, разные папки контента. gd = self.game_dir subst = { "auth_player_name": username, "version_name": self.id, - "game_directory": gd, + "game_directory": game_directory or gd, "assets_root": self.assets_dir(), "assets_index_name": self.asset_index.get("id", ""), "auth_uuid": uuid_hex, @@ -198,7 +202,7 @@ class Version: "version_type": self.type, "natives_directory": self.natives_dir(), "launcher_name": "PixelLauncher", - "launcher_version": "2.1.2", + "launcher_version": sys.modules[__package__].LAUNCHER_VERSION, "classpath": self.classpath_str(), "classpath_separator": os.pathsep, "library_directory": self.libraries_dir(), diff --git a/make_installer.py b/make_installer.py index f846674..fd60beb 100644 --- a/make_installer.py +++ b/make_installer.py @@ -26,7 +26,7 @@ STAGE = os.path.join(BUILD, "stage") ISCC = r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" PYVER = "%d.%d.%d" % sys.version_info[:3] -APP_VERSION = "2.1.2" # должен совпадать с LAUNCHER_VERSION в launcher/__init__.py +APP_VERSION = "2.1.3" # должен совпадать с LAUNCHER_VERSION в launcher/__init__.py SETUP_NAME = "PixelLauncherSetup%s.exe" % APP_VERSION @@ -83,6 +83,38 @@ def build_stage(): copied.append("tkinter/") log("tkinter/Tcl: " + ", ".join(copied) + " + tcl/") + # --- Pillow (опционально) — сглаженные иконки сборок; если её нет, + # лаунчер переключается на штатный Tk-путь + sites = [os.path.join(base, "Lib", "site-packages")] + try: + import site as _site + us = _site.getusersitepackages() + sites.extend(us if isinstance(us, (list, tuple)) else [us]) + except Exception: + pass + staged = [] + for site_dir in sites: + if not os.path.isdir(site_dir): + continue + for name in sorted(os.listdir(site_dir)): + if name != "PIL" and not name.lower().startswith("pillow"): + continue + src = os.path.join(site_dir, name) + dst = os.path.join(STAGE, name) + if os.path.isdir(src): + if os.path.isdir(dst): + shutil.rmtree(dst, ignore_errors=True) + shutil.copytree(src, dst, + ignore=shutil.ignore_patterns("__pycache__", + "*.pyc")) + elif os.path.isfile(src): + shutil.copy2(src, dst) + staged.append(name) + if staged: + break + log("Pillow: " + (", ".join(staged) if staged + else "не найдена — иконки без сглаживания")) + # ._pth: гарантируем точку в пути (иначе import launcher не найдёт) pth = [f for f in os.listdir(STAGE) if f.endswith("._pth")][0] pth_path = os.path.join(STAGE, pth) @@ -151,6 +183,9 @@ if __name__ == "__main__": "import tkinter,ssl,urllib.request,json,zipfile;" "r=tkinter.Tk(); r.withdraw(); r.destroy();" "import launcher.config, launcher.http, launcher.manifest;" + "import importlib,importlib.util;" + "importlib.import_module('PIL.Image')" + " if importlib.util.find_spec('PIL') else None;" "print('stage ok')"], capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=STAGE) diff --git a/requirements.txt b/requirements.txt index 2e3d4ee..03fdca1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ # Внешние зависимости не требуются. # Достаточно Python 3.8+ (всё реализовано на стандартной библиотеке: # tkinter, urllib, json, zipfile, concurrent.futures). +# Если доступна Pillow, лаунчер использует её для сглаженных иконок сборок +# (установщик PixelLauncher Pillow включает автоматически); без Pillow +# иконки рисуются штатным Tk — работает в любом случае.