6408 lines
281 KiB
Python
6408 lines
281 KiB
Python
"""PixelLauncher — компактный тёмный лаунчер в духе LiteLauncher.
|
||
|
||
Минималистичный дизайн: узкая панель с логотипом и ником сверху, пиксельный
|
||
пейзаж-баннер по центру, большая зелёная кнопка «ИГРАТЬ» и пилюля с версией.
|
||
Без сторонних библиотек (tkinter).
|
||
"""
|
||
|
||
import ctypes
|
||
import json
|
||
import math
|
||
import os
|
||
import queue
|
||
import random
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import tkinter as tk
|
||
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
|
||
from . import mods as mods_mod
|
||
from . import modrinth as modrinth_mod
|
||
from . import mrpack as mrpack_mod
|
||
from . import packs as packs_mod
|
||
from . import plugins as plugins_mod
|
||
from . import protocol as protocol_mod
|
||
from . import selfupdate
|
||
from . import stats as stats_mod
|
||
from . import tray as tray_mod
|
||
from . import version as version_mod
|
||
from . import worlds as worlds_mod
|
||
from .i18n import T, set_language
|
||
from . import LAUNCHER_VERSION
|
||
|
||
CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
||
|
||
# ------------------------------------------------------------------ палитра
|
||
BG = "#141414"
|
||
BAR = "#1b1b1b"
|
||
BAR_BD = "#2c2c2c"
|
||
PANEL = "#1e1e1e"
|
||
INPUT = "#101010"
|
||
INPUT_BD = "#333333"
|
||
HOVER = "#2a2a2a"
|
||
TEXT = "#f0f0f0"
|
||
DIM = "#8a8a8a"
|
||
FAINT = "#5c5c5c"
|
||
GREEN = "#6fae4a"
|
||
GREEN_HI = "#7fc257"
|
||
GREEN_LO = "#40702c"
|
||
GREEN_BD = "#2c4a1e"
|
||
OK = "#7fc257"
|
||
ERR = "#e0655f"
|
||
WARN = "#d8a13a"
|
||
PILL = "#242424"
|
||
PILL_BD = "#3a3a3a"
|
||
|
||
THEME = "dark" # текущая тема: "dark" или "light"
|
||
|
||
# Хром окна по темам (фоны/границы/текст). Акцент (GREEN*) считается
|
||
# отдельно через set_accent и от темы не зависит.
|
||
THEMES = {
|
||
"dark": {"BG": "#141414", "BAR": "#1b1b1b", "BAR_BD": "#2c2c2c",
|
||
"PANEL": "#1e1e1e", "INPUT": "#101010", "INPUT_BD": "#333333",
|
||
"HOVER": "#2a2a2a", "TEXT": "#f0f0f0", "DIM": "#8a8a8a",
|
||
"FAINT": "#5c5c5c", "PILL": "#242424", "PILL_BD": "#3a3a3a",
|
||
"OK": "#7fc257", "ERR": "#e0655f", "WARN": "#d8a13a"},
|
||
"light": {"BG": "#ededed", "BAR": "#f9f9f9", "BAR_BD": "#d4d4d4",
|
||
"PANEL": "#f6f6f6", "INPUT": "#ffffff", "INPUT_BD": "#c9c9c9",
|
||
"HOVER": "#e1e1e1", "TEXT": "#1c1c1c", "DIM": "#6b6b6b",
|
||
"FAINT": "#9b9b9b", "PILL": "#e7e7e7", "PILL_BD": "#d0d0d0",
|
||
"OK": "#4e9a34", "ERR": "#cc4038", "WARN": "#a8791a"},
|
||
}
|
||
|
||
|
||
def set_theme(name):
|
||
"""Меняет тему оформления (тёмная/светлая).
|
||
|
||
Как и set_accent — читается в момент отрисовки, поэтому после вызова
|
||
нужно пересобрать виджеты: self.configure(bg=BG) + _rebuild_ui().
|
||
"""
|
||
global THEME, BG, BAR, BAR_BD, PANEL, INPUT, INPUT_BD, HOVER, \
|
||
TEXT, DIM, FAINT, PILL, PILL_BD, OK, ERR, WARN
|
||
THEME = name if name in THEMES else "dark"
|
||
t = THEMES[THEME]
|
||
BG, BAR, BAR_BD = t["BG"], t["BAR"], t["BAR_BD"]
|
||
PANEL, INPUT, INPUT_BD = t["PANEL"], t["INPUT"], t["INPUT_BD"]
|
||
HOVER, TEXT, DIM, FAINT = t["HOVER"], t["TEXT"], t["DIM"], t["FAINT"]
|
||
PILL, PILL_BD = t["PILL"], t["PILL_BD"]
|
||
OK, ERR, WARN = t["OK"], t["ERR"], t["WARN"]
|
||
|
||
TYPE_RU = {"release": "релиз", "snapshot": "снапшот",
|
||
"old_alpha": "old alpha", "old_beta": "old beta"}
|
||
|
||
# Высота — естественный размер контента (баннер, кнопка запуска, новости
|
||
# и панель быстрых настроек в две строки): сжимать окно ниже нельзя,
|
||
# иначе нижние чипы обрезаются.
|
||
WIN_W, WIN_H = 660, 684
|
||
BANNER_W, BANNER_H = 560, 300
|
||
|
||
|
||
def mix(c1, c2, t):
|
||
a = [int(c1[i:i + 2], 16) for i in (1, 3, 5)]
|
||
b = [int(c2[i:i + 2], 16) for i in (1, 3, 5)]
|
||
return "#%02x%02x%02x" % tuple(round(a[i] + (b[i] - a[i]) * t)
|
||
for i in range(3))
|
||
|
||
|
||
def set_accent(hex_color):
|
||
"""Меняет акцентный цвет (кнопка ИГРАТЬ, прогресс, выделение списков).
|
||
|
||
Читается в момент отрисовки, поэтому достаточно перерисовать виджеты.
|
||
"""
|
||
global GREEN, GREEN_HI, GREEN_LO, GREEN_BD, ACCENT
|
||
ACCENT = hex_color
|
||
GREEN = hex_color
|
||
GREEN_HI = mix(hex_color, "#ffffff", 0.18)
|
||
GREEN_LO = mix(hex_color, "#000000", 0.42)
|
||
GREEN_BD = mix(hex_color, "#000000", 0.62)
|
||
|
||
|
||
def round_rect(c, x0, y0, x1, y1, r, fill, outline="", width=1):
|
||
"""Скруглённый прямоугольник на Canvas."""
|
||
c.create_polygon(x0 + r, y0, x1 - r, y0, x1, y0, x1, y0 + r,
|
||
x1, y1 - r, x1, y1, x1 - r, y1, x0 + r, y1,
|
||
x0, y1, x0, y1 - r, x0, y0 + r, x0, y0,
|
||
smooth=True, fill=fill, outline=outline, width=width)
|
||
|
||
|
||
def round_mask(cv, w, h, r, bg, seg=8):
|
||
"""Закрашивает углы холста цветом фона — эффект скруглённого баннера."""
|
||
def arc(cx, cy, a0, a1):
|
||
pts = []
|
||
for i in range(seg + 1):
|
||
a = math.radians(a0 + (a1 - a0) * i / seg)
|
||
pts += [cx + r * math.cos(a), cy + r * math.sin(a)]
|
||
return pts
|
||
|
||
for pts in ([0, 0] + arc(r, r, -90, -180),
|
||
[w, 0] + arc(w - r, r, -90, 0),
|
||
[0, h] + arc(r, h - r, 180, 90),
|
||
[w, h] + arc(w - r, h - r, 0, 90)):
|
||
cv.create_polygon(pts, fill=bg, outline="")
|
||
|
||
|
||
# ------------------------------------------- пиксельный шрифт 5x7
|
||
PIXEL_FONT = {
|
||
"A": (".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"),
|
||
"C": (".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."),
|
||
"E": ("#####", "#....", "#....", "####.", "#....", "#....", "#####"),
|
||
"H": ("#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"),
|
||
"I": ("#####", "..#..", "..#..", "..#..", "..#..", "..#..", "#####"),
|
||
"L": ("#....", "#....", "#....", "#....", "#....", "#....", "#####"),
|
||
"N": ("#...#", "##..#", "#.#.#", "#.#.#", "#..##", "#...#", "#...#"),
|
||
"P": ("####.", "#...#", "#...#", "####.", "#....", "#....", "#...."),
|
||
"R": ("####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"),
|
||
"U": ("#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."),
|
||
"X": ("#...#", "#...#", ".#.#.", "..#..", ".#.#.", "#...#", "#...#"),
|
||
"И": ("#...#", "#...#", "#..##", "#.#.#", "##..#", "#...#", "#...#"),
|
||
"Г": ("#####", "#....", "#....", "#....", "#....", "#....", "#...."),
|
||
"Т": ("#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."),
|
||
"Ь": ("#....", "#....", "#....", "#....", "####.", "#...#", "####."),
|
||
"А": (".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"),
|
||
"Р": ("####.", "#...#", "#...#", "####.", "#....", "#....", "#...."),
|
||
}
|
||
|
||
|
||
def pixel_text_width(text, scale):
|
||
return max(0, len(text) * 6 * scale - scale)
|
||
|
||
|
||
def draw_pixel_text(cv, x, y, text, scale, fill, shadow=None, sdx=3, sdy=3):
|
||
"""Рисует текст пиксельным шрифтом 5x7 (по клетке на пиксель)."""
|
||
if shadow:
|
||
_blit_text(cv, x + sdx, y + sdy, text, scale, shadow)
|
||
return _blit_text(cv, x, y, text, scale, fill)
|
||
|
||
|
||
def _blit_text(cv, x, y, text, scale, fill):
|
||
ox = x
|
||
for ch in text.upper():
|
||
glyph = PIXEL_FONT.get(ch)
|
||
if glyph is None: # пробел и неизвестные — просто отступ
|
||
ox += 6 * scale
|
||
continue
|
||
for ry, row in enumerate(glyph):
|
||
for rx, c in enumerate(row):
|
||
if c == "#":
|
||
px0 = ox + rx * scale
|
||
py0 = y + ry * scale
|
||
cv.create_rectangle(px0, py0, px0 + scale, py0 + scale,
|
||
fill=fill, outline="")
|
||
ox += 6 * scale
|
||
return pixel_text_width(text, scale)
|
||
|
||
|
||
# ============================================================ пиксель-арт
|
||
def draw_scene(cv, w, h, night=False, scene=None):
|
||
"""Пиксельный пейзаж. scene: day/night/nether/cave (None — по night)."""
|
||
if scene is None:
|
||
scene = "night" if night else "day"
|
||
if scene == "nether":
|
||
return _draw_nether(cv, w, h)
|
||
if scene == "cave":
|
||
return _draw_cave(cv, w, h)
|
||
night = scene == "night"
|
||
cell = 10
|
||
cols, rows = w // cell, h // cell
|
||
tint = (lambda c: mix(c, "#0a1020", 0.55)) if night else (lambda c: c)
|
||
|
||
def g(gx, gy, color, gx2=None, gy2=None):
|
||
cv.create_rectangle(gx * cell, gy * cell,
|
||
(gx2 if gx2 is not None else gx + 1) * cell,
|
||
(gy2 if gy2 is not None else gy + 1) * cell,
|
||
fill=color, outline="")
|
||
|
||
def px(gx, gy, x0, y0, x1, y1, color):
|
||
"""Пиксель внутри клетки (доли 0..1)."""
|
||
cv.create_rectangle(gx * cell + x0, gy * cell + y0,
|
||
gx * cell + x1, gy * cell + y1,
|
||
fill=color, outline="")
|
||
|
||
ground = 19 # верхний ряд земли
|
||
|
||
# Небо — градиент полосами во всю ширину
|
||
if night:
|
||
sky_top, sky_bot = "#0f1b33", "#1c2f52"
|
||
else:
|
||
sky_top, sky_bot = "#6fb6e6", "#b6e0f2"
|
||
for gy in range(ground):
|
||
color = mix(sky_top, sky_bot, gy / max(1, ground - 1))
|
||
cv.create_rectangle(0, gy * cell, w, (gy + 1) * cell,
|
||
fill=color, outline="")
|
||
|
||
# Звёзды (только ночью)
|
||
if night:
|
||
for sx, sy in ((5, 2), (12, 1), (18, 4), (24, 2), (21, 1), (9, 6),
|
||
(40, 1), (44, 3), (50, 2), (54, 5), (47, 7), (26, 5)):
|
||
g(sx, sy, "#cfd8ea")
|
||
|
||
# Солнце с лучами / луна
|
||
if night:
|
||
for gx in range(32, 37):
|
||
for gy in range(1, 6):
|
||
if gx in (32, 36) or gy in (1, 5):
|
||
g(gx, gy, "#a9b4d2")
|
||
for gx in range(33, 36):
|
||
for gy in range(2, 5):
|
||
g(gx, gy, "#f4f7ff")
|
||
for sx, sy in ((31, 2), (31, 4), (37, 2), (37, 4)):
|
||
g(sx, sy, "#8d99bd")
|
||
else:
|
||
for gx in range(30, 37):
|
||
for gy in range(0, 7):
|
||
if gx in (30, 36) or gy in (0, 6):
|
||
g(gx, gy, "#e3f4fe") # ореол
|
||
else:
|
||
g(gx, gy, "#b8e2f7")
|
||
for gx in range(32, 35):
|
||
for gy in range(2, 5):
|
||
g(gx, gy, "#ffffff") # ядро
|
||
for sx, sy in ((29, 0), (29, 6), (37, 0), (37, 6), (29, 3), (37, 3)):
|
||
g(sx, sy, "#ffffff") # лучи
|
||
|
||
# Облака
|
||
def cloud(cx, cy):
|
||
for dx, dy in ((0, 0), (1, 0), (2, 0), (3, 0), (1, -1), (2, -1),
|
||
(5, 0), (6, 0), (6, -1)):
|
||
g(cx + dx, cy + dy, tint("#ffffff"))
|
||
cloud(12, 2)
|
||
cloud(44, 4)
|
||
|
||
# Дальние горы
|
||
for gx in range(cols):
|
||
top = 11 + ((gx * 7) % 5)
|
||
c1 = "#b3d0e3" if (gx + top) % 2 else "#a3c3d9"
|
||
for gy in range(top, 16):
|
||
g(gx, gy, tint(c1))
|
||
|
||
# Низкие кусты-холмы на горизонте
|
||
for gx in range(cols):
|
||
top = 16 + ((gx * 5) % 3)
|
||
g(gx, top, tint("#3a7a34"))
|
||
if top + 1 < ground:
|
||
g(gx, top + 1, tint("#347030"))
|
||
for gy in range(top + 2, ground):
|
||
g(gx, gy, tint("#2f682c"))
|
||
|
||
# Поверхность: песчаная дорожка слева, трава справа
|
||
for gx in range(cols):
|
||
if gx <= 15:
|
||
g(gx, ground, tint("#d9c48b"))
|
||
px(gx, ground, 0, 0, 10, 3, tint("#e8d8a8"))
|
||
else:
|
||
g(gx, ground, tint("#6fae4a"))
|
||
px(gx, ground, 0, 0, 10, 3, tint("#8ec96a"))
|
||
|
||
# Земля: грунт с крапинами и вкраплениями камня
|
||
dirt, dirt_d = "#8a5a33", "#6b4a2f"
|
||
stone, stone_d = "#6f6f6f", "#5a5a5a"
|
||
for gx in range(cols):
|
||
for gy in range(ground + 1, rows - 3):
|
||
base = dirt if (gx + gy) % 7 else dirt_d
|
||
g(gx, gy, tint(base))
|
||
if (gx * 7 + gy * 13) % 11 == 0:
|
||
px(gx, gy, 3, 4, 6, 7, tint(dirt_d))
|
||
if (gx * 5 + gy * 3) % 23 == 0:
|
||
px(gx, gy, 5, 2, 8, 5, tint(stone))
|
||
for gy in range(rows - 3, rows):
|
||
base = stone if (gx * 3 + gy) % 5 else stone_d
|
||
g(gx, gy, tint(base))
|
||
|
||
# Каменный угол справа внизу (как на референсе)
|
||
for gx in range(44, cols):
|
||
for gy in range(rows - 5, rows - 3):
|
||
g(gx, gy, tint(stone if (gx + gy) % 3 else stone_d))
|
||
|
||
# Пруд слева
|
||
for gx in range(4, 14):
|
||
g(gx, ground + 1, tint("#3f7fd0"))
|
||
g(gx, ground + 2, tint("#3f7fd0"))
|
||
if (gx * 3) % 4 == 0:
|
||
px(gx, ground + 1, 1, 3, 7, 5, tint("#67a8e8"))
|
||
if gx in (4, 13):
|
||
g(gx, ground + 1, tint(dirt_d))
|
||
g(gx, ground + 2, tint(dirt_d))
|
||
|
||
# Дерево (большая крона справа)
|
||
trunk, trunk_d = "#6b4a2f", "#54391f"
|
||
leaf, leaf_h, leaf_d = "#4f8f3f", "#63a952", "#3f7a32"
|
||
for gy in range(14, ground):
|
||
g(35, gy, tint(trunk))
|
||
g(36, gy, tint(trunk_d))
|
||
for gx in range(30, 43):
|
||
for gy in range(7, 14):
|
||
corner = (gx in (30, 42) and gy in (7, 13))
|
||
g(gx, gy, tint(leaf_d if corner else leaf))
|
||
for gx, gy in ((31, 8), (38, 9), (41, 11), (34, 12), (40, 8), (36, 10)):
|
||
g(gx, gy, tint(leaf_h))
|
||
|
||
# Сундук
|
||
for gx in range(20, 23):
|
||
g(gx, 17, tint("#c8913f"))
|
||
g(gx, 18, tint("#a87433"))
|
||
g(20, 17, tint("#8a5a33"))
|
||
g(22, 17, tint("#8a5a33"))
|
||
px(21, 17, 3, 3, 7, 8, tint("#e8c84a"))
|
||
|
||
# Крипер (только голова над травой)
|
||
cr, cr_d = "#5da84e", "#4c8a40"
|
||
for gx in range(46, 50):
|
||
for gy in range(16, 19):
|
||
g(gx, gy, tint(cr_d if (gx + gy) % 5 == 0 else cr))
|
||
for gx, gy in ((46, 16), (49, 16), (47, 17), (48, 17),
|
||
(47, 18), (48, 18)):
|
||
g(gx, gy, "#1c1c1c")
|
||
|
||
# Цветы и травинки
|
||
for gx, color in ((17, "#d94040"), (25, "#e8d24a"), (41, "#d94040")):
|
||
px(gx, 18, 3, 0, 7, 4, tint(color))
|
||
px(gx, 18, 4, 4, 6, 10, tint("#4f8f3f"))
|
||
for gx in (16, 27, 43, 52):
|
||
px(gx, 18, 2, 4, 5, 10, tint("#8ec96a"))
|
||
px(gx, 18, 6, 6, 8, 10, tint("#6fae4a"))
|
||
|
||
|
||
def _scene_helpers(cv, w, h):
|
||
"""Общие помощники для пиксельных сцен: g() и px()."""
|
||
cell = 10
|
||
cols, rows = w // cell, h // cell
|
||
|
||
def g(gx, gy, color, gx2=None, gy2=None):
|
||
cv.create_rectangle(gx * cell, gy * cell,
|
||
(gx2 if gx2 is not None else gx + 1) * cell,
|
||
(gy2 if gy2 is not None else gy + 1) * cell,
|
||
fill=color, outline="")
|
||
|
||
def px(gx, gy, x0, y0, x1, y1, color):
|
||
cv.create_rectangle(gx * cell + x0, gy * cell + y0,
|
||
gx * cell + x1, gy * cell + y1,
|
||
fill=color, outline="")
|
||
return g, px, cell, cols, rows
|
||
|
||
|
||
def _draw_nether(cv, w, h):
|
||
"""Незер: багровое небо, лава, светокамень, порталь, огни."""
|
||
g, px, cell, cols, rows = _scene_helpers(cv, w, h)
|
||
ground = 15
|
||
|
||
# Небо — багровый градиент
|
||
top, bot = "#3d1515", "#7a2c2c"
|
||
for gy in range(ground + 4):
|
||
col = mix(top, bot, gy / max(1, ground + 3))
|
||
cv.create_rectangle(0, gy * cell, w, (gy + 1) * cell,
|
||
fill=col, outline="")
|
||
# Блики жара у горизонта
|
||
for gx in range(cols):
|
||
if (gx * 5) % 7 < 2:
|
||
px(gx, ground - 2, 2, 4, 8, 7, "#8f3a34")
|
||
|
||
# Потолок: сталактиты из незеррека
|
||
nr, nr_d = "#5c2323", "#471a1a"
|
||
for gx in range(cols):
|
||
depth = 1 + ((gx * 7) % 3)
|
||
for gy in range(depth):
|
||
g(gx, gy, nr_d if (gx + gy) % 3 else nr)
|
||
if (gx * 11) % 13 == 0:
|
||
g(gx, depth, nr_d)
|
||
|
||
# Лавовое озеро снизу
|
||
for gy in range(ground + 2, rows):
|
||
for gx in range(cols):
|
||
if gy == ground + 2 and (gx * 3) % 5:
|
||
continue # край озера неровный
|
||
c = "#e0641e" if (gx + gy) % 3 else "#c2521a"
|
||
g(gx, gy, c)
|
||
for gx in range(cols):
|
||
if (gx * 7) % 6 == 0:
|
||
px(gx, ground + 2, 1, 0, 7, 4, "#ffb52e")
|
||
if (gx * 3) % 11 == 0:
|
||
px(gx, ground + 3, 4, 3, 9, 7, "#ffd05a")
|
||
|
||
# Берег: незеррек с крапинами
|
||
for gx in range(cols):
|
||
if (gx * 3) % 5 < 2:
|
||
continue
|
||
g(gx, ground, "#5c2323")
|
||
g(gx, ground + 1, "#471a1a" if gx % 2 else "#5c2323")
|
||
if (gx * 7 + 3) % 9 == 0:
|
||
px(gx, ground, 3, 3, 6, 6, "#3d1515")
|
||
|
||
# Песок душ слева
|
||
for gx in range(3, 11):
|
||
g(gx, ground, "#8e7a5a")
|
||
if gx % 2:
|
||
px(gx, ground, 2, 2, 5, 5, "#7a6849")
|
||
# червонный нарост
|
||
if gx in (5, 7, 9):
|
||
g(gx, ground - 1, "#a03030")
|
||
px(gx, ground - 1, 3, 0, 6, 4, "#c04040")
|
||
|
||
# Столбы базальта
|
||
for gx0, hgt in ((14, 5), (30, 3)):
|
||
for gx in (gx0, gx0 + 1):
|
||
for gy in range(ground - hgt, ground):
|
||
g(gx, gy, "#3a3a3f" if (gx + gy) % 3 else "#2e2e33")
|
||
|
||
# Светокамень под потолком + ореол
|
||
for cx, cy in ((22, 3), (40, 2), (52, 4)):
|
||
for gx in range(cx, cx + 3):
|
||
for gy in range(cy, cy + 2):
|
||
g(gx, gy, "#f5d77a" if (gx + gy) % 2 else "#e0b95f")
|
||
for dx in (-1, 3):
|
||
g(cx + dx, cy, "#b98f45")
|
||
g(cx + 1, cy - 1, "#d4a954")
|
||
g(cx + 1, cy + 2, "#b98f45")
|
||
|
||
# Огни на берегу
|
||
for gx in (18, 35, 47):
|
||
px(gx, ground - 1, 3, 2, 6, 10, "#ff9a2e")
|
||
px(gx, ground - 1, 4, 4, 5, 8, "#ffd05a")
|
||
|
||
# Портал: обсидиановая рама с фиолетовым полем
|
||
for gx in range(42, 46):
|
||
g(gx, 10, "#1d1130")
|
||
g(gx, 14, "#1d1130")
|
||
for gy in range(10, 15):
|
||
g(42, gy, "#1d1130")
|
||
g(45, gy, "#1d1130")
|
||
for gx in range(43, 45):
|
||
for gy in range(11, 14):
|
||
g(gx, gy, "#8a3ff0" if (gx + gy) % 2 else "#6e2fd0")
|
||
px(43, 12, 1, 1, 4, 4, "#c79aff")
|
||
px(44, 11, 5, 5, 8, 8, "#b07af5")
|
||
|
||
|
||
def _draw_cave(cv, w, h):
|
||
"""Пещера: тёмный камень, руды, лава, факел со светом."""
|
||
g, px, cell, cols, rows = _scene_helpers(cv, w, h)
|
||
top, bot = "#1f2226", "#33383e"
|
||
|
||
def stone_at(gy):
|
||
return mix(top, bot, gy / max(1, rows - 1))
|
||
|
||
# Каменная тьма полосами
|
||
for gy in range(rows):
|
||
cv.create_rectangle(0, gy * cell, w, (gy + 1) * cell,
|
||
fill=stone_at(gy), outline="")
|
||
|
||
# Потолок со сталактитами
|
||
for gx in range(cols):
|
||
depth = 1 + ((gx * 5) % 4)
|
||
for gy in range(depth):
|
||
g(gx, gy, "#3a3f45" if (gx + gy) % 3 else "#2b2f34")
|
||
|
||
# Крупный каменный крап на стенах
|
||
for gx in range(cols):
|
||
for gy in range(rows):
|
||
if (gx * 7 + gy * 13) % 17 == 0:
|
||
px(gx, gy, 2, 2, 6, 6, "#454b52" if gy > rows // 2
|
||
else "#262a2f")
|
||
|
||
floor = 16
|
||
# Пол
|
||
for gx in range(cols):
|
||
for gy in range(floor, rows):
|
||
c = "#4a4f55" if (gx + gy) % 4 else "#3d4248"
|
||
g(gx, gy, c)
|
||
|
||
# Лавовый ручей слева внизу
|
||
for gx in range(0, 13):
|
||
for gy in range(floor + 1, rows):
|
||
g(gx, gy, "#e0641e" if (gx + gy) % 3 else "#c2521a")
|
||
for gx in (2, 6, 10):
|
||
px(gx, floor + 1, 1, 0, 7, 4, "#ffb52e")
|
||
g(13, floor + 1, "#3d4248")
|
||
g(13, floor + 2, "#3d4248")
|
||
|
||
# Руды: уголь, железо, золото, алмаз
|
||
ores = (
|
||
(((16, 11), (17, 11), (16, 12)), "#2b2b2b", "#1e1e1e"),
|
||
(((33, 8), (34, 8), (33, 9)), "#c98d6b", "#a06b4c"),
|
||
(((47, 12), (48, 12), (48, 13)), "#f5cf5a", "#c9a53c"),
|
||
(((27, 13), (28, 13), (27, 14)), "#5ee8e0", "#3cb8b0"),
|
||
)
|
||
for spots, c1, c2 in ores:
|
||
for i, (gx, gy) in enumerate(spots):
|
||
g(gx, gy, c1)
|
||
px(gx, gy, 1, 1, 4, 4, c2)
|
||
px(spots[0][0], spots[0][1], 6, 5, 9, 9, c2)
|
||
|
||
# Факел на стене со световым ореолом
|
||
fx, fy = 40, 10
|
||
for dx in range(-4, 5):
|
||
for dy in range(-4, 6):
|
||
dist = abs(dx) + abs(dy)
|
||
if dist == 0 or dist > 7:
|
||
continue
|
||
gx, gy = fx + dx, fy + dy
|
||
if not (0 <= gx < cols and 0 <= gy < rows):
|
||
continue
|
||
t = 0.34 * (8 - dist) / 7
|
||
g(gx, gy, mix(stone_at(gy), "#ffe3a0", t))
|
||
px(fx, fy, 3, 4, 7, 10, "#8a6a3f")
|
||
px(fx, fy, 4, 1, 6, 5, "#ffcf4a")
|
||
px(fx, fy, 4, 3, 6, 5, "#ff9a2e")
|
||
|
||
# Грибы и травинки на полу
|
||
for gx, c in ((20, "#5aa0c0"), (22, "#c05a5a"), (36, "#5aa0c0")):
|
||
px(gx, floor, 3, 3, 7, 6, c)
|
||
px(gx, floor, 4, 6, 6, 10, "#d8d8d8")
|
||
|
||
|
||
def _skin_parts(full=True):
|
||
"""Прямоугольники скина для фронтального вида.
|
||
|
||
(sx, sy, ex, ey, dx, dy): источник в текстуре и положение на канве
|
||
(в клетках). full=False — старый формат 64x32 (без наложений).
|
||
"""
|
||
if full:
|
||
return [
|
||
(8, 8, 16, 16, 4, 0), (40, 8, 48, 16, 4, 0), # голова + шляпа
|
||
(44, 20, 48, 32, 0, 8), (44, 36, 48, 48, 0, 8), # правая рука
|
||
(36, 52, 40, 64, 12, 8), (52, 52, 56, 64, 12, 8), # левая рука
|
||
(20, 20, 28, 32, 4, 8), (20, 36, 28, 48, 4, 8), # тело + куртка
|
||
(4, 20, 8, 32, 4, 20), (4, 36, 8, 48, 4, 20), # правая нога
|
||
(20, 52, 24, 64, 8, 20), (4, 52, 8, 64, 8, 20), # левая нога
|
||
]
|
||
return [
|
||
(8, 8, 16, 16, 4, 0),
|
||
(44, 20, 48, 32, 0, 8), (20, 20, 28, 32, 4, 8),
|
||
(44, 20, 48, 32, 12, 8),
|
||
(4, 20, 8, 32, 4, 20), (4, 20, 8, 32, 8, 20),
|
||
]
|
||
|
||
|
||
def _png_rgba(path):
|
||
"""Минимальный декодер PNG (8 бит, RGB/RGBA, без interlace).
|
||
|
||
Tk умеет показывать PNG, но photo get отдаёт только RGB — альфа
|
||
теряется, а у скинов прозрачный оверлей лежит как (0,0,0). Декодируем
|
||
сами: (ширина, высота, байты RGBA) либо None, если формат не поддержан.
|
||
"""
|
||
try:
|
||
import struct
|
||
import zlib
|
||
data = open(path, "rb").read()
|
||
if data[:8] != b"\x89PNG\r\n\x1a\n":
|
||
return None
|
||
pos, idat, meta = 8, [], None
|
||
while pos + 8 <= len(data):
|
||
ln = struct.unpack(">I", data[pos:pos + 4])[0]
|
||
typ = data[pos + 4:pos + 8]
|
||
chunk = data[pos + 8:pos + 8 + ln]
|
||
if typ == b"IHDR":
|
||
meta = struct.unpack(">IIBBBBB", chunk)
|
||
elif typ == b"IDAT":
|
||
idat.append(chunk)
|
||
elif typ == b"IEND":
|
||
break
|
||
pos += 12 + ln
|
||
if not meta:
|
||
return None
|
||
w, h, depth, ctype, _c, _f, inter = meta
|
||
if depth != 8 or inter != 0 or ctype not in (2, 6):
|
||
return None
|
||
ch = 3 if ctype == 2 else 4
|
||
raw = zlib.decompress(b"".join(idat))
|
||
stride = w * ch
|
||
out = bytearray()
|
||
prev = bytearray(stride)
|
||
p = 0
|
||
for _y in range(h):
|
||
ft = raw[p] # фильтр строки: 0..4
|
||
p += 1
|
||
line = bytearray(raw[p:p + stride])
|
||
p += stride
|
||
if ft == 1:
|
||
for x in range(ch, stride):
|
||
line[x] = (line[x] + line[x - ch]) & 255
|
||
elif ft == 2:
|
||
for x in range(stride):
|
||
line[x] = (line[x] + prev[x]) & 255
|
||
elif ft == 3:
|
||
for x in range(stride):
|
||
a = line[x - ch] if x >= ch else 0
|
||
line[x] = (line[x] + ((a + prev[x]) >> 1)) & 255
|
||
elif ft == 4:
|
||
for x in range(stride):
|
||
a = line[x - ch] if x >= ch else 0
|
||
c = prev[x - ch] if x >= ch else 0
|
||
b = prev[x]
|
||
pp = a + b - c
|
||
pa, pb, pc = abs(pp - a), abs(pp - b), abs(pp - c)
|
||
pr = a if (pa <= pb and pa <= pc) else (
|
||
b if pb <= pc else c)
|
||
line[x] = (line[x] + pr) & 255
|
||
prev = line
|
||
if ch == 3: # RGB → RGBA
|
||
for x in range(w):
|
||
out += line[x * 3:x * 3 + 3] + b"\xff"
|
||
else:
|
||
out += line
|
||
return w, h, bytes(out)
|
||
except Exception: # noqa: BLE001 — любой битый файл
|
||
return None
|
||
|
||
|
||
# ============================================================ виджеты
|
||
class Icon_label(tk.Label):
|
||
"""Невидимая кнопка-иконка в панели окна."""
|
||
|
||
def __init__(self, master, glyph, command, danger=False):
|
||
super().__init__(master, text=glyph, bg=BAR, fg=DIM,
|
||
font=("Segoe UI Symbol", 11), width=3, cursor="hand2")
|
||
self._cmd = command
|
||
self._danger = danger
|
||
self.bind("<Enter>", self._enter)
|
||
self.bind("<Leave>", self._leave)
|
||
self.bind("<Button-1>", self._click)
|
||
|
||
def _enter(self, _=None):
|
||
self.configure(bg="#e81123" if self._danger else HOVER,
|
||
fg="#ffffff" if self._danger else TEXT)
|
||
|
||
def _leave(self, _=None):
|
||
self.configure(bg=BAR, fg=DIM)
|
||
|
||
def _click(self, _=None):
|
||
if self._cmd:
|
||
self._cmd()
|
||
|
||
|
||
class PlayButton(tk.Canvas):
|
||
"""Большая зелёная кнопка в стиле Minecraft."""
|
||
|
||
def __init__(self, master, command, width=300, height=52):
|
||
super().__init__(master, width=width, height=height, bg=BG,
|
||
highlightthickness=0, cursor="hand2")
|
||
self.text = T("ИГРАТЬ")
|
||
self._cmd = command
|
||
self.h = height
|
||
self.state = "normal"
|
||
self.bind("<Configure>", lambda e: self._draw())
|
||
self.bind("<Enter>", self._enter)
|
||
self.bind("<Leave>", self._leave)
|
||
self.bind("<Button-1>", self._press)
|
||
self.bind("<ButtonRelease-1>", self._release)
|
||
|
||
def set_state(self, state):
|
||
self.state = state
|
||
self.configure(cursor="" if state == "disabled" else "hand2")
|
||
self._draw()
|
||
|
||
def _enter(self, _=None):
|
||
if self.state == "normal":
|
||
self.state = "hover"
|
||
self._draw()
|
||
|
||
def _leave(self, _=None):
|
||
if self.state in ("hover", "press"):
|
||
self.state = "normal"
|
||
self._draw()
|
||
|
||
def _press(self, _=None):
|
||
if self.state == "disabled":
|
||
return
|
||
self.state = "press"
|
||
self._draw()
|
||
|
||
def _release(self, _=None):
|
||
if self.state == "press":
|
||
self.state = "hover"
|
||
self._draw()
|
||
if self._cmd:
|
||
self._cmd()
|
||
|
||
def _draw(self):
|
||
w = self.winfo_width()
|
||
if w < 40:
|
||
return
|
||
self.delete("all")
|
||
r, depth = 8, 5
|
||
pressed = self.state == "press"
|
||
if self.state == "disabled":
|
||
if THEME == "light":
|
||
base, hi, lo, border = "#d5d5d5", "#e2e2e2", "#bcbcbc", "#a6a6a6"
|
||
tfg, tshadow = FAINT, "#9f9f9f"
|
||
else:
|
||
base, hi, lo, border = "#33383f", "#3d434b", "#262a30", "#20242a"
|
||
tfg, tshadow = FAINT, "#1a1a1a"
|
||
elif self.state in ("hover", "press"):
|
||
base, hi, lo, border = GREEN_HI, mix(GREEN_HI, "#ffffff", 0.35), GREEN, GREEN_BD
|
||
tfg, tshadow = "#ffffff", "#2c4a1e"
|
||
else:
|
||
base, hi, lo, border = GREEN, mix(GREEN, "#ffffff", 0.3), GREEN_LO, GREEN_BD
|
||
tfg, tshadow = "#ffffff", "#2c4a1e"
|
||
y_off = 1 if pressed else 0
|
||
# нижняя грань (эффект глубины)
|
||
round_rect(self, 2, depth, w - 2, self.h, r, lo, border, 2)
|
||
# лицевая грань
|
||
round_rect(self, 2, y_off, w - 2, self.h - depth + y_off, r,
|
||
base, border, 2)
|
||
# блик сверху
|
||
self.create_line(14, y_off + 5, w - 14, y_off + 5, fill=hi, width=2)
|
||
# пиксельный текст с тенью
|
||
scale = 5
|
||
tw = pixel_text_width(self.text, scale)
|
||
tx = (w - tw) // 2
|
||
ty = y_off + (self.h - depth - 7 * scale) // 2
|
||
draw_pixel_text(self, tx, ty, self.text, scale, tfg, tshadow, 2, 2)
|
||
|
||
|
||
class VersionPill(tk.Canvas):
|
||
"""Пилюля с версией игры под кнопкой."""
|
||
|
||
def __init__(self, master, command, width=230, height=28):
|
||
super().__init__(master, width=width, height=height, bg=BG,
|
||
highlightthickness=0, cursor="hand2")
|
||
self._text = "Minecraft …"
|
||
self._cmd = command
|
||
self.bind("<Configure>", lambda e: self._draw())
|
||
self.bind("<Enter>", lambda e: (self.configure(bg=HOVER), self._draw()))
|
||
self.bind("<Leave>", lambda e: (self.configure(bg=BG), self._draw()))
|
||
self.bind("<Button-1>", lambda e: self._cmd and self._cmd())
|
||
|
||
def set_text(self, text):
|
||
self._text = text
|
||
self._draw()
|
||
|
||
def _draw(self):
|
||
w = self.winfo_width()
|
||
if w < 40:
|
||
return
|
||
self.delete("all")
|
||
round_rect(self, 1, 1, w - 1, 27, 13, PILL, PILL_BD)
|
||
self.create_text(w // 2, 14, text=self._text, fill=DIM,
|
||
font=("Segoe UI", 9))
|
||
|
||
|
||
class SlimProgress(tk.Canvas):
|
||
"""Тонкая полоса прогресса по низу окна."""
|
||
|
||
def __init__(self, master, height=3):
|
||
super().__init__(master, height=height, bg=BAR, highlightthickness=0)
|
||
self.pct = 0.0
|
||
self.bind("<Configure>", lambda e: self._draw())
|
||
|
||
def set(self, pct):
|
||
self.pct = max(0.0, min(100.0, pct))
|
||
self._draw()
|
||
|
||
def _draw(self):
|
||
w = self.winfo_width()
|
||
self.delete("all")
|
||
if self.pct > 0 and w > 2:
|
||
self.create_rectangle(0, 0, max(2, w * self.pct / 100), 99,
|
||
fill=GREEN, outline="", tags="fill")
|
||
|
||
|
||
def _dark_titlebar(w):
|
||
"""Тёмная полоса заголовка у системного окна (Windows 10 1809+)."""
|
||
if os.name != "nt":
|
||
return
|
||
try:
|
||
import ctypes
|
||
# 1 — тёмный титлбар, 0 — системный светлый (светлая тема)
|
||
val = ctypes.c_int(1 if THEME == "dark" else 0)
|
||
for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE
|
||
if ctypes.windll.dwmapi.DwmSetWindowAttribute(
|
||
w.winfo_id(), attr, ctypes.byref(val),
|
||
ctypes.sizeof(val)) == 0:
|
||
break
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
|
||
# ============================================================ приложение
|
||
class App(tk.Tk):
|
||
def __init__(self, select_version=None, protocol_url=None,
|
||
select_profile=None):
|
||
super().__init__()
|
||
self.title("PixelLauncher")
|
||
self.overrideredirect(True)
|
||
self.geometry(f"{WIN_W}x{WIN_H}")
|
||
self.configure(bg=BG)
|
||
|
||
self.settings = config.load_settings()
|
||
set_language(self.settings.get("lang", "ru"))
|
||
set_theme(self.settings.get("theme", "dark"))
|
||
set_accent(self.settings.get("accent", GREEN))
|
||
self.configure(bg=BG)
|
||
self.ui_q = queue.Queue()
|
||
self.busy = False
|
||
self.versions = []
|
||
self.ver_meta = {}
|
||
self._latest = {}
|
||
self._news = None
|
||
self._icon_img = None
|
||
self._head_img = None
|
||
self._head_after = None
|
||
self._log_lines = []
|
||
self._menu = None
|
||
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
|
||
self._log_view_mode = "session" # "session" или имя файла лога
|
||
self._log_orig_lines = [] # строки выбранного файла (для фильтра)
|
||
self._find_state = {"idx": -1, "n": 0}
|
||
self._force_version = select_version # версия из CLI (ярлык)
|
||
self._tray = None
|
||
self._update_active = False # идёт проверка/загрузка обновления
|
||
self._update_quiet = False # проверка при старте (без лишних окон)
|
||
self._base_scaling = float(self.tk.call("tk", "scaling"))
|
||
self._game_proc = None # процесс игры (для плагинов)
|
||
self._pending_import = None # .mrpack из pixellauncher://
|
||
self.pl_q = queue.Queue() # UI-запросы плагинов/протокола
|
||
|
||
# статистика игры и прокси — рядом с папкой игры
|
||
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("<Configure>", 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)
|
||
self._init_plugins()
|
||
self.after(800, self._proto_tick)
|
||
if protocol_url:
|
||
self.after(1200, lambda: self._start_protocol(protocol_url))
|
||
self.after(6000, lambda: self._check_update(True)) # тихая проверка
|
||
self.bind("<Map>", self._on_map)
|
||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||
threading.Thread(target=self._load_versions_bg, daemon=True).start()
|
||
nick = self.var_nick.get().strip()
|
||
if re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick):
|
||
threading.Thread(target=self._load_head_bg, args=(nick,),
|
||
daemon=True).start()
|
||
|
||
# ------------------------------------------------------------ каркас
|
||
def _build(self):
|
||
# --- Строка окна (кастомный заголовок)
|
||
bar = tk.Frame(self, bg=BAR, height=44)
|
||
bar.pack(fill="x")
|
||
bar.pack_propagate(False)
|
||
left = tk.Frame(bar, bg=BAR)
|
||
left.pack(side="left", padx=(12, 0))
|
||
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"),
|
||
bg=BAR, fg=TEXT, font=("Segoe UI", 10),
|
||
cursor="hand2")
|
||
self.lbl_nick.pack(side="left", padx=(0, 6))
|
||
self.lbl_nick.bind("<Button-1>", lambda e: self._open_nick_menu())
|
||
self.head_canvas = tk.Canvas(right, width=24, height=24, bg=BAR,
|
||
highlightthickness=0)
|
||
self.head_canvas.pack(side="left", padx=(0, 8))
|
||
self._draw_head_placeholder()
|
||
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")
|
||
|
||
# перетаскивание окна за пустые места строки
|
||
self._make_draggable(left)
|
||
bar.bind("<Button-1>", self._drag_start)
|
||
bar.bind("<B1-Motion>", self._drag_move)
|
||
# двойной клик по строке — разворот/возврат, как у обычного окна
|
||
bar.bind("<Double-Button-1>", self._on_title_double)
|
||
self._bind_double(left)
|
||
|
||
# --- Основной контент: панель инстансов слева + контент справа
|
||
main = tk.Frame(self, bg=BG)
|
||
main.pack(fill="both", expand=True, pady=(26, 0))
|
||
|
||
# Панель инстансов (как 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("<Configure>",
|
||
lambda e: inst_cv.configure(
|
||
scrollregion=inst_cv.bbox("all")))
|
||
inst_cv.bind("<Configure>",
|
||
lambda e: (inst_cv.itemconfigure(inst_win, width=e.width),
|
||
self._toggle_inst_scroll()))
|
||
inst_cv.bind("<MouseWheel>",
|
||
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("<Configure>", self._on_banner_configure)
|
||
|
||
self.btn_play = PlayButton(content, self._on_play)
|
||
self.btn_play.pack(pady=(26, 0))
|
||
|
||
self.pill = VersionPill(content, self._open_versions)
|
||
self.pill.pack(pady=(14, 0))
|
||
|
||
# --- Лента новостей: последние версии из манифеста Mojang
|
||
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()
|
||
self.news_rows = tk.Frame(self.news, bg=BG)
|
||
self.news_rows.pack()
|
||
self._fill_news(self._news)
|
||
|
||
# --- Быстрые настройки прямо на главном экране: частые переключатели
|
||
# не требуют ни меню ⚙, ни диалога «Настройки».
|
||
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(content, text=T("Готово к запуску"), bg=BG, fg=DIM,
|
||
font=("Segoe UI", 8))
|
||
self.lbl_status.pack(pady=(12, 0))
|
||
|
||
# Кнопка отмены — видна только пока идёт загрузка
|
||
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,
|
||
cursor="hand2", font=("Segoe UI", 8, "bold"))
|
||
self.btn_cancel.pack(pady=(8, 0))
|
||
self.btn_cancel.pack_forget()
|
||
|
||
# --- Тонкий прогресс по низу
|
||
self.progress = SlimProgress(self)
|
||
self.progress.pack(side="bottom", fill="x")
|
||
|
||
# --- Ручка изменения размера окна (окно без рамки)
|
||
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(),
|
||
self.winfo_height())
|
||
|
||
def grip_move(e):
|
||
w = max(WIN_W, self._grip[2] + e.x_root - self._grip[0])
|
||
# ниже естественной высоты контента окно не сжимаем
|
||
h = max(self._min_height(),
|
||
self._grip[3] + e.y_root - self._grip[1])
|
||
self.geometry(f"{w}x{h}")
|
||
|
||
grip.bind("<Button-1>", grip_start)
|
||
grip.bind("<B1-Motion>", 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:
|
||
self.after_cancel(self._banner_after)
|
||
self._banner_after = self.after(160, self._render_banner)
|
||
|
||
def _render_banner(self, event=None):
|
||
if event is not None: # <Configure> — перерисуем через паузу
|
||
self._on_banner_configure()
|
||
return
|
||
self._banner_after = None
|
||
w = self.banner.winfo_width()
|
||
h = self.banner.winfo_height()
|
||
if w < 40 or h < 40:
|
||
return
|
||
cv = self.banner
|
||
cv.delete("all")
|
||
draw_scene(cv, w, h, scene=self._scene)
|
||
# Пиксельный заголовок поверх пейзажа
|
||
scale = 6
|
||
shadow = {"night": "#0d1420", "nether": "#2a0e0e",
|
||
"cave": "#14171a"}.get(self._scene, "#25331f")
|
||
draw_pixel_text(cv, 36, 34, "PIXEL", scale, "#ffffff", shadow, 3, 3)
|
||
draw_pixel_text(cv, 36, 90, "LAUNCHER", scale, "#ffffff", shadow, 3, 3)
|
||
round_mask(cv, w, h, 14, BG)
|
||
|
||
def _set_night(self, night):
|
||
self._set_scene("night" if night else "day")
|
||
|
||
def _pick_scene(self):
|
||
"""Сцена баннера из настроек; «random» — случайная при каждом старте."""
|
||
sc = self.settings.get("scene", "day")
|
||
if sc == "random":
|
||
return random.choice(("day", "night", "nether", "cave"))
|
||
return sc if sc in ("day", "night", "nether", "cave") else "day"
|
||
|
||
def _set_scene(self, scene, save=False):
|
||
self._scene = scene if scene in ("day", "night", "nether",
|
||
"cave") else "day"
|
||
self._night = self._scene == "night"
|
||
if save:
|
||
self.settings["scene"] = scene
|
||
config.save_settings(self.settings)
|
||
self._render_banner()
|
||
names = {"day": T("Дневной пейзаж"), "night": T("Ночной пейзаж"),
|
||
"nether": T("Пейзаж Нижнего мира"),
|
||
"cave": T("Пещерный пейзаж")}
|
||
self._log(T("Сцена: ") + names.get(self._scene, self._scene), "ok")
|
||
|
||
# ------------------------------------------------------------ окно
|
||
def _make_draggable(self, w):
|
||
"""Вешает перетаскивание окна (только для безопасных виджетов)."""
|
||
w.bind("<Button-1>", self._drag_start)
|
||
w.bind("<B1-Motion>", self._drag_move)
|
||
for c in w.winfo_children():
|
||
self._make_draggable(c)
|
||
|
||
def _bind_double(self, w):
|
||
"""Двойной клик по строке окна — разворот/возврат."""
|
||
w.bind("<Double-Button-1>", 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":
|
||
return
|
||
try:
|
||
import ctypes
|
||
u32 = ctypes.windll.user32
|
||
hwnd = u32.GetAncestor(self.winfo_id(), 2) # GA_ROOT
|
||
if not hwnd:
|
||
return
|
||
GWL_EXSTYLE, EX_APP, EX_TOOL = -20, 0x00040000, 0x00000080
|
||
style = u32.GetWindowLongW(hwnd, GWL_EXSTYLE)
|
||
u32.SetWindowLongW(hwnd, GWL_EXSTYLE,
|
||
(style | EX_APP) & ~EX_TOOL)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
def _minimize(self):
|
||
if os.name == "nt" and self._tray is not None:
|
||
self.withdraw() # сворачиваемся в трей (иконка уже там)
|
||
return
|
||
if os.name != "nt":
|
||
self.iconify()
|
||
return
|
||
try:
|
||
self._saved_geom = self.geometry()
|
||
self._pending_restore = True
|
||
self.overrideredirect(False)
|
||
self.iconify()
|
||
except tk.TclError:
|
||
self._pending_restore = False
|
||
|
||
def _on_map(self, event):
|
||
if event.widget is not self or not self._pending_restore:
|
||
return
|
||
self._pending_restore = False
|
||
|
||
def restore():
|
||
try:
|
||
self.overrideredirect(True)
|
||
if self._saved_geom:
|
||
self.geometry(self._saved_geom)
|
||
self._apply_taskbar_style()
|
||
except tk.TclError:
|
||
pass
|
||
self.after(20, restore)
|
||
|
||
def _logo(self, master, size):
|
||
c = tk.Canvas(master, width=size, height=size, bg=BAR,
|
||
highlightthickness=0)
|
||
u = max(1, size // 8)
|
||
pattern = ["gGgGgGgG", "gGgGgGgG", "dddddddd", "dDdDdddd",
|
||
"dddddddd", "dDddddDd", "dddddddd", "dDdddDdd"]
|
||
cmap = {"g": "#4f8f3f", "G": "#63a952", "d": "#6b4a2f", "D": "#7d5836"}
|
||
for ry, row in enumerate(pattern):
|
||
for rx, ch in enumerate(row):
|
||
c.create_rectangle(rx * u, ry * u, (rx + 1) * u, (ry + 1) * u,
|
||
fill=cmap[ch], outline="")
|
||
return c
|
||
|
||
def _center(self):
|
||
self.update_idletasks()
|
||
sw, sh = self.winfo_screenwidth(), self.winfo_screenheight()
|
||
x = max(0, (sw - WIN_W) // 2)
|
||
y = max(0, (sh - WIN_H) // 2 - 20)
|
||
self.geometry(f"+{x}+{y}")
|
||
|
||
def _set_icon(self):
|
||
try:
|
||
size = 32
|
||
img = tk.PhotoImage(width=size, height=size)
|
||
u = size // 8
|
||
# мини-версия логотипа (assets/make_logo.py, вариант gold):
|
||
# золотая P на тёмном слоте, травяной край, скруглённые углы
|
||
pattern = [".llllll.", ".pp..pp.", ".pp..pp.", ".pppppp.",
|
||
".pp.....", ".pp.....", ".pp.....", ".Gggggg."]
|
||
cmap = {".": "#1b1f2b", "l": "#ffd98a", "p": "#ffb454",
|
||
"g": "#7cb342", "G": "#5a8f32"}
|
||
corners = {(0, 0), (7, 0), (0, 7), (7, 7)}
|
||
for ry, row in enumerate(pattern):
|
||
for rx, ch in enumerate(row):
|
||
if (rx, ry) in corners:
|
||
continue
|
||
img.put(cmap[ch], to=(rx * u, ry * u, (rx + 1) * u, (ry + 1) * u))
|
||
self._icon_img = img
|
||
self.iconphoto(True, img)
|
||
except tk.TclError:
|
||
pass
|
||
|
||
# ------------------------------------------------------------ голова
|
||
def _draw_head_placeholder(self):
|
||
c = self.head_canvas
|
||
c.delete("all")
|
||
u = 3
|
||
pattern = ["hhhhhhhh", "hhhhhhhh", "hssssssh", "sswwbbss",
|
||
"ssssnnss", "ssmmmmss", "ssssssss", "ssssssss"]
|
||
cmap = {"h": "#2f241b", "s": "#b98a68", "w": "#f2f2f2", "b": "#4a6f9c",
|
||
"n": "#a97c5c", "m": "#7a5641"}
|
||
for ry, row in enumerate(pattern):
|
||
for rx, ch in enumerate(row):
|
||
c.create_rectangle(rx * u, ry * u, (rx + 1) * u, (ry + 1) * u,
|
||
fill=cmap[ch], outline="")
|
||
|
||
def _load_head_bg(self, nick):
|
||
cache_dir = os.path.join(self._game_dir(), ".launcher", "heads")
|
||
path = os.path.join(cache_dir, nick + ".gif")
|
||
try:
|
||
# кэш живёт сутки, дальше перечитываем с mc-heads
|
||
fresh = (os.path.isfile(path) and
|
||
time.time() - os.path.getmtime(path) < 86400)
|
||
if not fresh:
|
||
from . import http
|
||
data = http.get_bytes(f"https://mc-heads.net/avatar/{nick}/24",
|
||
timeout=10)
|
||
os.makedirs(cache_dir, exist_ok=True)
|
||
with open(path, "wb") as f:
|
||
f.write(data)
|
||
except Exception:
|
||
if not os.path.isfile(path): # сети нет и кэша нет — плейсхолдер
|
||
return
|
||
if os.path.isfile(path):
|
||
self.ui_q.put(("head", nick, path))
|
||
|
||
def _show_head(self, nick, path):
|
||
if self.var_nick.get().strip() != nick:
|
||
return
|
||
try:
|
||
img = tk.PhotoImage(file=path)
|
||
self._head_img = img
|
||
self.head_canvas.delete("all")
|
||
self.head_canvas.create_image(0, 0, anchor="nw", image=img)
|
||
except tk.TclError:
|
||
self._draw_head_placeholder()
|
||
|
||
# ------------------------------------------------------------ меню
|
||
def _open_nick_menu(self):
|
||
"""Быстрое переключение аккаунтов по клику на ник."""
|
||
m = tk.Menu(self, tearoff=0, bg=PANEL, fg=TEXT,
|
||
activebackground=HOVER, activeforeground=TEXT,
|
||
relief="flat", borderwidth=1, font=("Segoe UI", 9))
|
||
active = self.settings.get("active_account")
|
||
for a in self.settings.get("accounts") or [active]:
|
||
mark = "● " if a == active else ""
|
||
m.add_command(label=mark + a,
|
||
command=lambda x=a: self._switch_account(x))
|
||
m.add_separator()
|
||
m.add_command(label=T("Аккаунты…"), command=self._open_accounts)
|
||
self._menu = m
|
||
try:
|
||
m.tk_popup(self.lbl_nick.winfo_rootx(),
|
||
self.lbl_nick.winfo_rooty() + self.lbl_nick.winfo_height())
|
||
except tk.TclError:
|
||
pass
|
||
finally:
|
||
self._menu = None
|
||
try:
|
||
m.grab_release()
|
||
except tk.TclError:
|
||
pass
|
||
|
||
# ------------------------------------------------------------ плагины
|
||
def _init_plugins(self):
|
||
"""Хуки API плагинов, сканирование папки, событие launcher_ready."""
|
||
plugins_mod.bind_ui(
|
||
log=lambda m: self.ui_q.put(("log", m, None)),
|
||
message=lambda lvl, t, x: self.pl_q.put(("msg", lvl, t, x)),
|
||
ask_text=self._plugin_ask,
|
||
settings=lambda: self.settings,
|
||
game_dir=self._game_dir,
|
||
game_running=self._game_running,
|
||
)
|
||
threading.Thread(target=plugins_mod.scan, daemon=True).start()
|
||
self.after(900, lambda: self._emit("launcher_ready"))
|
||
|
||
def _emit(self, event, **kw):
|
||
try:
|
||
plugins_mod.emit(event, **kw)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
def _game_running(self):
|
||
p = self._game_proc
|
||
return bool(p) and p.poll() is None
|
||
|
||
def _plugin_ask(self, title, label, default=""):
|
||
"""Синхронный ввод текста для плагина (из любого потока)."""
|
||
ev = threading.Event()
|
||
holder = []
|
||
self.pl_q.put(("ask", title, label, default, ev, holder))
|
||
if not ev.wait(timeout=120):
|
||
return None
|
||
return holder[0] if holder else None
|
||
|
||
def _start_protocol(self, url):
|
||
"""pixellauncher://… → скачивание в фоне, результат в pl_q."""
|
||
parsed = protocol_mod.parse(url)
|
||
if parsed is None:
|
||
messagebox.showerror(
|
||
"PixelLauncher",
|
||
T("Неизвестная ссылка: ") + str(url), parent=self)
|
||
return
|
||
kind, target = parsed
|
||
|
||
def bg():
|
||
try:
|
||
if kind == "plugin":
|
||
plugins_mod.download_plugin(target)
|
||
plugins_mod.scan()
|
||
self.pl_q.put(("proto", "plugin", target, None))
|
||
elif kind == "mrpack":
|
||
path = protocol_mod.fetch_mrpack(target)
|
||
self.pl_q.put(("proto", "mrpack", path, None))
|
||
except Exception as e: # noqa: BLE001
|
||
self.pl_q.put(("proto", None, None, str(e)))
|
||
|
||
threading.Thread(target=bg, daemon=True).start()
|
||
|
||
def _proto_tick(self):
|
||
"""Очередь протокола + UI-запросы плагинов (каждые ~0.7 с)."""
|
||
self.after(700, self._proto_tick)
|
||
try:
|
||
while True:
|
||
msg = self.pl_q.get_nowait()
|
||
if msg[0] == "msg":
|
||
lvl, title, text = msg[1], msg[2], msg[3]
|
||
if lvl == "warn":
|
||
messagebox.showwarning(title, text, parent=self)
|
||
else:
|
||
messagebox.showinfo(title, text, parent=self)
|
||
elif msg[0] == "ask":
|
||
from tkinter import simpledialog
|
||
res = simpledialog.askstring(
|
||
msg[1], msg[2], parent=self, initialvalue=msg[3])
|
||
msg[5].append(res)
|
||
msg[4].set()
|
||
elif msg[0] == "proto":
|
||
self._proto_result(msg[1], msg[2], msg[3])
|
||
except queue.Empty:
|
||
pass
|
||
# ссылки от второго экземпляра лаунчера
|
||
for _, url in protocol_mod.queue_files():
|
||
self._start_protocol(url)
|
||
# отложенный импорт модпака — когда лаунчер свободен
|
||
if self._pending_import and not self.busy:
|
||
path, self._pending_import = self._pending_import, None
|
||
self._import_pack(path=path)
|
||
|
||
def _proto_result(self, kind, payload, err):
|
||
if err:
|
||
self._log(T("Протокол: ") + str(err), "err")
|
||
messagebox.showerror(
|
||
"PixelLauncher",
|
||
T("Не удалось выполнить ссылку: ") + str(err),
|
||
parent=self)
|
||
elif kind == "plugin":
|
||
self._log(T("Плагин установлен: ") + str(payload), "ok")
|
||
messagebox.showinfo(
|
||
"PixelLauncher",
|
||
T("Плагин установлен: ") + str(payload) + "\n"
|
||
+ T("Он уже загружен."), parent=self)
|
||
elif kind == "mrpack":
|
||
self._pending_import = payload
|
||
|
||
def _run_plugin_action(self, fn):
|
||
try:
|
||
fn()
|
||
except Exception as e: # noqa: BLE001
|
||
self._log(T("Действие плагина: ошибка: ") + str(e), "err")
|
||
|
||
def _open_plugins(self):
|
||
"""Установленные плагины: список, пересканирование, удаление."""
|
||
d = self._dialog(T("Плагины"), width=560)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=14, pady=10)
|
||
|
||
lb = tk.Listbox(body, height=10, bg=INPUT, fg=TEXT,
|
||
selectbackground=GREEN, selectforeground="#0d1a0d",
|
||
relief="solid", bd=1, highlightthickness=0,
|
||
font=("Consolas", 9), exportselection=False)
|
||
lb.pack(fill="both", expand=True)
|
||
info = tk.Label(body, text="", bg=BG, fg=DIM,
|
||
font=("Segoe UI", 8), justify="left", anchor="w")
|
||
info.pack(fill="x", pady=(6, 0))
|
||
entries = []
|
||
|
||
def refresh():
|
||
lb.delete(0, "end")
|
||
entries[:] = []
|
||
for p in plugins_mod.loaded():
|
||
entries.append({"kind": "ok", "p": p})
|
||
ver = (" (%s)" % p.version) if p.version else ""
|
||
lb.insert("end", "✓ " + p.file + ver + " — " + p.name)
|
||
for fname, err in plugins_mod.failures():
|
||
entries.append({"kind": "err", "file": fname, "err": err})
|
||
lb.insert("end", "✗ " + fname + " — " + err)
|
||
if not entries:
|
||
lb.insert("end", T("Плагинов нет. Загрузите из магазина."))
|
||
info.configure(text=T("Папка плагинов: ") + plugins_mod.PLUGIN_DIR)
|
||
|
||
def sel():
|
||
s = lb.curselection()
|
||
if not s or s[0] >= len(entries):
|
||
return None
|
||
return entries[s[0]]
|
||
|
||
def rescan():
|
||
done = threading.Event()
|
||
|
||
def go():
|
||
plugins_mod.scan()
|
||
done.set()
|
||
|
||
threading.Thread(target=go, daemon=True).start()
|
||
|
||
def wait_done():
|
||
if not d.winfo_exists():
|
||
return
|
||
if done.is_set():
|
||
refresh()
|
||
else:
|
||
d.after(150, wait_done)
|
||
|
||
wait_done()
|
||
|
||
def remove():
|
||
e = sel()
|
||
if not e:
|
||
return
|
||
fname = e["p"].file if e["kind"] == "ok" else e["file"]
|
||
if messagebox.askyesno(
|
||
T("Плагины"),
|
||
T("Удалить плагин {f}?").format(f=fname), parent=d):
|
||
plugins_mod.remove_file(fname)
|
||
rescan()
|
||
|
||
btns = tk.Frame(body, bg=BG)
|
||
btns.pack(fill="x", pady=(10, 0))
|
||
self._btn(btns, T("Пересканировать"), rescan, primary=True).pack(
|
||
side="left")
|
||
self._btn(btns, T("Удалить"), remove).pack(side="left", padx=(6, 0))
|
||
self._btn(btns, T("Открыть папку"),
|
||
lambda: self._open_explorer(plugins_mod.PLUGIN_DIR)).pack(
|
||
side="left", padx=(6, 0))
|
||
self._btn(btns, T("Магазин плагинов…"), self._open_store).pack(
|
||
side="left", padx=(6, 0))
|
||
self._btn(btns, T("Закрыть"), d.destroy).pack(side="right")
|
||
refresh()
|
||
self._finish_dialog(d)
|
||
|
||
def _open_store(self):
|
||
"""Магазин плагинов: каталог с сайта и установка в один клик."""
|
||
d = self._dialog(T("Магазин плагинов"), width=560)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=14, pady=10)
|
||
|
||
status = tk.Label(body, text=T("Загрузка каталога…"), bg=BG, fg=DIM,
|
||
font=("Segoe UI", 8), anchor="w")
|
||
status.pack(fill="x")
|
||
lb = tk.Listbox(body, height=10, 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=(6, 0))
|
||
catalog = []
|
||
|
||
def fill(items):
|
||
nonlocal catalog
|
||
catalog = items or []
|
||
have = set(plugins_mod.plugin_files())
|
||
lb.delete(0, "end")
|
||
for it in catalog:
|
||
mark = "✓ " if str(it.get("file")) in have else ""
|
||
lb.insert("end", "%s%s — %s"
|
||
% (mark, it.get("title") or it.get("file"),
|
||
it.get("desc") or ""))
|
||
status.configure(text=T("Плагинов в каталоге: ")
|
||
+ str(len(catalog)), fg=DIM)
|
||
|
||
def load():
|
||
status.configure(text=T("Загрузка каталога…"), fg=DIM)
|
||
box = {"cat": None, "err": None}
|
||
done = threading.Event()
|
||
|
||
def go():
|
||
try:
|
||
box["cat"] = plugins_mod.fetch_catalog()
|
||
except Exception as e: # noqa: BLE001
|
||
box["err"] = str(e)
|
||
done.set()
|
||
|
||
threading.Thread(target=go, daemon=True).start()
|
||
|
||
def wait():
|
||
if not d.winfo_exists():
|
||
return
|
||
if not done.is_set():
|
||
d.after(150, wait)
|
||
return
|
||
if box["err"]:
|
||
status.configure(
|
||
text=T("Каталог недоступен: ") + box["err"], fg=ERR)
|
||
else:
|
||
fill(box["cat"])
|
||
|
||
wait()
|
||
|
||
def install():
|
||
s = lb.curselection()
|
||
if not s or s[0] >= len(catalog):
|
||
return
|
||
fname = str(catalog[s[0]].get("file") or "")
|
||
btn_i.configure(state="disabled")
|
||
status.configure(text=T("Установка…"), fg=DIM)
|
||
res = {"err": None}
|
||
done = threading.Event()
|
||
|
||
def go():
|
||
try:
|
||
plugins_mod.download_plugin(fname)
|
||
plugins_mod.scan()
|
||
except Exception as e: # noqa: BLE001
|
||
res["err"] = str(e)
|
||
done.set()
|
||
|
||
threading.Thread(target=go, daemon=True).start()
|
||
|
||
def wait():
|
||
if not d.winfo_exists():
|
||
return
|
||
if not done.is_set():
|
||
d.after(150, wait)
|
||
return
|
||
btn_i.configure(state="normal")
|
||
if res["err"]:
|
||
status.configure(text=T("Ошибка: ") + res["err"],
|
||
fg=ERR)
|
||
else:
|
||
status.configure(
|
||
text=T("Плагин установлен: ") + fname, fg=OK)
|
||
fill(catalog)
|
||
|
||
wait()
|
||
|
||
btns = tk.Frame(body, bg=BG)
|
||
btns.pack(fill="x", pady=(10, 0))
|
||
btn_i = self._btn(btns, T("Установить"), install, primary=True)
|
||
btn_i.pack(side="left")
|
||
self._btn(btns, T("Обновить список"), load).pack(
|
||
side="left", padx=(6, 0))
|
||
self._btn(btns, T("Закрыть"), d.destroy).pack(side="right")
|
||
load()
|
||
self._finish_dialog(d)
|
||
|
||
def _open_packs(self):
|
||
"""Каталог модпаков: список с сайта и установка в один клик."""
|
||
d = self._dialog(T("Каталог модпаков"), width=580)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=14, pady=10)
|
||
|
||
status = tk.Label(body, text=T("Загрузка каталога…"), bg=BG, fg=DIM,
|
||
font=("Segoe UI", 8), anchor="w")
|
||
status.pack(fill="x")
|
||
lb = tk.Listbox(body, height=10, 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=(6, 0))
|
||
catalog = []
|
||
dl = {"cancel": None}
|
||
|
||
def fill(items):
|
||
nonlocal catalog
|
||
catalog = items or []
|
||
lb.delete(0, "end")
|
||
for it in catalog:
|
||
bits = []
|
||
if it.get("mc"):
|
||
bits.append("MC " + str(it["mc"]))
|
||
if it.get("loader"):
|
||
bits.append(str(it["loader"]))
|
||
if it.get("size"):
|
||
bits.append(str(it["size"]) + " " + T("МБ"))
|
||
tail = (" [" + " · ".join(bits) + "]") if bits else ""
|
||
lb.insert("end", "%s — %s%s"
|
||
% (it.get("title") or "?",
|
||
it.get("desc") or "", tail))
|
||
status.configure(text=T("Модпаков в каталоге: ")
|
||
+ str(len(catalog)), fg=DIM)
|
||
|
||
def load():
|
||
status.configure(text=T("Загрузка каталога…"), fg=DIM)
|
||
box = {"cat": None, "err": None}
|
||
done = threading.Event()
|
||
|
||
def go():
|
||
try:
|
||
box["cat"] = mrpack_mod.fetch_catalog()
|
||
except Exception as e: # noqa: BLE001
|
||
box["err"] = str(e)
|
||
done.set()
|
||
|
||
threading.Thread(target=go, daemon=True).start()
|
||
|
||
def wait():
|
||
if not d.winfo_exists():
|
||
return
|
||
if not done.is_set():
|
||
d.after(150, wait)
|
||
return
|
||
if box["err"]:
|
||
status.configure(
|
||
text=T("Каталог недоступен: ") + box["err"], fg=ERR)
|
||
else:
|
||
fill(box["cat"])
|
||
|
||
wait()
|
||
|
||
def install():
|
||
s = lb.curselection()
|
||
if not s or s[0] >= len(catalog):
|
||
return
|
||
if self.busy:
|
||
status.configure(
|
||
text=T("Дождитесь окончания текущей загрузки"), fg=ERR)
|
||
return
|
||
entry = catalog[s[0]]
|
||
url = mrpack_mod.pack_url(entry)
|
||
btn_i.configure(state="disabled")
|
||
ev = dl["cancel"] = threading.Event()
|
||
box = {"err": None, "pct": -1, "path": None}
|
||
done = threading.Event()
|
||
|
||
def go():
|
||
try:
|
||
safe = (re.sub(r"[^\w.\-]+", "_",
|
||
str(entry.get("title") or "pack"))[:60]
|
||
or "pack")
|
||
dest = os.path.join(tempfile.gettempdir(), "PixelLauncher",
|
||
safe + ".mrpack")
|
||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||
|
||
def prog(done_b, total_b):
|
||
box["pct"] = (int(done_b * 100 / total_b)
|
||
if total_b else -1)
|
||
http.download_file(url, dest, progress=prog, cancel=ev)
|
||
box["path"] = dest
|
||
except http.Cancelled:
|
||
box["err"] = T("Загрузка отменена")
|
||
except Exception as e: # noqa: BLE001
|
||
box["err"] = str(e)
|
||
done.set()
|
||
|
||
threading.Thread(target=go, daemon=True).start()
|
||
|
||
def wait():
|
||
if not done.is_set():
|
||
if not d.winfo_exists():
|
||
return
|
||
if box["pct"] >= 0:
|
||
status.configure(
|
||
text=T("Скачивание: ") + str(box["pct"]) + "%",
|
||
fg=DIM)
|
||
d.after(200, wait)
|
||
return
|
||
if not d.winfo_exists():
|
||
return
|
||
btn_i.configure(state="normal")
|
||
dl["cancel"] = None
|
||
if box["err"]:
|
||
status.configure(text=T("Ошибка: ") + box["err"], fg=ERR)
|
||
else:
|
||
path = box["path"]
|
||
d.destroy()
|
||
self._import_pack(path) # лоадер+файлы: главный прогресс
|
||
|
||
wait()
|
||
|
||
def close():
|
||
if dl.get("cancel") is not None:
|
||
dl["cancel"].set()
|
||
d.destroy()
|
||
|
||
btns = tk.Frame(body, bg=BG)
|
||
btns.pack(fill="x", pady=(10, 0))
|
||
btn_i = self._btn(btns, T("Установить"), install, primary=True)
|
||
btn_i.pack(side="left")
|
||
self._btn(btns, T("Обновить список"), load).pack(
|
||
side="left", padx=(6, 0))
|
||
self._btn(btns, T("Закрыть"), close).pack(side="right")
|
||
d.protocol("WM_DELETE_WINDOW", close)
|
||
load()
|
||
self._finish_dialog(d)
|
||
|
||
def _open_menu(self):
|
||
m = tk.Menu(self, tearoff=0, bg=PANEL, fg=TEXT,
|
||
activebackground=HOVER, activeforeground=TEXT,
|
||
relief="flat", borderwidth=1, font=("Segoe UI", 9))
|
||
m.add_command(label=T("Профиль…"), command=self._open_profile)
|
||
m.add_command(label=T("Профили…"), command=self._open_profiles)
|
||
m.add_command(label=T("Аккаунты…"), command=self._open_accounts)
|
||
m.add_command(label=T("Выбрать версию…"), command=self._open_versions)
|
||
m.add_command(label=T("Установить лоадер…"),
|
||
command=self._open_fabric)
|
||
m.add_command(label=T("Моды…"), command=self._open_mods)
|
||
m.add_command(label=T("Импорт модпака…"), command=self._import_pack)
|
||
m.add_command(label=T("Каталог модпаков…"), command=self._open_packs)
|
||
m.add_command(label=T("Сборки Modrinth…"), command=self._open_modrinth_packs)
|
||
m.add_command(label=T("Сборки CurseForge…"), command=self._open_curseforge_packs)
|
||
m.add_command(label=T("Плагины…"), command=self._open_plugins)
|
||
m.add_command(label=T("Магазин плагинов…"), command=self._open_store)
|
||
m.add_command(label=T("Скин-превью…"), command=self._open_skin)
|
||
m.add_separator()
|
||
m.add_command(label=T("Резервная копия миров"), command=self._on_backup)
|
||
m.add_command(label=T("Миры и копии…"), command=self._open_worlds)
|
||
m.add_command(label=T("Скачать эту версию"), command=self._on_download)
|
||
m.add_command(label=T("Проверить файлы игры…"), command=self._on_verify)
|
||
m.add_command(label=T("Очистка версий…"), command=self._open_cleanup)
|
||
m.add_command(label=T("История и статистика…"),
|
||
command=self._open_history)
|
||
scene_menu = tk.Menu(m, tearoff=0, bg=PANEL, fg=TEXT,
|
||
activebackground=HOVER, activeforeground=TEXT,
|
||
relief="flat", borderwidth=1,
|
||
font=("Segoe UI", 9))
|
||
for name, label in (("day", T("День")), ("night", T("Ночь")),
|
||
("nether", T("Незер")), ("cave", T("Пещера")),
|
||
("random", T("Случайно"))):
|
||
scene_menu.add_command(
|
||
label=("● " if self._scene == name else "") + label,
|
||
command=lambda n=name: self._set_scene(n, save=True))
|
||
m.add_cascade(label=T("Сцена баннера"), menu=scene_menu)
|
||
m.add_command(label=T("Создать ярлык версии…"),
|
||
command=self._make_shortcut)
|
||
m.add_separator()
|
||
m.add_command(label=T("Открыть папку игры"),
|
||
command=lambda: self._open_explorer(self._play_dir()))
|
||
m.add_command(label=T("Открыть папку миров"),
|
||
command=lambda: self._open_explorer(
|
||
os.path.join(self._play_dir(), "saves")))
|
||
m.add_command(label=T("Открыть скриншоты"),
|
||
command=lambda: self._open_explorer(
|
||
os.path.join(self._play_dir(), "screenshots")))
|
||
m.add_command(label=T("Галерея скриншотов…"),
|
||
command=self._open_gallery)
|
||
m.add_separator()
|
||
m.add_command(label=T("Журнал…"), command=self._open_log)
|
||
m.add_cascade(label=T("Быстрые настройки"),
|
||
menu=self._quick_settings(m))
|
||
m.add_command(label=T("Настройки…"), command=self._open_options)
|
||
m.add_command(label=T("Экспорт настроек…"),
|
||
command=self._export_settings)
|
||
m.add_command(label=T("Импорт настроек…"),
|
||
command=self._import_settings)
|
||
m.add_command(label=T("Проверить обновление лаунчера…"),
|
||
command=self._check_update)
|
||
m.add_command(label=T("О программе…"), command=self._open_about)
|
||
acts = plugins_mod.actions()
|
||
if acts:
|
||
m.add_separator()
|
||
for _label, _fn, _pname in acts:
|
||
m.add_command(
|
||
label=_label,
|
||
command=lambda f=_fn: self._run_plugin_action(f))
|
||
self._menu = m
|
||
try:
|
||
m.tk_popup(self.winfo_rootx() + self.winfo_width() - 170,
|
||
self.winfo_rooty() + 44)
|
||
except tk.TclError:
|
||
pass
|
||
finally:
|
||
self._menu = None
|
||
try:
|
||
m.grab_release()
|
||
except tk.TclError:
|
||
pass
|
||
if self._close_after_menu:
|
||
self._close_after_menu = False
|
||
self.after(1, self.destroy)
|
||
|
||
def _close_menu_if_open(self):
|
||
m = self._menu
|
||
if m is None:
|
||
return False
|
||
self._menu = None
|
||
try:
|
||
m.unpost()
|
||
except tk.TclError:
|
||
pass
|
||
try:
|
||
m.grab_release()
|
||
except tk.TclError:
|
||
pass
|
||
return True
|
||
|
||
# ------------------------------------------------------------ диалоги
|
||
def _dialog(self, title, width=420):
|
||
d = tk.Toplevel(self)
|
||
d.title(title)
|
||
d.configure(bg=BG)
|
||
d.transient(self)
|
||
d.grab_set()
|
||
d.resizable(False, False)
|
||
d._pl_width = width
|
||
self.after(10, lambda: _dark_titlebar(d))
|
||
return d
|
||
|
||
def _finish_dialog(self, d):
|
||
d.update_idletasks()
|
||
width = getattr(d, "_pl_width", 420)
|
||
h = d.winfo_reqheight()
|
||
d.geometry(f"{width}x{h}")
|
||
d.update_idletasks()
|
||
x = self.winfo_rootx() + max(0, (self.winfo_width() - width) // 2)
|
||
y = self.winfo_rooty() + max(0, (self.winfo_height() - h) // 2)
|
||
d.geometry(f"+{x}+{y}")
|
||
|
||
def _btn(self, master, text, command, primary=False):
|
||
b = tk.Button(master, text=text, command=command,
|
||
bg=GREEN if primary else PANEL,
|
||
fg="#ffffff" if primary else TEXT,
|
||
activebackground=GREEN_HI if primary else HOVER,
|
||
activeforeground="#ffffff",
|
||
relief="flat", bd=0, padx=14, pady=4, cursor="hand2",
|
||
font=("Segoe UI", 9, "bold" if primary else "normal"))
|
||
return b
|
||
|
||
def _field(self, parent, label, var, width=32, browse=None):
|
||
row = tk.Frame(parent, bg=BG)
|
||
row.pack(fill="x", pady=3)
|
||
tk.Label(row, text=label, bg=BG, fg=DIM, width=14, anchor="w",
|
||
font=("Segoe UI", 9)).pack(side="left")
|
||
e = tk.Entry(row, textvariable=var, width=width, font=("Segoe UI", 9),
|
||
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
|
||
bd=1, highlightbackground=INPUT_BD, highlightthickness=1)
|
||
e.pack(side="left", ipady=2)
|
||
e.bind("<FocusIn>", lambda ev: e.configure(highlightbackground=GREEN))
|
||
e.bind("<FocusOut>", lambda ev: e.configure(highlightbackground=INPUT_BD))
|
||
if browse:
|
||
self._btn(row, "…", browse).pack(side="left", padx=(4, 0))
|
||
return e
|
||
|
||
def _hint(self, parent, text, indent=108):
|
||
row = tk.Frame(parent, bg=BG)
|
||
row.pack(fill="x", pady=(1, 3))
|
||
tk.Frame(row, width=indent, bg=BG).pack(side="left")
|
||
tk.Label(row, text=text, bg=BG, fg=FAINT, anchor="w",
|
||
font=("Segoe UI", 8)).pack(side="left", fill="x")
|
||
|
||
def _open_profile(self):
|
||
d = self._dialog(T("Профиль игрока"))
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=14, pady=10)
|
||
|
||
var_nick = tk.StringVar(value=self.var_nick.get())
|
||
self._field(body, T("Ник:"), var_nick, 20)
|
||
|
||
ram_frame = tk.Frame(body, bg=BG)
|
||
ram_frame.pack(fill="x", pady=3)
|
||
tk.Label(ram_frame, text=T("ОЗУ (МБ):"), bg=BG, fg=DIM, width=14,
|
||
anchor="w", font=("Segoe UI", 9)).pack(side="left")
|
||
var_ram = tk.IntVar(value=self.settings.get("ram", 2048))
|
||
var_auto = tk.BooleanVar(value=bool(self.settings.get("auto_ram")))
|
||
scale = tk.Scale(ram_frame, from_=1024, to=self._ram_max(),
|
||
resolution=512, orient="horizontal", variable=var_ram,
|
||
bg=BG, fg=TEXT, troughcolor=INPUT, highlightthickness=0,
|
||
length=200, font=("Segoe UI", 8))
|
||
scale.pack(side="left")
|
||
|
||
def toggle_auto():
|
||
scale.configure(state="disabled" if var_auto.get() else "normal")
|
||
tk.Checkbutton(ram_frame, text=T("Авто (по памяти системы)"),
|
||
variable=var_auto, command=toggle_auto, bg=BG, fg=DIM,
|
||
selectcolor=INPUT, activebackground=BG, activeforeground=TEXT,
|
||
highlightthickness=0, font=("Segoe UI", 8)).pack(side="left",
|
||
padx=(8, 0))
|
||
if var_auto.get():
|
||
toggle_auto()
|
||
|
||
var_java = tk.StringVar(value=self.settings.get("java_path", ""))
|
||
self._field(body, T("Java:"), var_java, 30,
|
||
browse=lambda: self._pick_java_file(var_java))
|
||
self._hint(body, T("Пусто = подбор Java автоматически."))
|
||
|
||
var_jvm = tk.StringVar(value=self.settings.get("jvm_args", ""))
|
||
self._field(body, T("JVM-аргументы:"), var_jvm, 30)
|
||
|
||
# --- Пресет JVM (заполняет поле аргументов готовым набором)
|
||
presets = {
|
||
"": (T("(нет)"), ""),
|
||
"perf": (T("Производительность (1.16+)"),
|
||
"-XX:+UseG1GC -XX:+ParallelRefProcEnabled "
|
||
"-XX:MaxGCPauseMillis=200 -XX:+UnlockExperimentalVMOptions"
|
||
" -XX:+DisableExplicitGC -XX:+AlwaysPreTouch"),
|
||
"old": (T("Старые версии (Java 8)"),
|
||
"-XX:+UseConcMarkSweepGC -XX:+CMSClassUnloadingEnabled "
|
||
"-XX:PermSize=256m -XX:MaxPermSize=512m"),
|
||
}
|
||
pre_row = tk.Frame(body, bg=BG)
|
||
pre_row.pack(fill="x", pady=3)
|
||
tk.Label(pre_row, text=T("Пресет JVM:"), bg=BG, fg=DIM, width=14,
|
||
anchor="w", font=("Segoe UI", 9)).pack(side="left")
|
||
var_preset = tk.StringVar(value=presets.get(
|
||
self.settings.get("jvm_preset", ""), presets[""])[0])
|
||
cb_preset = ttk.Combobox(pre_row, textvariable=var_preset, width=28,
|
||
state="readonly",
|
||
values=[v[0] for v in presets.values()])
|
||
cb_preset.pack(side="left")
|
||
|
||
def apply_preset(_e=None):
|
||
for key, (label, args) in presets.items():
|
||
if label == var_preset.get():
|
||
var_jvm.set(args)
|
||
var_preset_key[0] = key
|
||
break
|
||
var_preset_key = [self.settings.get("jvm_preset", "")
|
||
if self.settings.get("jvm_preset", "") in presets
|
||
else ""]
|
||
cb_preset.bind("<<ComboboxSelected>>", apply_preset)
|
||
|
||
# --- Окно игры: разрешение и полный экран
|
||
win_row = tk.Frame(body, bg=BG)
|
||
win_row.pack(fill="x", pady=3)
|
||
tk.Label(win_row, text=T("Окно игры:"), bg=BG, fg=DIM, width=14,
|
||
anchor="w", font=("Segoe UI", 9)).pack(side="left")
|
||
var_ww = tk.StringVar(value=str(self.settings.get("win_w") or ""))
|
||
var_wh = tk.StringVar(value=str(self.settings.get("win_h") or ""))
|
||
tk.Label(win_row, text=T("Ширина:"), bg=BG, fg=FAINT,
|
||
font=("Segoe UI", 8)).pack(side="left")
|
||
tk.Entry(win_row, textvariable=var_ww, width=6, font=("Segoe UI", 9),
|
||
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
|
||
bd=1, highlightthickness=0).pack(side="left", padx=(3, 8))
|
||
tk.Label(win_row, text=T("Высота:"), bg=BG, fg=FAINT,
|
||
font=("Segoe UI", 8)).pack(side="left")
|
||
tk.Entry(win_row, textvariable=var_wh, width=6, font=("Segoe UI", 9),
|
||
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
|
||
bd=1, highlightthickness=0).pack(side="left", padx=(3, 8))
|
||
var_fs = tk.BooleanVar(value=bool(self.settings.get("fullscreen")))
|
||
tk.Checkbutton(win_row, text=T("Полный экран"), variable=var_fs,
|
||
bg=BG, fg=DIM, selectcolor=INPUT, activebackground=BG,
|
||
activeforeground=TEXT, highlightthickness=0,
|
||
font=("Segoe UI", 8)).pack(side="left")
|
||
self._hint(body, T("0 или пусто = как в игре."))
|
||
|
||
btns = tk.Frame(body, bg=BG)
|
||
btns.pack(fill="x", pady=(12, 0))
|
||
|
||
def save():
|
||
nick = var_nick.get().strip()
|
||
if not re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick):
|
||
messagebox.showwarning("PixelLauncher",
|
||
T("Ник: 3-16 символов, латиница, цифры и _"),
|
||
parent=d)
|
||
return
|
||
try:
|
||
ww = int(var_ww.get().strip() or 0)
|
||
wh = int(var_wh.get().strip() or 0)
|
||
if ww < 0 or wh < 0 or ww > 10000 or wh > 10000:
|
||
raise ValueError
|
||
except ValueError:
|
||
messagebox.showwarning("PixelLauncher",
|
||
T("0 или пусто = как в игре."),
|
||
parent=d)
|
||
return
|
||
self._rename_active_account(nick)
|
||
self.settings.update({
|
||
"username": nick, "ram": int(var_ram.get()),
|
||
"auto_ram": bool(var_auto.get()),
|
||
"java_path": var_java.get().strip(),
|
||
"jvm_args": var_jvm.get().strip(),
|
||
"jvm_preset": var_preset_key[0],
|
||
"win_w": ww, "win_h": wh,
|
||
"fullscreen": bool(var_fs.get()),
|
||
})
|
||
config.save_settings(self.settings)
|
||
self.var_nick.set(nick)
|
||
self.lbl_nick.configure(text=nick)
|
||
self._log(T("Профиль сохранён: ") + nick, "ok")
|
||
threading.Thread(target=self._load_head_bg, args=(nick,),
|
||
daemon=True).start()
|
||
d.destroy()
|
||
|
||
self._btn(btns, T("Скин-превью…"), self._open_skin).pack(side="left")
|
||
self._btn(btns, T("Сохранить"), save, primary=True).pack(side="right")
|
||
self._btn(btns, T("Отмена"), d.destroy).pack(side="right", padx=(0, 6))
|
||
d.bind("<Return>", 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("<Button-1>", make_select())
|
||
row.bind("<Button-3>", make_ctx())
|
||
for ch in row.winfo_children():
|
||
ch.bind("<Button-1>", make_select())
|
||
ch.bind("<Button-3>", make_ctx())
|
||
for g in ch.winfo_children():
|
||
g.bind("<Button-1>", make_select())
|
||
g.bind("<Button-3>", 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("<Return>", 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("<Return>", lambda e: save())
|
||
self._finish_dialog(d)
|
||
|
||
def _open_profiles(self):
|
||
"""Список профилей запуска: применить, создать, изменить, удалить."""
|
||
d = self._dialog(T("Профили"), width=660)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=14, pady=10)
|
||
|
||
lb = tk.Listbox(body, height=10, 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)
|
||
lbl_hint = tk.Label(body, text="", bg=BG, fg=FAINT, anchor="w",
|
||
font=("Segoe UI", 8))
|
||
lbl_hint.pack(fill="x", pady=(4, 0))
|
||
|
||
def profiles():
|
||
return list(self.settings.get("profiles") or [])
|
||
|
||
def refresh():
|
||
lb.delete(0, "end")
|
||
ps = profiles()
|
||
active = self.settings.get("active_profile") or ""
|
||
for p in ps:
|
||
mods_n = ""
|
||
if isinstance(p.get("mods"), list):
|
||
mods_n = f" · {T('Моды')}: {len(p['mods'])}"
|
||
lb.insert("end", ("● " if p.get("name") == active else " ")
|
||
+ str(p.get("name", "?")) + " — "
|
||
+ str(p.get("username", "")) + " · "
|
||
+ str(p.get("version", "")) + mods_n)
|
||
lbl_hint.configure(text=T("Список профилей пуст") if not ps else "")
|
||
if ps:
|
||
lb.selection_set(0)
|
||
|
||
def after_edit():
|
||
refresh()
|
||
try:
|
||
d.grab_set()
|
||
except tk.TclError:
|
||
pass
|
||
|
||
def selected():
|
||
sel = lb.curselection()
|
||
ps = profiles()
|
||
if not sel or sel[0] >= len(ps):
|
||
return None
|
||
return ps[sel[0]]
|
||
|
||
def apply():
|
||
p = selected()
|
||
if not p:
|
||
messagebox.showinfo("PixelLauncher", T("Выберите профиль"),
|
||
parent=d)
|
||
return
|
||
self.apply_profile(p)
|
||
refresh()
|
||
|
||
def make_shortcut():
|
||
p = selected()
|
||
if not p:
|
||
messagebox.showinfo("PixelLauncher", T("Выберите профиль"),
|
||
parent=d)
|
||
return
|
||
self._make_shortcut(p)
|
||
refresh()
|
||
|
||
def clear_profile():
|
||
self.settings["active_profile"] = ""
|
||
config.save_settings(self.settings)
|
||
self._update_pill()
|
||
self._log(T("Без профиля"), None)
|
||
refresh()
|
||
|
||
def delete_profile():
|
||
p = selected()
|
||
if not p:
|
||
return
|
||
if not messagebox.askyesno(
|
||
"PixelLauncher",
|
||
T("Удалить профиль {p}?").format(p=p.get("name", "")),
|
||
parent=d):
|
||
return
|
||
self.settings["profiles"] = [x for x in profiles() if x is not p]
|
||
if self.settings.get("active_profile") == p.get("name"):
|
||
self.settings["active_profile"] = ""
|
||
self._update_pill()
|
||
config.save_settings(self.settings)
|
||
self._log(T("Профиль удалён: ") + str(p.get("name", "")), "ok")
|
||
refresh()
|
||
|
||
lb.bind("<Double-Button-1>", lambda e: apply())
|
||
btns = tk.Frame(body, bg=BG)
|
||
btns.pack(fill="x", pady=(10, 0))
|
||
self._btn(btns, T("Применить"), apply, primary=True).pack(side="right")
|
||
self._btn(btns, T("Закрыть"), d.destroy).pack(side="right",
|
||
padx=(0, 6))
|
||
self._btn(btns, T("Без профиля"), clear_profile).pack(side="left")
|
||
self._btn(btns, T("Удалить"), delete_profile).pack(side="left",
|
||
padx=(6, 0))
|
||
self._btn(btns, T("Изменить"),
|
||
lambda: selected() and self._profile_editor(
|
||
d, selected(), after_edit)).pack(side="left", padx=(6, 0))
|
||
self._btn(btns, T("Новый профиль"),
|
||
lambda: self._profile_editor(d, None, after_edit)).pack(
|
||
side="left", padx=(6, 0))
|
||
self._btn(btns, T("Ярлык"), make_shortcut).pack(side="left",
|
||
padx=(6, 0))
|
||
refresh()
|
||
self._finish_dialog(d)
|
||
return d
|
||
|
||
def _profile_editor(self, parent, p, on_done):
|
||
"""Форма создания/изменения профиля запуска. p=None — новый."""
|
||
e = self._dialog(T("Профиль"), width=430)
|
||
try:
|
||
e.transient(parent)
|
||
except tk.TclError:
|
||
pass
|
||
body = tk.Frame(e, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=14, pady=10)
|
||
cur = p or {}
|
||
var_name = tk.StringVar(value=str(cur.get("name", "")))
|
||
self._field(body, T("Имя:"), var_name, 22)
|
||
var_nick = tk.StringVar(
|
||
value=str(cur.get("username") or self.var_nick.get()))
|
||
self._field(body, T("Ник:"), var_nick, 22)
|
||
var_ver = tk.StringVar(
|
||
value=str(cur.get("version") or self.var_ver.get()))
|
||
self._field(body, T("Версия:"), var_ver, 22)
|
||
var_ram = tk.StringVar(value=str(
|
||
cur.get("ram") or self.settings.get("ram", 2048)))
|
||
self._field(body, T("ОЗУ (МБ):"), var_ram, 8)
|
||
var_fs = tk.BooleanVar(value=bool(
|
||
cur.get("fullscreen", self.settings.get("fullscreen"))))
|
||
tk.Checkbutton(body, text=T("Полный экран"), variable=var_fs, bg=BG,
|
||
fg=DIM, selectcolor=INPUT, activebackground=BG,
|
||
activeforeground=TEXT, highlightthickness=0,
|
||
font=("Segoe UI", 9)).pack(anchor="w", pady=3)
|
||
var_mods = tk.BooleanVar(value=isinstance(cur.get("mods"), list))
|
||
tk.Checkbutton(body, text=T("Запомнить включённые моды"),
|
||
variable=var_mods, bg=BG, fg=DIM, selectcolor=INPUT,
|
||
activebackground=BG, activeforeground=TEXT,
|
||
highlightthickness=0,
|
||
font=("Segoe UI", 9)).pack(anchor="w", pady=3)
|
||
self._hint(body, T("Профиль хранит ник, версию, ОЗУ и по желанию "
|
||
"набор включённых модов."), indent=0)
|
||
|
||
def save():
|
||
name = var_name.get().strip()
|
||
nick = var_nick.get().strip()
|
||
ver = var_ver.get().strip()
|
||
if not name:
|
||
messagebox.showwarning("PixelLauncher", T("Имя:"), parent=e)
|
||
return
|
||
if not re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick):
|
||
messagebox.showwarning(
|
||
"PixelLauncher",
|
||
T("Ник: 3-16 символов, латиница, цифры и _"), parent=e)
|
||
return
|
||
if not ver:
|
||
messagebox.showwarning("PixelLauncher", T("Версия:"),
|
||
parent=e)
|
||
return
|
||
try:
|
||
ram = int(var_ram.get().strip() or 2048)
|
||
if ram < 256 or ram > 65536:
|
||
raise ValueError
|
||
except ValueError:
|
||
messagebox.showwarning("PixelLauncher", T("ОЗУ (МБ):"),
|
||
parent=e)
|
||
return
|
||
mods = None
|
||
if var_mods.get():
|
||
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,
|
||
"win_w": int(self.settings.get("win_w") or 0),
|
||
"win_h": int(self.settings.get("win_h") or 0),
|
||
"java_path": str(self.settings.get("java_path") or ""),
|
||
"jvm_args": str(self.settings.get("jvm_args") or ""),
|
||
"jvm_preset": str(self.settings.get("jvm_preset") or ""),
|
||
"auto_ram": bool(self.settings.get("auto_ram"))}
|
||
ps = [x for x in (self.settings.get("profiles") or [])
|
||
if x is not p]
|
||
ps.append(prof)
|
||
ps.sort(key=lambda x: str(x.get("name", "")).lower())
|
||
self.settings["profiles"] = ps
|
||
config.save_settings(self.settings)
|
||
e.destroy()
|
||
on_done()
|
||
|
||
self._btn(body, T("Сохранить"), save, primary=True).pack(anchor="e",
|
||
pady=(10, 0))
|
||
e.bind("<Return>", lambda ev: save())
|
||
self._finish_dialog(e)
|
||
return e
|
||
|
||
def apply_profile(self, p):
|
||
"""Применяет профиль запуска: ник, версию, ОЗУ, окно, набор модов."""
|
||
old_prof = str(self.settings.get("active_profile") or "")
|
||
old_acc = self.settings.get("active_account")
|
||
nick = str(p.get("username") or "").strip()
|
||
if nick:
|
||
accs = list(self.settings.get("accounts") or [])
|
||
if nick in accs:
|
||
self.settings["active_account"] = nick
|
||
else:
|
||
self._rename_active_account(nick)
|
||
self.settings["username"] = nick
|
||
self.var_nick.set(nick)
|
||
self.lbl_nick.configure(text=nick)
|
||
if nick != old_acc:
|
||
self._emit("account_switched", username=nick)
|
||
ver = str(p.get("version") or "").strip()
|
||
if ver:
|
||
self.var_ver.set(ver)
|
||
for k in ("ram", "auto_ram", "fullscreen", "win_w", "win_h",
|
||
"java_path", "jvm_args", "jvm_preset"):
|
||
if k in p:
|
||
self.settings[k] = p[k]
|
||
self.settings["version"] = self.var_ver.get().strip()
|
||
mods = p.get("mods")
|
||
if isinstance(mods, list):
|
||
def base_of(fn):
|
||
low = str(fn).lower()
|
||
if low.endswith(".disabled"):
|
||
low = low[:-len(".disabled")]
|
||
if low.endswith(".jar"):
|
||
low = low[:-4]
|
||
return low
|
||
want = {base_of(x) for x in mods}
|
||
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._play_dir(), m["file"], on)
|
||
new_prof = str(p.get("name") or "")
|
||
self.settings["active_profile"] = new_prof
|
||
config.save_settings(self.settings)
|
||
if new_prof and new_prof != old_prof:
|
||
self._emit("profile_switched", profile_name=new_prof)
|
||
self._update_pill()
|
||
self._log(T("Профиль применён: ") + self.settings["active_profile"],
|
||
"ok")
|
||
|
||
def _apply_profile_at_start(self, name):
|
||
"""Профиль из CLI (ярлык рабочего стола): применяем, если найден."""
|
||
for p in self.settings.get("profiles") or []:
|
||
if str(p.get("name") or "") == str(name):
|
||
self.apply_profile(p)
|
||
ver = str(p.get("version") or "").strip()
|
||
if ver:
|
||
# версию выбираем после загрузки списка (см. _load_versions)
|
||
self._force_version = ver
|
||
return
|
||
self._log(T("Профиль не найден: ") + str(name), "warn")
|
||
|
||
def _open_worlds(self):
|
||
"""Список миров: копия по кнопке, удаление копий и миров."""
|
||
d = self._dialog(T("Миры и копии"), width=620)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=14, pady=10)
|
||
|
||
cols = tk.Frame(body, bg=BG)
|
||
cols.pack(fill="both", expand=True)
|
||
left = tk.Frame(cols, bg=BG)
|
||
left.pack(side="left", fill="both", expand=True)
|
||
right = tk.Frame(cols, bg=BG)
|
||
right.pack(side="left", fill="both", expand=True, padx=(10, 0))
|
||
|
||
tk.Label(left, text=T("Миры"), bg=BG, fg=TEXT,
|
||
font=("Segoe UI", 9, "bold")).pack(anchor="w")
|
||
lb_w = tk.Listbox(left, height=10, bg=INPUT, fg=TEXT,
|
||
selectbackground=GREEN, selectforeground="#0d1a0d",
|
||
relief="solid", bd=1, highlightthickness=0,
|
||
font=("Consolas", 9), exportselection=False)
|
||
lb_w.pack(fill="both", expand=True, pady=(4, 0))
|
||
tk.Label(right, text=T("Копии"), bg=BG, fg=TEXT,
|
||
font=("Segoe UI", 9, "bold")).pack(anchor="w")
|
||
lb_b = tk.Listbox(right, height=10, bg=INPUT, fg=TEXT,
|
||
selectbackground=GREEN, selectforeground="#0d1a0d",
|
||
relief="solid", bd=1, highlightthickness=0,
|
||
font=("Consolas", 9), exportselection=False)
|
||
lb_b.pack(fill="both", expand=True, pady=(4, 0))
|
||
|
||
lq = queue.Queue()
|
||
|
||
def poll():
|
||
if not d.winfo_exists():
|
||
return
|
||
try:
|
||
while True:
|
||
msg = lq.get_nowait()
|
||
if msg[0] == "log":
|
||
self._log(msg[1], msg[2] if len(msg) > 2 else None)
|
||
elif msg[0] == "refresh":
|
||
refresh_all()
|
||
except queue.Empty:
|
||
pass
|
||
d.after(120, poll)
|
||
d.after(120, poll)
|
||
|
||
worlds, backups = [], []
|
||
|
||
def sel_world():
|
||
sel = lb_w.curselection()
|
||
if not sel or sel[0] >= len(worlds):
|
||
return None
|
||
return worlds[sel[0]]
|
||
|
||
def sel_backup():
|
||
sel = lb_b.curselection()
|
||
if not sel or sel[0] >= len(backups):
|
||
return None
|
||
return backups[sel[0]]
|
||
|
||
def refresh_worlds():
|
||
lb_w.delete(0, "end")
|
||
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'])}, "
|
||
f"{stats_mod.fmt_time(w['mtime'])})")
|
||
if worlds:
|
||
lb_w.selection_set(0)
|
||
|
||
def refresh_backups():
|
||
lb_b.delete(0, "end")
|
||
backups[:] = []
|
||
w = sel_world()
|
||
if w:
|
||
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'])})")
|
||
if not w:
|
||
lb_b.insert("end", T("Выберите мир"))
|
||
|
||
def refresh_all():
|
||
refresh_worlds()
|
||
refresh_backups()
|
||
|
||
refresh_all()
|
||
lb_w.bind("<<ListboxSelect>>", lambda e: refresh_backups())
|
||
|
||
def make_backup():
|
||
w = sel_world()
|
||
if not w:
|
||
messagebox.showinfo("PixelLauncher", T("Выберите мир"),
|
||
parent=d)
|
||
return
|
||
|
||
def bg():
|
||
try:
|
||
cap = int(self.settings.get("world_backups", 5))
|
||
path = worlds_mod.backup_world(
|
||
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"))
|
||
except Exception as err: # noqa: BLE001
|
||
lq.put(("log", T("Ошибка: ") + str(err), "err"))
|
||
lq.put(("refresh",))
|
||
threading.Thread(target=bg, daemon=True).start()
|
||
|
||
def delete_backup():
|
||
b = sel_backup()
|
||
w = sel_world()
|
||
if not b or not w:
|
||
return
|
||
if not messagebox.askyesno(
|
||
"PixelLauncher",
|
||
T("Удалить копию {b}?").format(b=b["name"]),
|
||
parent=d):
|
||
return
|
||
if worlds_mod.remove_backup(self._play_dir(), w["name"],
|
||
b["name"]):
|
||
self._log(T("Копия удалена: ") + b["name"], "ok")
|
||
refresh_backups()
|
||
|
||
def open_folder():
|
||
w = sel_world()
|
||
if not w:
|
||
return
|
||
path = worlds_mod.backups_dir(self._play_dir(), w["name"])
|
||
os.makedirs(path, exist_ok=True)
|
||
self._open_explorer(path)
|
||
|
||
def delete_world():
|
||
w = sel_world()
|
||
if not w:
|
||
return
|
||
if not messagebox.askyesno(
|
||
"PixelLauncher",
|
||
T("Удалить мир {w}? Это нельзя отменить.").format(
|
||
w=w["name"]), parent=d):
|
||
return
|
||
if worlds_mod.remove_world(self._play_dir(), w["name"]):
|
||
self._log(T("Мир удалён: ") + w["name"], "ok")
|
||
refresh_all()
|
||
|
||
btns = tk.Frame(body, bg=BG)
|
||
btns.pack(fill="x", pady=(10, 0))
|
||
self._btn(btns, T("Закрыть"), d.destroy).pack(side="right")
|
||
self._btn(btns, T("Открыть папку копий"), open_folder).pack(
|
||
side="right", padx=(0, 6))
|
||
self._btn(btns, T("Удалить мир"), delete_world).pack(side="left")
|
||
self._btn(btns, T("Удалить копию"), delete_backup).pack(
|
||
side="left", padx=(6, 0))
|
||
self._btn(btns, T("Создать копию"), make_backup, primary=True).pack(
|
||
side="left", padx=(6, 0))
|
||
self._finish_dialog(d)
|
||
return d
|
||
|
||
def _rename_active_account(self, nick):
|
||
"""Переименовывает активный аккаунт в списке (ник в профиле = аккаунт)."""
|
||
accs = list(self.settings.get("accounts") or [])
|
||
old = self.settings.get("active_account")
|
||
if old in accs:
|
||
accs[accs.index(old)] = nick
|
||
elif nick not in accs:
|
||
accs.append(nick)
|
||
seen, out = set(), []
|
||
for a in accs:
|
||
if a not in seen:
|
||
seen.add(a)
|
||
out.append(a)
|
||
self.settings["accounts"] = out
|
||
self.settings["active_account"] = nick
|
||
|
||
def _open_versions(self):
|
||
d = self._dialog(T("Выбор версии"), width=470)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=14, pady=10)
|
||
|
||
var_q = tk.StringVar()
|
||
e = tk.Entry(body, textvariable=var_q, font=("Segoe UI", 9),
|
||
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
|
||
bd=1, highlightbackground=INPUT_BD, highlightthickness=1)
|
||
e.pack(fill="x", ipady=2)
|
||
e.focus_set()
|
||
|
||
lb = tk.Listbox(body, height=16, bg=INPUT, fg=TEXT, selectbackground=GREEN,
|
||
selectforeground="#0d1a0d", relief="solid", bd=1,
|
||
highlightthickness=0, font=("Consolas", 9),
|
||
exportselection=False)
|
||
lb.pack(fill="both", expand=True, pady=(8, 0))
|
||
tk.Label(body, text=T("★ — закрепить версию, двойной клик — выбрать"),
|
||
bg=BG, fg=FAINT, font=("Segoe UI", 8)).pack(anchor="w",
|
||
pady=(4, 0))
|
||
|
||
counts = {} # сколько раз запускалась версия — частые сверху
|
||
try:
|
||
for r in stats_mod.totals().get("per_version") or []:
|
||
counts[r["ver"]] = int(r.get("count") or 0)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
var_only_fav = tk.BooleanVar(value=False)
|
||
rows = []
|
||
|
||
def refresh(*_args):
|
||
# trace_add вызывает колбэк с 3 аргументами (имя, индекс, оп)
|
||
q = var_q.get().strip().lower()
|
||
favs = set(self.settings.get("fav_versions") or [])
|
||
items = []
|
||
for idx, v in enumerate(self.versions):
|
||
if q and q not in v.lower():
|
||
continue
|
||
base = v.split(" ")[0]
|
||
vtype, date = self.ver_meta.get(base, ("", ""))
|
||
mark = {"release": "", "snapshot": T("[снапшот]"),
|
||
"local": T("[локальная]")}.get(vtype, "")
|
||
star = "★ " if base in favs else " "
|
||
items.append([0 if base in favs else 1,
|
||
-counts.get(base, 0), idx,
|
||
v, f"{star}{v:<26} {date} {mark}"])
|
||
if var_only_fav.get():
|
||
items = [it for it in items if it[0] == 0]
|
||
items.sort(key=lambda it: (it[0], it[1], it[2]))
|
||
lb.delete(0, "end")
|
||
rows.clear()
|
||
for it in items:
|
||
lb.insert("end", it[4])
|
||
rows.append(it[3])
|
||
if rows:
|
||
lb.selection_set(0)
|
||
|
||
var_q.trace_add("write", refresh)
|
||
var_only_fav.trace_add("write", refresh)
|
||
refresh()
|
||
|
||
def selected_base():
|
||
sel = lb.curselection()
|
||
if not sel or sel[0] >= len(rows):
|
||
return None
|
||
return rows[sel[0]].split(" ")[0]
|
||
|
||
def toggle_fav():
|
||
base = selected_base()
|
||
if not base:
|
||
return
|
||
favs = list(self.settings.get("fav_versions") or [])
|
||
if base in favs:
|
||
favs.remove(base)
|
||
else:
|
||
favs.append(base)
|
||
self.settings["fav_versions"] = favs
|
||
config.save_settings(self.settings)
|
||
refresh()
|
||
|
||
def choose(_=None):
|
||
sel = lb.curselection()
|
||
if not sel or sel[0] >= len(rows):
|
||
return
|
||
self._pick_version(rows[sel[0]])
|
||
d.destroy()
|
||
|
||
lb.bind("<Double-Button-1>", choose)
|
||
btns = tk.Frame(body, bg=BG)
|
||
btns.pack(fill="x", pady=(10, 0))
|
||
self._btn(btns, T("Выбрать"), choose, primary=True).pack(side="right")
|
||
self._btn(btns, T("Обновить список"),
|
||
lambda: (self._reload_versions(), d.destroy())).pack(side="right",
|
||
padx=(0, 6))
|
||
self._btn(btns, T("★ Закрепить/убрать"), toggle_fav).pack(side="left")
|
||
tk.Checkbutton(btns, text=T("Только избранные"), variable=var_only_fav,
|
||
bg=BG, fg=DIM, selectcolor=INPUT, activebackground=BG,
|
||
activeforeground=TEXT, highlightthickness=0,
|
||
font=("Segoe UI", 9)).pack(side="left", padx=(8, 0))
|
||
d.bind("<Return>", lambda e: choose())
|
||
self._finish_dialog(d)
|
||
return d
|
||
|
||
def _build_quick_bar(self, parent):
|
||
"""Кликабельные чипы быстрых настроек на главном экране.
|
||
|
||
Дублируют подменю ⚙ → «Быстрые настройки»: язык, масштаб,
|
||
автозагрузка, трей и авто-бэкап меняются одним кликом.
|
||
При узком окне чипы переносятся на следующие строки, а не
|
||
обрезаются правым краем.
|
||
"""
|
||
def _avail():
|
||
"""Ширина под чипы с запасом по краям панели.
|
||
|
||
0 — ширина ещё неизвестна (окно не отрисовано): построим
|
||
чипы позже, когда придёт первый <Configure>.
|
||
"""
|
||
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
|
||
|
||
state = {"w": 0} # ширина, под которую чипы сейчас разложены
|
||
|
||
def build(_event=None):
|
||
"""Пересоздаёт чипы строками ровно по ширине панели."""
|
||
avail = _avail()
|
||
if avail <= 0:
|
||
state["w"] = 0 # ждём первого <Configure> с реальной шириной
|
||
return
|
||
state["w"] = avail
|
||
for old in list(parent.winfo_children()):
|
||
old.destroy() # прежние строки и чипы
|
||
chips = []
|
||
|
||
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("<Button-1>", lambda e: command())
|
||
lbl.bind("<Enter>", lambda e: lbl.configure(bg=HOVER, fg=TEXT))
|
||
lbl.bind("<Leave>", lambda e: lbl.configure(bg=bg, fg=fg))
|
||
return lbl
|
||
|
||
s = self.settings
|
||
on, off = T("вкл"), T("выкл")
|
||
cur_lang = s.get("lang", "ru")
|
||
|
||
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")))
|
||
|
||
self.quick_ver_chip = chip(self._ver_chip_text(),
|
||
self._open_versions)
|
||
|
||
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("<Configure>", on_resize)
|
||
build()
|
||
|
||
@staticmethod
|
||
def _fmt_ram(mb):
|
||
try:
|
||
gb = float(mb) / 1024
|
||
except (TypeError, ValueError):
|
||
gb = 2.0
|
||
return ("%.1f" % gb).rstrip("0").rstrip(".") + " " + T("ГБ")
|
||
|
||
def _ver_chip_text(self):
|
||
vid = self._selected_version_id()
|
||
if len(vid) > 14:
|
||
vid = vid[:13] + "…"
|
||
return T("Версия") + ": " + vid
|
||
|
||
def _cycle_ram(self):
|
||
steps = [None, 2048, 4096, 6144, 8192]
|
||
if self.settings.get("auto_ram"):
|
||
cur = None
|
||
else:
|
||
try:
|
||
cur = int(self.settings.get("ram", 2048))
|
||
except (TypeError, ValueError):
|
||
cur = 2048
|
||
try:
|
||
nxt = steps[(steps.index(cur) + 1) % len(steps)]
|
||
except ValueError:
|
||
nxt = steps[0]
|
||
if nxt is None:
|
||
self.settings["auto_ram"] = True
|
||
else:
|
||
self.settings["auto_ram"] = False
|
||
self.settings["ram"] = nxt
|
||
config.save_settings(self.settings)
|
||
self.after_idle(self._rebuild_ui)
|
||
|
||
def _cycle_scale(self):
|
||
"""Масштаб по кругу: 100 → 125 → 150 → 100 %."""
|
||
steps = (100, 125, 150)
|
||
cur = int(round(float(self.settings.get("ui_scale", 1.0)) * 100))
|
||
try:
|
||
nxt = steps[(steps.index(cur) + 1) % len(steps)]
|
||
except ValueError:
|
||
nxt = 100
|
||
self._quick_scale(nxt)
|
||
|
||
def _toggle_autostart(self):
|
||
on = not bool(self.settings.get("autostart"))
|
||
if not self._set_autostart(on):
|
||
return
|
||
self.settings["autostart"] = on
|
||
config.save_settings(self.settings)
|
||
self.after_idle(self._rebuild_ui)
|
||
|
||
def _toggle_tray(self):
|
||
self.settings["minimize_to_tray_on_launch"] = not bool(
|
||
self.settings.get("minimize_to_tray_on_launch"))
|
||
config.save_settings(self.settings)
|
||
self.after_idle(self._rebuild_ui)
|
||
|
||
def _toggle_backup(self):
|
||
self.settings["auto_backup"] = not bool(self.settings.get("auto_backup"))
|
||
config.save_settings(self.settings)
|
||
self.after_idle(self._rebuild_ui)
|
||
|
||
def _quick_settings(self, parent):
|
||
"""Подменю быстрых настроек в главном меню.
|
||
|
||
Дублирует чекбоксы и выборы диалога «Настройки» (тема, автозагрузка,
|
||
трей, авто-бэкап, масштаб, язык): переключение мгновенное и с
|
||
сохранением — не нужно открывать настройки ради одного пункта.
|
||
Пересборку окна откладываем через after_idle: меню к этому моменту
|
||
уже закрыто, а _rebuild_ui уничтожает все виджеты главного окна.
|
||
"""
|
||
def sub():
|
||
return tk.Menu(parent, tearoff=0, bg=PANEL, fg=TEXT,
|
||
activebackground=HOVER, activeforeground=TEXT,
|
||
relief="flat", borderwidth=1,
|
||
font=("Segoe UI", 9))
|
||
|
||
qs = sub()
|
||
qs.configure(selectcolor=INPUT)
|
||
|
||
var_light = tk.BooleanVar(value=self.settings.get("theme") == "light")
|
||
|
||
def t_light():
|
||
self.settings["theme"] = "light" if var_light.get() else "dark"
|
||
config.save_settings(self.settings)
|
||
set_theme(self.settings["theme"])
|
||
self.after_idle(self._rebuild_ui)
|
||
|
||
qs.add_checkbutton(label=T("Светлая тема"), variable=var_light,
|
||
command=t_light)
|
||
|
||
var_auto = tk.BooleanVar(value=bool(self.settings.get("autostart")))
|
||
|
||
def t_auto():
|
||
if not self._set_autostart(bool(var_auto.get())):
|
||
var_auto.set(not var_auto.get()) # сбой — вернуть флажок
|
||
return
|
||
self.settings["autostart"] = bool(var_auto.get())
|
||
config.save_settings(self.settings)
|
||
|
||
qs.add_checkbutton(label=T("С автозагрузкой Windows"),
|
||
variable=var_auto, command=t_auto)
|
||
|
||
var_tray = tk.BooleanVar(
|
||
value=bool(self.settings.get("minimize_to_tray_on_launch")))
|
||
|
||
def t_tray():
|
||
self.settings["minimize_to_tray_on_launch"] = bool(var_tray.get())
|
||
config.save_settings(self.settings)
|
||
self._log(T("Настройки сохранены"), "ok")
|
||
|
||
qs.add_checkbutton(label=T("Прятать в трей при запуске игры"),
|
||
variable=var_tray, command=t_tray)
|
||
|
||
var_bak = tk.BooleanVar(value=bool(self.settings.get("auto_backup")))
|
||
|
||
def t_bak():
|
||
self.settings["auto_backup"] = bool(var_bak.get())
|
||
config.save_settings(self.settings)
|
||
self._log(T("Настройки сохранены"), "ok")
|
||
|
||
qs.add_checkbutton(label=T("Авто-бэкап мира перед запуском"),
|
||
variable=var_bak, command=t_bak)
|
||
|
||
cur_scale = int(float(self.settings.get("ui_scale", 1.0)) * 100)
|
||
sc_menu = sub()
|
||
for pct in (100, 125, 150):
|
||
sc_menu.add_command(
|
||
label=("● " if pct == cur_scale else "") + f"{pct}%",
|
||
command=lambda p=pct: self._quick_scale(p))
|
||
qs.add_cascade(label=T("Масштаб интерфейса:"), menu=sc_menu)
|
||
|
||
cur_lang = self.settings.get("lang", "ru")
|
||
lang_menu = sub()
|
||
for code, name in (("ru", "Русский"), ("en", "English")):
|
||
lang_menu.add_command(
|
||
label=("● " if code == cur_lang else "") + name,
|
||
command=lambda c=code: self._quick_lang(c))
|
||
qs.add_cascade(label=T("Язык:"), menu=lang_menu)
|
||
return qs
|
||
|
||
def _quick_scale(self, pct):
|
||
"""Масштаб интерфейса из быстрого меню — сразу и с сохранением."""
|
||
self.settings["ui_scale"] = pct / 100
|
||
config.save_settings(self.settings)
|
||
self._apply_ui_scale()
|
||
self.after_idle(self._rebuild_ui)
|
||
|
||
def _quick_lang(self, code):
|
||
"""Язык интерфейса из быстрого меню — сразу и с сохранением."""
|
||
if self.settings.get("lang") == code:
|
||
return
|
||
self.settings["lang"] = code
|
||
config.save_settings(self.settings)
|
||
set_language(code)
|
||
self.after_idle(self._rebuild_ui)
|
||
|
||
def _open_options(self):
|
||
d = self._dialog(T("Настройки"), width=640)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=14, pady=10)
|
||
var_dir = tk.StringVar(value=self._game_dir())
|
||
self._field(body, T("Папка игры:"), var_dir, 34,
|
||
browse=lambda: self._pick_dir(var_dir))
|
||
self._hint(body, T("Здесь хранятся .minecraft, миры и файлы игры."))
|
||
|
||
row = tk.Frame(body, bg=BG)
|
||
row.pack(fill="x", pady=(10, 0))
|
||
self._btn(row, T("Открыть папку игры"),
|
||
lambda: self._open_explorer(self._play_dir())).pack(side="left")
|
||
self._btn(row, T("Открыть папку миров"),
|
||
lambda: self._open_explorer(
|
||
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._play_dir(), "screenshots"))).pack(
|
||
side="left", padx=(6, 0))
|
||
self._btn(row, T("Галерея"),
|
||
self._open_gallery).pack(side="left", padx=(6, 0))
|
||
|
||
# --- Язык
|
||
lang_row = tk.Frame(body, bg=BG)
|
||
lang_row.pack(fill="x", pady=(12, 0))
|
||
tk.Label(lang_row, text=T("Язык:"), bg=BG, fg=DIM, width=14,
|
||
anchor="w", font=("Segoe UI", 9)).pack(side="left")
|
||
var_lang = tk.StringVar(value=self.settings.get("lang", "ru"))
|
||
for code, name in (("ru", "Русский"), ("en", "English")):
|
||
tk.Radiobutton(lang_row, text=name, variable=var_lang, value=code,
|
||
bg=BG, fg=TEXT, selectcolor=INPUT, indicatoron=True,
|
||
activebackground=BG, activeforeground=TEXT,
|
||
highlightthickness=0,
|
||
font=("Segoe UI", 9)).pack(side="left", padx=(0, 10))
|
||
|
||
# --- Тема оформления
|
||
th_row = tk.Frame(body, bg=BG)
|
||
th_row.pack(fill="x", pady=(8, 0))
|
||
tk.Label(th_row, text=T("Тема:"), bg=BG, fg=DIM, width=14,
|
||
anchor="w", font=("Segoe UI", 9)).pack(side="left")
|
||
var_theme = tk.StringVar(value=self.settings.get("theme", "dark"))
|
||
for th_code, th_name in (("dark", T("Тёмная")),
|
||
("light", T("Светлая"))):
|
||
tk.Radiobutton(th_row, text=th_name, variable=var_theme,
|
||
value=th_code, bg=BG, fg=TEXT, selectcolor=INPUT,
|
||
indicatoron=True, activebackground=BG,
|
||
activeforeground=TEXT, highlightthickness=0,
|
||
font=("Segoe UI", 9)).pack(side="left", padx=(0, 10))
|
||
|
||
# --- Акцентный цвет
|
||
acc_row = tk.Frame(body, bg=BG)
|
||
acc_row.pack(fill="x", pady=(8, 0))
|
||
tk.Label(acc_row, text=T("Акцентный цвет:"), bg=BG, fg=DIM, width=14,
|
||
anchor="w", font=("Segoe UI", 9)).pack(side="left")
|
||
var_accent = tk.StringVar(value=self.settings.get("accent", GREEN))
|
||
for c in ("#6fae4a", "#3f9bd6", "#d6a53f", "#c95b5b", "#9b6fd6"):
|
||
sw = tk.Canvas(acc_row, width=18, height=18, bg=BG,
|
||
highlightthickness=1, highlightbackground=BAR_BD,
|
||
cursor="hand2")
|
||
sw.create_rectangle(1, 1, 17, 17, fill=c, outline="")
|
||
|
||
def pick(col=c, canvas=sw):
|
||
var_accent.set(col)
|
||
set_accent(col)
|
||
self.btn_play._draw()
|
||
self.progress._draw()
|
||
for w2 in canvas.master.winfo_children():
|
||
if isinstance(w2, tk.Canvas):
|
||
w2.configure(highlightbackground=BAR_BD)
|
||
canvas.configure(highlightbackground=c)
|
||
sw.bind("<Button-1>", lambda e, f=pick: f())
|
||
sw.pack(side="left", padx=(0, 6))
|
||
if c == var_accent.get():
|
||
sw.configure(highlightbackground=c)
|
||
|
||
# --- Автозагрузка и трей
|
||
run_row = tk.Frame(body, bg=BG)
|
||
run_row.pack(fill="x", pady=(10, 0))
|
||
tk.Label(run_row, text=T("Запуск:"), bg=BG, fg=DIM, width=14,
|
||
anchor="w", font=("Segoe UI", 9)).pack(side="left")
|
||
var_auto = tk.BooleanVar(value=bool(self.settings.get("autostart")))
|
||
tk.Checkbutton(run_row, text=T("С автозагрузкой Windows"),
|
||
variable=var_auto, bg=BG, fg=TEXT, selectcolor=INPUT,
|
||
activebackground=BG, activeforeground=TEXT,
|
||
highlightthickness=0,
|
||
font=("Segoe UI", 9)).pack(side="left")
|
||
var_tray_on = tk.BooleanVar(value=bool(
|
||
self.settings.get("minimize_to_tray_on_launch")))
|
||
tk.Checkbutton(run_row, text=T("Прятать в трей при запуске игры"),
|
||
variable=var_tray_on, bg=BG, fg=TEXT, selectcolor=INPUT,
|
||
activebackground=BG, activeforeground=TEXT,
|
||
highlightthickness=0,
|
||
font=("Segoe UI", 9)).pack(side="left", padx=(10, 0))
|
||
|
||
# --- Прокси (HTTP и SOCKS5 — своя реализация в http.py)
|
||
proxy = self.settings.get("proxy") or {}
|
||
tk.Label(body, text=T("Прокси:"), bg=BG, fg=DIM, anchor="w",
|
||
font=("Segoe UI", 9)).pack(anchor="w", pady=(12, 2))
|
||
px1 = tk.Frame(body, bg=BG)
|
||
px1.pack(fill="x")
|
||
var_px_on = tk.BooleanVar(value=bool(proxy.get("enabled")))
|
||
tk.Checkbutton(px1, text=T("Использовать прокси"),
|
||
variable=var_px_on, bg=BG, fg=TEXT, selectcolor=INPUT,
|
||
activebackground=BG, activeforeground=TEXT,
|
||
highlightthickness=0,
|
||
font=("Segoe UI", 9)).pack(side="left")
|
||
tk.Label(px1, text=T("Тип:"), bg=BG, fg=DIM,
|
||
font=("Segoe UI", 9)).pack(side="left", padx=(12, 3))
|
||
var_px_type = tk.StringVar(value=proxy.get("type", "http"))
|
||
ttk.Combobox(px1, textvariable=var_px_type, width=7, state="readonly",
|
||
values=("http", "socks5")).pack(side="left")
|
||
tk.Label(px1, text=T("Хост:"), bg=BG, fg=DIM,
|
||
font=("Segoe UI", 9)).pack(side="left", padx=(12, 3))
|
||
var_px_host = tk.StringVar(value=proxy.get("host", ""))
|
||
tk.Entry(px1, textvariable=var_px_host, width=16, font=("Segoe UI", 9),
|
||
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
|
||
bd=1, highlightthickness=0).pack(side="left")
|
||
tk.Label(px1, text=T("Порт:"), bg=BG, fg=DIM,
|
||
font=("Segoe UI", 9)).pack(side="left", padx=(8, 3))
|
||
var_px_port = tk.StringVar(value=str(proxy.get("port", 1080)))
|
||
tk.Entry(px1, textvariable=var_px_port, width=6, font=("Segoe UI", 9),
|
||
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
|
||
bd=1, highlightthickness=0).pack(side="left")
|
||
px2 = tk.Frame(body, bg=BG)
|
||
px2.pack(fill="x", pady=(3, 0))
|
||
tk.Label(px2, text=T("Логин:"), bg=BG, fg=DIM, width=14,
|
||
anchor="w", font=("Segoe UI", 9)).pack(side="left")
|
||
var_px_user = tk.StringVar(value=proxy.get("user", ""))
|
||
tk.Entry(px2, textvariable=var_px_user, width=14,
|
||
font=("Segoe UI", 9), bg=INPUT, fg=TEXT, insertbackground=TEXT,
|
||
relief="solid", bd=1, highlightthickness=0).pack(side="left",
|
||
padx=(0, 8))
|
||
tk.Label(px2, text=T("Пароль:"), bg=BG, fg=DIM,
|
||
font=("Segoe UI", 9)).pack(side="left")
|
||
var_px_pass = tk.StringVar(value=proxy.get("pass", ""))
|
||
tk.Entry(px2, textvariable=var_px_pass, width=14, show="•",
|
||
font=("Segoe UI", 9), bg=INPUT, fg=TEXT, insertbackground=TEXT,
|
||
relief="solid", bd=1, highlightthickness=0).pack(side="left",
|
||
padx=(3, 6))
|
||
self._btn(px2, T("Проверить прокси"),
|
||
lambda: self._test_proxy(d, var_px_on, var_px_type,
|
||
var_px_host, var_px_port,
|
||
var_px_user,
|
||
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))
|
||
tk.Label(sc_row, text=T("Масштаб интерфейса:"), bg=BG, fg=DIM,
|
||
width=14, anchor="w", font=("Segoe UI", 9)).pack(side="left")
|
||
var_scale = tk.StringVar(
|
||
value=f"{int(float(self.settings.get('ui_scale', 1.0)) * 100)}%")
|
||
ttk.Combobox(sc_row, textvariable=var_scale, width=7, state="readonly",
|
||
values=("100%", "125%", "150%")).pack(side="left")
|
||
tk.Label(sc_row, text=T("Применится сразу (шрифты)."), bg=BG, fg=FAINT,
|
||
font=("Segoe UI", 8)).pack(side="left", padx=(8, 0))
|
||
|
||
# --- Авто-бэкап
|
||
var_backup = tk.BooleanVar(value=bool(self.settings.get("auto_backup")))
|
||
tk.Checkbutton(body, text=T("Авто-бэкап мира перед запуском"),
|
||
variable=var_backup, bg=BG, fg=TEXT, selectcolor=INPUT,
|
||
activebackground=BG, activeforeground=TEXT,
|
||
highlightthickness=0, font=("Segoe UI", 9)).pack(
|
||
anchor="w", pady=(10, 0))
|
||
|
||
# --- Зеркала скачивания
|
||
tk.Label(body, text=T("Зеркала ресурсов (по одному в строке, пусто = "
|
||
"официальные):"), bg=BG, fg=DIM, anchor="w",
|
||
font=("Segoe UI", 9)).pack(anchor="w", pady=(12, 2))
|
||
txt_assets = tk.Text(body, height=2, font=("Consolas", 9), bg=INPUT,
|
||
fg=TEXT, insertbackground=TEXT, relief="solid", bd=1,
|
||
highlightthickness=0)
|
||
txt_assets.pack(fill="x")
|
||
txt_assets.insert("1.0", "\n".join(
|
||
self.settings.get("asset_mirrors") or []))
|
||
|
||
tk.Label(body, text=T("Зеркала библиотек (по одному в строке, пусто = "
|
||
"официальные):"), bg=BG, fg=DIM, anchor="w",
|
||
font=("Segoe UI", 9)).pack(anchor="w", pady=(8, 2))
|
||
txt_libs = tk.Text(body, height=2, font=("Consolas", 9), bg=INPUT,
|
||
fg=TEXT, insertbackground=TEXT, relief="solid", bd=1,
|
||
highlightthickness=0)
|
||
txt_libs.pack(fill="x")
|
||
txt_libs.insert("1.0", "\n".join(
|
||
self.settings.get("lib_mirrors") or []))
|
||
|
||
btns = tk.Frame(body, bg=BG)
|
||
btns.pack(fill="x", pady=(12, 0))
|
||
|
||
def lines(widget):
|
||
return [x.strip() for x in widget.get("1.0", "end").splitlines()
|
||
if x.strip()]
|
||
|
||
def save():
|
||
path = var_dir.get().strip()
|
||
if path:
|
||
self.settings["game_dir"] = path
|
||
os.makedirs(path, exist_ok=True)
|
||
stats_mod.set_path(os.path.join(path, ".launcher"))
|
||
self.settings["lang"] = var_lang.get()
|
||
self.settings["accent"] = var_accent.get()
|
||
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:
|
||
px_port = 1080
|
||
self.settings["proxy"] = {
|
||
"enabled": bool(var_px_on.get()),
|
||
"type": var_px_type.get() if var_px_type.get() in
|
||
("http", "socks5") else "http",
|
||
"host": var_px_host.get().strip(),
|
||
"port": px_port,
|
||
"user": var_px_user.get().strip(),
|
||
"pass": var_px_pass.get(),
|
||
}
|
||
try:
|
||
self.settings["ui_scale"] = (float(var_scale.get()
|
||
.rstrip("%")) / 100)
|
||
except ValueError:
|
||
self.settings["ui_scale"] = 1.0
|
||
self.settings["auto_backup"] = bool(var_backup.get())
|
||
new_auto = bool(var_auto.get())
|
||
if new_auto != bool(self.settings.get("autostart")):
|
||
self._set_autostart(new_auto)
|
||
self.settings["autostart"] = new_auto
|
||
self.settings["minimize_to_tray_on_launch"] = bool(
|
||
var_tray_on.get())
|
||
config.save_settings(self.settings)
|
||
set_language(self.settings["lang"])
|
||
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()
|
||
self._log(T("Настройки сохранены"), "ok")
|
||
|
||
self._btn(btns, T("Сохранить"), save, primary=True).pack(side="right")
|
||
self._btn(btns, T("Отмена"), d.destroy).pack(side="right", padx=(0, 6))
|
||
self._finish_dialog(d)
|
||
|
||
def _open_modrinth_packs(self):
|
||
"""Сборки Modrinth: поиск, выбор версии, установка."""
|
||
self._open_pack_browser("modrinth")
|
||
|
||
def _open_curseforge_packs(self):
|
||
"""Сборки CurseForge: поиск, выбор файла, установка."""
|
||
self._open_pack_browser("curseforge")
|
||
|
||
def _open_pack_browser(self, source):
|
||
"""Диалог сборок: Modrinth (source='modrinth') или CurseForge."""
|
||
is_mr = source == "modrinth"
|
||
d = self._dialog(T("Modrinth: сборки") if is_mr
|
||
else T("CurseForge: сборки"), width=700)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=12, pady=10)
|
||
|
||
top = tk.Frame(body, bg=BG)
|
||
top.pack(fill="x")
|
||
var_q = tk.StringVar()
|
||
ent = tk.Entry(top, textvariable=var_q, width=42, font=("Segoe UI", 9),
|
||
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
|
||
bd=1, highlightbackground=INPUT_BD, highlightthickness=1)
|
||
ent.pack(side="left", ipady=2)
|
||
ent.bind("<FocusIn>", lambda e: ent.configure(highlightbackground=GREEN))
|
||
ent.bind("<FocusOut>",
|
||
lambda e: ent.configure(highlightbackground=INPUT_BD))
|
||
tk.Label(top, text=T("Поиск сборок"), bg=BG, fg=DIM,
|
||
font=("Segoe UI", 9)).pack(side="left", padx=(8, 0))
|
||
|
||
status = tk.Label(body, text=T("Загрузка…"), bg=BG, fg=DIM, anchor="w",
|
||
font=("Segoe UI", 8))
|
||
status.pack(fill="x", pady=(8, 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("<Configure>",
|
||
lambda e: cv.configure(scrollregion=cv.bbox("all")))
|
||
cv.bind("<Configure>",
|
||
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("<Button-1>", lambda e, i=i: _select(i))
|
||
w.bind("<MouseWheel>", _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))
|
||
lbl_files.pack(fill="x", pady=(8, 0))
|
||
lb_f = tk.Listbox(body, height=6, bg=INPUT, fg=TEXT,
|
||
selectbackground=GREEN, selectforeground="#0d1a0d",
|
||
relief="solid", bd=1, highlightthickness=0,
|
||
font=("Segoe UI", 9), exportselection=False)
|
||
lb_f.pack(fill="both", expand=True, pady=(4, 0))
|
||
|
||
packs = []
|
||
files = []
|
||
timer = {"id": None}
|
||
|
||
def run(fn, ok, err):
|
||
"""Выполняет fn в фоне, результат отдаёт в GUI-поток."""
|
||
state = {"done": False, "data": None, "error": None}
|
||
|
||
def go():
|
||
try:
|
||
state["data"] = fn()
|
||
except Exception as e: # noqa: BLE001
|
||
state["error"] = e
|
||
state["done"] = True
|
||
|
||
threading.Thread(target=go, daemon=True).start()
|
||
|
||
def wait():
|
||
try:
|
||
if not d.winfo_exists():
|
||
return
|
||
except tk.TclError:
|
||
return
|
||
if not state["done"]:
|
||
d.after(120, wait)
|
||
return
|
||
if state["error"] is not None:
|
||
err(state["error"])
|
||
else:
|
||
ok(state["data"])
|
||
wait()
|
||
|
||
def fmt_size(n):
|
||
n = int(n or 0)
|
||
if n <= 0:
|
||
return ""
|
||
if n >= 1024 * 1024:
|
||
return "%.1f %s" % (n / (1024.0 * 1024.0), T("МБ"))
|
||
if n >= 1024:
|
||
return "%d %s" % (n // 1024, T("КБ"))
|
||
return "%d %s" % (n, T("Б"))
|
||
|
||
def search_packs():
|
||
q = var_q.get().strip()
|
||
status.configure(text=T("Загрузка…"), fg=DIM)
|
||
|
||
def ok(res):
|
||
nonlocal packs
|
||
out, total = res
|
||
packs = out
|
||
files.clear()
|
||
lb_f.delete(0, "end")
|
||
_fill_rows(out)
|
||
status.configure(text=T("Найдено: ") + str(total), fg=DIM)
|
||
|
||
def err(e):
|
||
status.configure(text=T("Ошибка: ") + str(e), fg=ERR)
|
||
|
||
if is_mr:
|
||
api = lambda: modrinth_mod.search_modpacks(q, limit=30)
|
||
else:
|
||
api = lambda: curseforge_mod.search_modpacks(q, page_size=30)
|
||
run(api, ok, err)
|
||
|
||
def schedule_search(*_a):
|
||
if timer["id"] is not None:
|
||
try:
|
||
d.after_cancel(timer["id"])
|
||
except tk.TclError:
|
||
pass
|
||
timer["id"] = d.after(400, search_packs)
|
||
|
||
def load_files(*_a):
|
||
sel = sel_idx["i"]
|
||
lb_f.delete(0, "end")
|
||
files.clear()
|
||
if sel is None or sel >= len(packs):
|
||
lbl_files.configure(text=T("Файлы сборки"), fg=DIM)
|
||
return
|
||
p = packs[sel]
|
||
status.configure(text=T("Загрузка…"), fg=DIM)
|
||
|
||
def ok(res):
|
||
files.clear()
|
||
files.extend(res)
|
||
lb_f.delete(0, "end")
|
||
for f in res:
|
||
bits = []
|
||
gv = f.get("game_versions") or []
|
||
ld = f.get("loaders") or []
|
||
if gv:
|
||
bits.append(", ".join(str(x) for x in gv[:3]))
|
||
if ld:
|
||
bits.append(", ".join(str(x) for x in ld[:2]))
|
||
if f.get("size"):
|
||
bits.append(fmt_size(f["size"]))
|
||
if f.get("date"):
|
||
bits.append(str(f["date"]))
|
||
name = f.get("name") or f.get("file_name") or "?"
|
||
tail = (" [" + " · ".join(bits) + "]") if bits else ""
|
||
lb_f.insert("end", "%s%s" % (name, tail))
|
||
lbl_files.configure(
|
||
text=T("Файлы сборки") + ": " + str(len(files)), fg=DIM)
|
||
status.configure(text=T("Найдено: ") + str(len(files)), fg=DIM)
|
||
|
||
def err(e):
|
||
status.configure(text=T("Ошибка: ") + str(e), fg=ERR)
|
||
|
||
if is_mr:
|
||
pid = p.get("slug") or p.get("id")
|
||
api = lambda: modrinth_mod.get_modpack_versions(pid)
|
||
else:
|
||
api = lambda: curseforge_mod.get_files(p.get("id"))
|
||
run(api, ok, err)
|
||
|
||
def install_sel():
|
||
sel = sel_idx["i"]
|
||
if sel is None or sel >= len(packs):
|
||
status.configure(text=T("Выберите сборку"), fg=ERR)
|
||
return
|
||
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 "?"
|
||
d.destroy()
|
||
if is_mr:
|
||
pid = p.get("slug") or p.get("id")
|
||
ver = (f or {}).get("id")
|
||
|
||
def worker(log, prog, cancel):
|
||
return packs_mod.install_pack_modrinth(
|
||
pid, game_dir=self._play_dir(), version=ver, log=log,
|
||
progress=prog, cancel=cancel)
|
||
else:
|
||
mid = p.get("id")
|
||
fid = (f or {}).get("id") or p.get("file_id")
|
||
|
||
def worker(log, prog, cancel):
|
||
return packs_mod.install_pack_curseforge(
|
||
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_f.bind("<Double-Button-1>", lambda e: install_sel())
|
||
|
||
btns = tk.Frame(body, bg=BG)
|
||
btns.pack(fill="x", pady=(10, 0))
|
||
self._btn(btns, T("Установить"), install_sel, primary=True).pack(
|
||
side="left")
|
||
self._btn(btns, T("Обновить список"), search_packs).pack(
|
||
side="left", padx=(6, 0))
|
||
self._btn(btns, T("Закрыть"), d.destroy).pack(side="right")
|
||
search_packs()
|
||
self._finish_dialog(d)
|
||
|
||
def _install_pack_async(self, worker, label):
|
||
"""Ставит сборку в фоне: прогресс и лог — в главном окне."""
|
||
if self.busy:
|
||
return
|
||
self.busy = True
|
||
self._cancel = threading.Event()
|
||
cancel = self._cancel
|
||
self.btn_play.set_state("disabled")
|
||
self.btn_cancel.configure(state="normal")
|
||
self.btn_cancel.pack()
|
||
self.progress.set(0)
|
||
|
||
def bg():
|
||
log = lambda m, t=None: self.ui_q.put(("log", m, t)) # noqa: E731
|
||
|
||
def prog(f):
|
||
self.ui_q.put(("progress", f * 100, 100, T("Сборка")))
|
||
try:
|
||
log(label)
|
||
vid, got = worker(log, prog, cancel)
|
||
if vid:
|
||
self.ui_q.put(("use_version", vid))
|
||
self.ui_q.put(("reload_versions",))
|
||
log(T("Модпак установлен") + (f" ({got})" if got else ""), "ok")
|
||
except http.Cancelled:
|
||
log(T("Загрузка отменена"), "warn")
|
||
except Exception as e: # noqa: BLE001
|
||
log(T("Ошибка: ") + str(e), "err")
|
||
finally:
|
||
self.ui_q.put(("done",))
|
||
threading.Thread(target=bg, daemon=True).start()
|
||
|
||
def _rebuild_ui(self):
|
||
"""Пересобирает главное окно (после смены языка/настроек)."""
|
||
for c in self.winfo_children():
|
||
if isinstance(c, tk.Toplevel):
|
||
continue
|
||
try:
|
||
c.destroy()
|
||
except tk.TclError:
|
||
pass
|
||
self._log_txt_widget = None
|
||
self.configure(bg=BG)
|
||
self._build()
|
||
self._load_settings_to_ui()
|
||
self._update_pill()
|
||
self._fill_news(self._news)
|
||
if self.busy:
|
||
self.btn_play.set_state("disabled")
|
||
self.btn_cancel.pack()
|
||
nick = self.var_nick.get().strip()
|
||
if re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick):
|
||
threading.Thread(target=self._load_head_bg, args=(nick,),
|
||
daemon=True).start()
|
||
|
||
def _open_log(self):
|
||
d = self._dialog(T("Журнал"), width=620)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=12, pady=10)
|
||
|
||
# --- источник: живой сеанс или архивный файл игры
|
||
logs_dir = os.path.join(self._game_dir(), ".launcher", "logs")
|
||
try:
|
||
files = sorted((f for f in os.listdir(logs_dir)
|
||
if f.startswith("game-") and f.endswith(".log")),
|
||
reverse=True)[:40]
|
||
except OSError:
|
||
files = []
|
||
top = tk.Frame(body, bg=BG)
|
||
top.pack(fill="x")
|
||
tk.Label(top, text=T("Архив логов:"), bg=BG, fg=DIM,
|
||
font=("Segoe UI", 9)).pack(side="left")
|
||
var_src = tk.StringVar(value=T("Сеанс (живой)"))
|
||
ttk.Combobox(top, textvariable=var_src, width=38, state="readonly",
|
||
values=[T("Сеанс (живой)")] + files).pack(side="left",
|
||
padx=(6, 0))
|
||
|
||
txt = tk.Text(body, bg=INPUT, fg=DIM, font=("Consolas", 9),
|
||
relief="solid", bd=1, highlightthickness=0, state="disabled",
|
||
wrap="word", width=70, height=15)
|
||
txt.pack(fill="both", expand=True, pady=(8, 0))
|
||
txt.tag_configure("ok", foreground=OK)
|
||
txt.tag_configure("err", foreground=ERR)
|
||
txt.tag_configure("warn", foreground=WARN)
|
||
txt.tag_configure("ts", foreground=FAINT)
|
||
if THEME == "light":
|
||
txt.tag_configure("game", foreground=GREEN_LO)
|
||
txt.tag_configure("hit", background=mix(GREEN, "#ffffff", 0.75),
|
||
foreground=TEXT)
|
||
else:
|
||
txt.tag_configure("game", foreground="#a8c48a")
|
||
txt.tag_configure("hit", background="#4a5a3a",
|
||
foreground="#ffffff")
|
||
txt.tag_lower("hit")
|
||
self._log_txt_widget = txt
|
||
self._log_view_mode = "session"
|
||
|
||
# --- фильтр «только ошибки» + поиск Ctrl+F
|
||
var_err = tk.BooleanVar(value=False)
|
||
err_keys = ("error", "exception", "fatal", "caused by",
|
||
"outofmemory", "crash", "ошибка", "фатальн")
|
||
|
||
def render_file():
|
||
if self._log_view_mode == "session":
|
||
return
|
||
lines_ = self._log_orig_lines
|
||
if var_err.get():
|
||
lines_ = [ln for ln in lines_
|
||
if any(k in ln.lower() for k in err_keys)]
|
||
txt.configure(state="normal")
|
||
txt.delete("1.0", "end")
|
||
for ln in lines_[:6000]:
|
||
txt.insert("end", ln + "\n", "game")
|
||
txt.configure(state="disabled")
|
||
|
||
def load_src(*_a):
|
||
src = var_src.get()
|
||
self._find_state.update(idx=-1, n=0)
|
||
txt.tag_remove("hit", "1.0", "end")
|
||
if src == T("Сеанс (живой)"):
|
||
self._log_view_mode = "session"
|
||
self._log_orig_lines = []
|
||
err_chk.configure(state="disabled")
|
||
txt.configure(state="normal")
|
||
txt.delete("1.0", "end")
|
||
for ts, msg, tag in self._log_lines:
|
||
txt.insert("end", ts + " ", "ts")
|
||
txt.insert("end", msg + "\n", tag or ())
|
||
txt.configure(state="disabled")
|
||
lbl_cnt.configure(text="")
|
||
else:
|
||
self._log_view_mode = src
|
||
err_chk.configure(state="normal")
|
||
try:
|
||
with open(os.path.join(logs_dir, src), encoding="utf-8",
|
||
errors="replace") as f:
|
||
self._log_orig_lines = f.read().splitlines()
|
||
except OSError as e:
|
||
self._log_orig_lines = ["<" + str(e) + ">"]
|
||
render_file()
|
||
lbl_cnt.configure(text=f"{len(self._log_orig_lines)} "
|
||
+ T("строка"))
|
||
var_src.trace_add("write", load_src)
|
||
|
||
find_row = tk.Frame(body, bg=BG)
|
||
find_row.pack(fill="x", pady=(8, 0))
|
||
tk.Label(find_row, text=T("Найти:"), bg=BG, fg=DIM,
|
||
font=("Segoe UI", 9)).pack(side="left")
|
||
var_find = tk.StringVar()
|
||
e_find = tk.Entry(find_row, textvariable=var_find, width=26,
|
||
font=("Segoe UI", 9), bg=INPUT, fg=TEXT,
|
||
insertbackground=TEXT, relief="solid", bd=1,
|
||
highlightthickness=0)
|
||
e_find.pack(side="left", padx=(4, 6))
|
||
lbl_cnt = tk.Label(find_row, text="", bg=BG, fg=DIM,
|
||
font=("Segoe UI", 8))
|
||
err_chk = tk.Checkbutton(find_row, text=T("только ошибки"),
|
||
variable=var_err, command=render_file,
|
||
bg=BG, fg=DIM, selectcolor=INPUT,
|
||
activebackground=BG, activeforeground=TEXT,
|
||
highlightthickness=0, state="disabled",
|
||
font=("Segoe UI", 8))
|
||
err_chk.pack(side="right")
|
||
|
||
def goto(delta):
|
||
q = var_find.get()
|
||
matches = []
|
||
if q:
|
||
pos = "1.0"
|
||
while True:
|
||
pos = txt.search(q, pos, stopindex="end", nocase=True)
|
||
if not pos:
|
||
break
|
||
end = "%s+%dc" % (pos, len(q))
|
||
matches.append((pos, end))
|
||
pos = end
|
||
txt.tag_remove("hit", "1.0", "end")
|
||
if not matches:
|
||
self._find_state.update(idx=-1, n=0)
|
||
lbl_cnt.configure(text=T("Нет совпадений"), fg=ERR)
|
||
return
|
||
idx = (self._find_state.get("idx", -1) + delta) % len(matches)
|
||
self._find_state.update(idx=idx, n=len(matches))
|
||
a, b = matches[idx]
|
||
txt.tag_add("hit", a, b)
|
||
txt.see(a)
|
||
lbl_cnt.configure(text=T("Найдено: ") + f"{idx + 1}/{len(matches)}",
|
||
fg=DIM)
|
||
|
||
e_find.bind("<Return>", lambda e: goto(1))
|
||
e_find.bind("<Shift-Return>", lambda e: goto(-1))
|
||
txt.bind("<Control-f>", lambda e: (e_find.focus_set(), "break")[1])
|
||
self._btn(find_row, "▶", lambda: goto(1)).pack(side="left", padx=2)
|
||
self._btn(find_row, "◀", lambda: goto(-1)).pack(side="left")
|
||
load_src() # показать текущий сеанс сразу при открытии
|
||
|
||
def on_close():
|
||
d.destroy()
|
||
try:
|
||
if self._log_txt_widget is txt:
|
||
self._log_txt_widget = None
|
||
self._log_view_mode = "session"
|
||
except tk.TclError:
|
||
self._log_txt_widget = None
|
||
self._log_view_mode = "session"
|
||
d.protocol("WM_DELETE_WINDOW", on_close)
|
||
|
||
def copy_all():
|
||
try:
|
||
self.clipboard_clear()
|
||
self.clipboard_append(txt.get("1.0", "end-1c"))
|
||
except tk.TclError:
|
||
pass
|
||
txt.bind("<Control-c>", lambda e: (copy_all(), "break")[1])
|
||
btns = tk.Frame(body, bg=BG)
|
||
btns.pack(fill="x", pady=(10, 0))
|
||
self._btn(btns, T("Копировать"), copy_all).pack(side="left")
|
||
self._btn(btns, T("Обновить"), load_src).pack(side="left", padx=(6, 0))
|
||
self._btn(btns, T("Закрыть"), on_close, primary=True).pack(side="right")
|
||
self._finish_dialog(d)
|
||
|
||
def _journal_open(self):
|
||
txt = self._log_txt_widget
|
||
return txt is not None and txt.winfo_exists()
|
||
|
||
# ------------------------------------------------------------ новшества
|
||
def _apply_ui_scale(self):
|
||
"""Масштаб шрифтов (tk scaling) из настроек — сразу, без перезапуска."""
|
||
try:
|
||
sc = float(self.settings.get("ui_scale", 1.0))
|
||
except (TypeError, ValueError):
|
||
sc = 1.0
|
||
self.tk.call("tk", "scaling", self._base_scaling * sc)
|
||
|
||
def _test_proxy(self, d, var_on, var_type, var_host, var_port,
|
||
var_user, var_pass):
|
||
"""Пробный запрос через прокси из формы (результат — в журнал)."""
|
||
try:
|
||
port = int(var_port.get().strip() or 1080)
|
||
except ValueError:
|
||
port = 1080
|
||
cfg = {"enabled": True,
|
||
"type": var_type.get() if var_type.get() in ("http", "socks5")
|
||
else "http",
|
||
"host": var_host.get().strip(), "port": port,
|
||
"user": var_user.get(),
|
||
"pass": var_pass.get()}
|
||
|
||
def bg():
|
||
http.configure(cfg)
|
||
try:
|
||
http.get_bytes("https://api.modrinth.com/v2/search?limit=1",
|
||
timeout=12)
|
||
ok, err = True, ""
|
||
except Exception as e: # noqa: BLE001
|
||
ok, err = False, str(e)
|
||
finally:
|
||
http.configure(self.settings.get("proxy"))
|
||
self.ui_q.put(("log", T("Прокси работает") if ok
|
||
else T("Прокси не работает: ") + err,
|
||
"ok" if ok else "err"))
|
||
threading.Thread(target=bg, daemon=True).start()
|
||
|
||
def _version_context(self):
|
||
"""(mc-версия, лоадер) выбранной версии: fabric-*/quilt-* — локальные."""
|
||
vid = self._selected_version_id()
|
||
m = re.match(r"(fabric|quilt)-loader-.+-(\d[\w.\-]*)$", vid)
|
||
if m:
|
||
return m.group(2), m.group(1)
|
||
return vid, ""
|
||
|
||
def _startup_mod_updates(self):
|
||
"""Тихая фоновая проверка обновлений модов при старте."""
|
||
def bg():
|
||
try:
|
||
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._play_dir()):
|
||
return
|
||
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}. "
|
||
"Откройте «Моды» для обновления.")
|
||
.format(n=len(ups)), "warn"))
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
threading.Thread(target=bg, daemon=True).start()
|
||
|
||
def _open_mods(self):
|
||
mc, loader = self._version_context()
|
||
d = self._dialog(T("Моды"), width=600)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=14, pady=10)
|
||
lq = queue.Queue()
|
||
state = {"hits": []}
|
||
mc_f = mc if re.match(r"\d", mc) else ""
|
||
q_loader = loader if loader in ("fabric", "quilt") else ""
|
||
|
||
def poll():
|
||
if not d.winfo_exists():
|
||
return
|
||
try:
|
||
while True:
|
||
msg = lq.get_nowait()
|
||
if msg[0] == "search":
|
||
state["hits"] = msg[1]
|
||
res_lb.delete(0, "end")
|
||
for h in state["hits"]:
|
||
res_lb.insert(
|
||
"end", f"{h['title']} · "
|
||
f"{h['downloads']:,}".replace(",", " ")
|
||
+ " " + T("загрузок"))
|
||
if state["hits"]:
|
||
res_lb.selection_set(0)
|
||
show_desc()
|
||
lbl_desc.configure(
|
||
text=T("Ничего не найдено")
|
||
if not state["hits"] else "")
|
||
elif msg[0] == "updates":
|
||
show_updates(msg[1])
|
||
elif msg[0] == "mods":
|
||
refresh_local()
|
||
elif msg[0] == "log":
|
||
self._log(msg[1], msg[2] if len(msg) > 2 else None)
|
||
except queue.Empty:
|
||
pass
|
||
d.after(80, poll)
|
||
d.after(80, poll)
|
||
|
||
# --- поиск
|
||
head = tk.Frame(body, bg=BG)
|
||
head.pack(fill="x")
|
||
tk.Label(head, text=T("Поиск модов на Modrinth:") + " "
|
||
+ T("Моды для {v}").format(v=mc)
|
||
+ (" · " + q_loader if q_loader else ""), bg=BG, fg=DIM,
|
||
font=("Segoe UI", 9)).pack(anchor="w", pady=(0, 4))
|
||
row = tk.Frame(body, bg=BG)
|
||
row.pack(fill="x")
|
||
var_q = tk.StringVar()
|
||
e = tk.Entry(row, textvariable=var_q, font=("Segoe UI", 9),
|
||
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
|
||
bd=1, highlightthickness=1, highlightbackground=INPUT_BD)
|
||
e.pack(side="left", fill="x", expand=True, ipady=2)
|
||
e.focus_set()
|
||
|
||
def do_search(*_a):
|
||
q = var_q.get().strip() # читаем в главном потоке (Tk не потокобезопасен)
|
||
|
||
def bg():
|
||
try:
|
||
hits, _total = modrinth_mod.search(q, mc_f, q_loader)
|
||
except Exception as e_: # noqa: BLE001
|
||
hits = []
|
||
lq.put(("log", T("Ошибка: ") + str(e_), "err"))
|
||
lq.put(("search", hits))
|
||
lq.put(("log", T("Идёт поиск…")))
|
||
threading.Thread(target=bg, daemon=True).start()
|
||
self._btn(row, T("Искать"), do_search).pack(side="left", padx=(6, 0))
|
||
e.bind("<Return>", lambda x: do_search())
|
||
|
||
res_lb = tk.Listbox(body, height=7, bg=INPUT, fg=TEXT,
|
||
selectbackground=GREEN, selectforeground="#0d1a0d",
|
||
relief="solid", bd=1, highlightthickness=0,
|
||
font=("Segoe UI", 9), exportselection=False)
|
||
res_lb.pack(fill="x", pady=(8, 0))
|
||
lbl_desc = tk.Label(body, text="", bg=BG, fg=DIM, anchor="w",
|
||
justify="left", wraplength=560, height=3,
|
||
font=("Segoe UI", 8))
|
||
|
||
def show_desc(*_a):
|
||
sel = res_lb.curselection()
|
||
if sel and sel[0] < len(state["hits"]):
|
||
lbl_desc.configure(text=state["hits"][sel[0]]["desc"][:280])
|
||
res_lb.bind("<<ListboxSelect>>", show_desc)
|
||
lbl_desc.pack(fill="x", pady=(2, 0))
|
||
|
||
def install_mod():
|
||
sel = res_lb.curselection()
|
||
if not sel or sel[0] >= len(state["hits"]):
|
||
return
|
||
h = state["hits"][sel[0]]
|
||
|
||
def bg():
|
||
try:
|
||
vs = modrinth_mod.versions(h["id"], mc_f, q_loader)
|
||
if not vs:
|
||
lq.put(("log", T("Ничего не найдено"), "warn"))
|
||
return
|
||
v = vs[0]
|
||
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,
|
||
size=v["size"])
|
||
lq.put(("log", (T("Мод установлен: ") + v["filename"])
|
||
if ok else T("Ошибка: ") + v["filename"],
|
||
"ok" if ok else "err"))
|
||
except Exception as e_: # noqa: BLE001
|
||
lq.put(("log", T("Ошибка: ") + str(e_), "err"))
|
||
finally:
|
||
lq.put(("mods",))
|
||
threading.Thread(target=bg, daemon=True).start()
|
||
self._btn(body, T("Установить"), install_mod).pack(anchor="w",
|
||
pady=(4, 0))
|
||
|
||
# --- установленные моды
|
||
tk.Label(body, text=T("Установленные моды"), bg=BG, fg=TEXT,
|
||
font=("Segoe UI", 9, "bold")).pack(anchor="w", pady=(10, 2))
|
||
loc_lb = tk.Listbox(body, height=5, bg=INPUT, fg=TEXT,
|
||
selectbackground=GREEN, selectforeground="#0d1a0d",
|
||
relief="solid", bd=1, highlightthickness=0,
|
||
font=("Consolas", 9), exportselection=False)
|
||
loc_lb.pack(fill="x")
|
||
|
||
def refresh_local():
|
||
loc_lb.delete(0, "end")
|
||
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(
|
||
m["size"]) + ")")
|
||
refresh_local()
|
||
|
||
def selected_mod():
|
||
sel = loc_lb.curselection()
|
||
if not sel or sel[0] >= len(state.get("local") or []):
|
||
return None
|
||
return state["local"][sel[0]]
|
||
|
||
def toggle_mod():
|
||
m = selected_mod()
|
||
if not m:
|
||
return
|
||
new = mods_mod.set_enabled(self._play_dir(), m["file"],
|
||
not m["enabled"])
|
||
if new:
|
||
self._log((T("Мод включён: ") if not m["enabled"]
|
||
else T("Мод отключён: ")) + m["name"], "ok")
|
||
refresh_local()
|
||
|
||
def delete_mod():
|
||
m = selected_mod()
|
||
if not m:
|
||
return
|
||
if not messagebox.askyesno(
|
||
"PixelLauncher",
|
||
T("Удалить") + " " + m["name"] + "?", parent=d):
|
||
return
|
||
if mods_mod.remove_mod(self._play_dir(), m["file"]):
|
||
self._log(T("Мод удалён: ") + m["name"], "ok")
|
||
refresh_local()
|
||
|
||
def api_mod():
|
||
if not q_loader:
|
||
self._log(T("Нет Fabric/Quilt-версии — API не ставится."),
|
||
"warn")
|
||
return
|
||
|
||
def bg():
|
||
n = modrinth_mod.ensure_loader_api(
|
||
self._game_dir(), mc_f, q_loader,
|
||
log=lambda m_: lq.put(("log", m_)))
|
||
if not n:
|
||
lq.put(("log", T("Уже установлено"), "ok"))
|
||
lq.put(("mods",))
|
||
threading.Thread(target=bg, daemon=True).start()
|
||
|
||
# --- проверка обновлений установленных модов
|
||
state["updates"] = []
|
||
|
||
def check_updates():
|
||
lq.put(("log", T("Проверка обновлений…")))
|
||
|
||
def bg():
|
||
try:
|
||
ups = mods_mod.check_updates(
|
||
self._game_dir(), mc_f, q_loader,
|
||
log=lambda m_: lq.put(("log", m_)))
|
||
except Exception as err: # noqa: BLE001
|
||
ups = []
|
||
lq.put(("log", T("Ошибка: ") + str(err), "err"))
|
||
lq.put(("updates", ups))
|
||
threading.Thread(target=bg, daemon=True).start()
|
||
|
||
def show_updates(ups):
|
||
state["updates"] = ups
|
||
if not ups:
|
||
self._log(T("Обновлений не найдено"), "ok")
|
||
return
|
||
self._log(T("Обновлений: ") + str(len(ups)), "warn")
|
||
for u in ups:
|
||
self._log("→ " + u["name"] + ": "
|
||
+ (u["cur"] or "?") + " → " + u["new_ver"], "warn")
|
||
ud = self._dialog(T("Обновления модов"), width=520)
|
||
ubody = tk.Frame(ud, bg=BG)
|
||
ubody.pack(fill="both", expand=True, padx=14, pady=10)
|
||
ulb = tk.Listbox(ubody, height=min(10, len(ups)), bg=INPUT, fg=TEXT,
|
||
selectbackground=GREEN, selectforeground="#0d1a0d",
|
||
relief="solid", bd=1, highlightthickness=0,
|
||
font=("Consolas", 9), exportselection=False)
|
||
ulb.pack(fill="both", expand=True)
|
||
for u in ups:
|
||
ulb.insert("end", f"{u['name']:<28} "
|
||
f"{u['cur'] or '?'} → {u['new_ver']}")
|
||
if ups:
|
||
ulb.selection_set(0)
|
||
|
||
def apply_updates(indices):
|
||
picked = [ups[i] for i in indices
|
||
if 0 <= i < len(ups)]
|
||
if not picked:
|
||
return
|
||
ud.destroy()
|
||
|
||
def bg():
|
||
ok_n = 0
|
||
for u in picked:
|
||
lq.put(("log", T("Обновление: ") + u["new_file"]))
|
||
if mods_mod.apply_update(self._play_dir(), u):
|
||
ok_n += 1
|
||
lq.put(("log", T("Мод обновлён: ") + u["name"],
|
||
"ok"))
|
||
else:
|
||
lq.put(("log", T("Не удалось обновить: ")
|
||
+ u["new_file"], "err"))
|
||
lq.put(("log", T("Обновлено: ") + str(ok_n) + "/"
|
||
+ str(len(picked)), "ok" if ok_n == len(picked)
|
||
else "warn"))
|
||
lq.put(("mods",))
|
||
threading.Thread(target=bg, daemon=True).start()
|
||
|
||
ubtns = tk.Frame(ubody, bg=BG)
|
||
ubtns.pack(fill="x", pady=(10, 0))
|
||
self._btn(ubtns, T("Закрыть"), ud.destroy).pack(side="right")
|
||
self._btn(ubtns, T("Обновить всё"),
|
||
lambda: apply_updates(range(len(ups)))).pack(
|
||
side="right", padx=(0, 6))
|
||
|
||
def update_sel():
|
||
sel = ulb.curselection()
|
||
if sel:
|
||
apply_updates(list(sel))
|
||
self._btn(ubtns, T("Обновить выбранное"), update_sel).pack(
|
||
side="left")
|
||
self._finish_dialog(ud)
|
||
|
||
brow = tk.Frame(body, bg=BG)
|
||
brow.pack(fill="x", pady=(6, 0))
|
||
self._btn(brow, T("Включить") + "/" + T("Выключить"),
|
||
toggle_mod).pack(side="left")
|
||
self._btn(brow, T("Удалить"), delete_mod).pack(side="left",
|
||
padx=(6, 0))
|
||
self._btn(brow, T("Установить Fabric API"),
|
||
api_mod).pack(side="left", padx=(6, 0))
|
||
self._btn(brow, T("Проверить обновления"),
|
||
check_updates).pack(side="left", padx=(6, 0))
|
||
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._play_dir()))).pack(side="right")
|
||
self._finish_dialog(d)
|
||
return d
|
||
|
||
def _import_pack(self, path=None):
|
||
"""Импорт модпака .mrpack: лоадер + файлы + overrides."""
|
||
if self.busy:
|
||
return
|
||
if path is None:
|
||
path = filedialog.askopenfilename(
|
||
parent=self, title=T("Модпаки"),
|
||
filetypes=[("mrpack", "*.mrpack"), (T("Все файлы"), "*.*")])
|
||
if not path:
|
||
return
|
||
self.busy = True
|
||
self._cancel = threading.Event()
|
||
self.btn_play.set_state("disabled")
|
||
self.btn_cancel.configure(state="normal")
|
||
self.btn_cancel.pack()
|
||
self.progress.set(0)
|
||
|
||
def bg():
|
||
log = lambda m, t=None: self.ui_q.put(("log", m, t)) # noqa: E731
|
||
try:
|
||
log(T("Установка модпака: ") + os.path.basename(path))
|
||
vid, got = mrpack_mod.install(
|
||
self._play_dir(), path, log=log,
|
||
progress=lambda f: self.ui_q.put(
|
||
("progress", f * 100, 100, T("Модпак"))),
|
||
cancel=self._cancel)
|
||
log(T("Модпак установлен: ") + os.path.basename(path)
|
||
+ f" ({got})", "ok")
|
||
if vid:
|
||
self.ui_q.put(("use_version", vid))
|
||
self.ui_q.put(("reload_versions",))
|
||
except http.Cancelled:
|
||
log(T("Загрузка отменена"), "warn")
|
||
except Exception as e: # noqa: BLE001
|
||
log(T("Ошибка: ") + str(e), "err")
|
||
finally:
|
||
self.ui_q.put(("done",))
|
||
threading.Thread(target=bg, daemon=True).start()
|
||
|
||
def _open_history(self):
|
||
d = self._dialog(T("История и статистика"), width=560)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=14, pady=10)
|
||
|
||
def fill():
|
||
t = stats_mod.totals()
|
||
lbl_top.configure(
|
||
text=T("Всего времени: ")
|
||
+ stats_mod.fmt_duration(t["total_seconds"])
|
||
+ " · " + T("Запусков: ") + str(t["total_launches"]))
|
||
lb1.delete(0, "end")
|
||
for r in t["per_version"]:
|
||
lb1.insert("end", f"{r['ver']:<26} "
|
||
+ str(r["count"]) + " " + T("шт.") + " · "
|
||
+ stats_mod.fmt_duration(r["seconds"]) + " · "
|
||
+ stats_mod.fmt_time(r["last"])
|
||
+ (f" · {r['crashes']} " + T("вылетов")
|
||
if r["crashes"] else ""))
|
||
lb2.delete(0, "end")
|
||
for r in t["recent"]:
|
||
ok_txt = T("Ок") if r.get("ok") else f"{T('вылет')} ({r.get('code')})"
|
||
lb2.insert("end", stats_mod.fmt_time(r.get("ts")) + " · "
|
||
+ str(r.get("ver")) + " · " + ok_txt + " · "
|
||
+ stats_mod.fmt_duration(r.get("seconds")))
|
||
|
||
lbl_top = tk.Label(body, text="", bg=BG, fg=TEXT, anchor="w",
|
||
font=("Segoe UI", 10, "bold"))
|
||
lbl_top.pack(fill="x")
|
||
lb1 = tk.Listbox(body, height=7, bg=INPUT, fg=TEXT, relief="solid",
|
||
bd=1, highlightthickness=0, font=("Consolas", 9),
|
||
exportselection=False)
|
||
lb1.pack(fill="x", pady=(8, 0))
|
||
tk.Label(body, text=T("Последние запуски"), bg=BG, fg=DIM,
|
||
font=("Segoe UI", 9)).pack(anchor="w", pady=(10, 2))
|
||
lb2 = tk.Listbox(body, height=6, bg=INPUT, fg=DIM, relief="solid",
|
||
bd=1, highlightthickness=0, font=("Consolas", 9),
|
||
exportselection=False)
|
||
lb2.pack(fill="x")
|
||
fill()
|
||
|
||
def clear_hist():
|
||
if not messagebox.askyesno("PixelLauncher",
|
||
T("Очистить историю") + "?",
|
||
parent=d):
|
||
return
|
||
stats_mod.clear()
|
||
fill()
|
||
self._log(T("История очищена"), "ok")
|
||
|
||
btns = tk.Frame(body, bg=BG)
|
||
btns.pack(fill="x", pady=(12, 0))
|
||
self._btn(btns, T("Очистить историю"), clear_hist).pack(side="left")
|
||
self._btn(btns, T("Закрыть"), d.destroy, primary=True).pack(
|
||
side="right")
|
||
self._finish_dialog(d)
|
||
|
||
def _open_skin(self):
|
||
d = self._dialog(T("Скин-превью"), width=300)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=14, pady=10)
|
||
var_nick = tk.StringVar(value=self.var_nick.get())
|
||
row = tk.Frame(body, bg=BG)
|
||
row.pack(fill="x")
|
||
tk.Label(row, text=T("Ник:"), bg=BG, fg=DIM,
|
||
font=("Segoe UI", 9)).pack(side="left")
|
||
e = tk.Entry(row, textvariable=var_nick, width=16,
|
||
font=("Segoe UI", 9), bg=INPUT, fg=TEXT,
|
||
insertbackground=TEXT, relief="solid", bd=1,
|
||
highlightthickness=0)
|
||
e.pack(side="left", padx=(4, 6), ipady=2)
|
||
e.focus_set()
|
||
Z = 8
|
||
cv = tk.Canvas(body, width=16 * Z + 8, height=32 * Z + 8, bg="#0c0c0c",
|
||
highlightthickness=1, highlightbackground=INPUT_BD)
|
||
cv.pack(pady=(10, 0))
|
||
refs = []
|
||
|
||
def render(path):
|
||
if not cv.winfo_exists():
|
||
return
|
||
try:
|
||
img = tk.PhotoImage(file=path)
|
||
except tk.TclError:
|
||
self._log(T("Скин не найден"), "err")
|
||
return
|
||
refs.clear()
|
||
refs.append(img) # PhotoImage живёт, пока ссылка в списке
|
||
cv.delete("all")
|
||
dec = _png_rgba(path) # альфа из PNG (tk photo get её не отдаёт)
|
||
cells = {} # клетка -> (r, g, b) с учётом наложений
|
||
for sx, sy, ex, ey, dx, dy in _skin_parts(img.height() >= 64):
|
||
for j in range(ey - sy):
|
||
for i in range(ex - sx):
|
||
if dec:
|
||
off = ((sy + j) * dec[0] + sx + i) * 4
|
||
rgba = dec[2][off:off + 4]
|
||
rgb = (rgba[0], rgba[1], rgba[2])
|
||
alpha = rgba[3]
|
||
else:
|
||
try:
|
||
px = img.get(sx + i, sy + j)
|
||
except tk.TclError:
|
||
return
|
||
rgb = tuple(px[:3])
|
||
# без альфы: чистый чёрный — вероятная прозрачность
|
||
alpha = 0 if rgb == (0, 0, 0) else 255
|
||
if not alpha:
|
||
continue
|
||
key = (dx + i, dy + j)
|
||
if alpha >= 255:
|
||
cells[key] = rgb
|
||
else:
|
||
base = cells.get(key)
|
||
if base is None:
|
||
cells[key] = rgb
|
||
else: # полупрозрачный оверлей поверх базы
|
||
cells[key] = tuple(
|
||
(rgb[k] * alpha + base[k] * (255 - alpha))
|
||
// 255 for k in range(3))
|
||
for (gx, gy), rgb in cells.items():
|
||
x0 = 4 + gx * Z
|
||
y0 = 4 + gy * Z
|
||
cv.create_rectangle(x0, y0, x0 + Z, y0 + Z,
|
||
fill="#%02x%02x%02x" % rgb, outline="")
|
||
self._log(T("Скин загружен"), "ok")
|
||
|
||
def load_nick():
|
||
nick = var_nick.get().strip()
|
||
if not re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick):
|
||
self._log(T("Ник: 3-16 символов, латиница, цифры и _"), "warn")
|
||
return
|
||
cache = os.path.join(self._game_dir(), ".launcher", "heads",
|
||
nick + ".skin.png")
|
||
try:
|
||
fresh = (os.path.isfile(cache) and time.time()
|
||
- os.path.getmtime(cache) < 86400)
|
||
if not fresh:
|
||
data = http.get_bytes(
|
||
f"https://mc-heads.net/skin/{nick}", timeout=10)
|
||
os.makedirs(os.path.dirname(cache), exist_ok=True)
|
||
with open(cache, "wb") as f:
|
||
f.write(data)
|
||
self.ui_q.put(("skin_path", cache))
|
||
except Exception as e: # noqa: BLE001
|
||
self.ui_q.put(("log", T("Скин не найден") + ": " + str(e),
|
||
"err"))
|
||
|
||
def load_file():
|
||
path = filedialog.askopenfilename(
|
||
parent=d, title=T("Своё фото…"),
|
||
filetypes=[("PNG", "*.png"), (T("Все файлы"), "*.*")])
|
||
if path:
|
||
render(path)
|
||
|
||
brow = tk.Frame(body, bg=BG)
|
||
brow.pack(fill="x", pady=(10, 0))
|
||
self._btn(brow, T("Обновить"),
|
||
lambda: threading.Thread(target=load_nick,
|
||
daemon=True).start()).pack(
|
||
side="left")
|
||
self._btn(brow, T("Своё фото…"), load_file).pack(side="left",
|
||
padx=(6, 0))
|
||
self._skin_render = render
|
||
|
||
def close():
|
||
if getattr(self, "_skin_render", None) is render:
|
||
self._skin_render = None
|
||
d.destroy()
|
||
d.protocol("WM_DELETE_WINDOW", close)
|
||
self._finish_dialog(d)
|
||
threading.Thread(target=load_nick, daemon=True).start()
|
||
|
||
def _make_shortcut(self, profile=None):
|
||
"""Ярлык на рабочем столе: pythonw main.py [--version|--profile].
|
||
|
||
profile — dict профиля: ярлык создаётся сразу на рабочем столе
|
||
без диалога и открывает лаунчер с применением этого профиля.
|
||
"""
|
||
name = ""
|
||
if profile is not None:
|
||
name = str(profile.get("name") or "").strip()
|
||
safe = re.sub(r'[<>:"/\\|?*]', "", name)[:60].strip() or "profile"
|
||
desk = os.path.join(os.path.expanduser("~"), "Desktop")
|
||
if not os.path.isdir(desk):
|
||
desk = os.path.join(os.path.expanduser("~"), "OneDrive",
|
||
"Desktop")
|
||
path = os.path.join(desk, f"PixelLauncher {safe}.lnk")
|
||
else:
|
||
vid = self.var_ver.get().strip() or self._selected_version_id()
|
||
safe = re.sub(r"[^\w.\-]+", "_", vid)[:50] or "version"
|
||
path = filedialog.asksaveasfilename(
|
||
parent=self, title=T("Создать ярлык версии…"),
|
||
defaultextension=".lnk",
|
||
initialfile=f"PixelLauncher {safe}.lnk",
|
||
filetypes=[("Ярлык", "*.lnk")])
|
||
if not path:
|
||
return
|
||
exe = sys.executable
|
||
if exe.lower().endswith("python.exe"):
|
||
cand = exe[:-len("python.exe")] + "pythonw.exe"
|
||
if os.path.isfile(cand):
|
||
exe = cand
|
||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
main_py = os.path.join(root, "main.py")
|
||
|
||
def ps(s): # строка для PowerShell (одинарные кавычки)
|
||
return "'" + str(s).replace("'", "''") + "'"
|
||
if profile is not None:
|
||
qname = name.replace('"', "'")
|
||
args = f'"{main_py}" --profile "{qname}"'
|
||
else:
|
||
args = f'"{main_py}" --version {vid}'
|
||
ico = next((c for c in (os.path.join(root, "launcher.ico"),
|
||
os.path.join(root, "build", "launcher.ico"))
|
||
if os.path.isfile(c)), "")
|
||
script = ("$sh = New-Object -ComObject WScript.Shell; "
|
||
f"$l = $sh.CreateShortcut({ps(path)}); "
|
||
f"$l.TargetPath = {ps(exe)}; "
|
||
f"$l.Arguments = {ps(args)}; "
|
||
f"$l.WorkingDirectory = {ps(root)}; "
|
||
+ (f"$l.IconLocation = {ps(ico)}; " if ico else "")
|
||
+ "$l.Save()")
|
||
try:
|
||
r = subprocess.run(
|
||
["powershell", "-NoProfile", "-STA", "-Command", script],
|
||
capture_output=True, timeout=25,
|
||
creationflags=CREATE_NO_WINDOW)
|
||
if r.returncode != 0:
|
||
raise RuntimeError(
|
||
r.stderr.decode(errors="replace")[:200] or "rc!=0")
|
||
if profile is not None and os.path.isfile(path):
|
||
# выделяем ярлык в проводнике — сразу видно результат
|
||
subprocess.run(["explorer", "/select,", path],
|
||
capture_output=True, timeout=10,
|
||
creationflags=CREATE_NO_WINDOW)
|
||
self._log(T("Ярлык создан: ") + path, "ok")
|
||
except Exception as e: # noqa: BLE001
|
||
self._log(T("Не удалось создать ярлык") + ": " + str(e), "err")
|
||
|
||
def _set_autostart(self, on):
|
||
"""Ярлык в папке автозагрузки Windows (или его удаление)."""
|
||
base = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||
path = os.path.join(base, "Microsoft", "Windows", "Start Menu",
|
||
"Programs", "Startup", "PixelLauncher.lnk")
|
||
try:
|
||
if not on:
|
||
if os.path.isfile(path):
|
||
os.remove(path)
|
||
self._log(T("Автозагрузка отключена"), "ok")
|
||
return True
|
||
exe = sys.executable
|
||
if exe.lower().endswith("python.exe"):
|
||
cand = exe[:-len("python.exe")] + "pythonw.exe"
|
||
if os.path.isfile(cand):
|
||
exe = cand
|
||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
main_py = os.path.join(root, "main.py")
|
||
ico = next((c for c in (os.path.join(root, "launcher.ico"),
|
||
os.path.join(root, "build", "launcher.ico"))
|
||
if os.path.isfile(c)), "")
|
||
|
||
def ps(s):
|
||
return "'" + str(s).replace("'", "''") + "'"
|
||
script = ("$sh = New-Object -ComObject WScript.Shell; "
|
||
f"$l = $sh.CreateShortcut({ps(path)}); "
|
||
f"$l.TargetPath = {ps(exe)}; "
|
||
f"$l.Arguments = {ps('\"' + main_py + '\"')}; "
|
||
f"$l.WorkingDirectory = {ps(root)}; "
|
||
+ (f"$l.IconLocation = {ps(ico)}; " if ico else "")
|
||
+ "$l.Save()")
|
||
r = subprocess.run(
|
||
["powershell", "-NoProfile", "-STA", "-Command", script],
|
||
capture_output=True, timeout=25, creationflags=CREATE_NO_WINDOW)
|
||
if r.returncode != 0:
|
||
raise RuntimeError(
|
||
r.stderr.decode(errors="replace")[:200] or "rc!=0")
|
||
self._log(T("Автозагрузка включена: ") + path, "ok")
|
||
return True
|
||
except Exception as e: # noqa: BLE001
|
||
self._log(T("Не удалось изменить автозагрузку") + ": " + str(e),
|
||
"err")
|
||
return False
|
||
|
||
def _export_settings(self):
|
||
path = filedialog.asksaveasfilename(
|
||
parent=self, title=T("Экспорт настроек…"),
|
||
defaultextension=".json",
|
||
initialfile="pixellauncher-settings.json",
|
||
filetypes=[("JSON", "*.json")])
|
||
if not path:
|
||
return
|
||
try:
|
||
self._save_settings()
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(self.settings, f, ensure_ascii=False, indent=2)
|
||
self._log(T("Настройки экспортированы: ") + path, "ok")
|
||
except OSError as e: # noqa: BLE001
|
||
self._log(T("Ошибка: ") + str(e), "err")
|
||
|
||
def _import_settings(self):
|
||
path = filedialog.askopenfilename(
|
||
parent=self, title=T("Импорт настроек…"),
|
||
filetypes=[("JSON", "*.json"), (T("Все файлы"), "*.*")])
|
||
if not path:
|
||
return
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
if not isinstance(data, dict):
|
||
raise ValueError("не JSON-объект")
|
||
except (OSError, ValueError) as e: # noqa: BLE001
|
||
self._log(T("Не удалось прочитать файл настроек") + ": "
|
||
+ str(e), "err")
|
||
return
|
||
merged = dict(config.DEFAULTS)
|
||
merged.update(data)
|
||
config._migrate(merged)
|
||
self.settings.clear()
|
||
self.settings.update(merged)
|
||
config.save_settings(self.settings)
|
||
set_language(self.settings.get("lang", "ru"))
|
||
set_theme(self.settings.get("theme", "dark"))
|
||
set_accent(self.settings.get("accent", GREEN))
|
||
http.configure(self.settings.get("proxy"))
|
||
stats_mod.set_path(os.path.join(self._game_dir(), ".launcher"))
|
||
self._scene = self._pick_scene()
|
||
self._night = self._scene == "night"
|
||
self._apply_ui_scale()
|
||
self._rebuild_ui()
|
||
self._reload_versions()
|
||
nick = self.settings.get("username", "Player")
|
||
threading.Thread(target=self._load_head_bg, args=(nick,),
|
||
daemon=True).start()
|
||
self._log(T("Настройки импортированы"), "ok")
|
||
|
||
# ------------------------------------------------------------ трей
|
||
def _start_tray(self):
|
||
if os.name != "nt" or self._tray is not None:
|
||
return
|
||
try:
|
||
hicon = tray_mod.window_icon(self.winfo_id())
|
||
self._tray = tray_mod.Tray(
|
||
on_activate=lambda: self.ui_q.put(("tray", "show")),
|
||
on_command=lambda name: self.ui_q.put(("tray", name)),
|
||
commands=[(T("Играть"), "play"), (T("Журнал"), "journal")])
|
||
self._tray.start(tooltip="PixelLauncher", hicon=hicon)
|
||
except Exception: # noqa: BLE001 — трей не критичен
|
||
self._tray = None
|
||
|
||
def _tray_event(self, name):
|
||
if name == "exit":
|
||
self._on_close()
|
||
return
|
||
try:
|
||
self.deiconify()
|
||
self.lift()
|
||
self.focus_force()
|
||
except tk.TclError:
|
||
pass
|
||
if name == "play":
|
||
self._on_play()
|
||
elif name == "journal" and not self._journal_open():
|
||
self._open_log()
|
||
|
||
def _tray_notify(self, title, text, warn=False):
|
||
if self._tray is not None:
|
||
try:
|
||
self._tray.notify(str(title), str(text), warn=bool(warn))
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
# ------------------------------------------------------------ обновление
|
||
def _check_update(self, quiet=False):
|
||
"""Проверяет /update.json; quiet — показывать окна только если есть
|
||
новая версия."""
|
||
if self._update_active:
|
||
return
|
||
self._update_active = True
|
||
self._update_quiet = bool(quiet)
|
||
if not quiet:
|
||
self.lbl_status.configure(text=T("Проверка обновления…"), fg=DIM)
|
||
threading.Thread(target=self._update_check_bg, args=(quiet,),
|
||
daemon=True).start()
|
||
|
||
def _update_check_bg(self, quiet):
|
||
try:
|
||
manifest = selfupdate.check()
|
||
error = None
|
||
except Exception as e: # noqa: BLE001
|
||
manifest, error = None, str(e)
|
||
self.ui_q.put(("update_found", manifest, error, quiet))
|
||
|
||
def _on_update_found(self, manifest, error, quiet):
|
||
"""Главный поток: диалог о новой версии, запуск скачивания."""
|
||
if error:
|
||
self._update_active = False
|
||
self._update_quiet = False
|
||
if not quiet:
|
||
self.lbl_status.configure(text=T("Готово к запуску"), fg=DIM)
|
||
messagebox.showerror(
|
||
T("Обновление"),
|
||
T("Не удалось проверить обновления: ") + error,
|
||
parent=self)
|
||
return
|
||
if not manifest:
|
||
self._update_active = False
|
||
self._update_quiet = False
|
||
if not quiet:
|
||
self.lbl_status.configure(text=T("Готово к запуску"), fg=DIM)
|
||
messagebox.showinfo(T("Обновление"),
|
||
T("У вас последняя версия"),
|
||
parent=self)
|
||
return
|
||
notes = str(manifest.get("notes") or "").strip()
|
||
text = T("Новая версия {v} (у вас {c}).\n\n{notes}\n\n"
|
||
"Скачать и установить?").format(
|
||
v=str(manifest.get("version")), c=LAUNCHER_VERSION,
|
||
notes=notes or "—")
|
||
if not messagebox.askyesno(T("Обновление"), text, parent=self):
|
||
self._update_active = False
|
||
self._update_quiet = False
|
||
if not quiet:
|
||
self.lbl_status.configure(text=T("Готово к запуску"), fg=DIM)
|
||
return
|
||
self._begin_update(manifest)
|
||
|
||
def _begin_update(self, manifest):
|
||
if self.busy:
|
||
self._update_active = False
|
||
self._update_quiet = False
|
||
messagebox.showinfo(T("Обновление"),
|
||
T("Дождитесь окончания текущей операции"),
|
||
parent=self)
|
||
return
|
||
self.busy = True
|
||
self._cancel = threading.Event()
|
||
self.btn_play.set_state("disabled")
|
||
self.btn_cancel.configure(state="normal")
|
||
self.btn_cancel.pack()
|
||
self.progress.set(0)
|
||
self.lbl_status.configure(text=T("Скачивание обновления"), fg=DIM)
|
||
threading.Thread(target=self._update_download_bg, args=(manifest,),
|
||
daemon=True).start()
|
||
|
||
def _update_download_bg(self, manifest):
|
||
try:
|
||
path = selfupdate.download(
|
||
manifest,
|
||
progress=lambda d, t: self.ui_q.put(("update_progress",
|
||
d, t)),
|
||
cancel=self._cancel)
|
||
self.ui_q.put(("update_ready", path))
|
||
except http.Cancelled:
|
||
self.ui_q.put(("update_fail", T("Отменено")))
|
||
except Exception as e: # noqa: BLE001
|
||
self.ui_q.put(("update_fail", str(e)))
|
||
|
||
def _update_reset(self):
|
||
"""Снимает состояние занятости обновления."""
|
||
self._update_active = False
|
||
self._update_quiet = False
|
||
self.busy = False
|
||
self._cancel = None
|
||
self.btn_play.set_state("normal")
|
||
try:
|
||
self.btn_cancel.configure(state="normal")
|
||
self.btn_cancel.pack_forget()
|
||
except tk.TclError:
|
||
pass
|
||
|
||
def _on_update_ready(self, path):
|
||
self._update_reset()
|
||
try:
|
||
selfupdate.run_installer(path)
|
||
except Exception as e: # noqa: BLE001
|
||
messagebox.showerror(
|
||
T("Обновление"),
|
||
T("Не удалось запустить установщик: ") + str(e),
|
||
parent=self)
|
||
return
|
||
self._log(T("Запускается установщик обновления…"))
|
||
self.lbl_status.configure(
|
||
text=T("Запускается установщик обновления…"), fg=DIM)
|
||
# установщик заменит файлы лаунчера — выходим, пока файлы не заняты
|
||
self.after(800, self._on_close)
|
||
|
||
def _on_update_fail(self, text):
|
||
self._update_reset()
|
||
self.progress.set(0)
|
||
self.lbl_status.configure(text=T("Готово к запуску"), fg=DIM)
|
||
self._log(T("Обновление не установлено: ") + text, "warn")
|
||
if text != T("Отменено"):
|
||
messagebox.showerror(T("Обновление"), text, parent=self)
|
||
|
||
def _open_about(self):
|
||
d = self._dialog(T("О программе"), width=360)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=16, pady=12)
|
||
tk.Label(body, text=f"PixelLauncher {LAUNCHER_VERSION}", bg=BG,
|
||
fg=TEXT, font=("Segoe UI", 12, "bold")).pack(anchor="w")
|
||
txt = T("Компактный оффлайн-лаунчер Minecraft\nв стиле LiteLauncher.\n\n"
|
||
"• Все версии из манифеста Mojang\n"
|
||
"• Автодокачка файлов с проверкой SHA-1\n"
|
||
"• Автоподбор Java, копии миров\n"
|
||
"• Без сторонних библиотек")
|
||
tk.Label(body, text=txt, bg=BG, fg=DIM, font=("Segoe UI", 9),
|
||
justify="left").pack(anchor="w", pady=(6, 0))
|
||
links = tk.Frame(body, bg=BG)
|
||
links.pack(anchor="w", pady=(10, 0))
|
||
for name, url in (("minecraft.net", "https://www.minecraft.net"),
|
||
(T("Манифест Mojang"),
|
||
"https://launchermeta.mojang.com/mc/game/"
|
||
"version_manifest_v2.json")):
|
||
l = tk.Label(links, text=name, bg=BG, fg=OK, cursor="hand2",
|
||
font=("Segoe UI", 9, "underline"))
|
||
l.pack(side="left", padx=(0, 12))
|
||
l.bind("<Button-1>", lambda e, u=url: webbrowser.open(u))
|
||
btns = tk.Frame(body, bg=BG)
|
||
btns.pack(fill="x", pady=(14, 0))
|
||
self._btn(btns, T("Проверить обновление…"),
|
||
lambda: (d.destroy(), self._check_update())
|
||
).pack(side="left")
|
||
self._btn(btns, T("Закрыть"), d.destroy, primary=True).pack(side="right")
|
||
self._finish_dialog(d)
|
||
|
||
def _pick_java_file(self, var):
|
||
path = filedialog.askopenfilename(
|
||
parent=self, title=T("Выберите java.exe"),
|
||
filetypes=[(T("Java"), "java.exe java"), (T("Все файлы"), "*.*")])
|
||
if path:
|
||
var.set(path)
|
||
|
||
def _pick_dir(self, var):
|
||
path = filedialog.askdirectory(parent=self, title=T("Папка игры"))
|
||
if path:
|
||
var.set(path)
|
||
|
||
# ------------------------------------------------------------ аккаунты
|
||
def _ask_text(self, title, label, initial=""):
|
||
"""Модальный ввод строки. Возвращает текст или None (отмена)."""
|
||
d = self._dialog(title, width=340)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=14, pady=10)
|
||
tk.Label(body, text=label, bg=BG, fg=DIM, anchor="w",
|
||
font=("Segoe UI", 9)).pack(anchor="w")
|
||
var = tk.StringVar(value=initial)
|
||
e = tk.Entry(body, textvariable=var, font=("Segoe UI", 10),
|
||
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
|
||
bd=1, highlightbackground=INPUT_BD, highlightthickness=1)
|
||
e.pack(fill="x", ipady=2, pady=(6, 0))
|
||
e.focus_set()
|
||
result = [None]
|
||
|
||
def ok():
|
||
v = var.get().strip()
|
||
if not re.fullmatch(r"[A-Za-z0-9_]{3,16}", v):
|
||
messagebox.showwarning(
|
||
"PixelLauncher",
|
||
T("Ник: 3-16 символов, латиница, цифры и _"), parent=d)
|
||
return
|
||
result[0] = v
|
||
d.destroy()
|
||
|
||
btns = tk.Frame(body, bg=BG)
|
||
btns.pack(fill="x", pady=(12, 0))
|
||
self._btn(btns, T("Сохранить"), ok, primary=True).pack(side="right")
|
||
self._btn(btns, T("Отмена"), d.destroy).pack(side="right", padx=(0, 6))
|
||
d.bind("<Return>", lambda e: ok())
|
||
self._finish_dialog(d)
|
||
self.wait_window(d)
|
||
return result[0]
|
||
|
||
def _switch_account(self, nick):
|
||
"""Делает ник активным аккаунтом и сохраняет настройки."""
|
||
self.settings["active_account"] = nick
|
||
self.settings["username"] = nick
|
||
config.save_settings(self.settings)
|
||
self._emit("account_switched", username=str(nick))
|
||
self.var_nick.set(nick)
|
||
self.lbl_nick.configure(text=nick)
|
||
self._log(T("Аккаунт переключён: ") + nick, "ok")
|
||
threading.Thread(target=self._load_head_bg, args=(nick,),
|
||
daemon=True).start()
|
||
|
||
def _open_accounts(self):
|
||
d = self._dialog(T("Аккаунты"), width=380)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=14, pady=10)
|
||
|
||
lb = tk.Listbox(body, height=8, bg=INPUT, fg=TEXT, selectbackground=GREEN,
|
||
selectforeground="#0d1a0d", relief="solid", bd=1,
|
||
highlightthickness=0, font=("Consolas", 10),
|
||
exportselection=False)
|
||
lb.pack(fill="both", expand=True)
|
||
|
||
def refresh():
|
||
lb.delete(0, "end")
|
||
active = self.settings.get("active_account")
|
||
for a in self.settings.get("accounts") or []:
|
||
lb.insert("end", ("● " if a == active else " ") + a)
|
||
if lb.size():
|
||
lb.selection_set(0)
|
||
|
||
refresh()
|
||
|
||
def selected():
|
||
sel = lb.curselection()
|
||
accs = self.settings.get("accounts") or []
|
||
if not sel or sel[0] >= len(accs):
|
||
return None
|
||
return accs[sel[0]]
|
||
|
||
def switch(_=None):
|
||
nick = selected()
|
||
if nick:
|
||
self._switch_account(nick)
|
||
d.destroy()
|
||
|
||
def add():
|
||
nick = self._ask_text(T("Добавить аккаунт…"), T("Ник:"))
|
||
if not nick:
|
||
return
|
||
accs = self.settings.get("accounts") or []
|
||
if nick not in accs:
|
||
accs.append(nick)
|
||
self.settings["accounts"] = accs
|
||
config.save_settings(self.settings)
|
||
refresh()
|
||
|
||
def remove():
|
||
nick = selected()
|
||
if not nick:
|
||
return
|
||
accs = self.settings.get("accounts") or []
|
||
if len(accs) <= 1:
|
||
messagebox.showwarning(
|
||
"PixelLauncher",
|
||
T("Нельзя удалить последний аккаунт"), parent=d)
|
||
return
|
||
accs.remove(nick)
|
||
self.settings["accounts"] = accs
|
||
if self.settings.get("active_account") == nick:
|
||
self._switch_account(accs[0])
|
||
config.save_settings(self.settings)
|
||
refresh()
|
||
|
||
btns = tk.Frame(body, bg=BG)
|
||
btns.pack(fill="x", pady=(10, 0))
|
||
self._btn(btns, T("Выбрать"), switch, primary=True).pack(side="right")
|
||
self._btn(btns, T("Удалить"), remove).pack(side="right", padx=(0, 6))
|
||
self._btn(btns, T("Добавить аккаунт…"), add).pack(side="left")
|
||
lb.bind("<Double-Button-1>", switch)
|
||
self._finish_dialog(d)
|
||
|
||
# ------------------------------------------------------------ Fabric/Quilt
|
||
def _open_fabric(self):
|
||
if self.busy:
|
||
return
|
||
d = self._dialog(T("Установка лоадера"), width=500)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=14, pady=10)
|
||
|
||
var_loader = tk.StringVar(value="fabric")
|
||
row0 = tk.Frame(body, bg=BG)
|
||
row0.pack(fill="x", pady=(0, 6))
|
||
tk.Label(row0, text=T("Лоадер:"), bg=BG, fg=DIM, width=14, anchor="w",
|
||
font=("Segoe UI", 9)).pack(side="left")
|
||
for code, name in (("fabric", T("Fabric")), ("quilt", T("Quilt")),
|
||
("forge", T("Forge")), ("neoforge", T("NeoForge"))):
|
||
tk.Radiobutton(row0, text=name, variable=var_loader, value=code,
|
||
command=lambda: load_list(), bg=BG, fg=TEXT,
|
||
selectcolor=INPUT, activebackground=BG,
|
||
activeforeground=TEXT, highlightthickness=0,
|
||
font=("Segoe UI", 9)).pack(side="left", padx=(0, 6))
|
||
|
||
tk.Label(body, text=T("Игровая версия:"), bg=BG, fg=DIM, anchor="w",
|
||
font=("Segoe UI", 9)).pack(anchor="w", pady=(4, 2))
|
||
lb = tk.Listbox(body, height=9, bg=INPUT, fg=TEXT, selectbackground=GREEN,
|
||
selectforeground="#0d1a0d", relief="solid", bd=1,
|
||
highlightthickness=0, font=("Consolas", 9),
|
||
exportselection=False)
|
||
lb.pack(fill="both", expand=True)
|
||
|
||
row1 = tk.Frame(body, bg=BG)
|
||
row1.pack(fill="x", pady=(8, 0))
|
||
tk.Label(row1, text=T("Версия лоадера:"), bg=BG, fg=DIM, width=14,
|
||
anchor="w", font=("Segoe UI", 9)).pack(side="left")
|
||
combo = ttk.Combobox(row1, state="readonly", width=24,
|
||
font=("Segoe UI", 9))
|
||
combo.pack(side="left")
|
||
|
||
status = tk.Label(body, text="", bg=BG, fg=FAINT, anchor="w",
|
||
font=("Segoe UI", 8))
|
||
status.pack(fill="x", pady=(6, 0))
|
||
note = tk.Label(body, text="", bg=BG, fg=FAINT,
|
||
anchor="w", font=("Segoe UI", 8))
|
||
note.pack(anchor="w", pady=(2, 0))
|
||
|
||
q = queue.Queue()
|
||
rows = []
|
||
gen = [0] # номер запроса версий лоадера (защита от гонки)
|
||
FML = ("forge", "neoforge")
|
||
|
||
def set_note():
|
||
if var_loader.get() in FML:
|
||
note.configure(text=T("Инсталлятор сам скачает библиотеки и "
|
||
"профиль — может занять несколько "
|
||
"минут."))
|
||
else:
|
||
note.configure(text=T("Ставится только JSON профиля — файлы "
|
||
"докачаются при запуске."))
|
||
|
||
def ask_loads(gv):
|
||
"""Версии Forge/NeoForge для выбранной игры."""
|
||
gen[0] += 1
|
||
my, loader = gen[0], var_loader.get()
|
||
|
||
def bg():
|
||
try:
|
||
q.put(("loads", loader, gv, my,
|
||
forge_mod.loader_versions(loader, gv)))
|
||
except Exception as e: # noqa: BLE001
|
||
q.put(("loads", loader, gv, my, []))
|
||
q.put(("list_err", loader, str(e)))
|
||
threading.Thread(target=bg, daemon=True).start()
|
||
|
||
def on_mc(_e=None):
|
||
if var_loader.get() not in FML:
|
||
return
|
||
sel = lb.curselection()
|
||
if not sel or sel[0] >= len(rows):
|
||
return
|
||
gv = rows[sel[0]]
|
||
combo["values"] = []
|
||
combo.set("")
|
||
status.configure(text=T("Загрузка списка версий лоадера..."),
|
||
fg=FAINT)
|
||
ask_loads(gv)
|
||
|
||
lb.bind("<<ListboxSelect>>", on_mc)
|
||
|
||
def load_list():
|
||
loader = var_loader.get()
|
||
rows.clear()
|
||
lb.delete(0, "end")
|
||
combo["values"] = []
|
||
combo.set("")
|
||
set_note()
|
||
status.configure(text=T("Загрузка списка версий лоадера..."), fg=FAINT)
|
||
|
||
def bg():
|
||
try:
|
||
if loader in FML:
|
||
games = forge_mod.game_versions(loader)
|
||
q.put(("list", loader, games, [], True))
|
||
else:
|
||
games = mods_mod.game_versions(loader)
|
||
loads = mods_mod.loader_versions(loader)
|
||
q.put(("list", loader, games, loads, False))
|
||
except Exception as e: # noqa: BLE001
|
||
q.put(("list_err", loader, str(e)))
|
||
threading.Thread(target=bg, daemon=True).start()
|
||
|
||
def poll():
|
||
try:
|
||
while True:
|
||
msg = q.get_nowait()
|
||
cur = var_loader.get()
|
||
if msg[0] == "list":
|
||
_, loader, games, loads, wait_mc = msg
|
||
if loader != cur:
|
||
continue
|
||
lb.delete(0, "end")
|
||
rows.clear()
|
||
for gid, stable in games:
|
||
rows.append(gid)
|
||
lb.insert("end", gid + ("" if stable else
|
||
" " + T("[снапшот]")))
|
||
if rows:
|
||
lb.selection_set(0)
|
||
if wait_mc:
|
||
combo["values"] = []
|
||
status.configure(text="", fg=FAINT)
|
||
on_mc()
|
||
else:
|
||
combo["values"] = loads
|
||
if loads:
|
||
combo.current(0)
|
||
status.configure(text="", fg=FAINT)
|
||
elif msg[0] == "list_err":
|
||
if msg[1] != cur:
|
||
continue
|
||
status.configure(
|
||
text=T("Не удалось получить список версий "
|
||
"лоадера: ") + msg[2], fg=ERR)
|
||
elif msg[0] == "loads":
|
||
_, loader, gv, my, loads = msg
|
||
if loader != cur or my != gen[0]:
|
||
continue
|
||
sel = lb.curselection()
|
||
if not sel or sel[0] >= len(rows) or rows[sel[0]] != gv:
|
||
continue
|
||
combo["values"] = loads
|
||
if loads:
|
||
combo.current(0)
|
||
status.configure(text="", fg=FAINT)
|
||
else:
|
||
status.configure(
|
||
text=T("Нет версий лоадера для этой игры"),
|
||
fg=ERR)
|
||
elif msg[0] == "log":
|
||
status.configure(text=msg[1], fg=FAINT)
|
||
elif msg[0] == "installed":
|
||
vid = msg[1]
|
||
if vid not in self.versions:
|
||
pos = len([x for x in self.versions
|
||
if x.startswith("latest-")])
|
||
self.versions.insert(pos, vid)
|
||
self.ver_meta[vid] = ("local", "")
|
||
self._pick_version(vid)
|
||
self._log(T("Установлено: ") + vid, "ok")
|
||
d.destroy()
|
||
return
|
||
elif msg[0] == "install_err":
|
||
status.configure(text=T("Ошибка: ") + msg[1], fg=ERR)
|
||
btn_install.configure(state="normal")
|
||
except queue.Empty:
|
||
pass
|
||
if d.winfo_exists():
|
||
d.after(120, poll)
|
||
|
||
def install():
|
||
sel = lb.curselection()
|
||
if not sel or sel[0] >= len(rows):
|
||
return
|
||
gv = rows[sel[0]]
|
||
lv = combo.get().strip()
|
||
loader = var_loader.get()
|
||
gd = self._game_dir()
|
||
if loader in FML and not lv:
|
||
status.configure(text=T("Выберите версию лоадера"), fg=ERR)
|
||
return
|
||
btn_install.configure(state="disabled")
|
||
status.configure(text=T("Установка..."), fg=FAINT)
|
||
|
||
def bg():
|
||
try:
|
||
if loader in FML:
|
||
vid = forge_mod.install(
|
||
gd, loader, gv, lv or None,
|
||
log=lambda m: q.put(("log", m)))
|
||
else:
|
||
vid = mods_mod.install(gd, loader, gv, lv or None)
|
||
q.put(("installed", vid))
|
||
except Exception as e: # noqa: BLE001
|
||
q.put(("install_err", str(e)))
|
||
threading.Thread(target=bg, daemon=True).start()
|
||
|
||
btns = tk.Frame(body, bg=BG)
|
||
btns.pack(fill="x", pady=(10, 0))
|
||
btn_install = self._btn(btns, T("Установить"), install, primary=True)
|
||
btn_install.pack(side="right")
|
||
self._btn(btns, T("Закрыть"), d.destroy).pack(side="right", padx=(0, 6))
|
||
d.after(100, poll)
|
||
load_list()
|
||
self._finish_dialog(d)
|
||
return d
|
||
|
||
# ------------------------------------------------------------ очистка
|
||
def _open_cleanup(self):
|
||
if self.busy:
|
||
return
|
||
d = self._dialog(T("Очистка версий"), width=540)
|
||
body = tk.Frame(d, bg=BG)
|
||
body.pack(fill="both", expand=True, padx=14, pady=10)
|
||
|
||
lb = tk.Listbox(body, height=13, bg=INPUT, fg=TEXT, selectbackground=GREEN,
|
||
selectforeground="#0d1a0d", relief="solid", bd=1,
|
||
highlightthickness=0, font=("Consolas", 9),
|
||
exportselection=False)
|
||
lb.pack(fill="both", expand=True)
|
||
info = tk.Label(body, text=T("Считаю размеры..."), bg=BG, fg=FAINT,
|
||
anchor="w", font=("Segoe UI", 8))
|
||
info.pack(fill="x", pady=(6, 0))
|
||
|
||
q = queue.Queue()
|
||
rows = []
|
||
|
||
def scan():
|
||
def bg():
|
||
found = []
|
||
vroot = os.path.join(self._game_dir(), "versions")
|
||
try:
|
||
names = sorted(os.listdir(vroot))
|
||
except OSError:
|
||
names = []
|
||
for name in names:
|
||
path = os.path.join(vroot, name)
|
||
if not os.path.isdir(path):
|
||
continue
|
||
size = 0
|
||
for root, _, files in os.walk(path):
|
||
for f in files:
|
||
try:
|
||
size += os.path.getsize(os.path.join(root, f))
|
||
except OSError:
|
||
pass
|
||
found.append((name, size))
|
||
found.sort(key=lambda r: -r[1])
|
||
q.put(("scan", found))
|
||
threading.Thread(target=bg, daemon=True).start()
|
||
|
||
def poll():
|
||
try:
|
||
while True:
|
||
msg = q.get_nowait()
|
||
if msg[0] == "scan":
|
||
rows[:] = msg[1]
|
||
lb.delete(0, "end")
|
||
for vid, size in rows:
|
||
lb.insert("end", "%-32s %8.1f %s" %
|
||
(vid, size / 1048576, T("МБ")))
|
||
total = sum(s for _, s in rows)
|
||
info.configure(text="%d · %.0f %s" %
|
||
(len(rows), total / 1048576, T("МБ")))
|
||
elif msg[0] == "assets_done":
|
||
count, freed = msg[1], msg[2]
|
||
info.configure(
|
||
text=T("Удалено неиспользуемых ассетов: ") +
|
||
"%d · %.1f %s" % (count, freed / 1048576, T("МБ")),
|
||
fg=OK)
|
||
self._log(
|
||
T("Удалено неиспользуемых ассетов: ") +
|
||
"%.1f %s" % (freed / 1048576, T("МБ")), "ok")
|
||
btn_assets.configure(state="normal")
|
||
except queue.Empty:
|
||
pass
|
||
if d.winfo_exists():
|
||
d.after(150, poll)
|
||
|
||
def remove():
|
||
sel = lb.curselection()
|
||
if not sel or sel[0] >= len(rows):
|
||
return
|
||
vid = rows[sel[0]][0]
|
||
if vid == self._selected_version_id():
|
||
messagebox.showwarning(
|
||
"PixelLauncher",
|
||
T("Версия используется как выбранная — смените её "
|
||
"в списке."), parent=d)
|
||
return
|
||
if not messagebox.askyesno(
|
||
"PixelLauncher",
|
||
T("Удалить версию {v}?").format(v=vid), parent=d):
|
||
return
|
||
shutil.rmtree(os.path.join(self._game_dir(), "versions", vid),
|
||
ignore_errors=True)
|
||
self.versions = [v for v in self.versions if v != vid]
|
||
del rows[sel[0]]
|
||
lb.delete(sel[0])
|
||
self._log(T("Версия удалена: ") + vid, "ok")
|
||
|
||
def clean_assets():
|
||
btn_assets.configure(state="disabled")
|
||
info.configure(text=T("Поиск неиспользуемых ассетов..."))
|
||
|
||
def bg():
|
||
assets = os.path.join(self._game_dir(), "assets")
|
||
used = set()
|
||
idx_dir = os.path.join(assets, "indexes")
|
||
try:
|
||
names = os.listdir(idx_dir)
|
||
except OSError:
|
||
names = []
|
||
for name in names:
|
||
if not name.endswith(".json"):
|
||
continue
|
||
try:
|
||
with open(os.path.join(idx_dir, name),
|
||
encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
for obj in (data.get("objects") or {}).values():
|
||
h = obj.get("hash")
|
||
if h:
|
||
used.add(h)
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
freed = count = 0
|
||
obj_dir = os.path.join(assets, "objects")
|
||
try:
|
||
subs = os.listdir(obj_dir)
|
||
except OSError:
|
||
subs = []
|
||
for sub in subs:
|
||
sp = os.path.join(obj_dir, sub)
|
||
if not os.path.isdir(sp):
|
||
continue
|
||
for f in os.listdir(sp):
|
||
if f in used:
|
||
continue
|
||
fp = os.path.join(sp, f)
|
||
try:
|
||
freed += os.path.getsize(fp)
|
||
os.remove(fp)
|
||
count += 1
|
||
except OSError:
|
||
pass
|
||
q.put(("assets_done", count, freed))
|
||
threading.Thread(target=bg, daemon=True).start()
|
||
|
||
btns = tk.Frame(body, bg=BG)
|
||
btns.pack(fill="x", pady=(10, 0))
|
||
self._btn(btns, T("Закрыть"), d.destroy, primary=True).pack(side="right")
|
||
btn_assets = self._btn(btns, T("Удалить неиспользуемые ассеты"),
|
||
clean_assets)
|
||
btn_assets.pack(side="right", padx=(0, 6))
|
||
self._btn(btns, T("Удалить выбранную версию"), remove).pack(side="left")
|
||
d.after(100, poll)
|
||
scan()
|
||
self._finish_dialog(d)
|
||
|
||
# ------------------------------------------------------------ лог/статус
|
||
def _append_log(self, msg, tag=None):
|
||
"""Добавляет строку в журнал (и в открытое окно журнала)."""
|
||
self._log_lines.append((time.strftime("%H:%M:%S"), msg, tag))
|
||
if len(self._log_lines) > 800:
|
||
self._log_lines = self._log_lines[-800:]
|
||
txt = self._log_txt_widget
|
||
if (txt is not None and txt.winfo_exists()
|
||
and self._log_view_mode == "session"):
|
||
txt.configure(state="normal")
|
||
txt.insert("end", time.strftime("%H:%M:%S") + " ", "ts")
|
||
txt.insert("end", msg + "\n", tag or ())
|
||
txt.see("end")
|
||
txt.configure(state="disabled")
|
||
|
||
def _log(self, msg, tag=None):
|
||
color = {"ok": OK, "err": ERR, "warn": WARN}.get(tag, DIM)
|
||
self.lbl_status.configure(text=msg, fg=color)
|
||
self._append_log(msg, tag)
|
||
|
||
def _on_game_exit(self, code, path, vid=None, secs=0):
|
||
"""Реакция на завершение игры: код != 0 — показываем хвост лога."""
|
||
self._game_proc = None
|
||
self._emit("game_exit",
|
||
profile_name=str(self.settings.get("active_profile")
|
||
or ""),
|
||
crashed=bool(code), exit_code=code)
|
||
ok = not code
|
||
if vid:
|
||
stats_mod.record_finish(vid, secs, ok, code)
|
||
self.ui_q.put(("tray_notify",
|
||
T("Игра завершилась") if ok
|
||
else T("Игра аварийно завершилась"),
|
||
vid or "", not ok))
|
||
if ok:
|
||
self._log(T("Игра завершилась"), "warn")
|
||
return
|
||
self._log(T("Игра аварийно завершилась (код {c}). Хвост лога:").format(
|
||
c=code), "err")
|
||
tail = []
|
||
try:
|
||
with open(path, encoding="utf-8", errors="replace") as f:
|
||
tail = [ln.rstrip("\n") for ln in f.readlines()[-30:]]
|
||
except OSError:
|
||
pass
|
||
for ln in tail:
|
||
if ln.strip():
|
||
self._append_log(ln, "err")
|
||
if path:
|
||
self._append_log(T("Полный лог: ") + path, None)
|
||
# --- разбор причины вылета
|
||
res = crash_mod.analyze(path=path)
|
||
if res["kind"] == "oom":
|
||
self._append_log(T("Причина: ") + res["exc"], "err")
|
||
self._append_log(T("Похоже, не хватило ОЗУ. Увеличьте её в "
|
||
"профиле или выберите пресет."), "warn")
|
||
elif res["kind"] == "java":
|
||
self._append_log(T("Проблема с Java: ") + res["exc"], "err")
|
||
elif res["kind"] == "crash" and res["exc"]:
|
||
self._append_log(T("Причина: ") + res["exc"]
|
||
+ ((": " + res["msg"]) if res["msg"] else ""),
|
||
"err")
|
||
culprit = res.get("culprit")
|
||
gd = self._game_dir()
|
||
if culprit and culprit.lower().endswith(".jar") and os.path.isfile(
|
||
os.path.join(mods_mod.mods_dir(gd), culprit)):
|
||
if messagebox.askyesno(
|
||
T("Игра аварийно завершилась"),
|
||
T("Вероятная причина — мод {m}. Отключить его?").format(
|
||
m=culprit)):
|
||
if mods_mod.set_enabled(gd, culprit, False):
|
||
self._log(T("Мод отключён: ") + culprit, "ok")
|
||
if not self._journal_open():
|
||
self._open_log()
|
||
|
||
def _append_game_line(self, line):
|
||
"""Строка живого вывода игры — в открытое окно журнала."""
|
||
txt = self._log_txt_widget
|
||
if (txt is None or not txt.winfo_exists()
|
||
or self._log_view_mode != "session"):
|
||
return
|
||
txt.configure(state="normal")
|
||
txt.insert("end", line + "\n", "game")
|
||
txt.see("end")
|
||
try:
|
||
n = int(float(txt.index("end-1c").split(".")[0]))
|
||
if n > 3000:
|
||
txt.delete("1.0", "800.0")
|
||
except (ValueError, tk.TclError):
|
||
pass
|
||
txt.configure(state="disabled")
|
||
|
||
# ------------------------------------------------------------ данные
|
||
def _load_settings_to_ui(self):
|
||
self.var_nick = tk.StringVar(value=self.settings.get("username", "Player"))
|
||
self.var_ver = tk.StringVar(value=self.settings.get("version", "latest-release"))
|
||
self.lbl_nick.configure(text=self.var_nick.get())
|
||
|
||
def _save_settings(self):
|
||
self.settings.update({
|
||
"username": self.var_nick.get().strip(),
|
||
"version": self.var_ver.get().strip(),
|
||
})
|
||
config.save_settings(self.settings)
|
||
|
||
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":
|
||
import ctypes
|
||
|
||
class MEMORYSTATUSEX(ctypes.Structure):
|
||
_fields_ = [
|
||
("dwLength", ctypes.c_ulong), ("dwMemoryLoad", ctypes.c_ulong),
|
||
("ullTotalPhys", ctypes.c_ulonglong),
|
||
("ullAvailPhys", ctypes.c_ulonglong),
|
||
("ullTotalPageFile", ctypes.c_ulonglong),
|
||
("ullAvailPageFile", ctypes.c_ulonglong),
|
||
("ullTotalVirtual", ctypes.c_ulonglong),
|
||
("ullAvailVirtual", ctypes.c_ulonglong),
|
||
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
|
||
]
|
||
m = MEMORYSTATUSEX()
|
||
m.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
|
||
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m))
|
||
return max(4096, min(32768, int(m.ullTotalPhys // (1024 * 1024))))
|
||
except Exception:
|
||
pass
|
||
return 8192
|
||
|
||
def _open_explorer(self, d):
|
||
os.makedirs(d, exist_ok=True)
|
||
if os.name == "nt":
|
||
os.startfile(d) # noqa: S606
|
||
else:
|
||
subprocess.Popen(["xdg-open", d], creationflags=CREATE_NO_WINDOW)
|
||
|
||
# ------------------------------------------------------------ галерея
|
||
def _open_gallery(self):
|
||
"""Галерея скриншотов: сетка превью, просмотр, удаление."""
|
||
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._play_dir(), "screenshots")
|
||
cols, tw, th = 4, 136, 84
|
||
|
||
cv = tk.Canvas(body, bg=BG, highlightthickness=0)
|
||
sb = tk.Scrollbar(body, orient="vertical", command=cv.yview)
|
||
cv.configure(yscrollcommand=sb.set)
|
||
sb.pack(side="right", fill="y")
|
||
cv.pack(side="left", fill="both", expand=True)
|
||
grid = tk.Frame(cv, bg=BG)
|
||
cv_win = cv.create_window((0, 0), window=grid, anchor="nw")
|
||
grid.bind("<Configure>",
|
||
lambda e: cv.configure(scrollregion=cv.bbox("all")))
|
||
cv.bind("<Configure>",
|
||
lambda e: cv.itemconfigure(cv_win, width=e.width))
|
||
cv.bind("<MouseWheel>",
|
||
lambda e: cv.yview_scroll(-1 if e.delta > 0 else 1, "units"))
|
||
|
||
def list_shots():
|
||
try:
|
||
fs = [os.path.join(shot_dir, fn) for fn in os.listdir(shot_dir)
|
||
if fn.lower().endswith(".png")]
|
||
fs = [p for p in fs if os.path.isfile(p)]
|
||
fs.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||
return fs[:48]
|
||
except OSError:
|
||
return []
|
||
|
||
photos = [] # ссылки, чтобы Tk не освободил картинки
|
||
|
||
def load_thumb(cnv, path):
|
||
if not cnv.winfo_exists():
|
||
return
|
||
try:
|
||
img = tk.PhotoImage(file=path)
|
||
k = max(1, (img.width() + tw - 1) // tw,
|
||
(img.height() + th - 1) // th)
|
||
small = img.subsample(k, k)
|
||
photos.append(small)
|
||
cnv.create_image(tw // 2, th // 2, image=small)
|
||
except tk.TclError:
|
||
cnv.create_text(tw // 2, th // 2, text="?", fill=FAINT,
|
||
font=("Segoe UI", 14))
|
||
|
||
def open_view(path):
|
||
if not os.path.isfile(path):
|
||
return
|
||
v = tk.Toplevel(self)
|
||
v.configure(bg=BG)
|
||
v.title(os.path.basename(path))
|
||
vw, vh = 960, 620
|
||
v.geometry(f"{vw}x{vh}+"
|
||
f"{self.winfo_rootx() + (self.winfo_width() - vw) // 2}"
|
||
f"+{self.winfo_rooty() + (self.winfo_height() - vh) // 2}")
|
||
_dark_titlebar(v)
|
||
holder = {"img": None}
|
||
pic = tk.Label(v, bg="#0c0c0c")
|
||
pic.pack(fill="both", expand=True, padx=10, pady=10)
|
||
try:
|
||
img = tk.PhotoImage(file=path)
|
||
k = max(1, (img.width() + vw - 61) // (vw - 40),
|
||
(img.height() + vh - 121) // (vh - 100))
|
||
if k > 1:
|
||
img = img.subsample(k, k)
|
||
holder["img"] = img
|
||
pic.configure(image=img)
|
||
except tk.TclError as e: # noqa: BLE001
|
||
pic.configure(text=T("Не удалось открыть скриншот") + ": "
|
||
+ str(e), fg=ERR)
|
||
btns = tk.Frame(v, bg=BG)
|
||
btns.pack(fill="x", padx=10, pady=(0, 10))
|
||
|
||
def open_file():
|
||
try:
|
||
os.startfile(path) # noqa: S606
|
||
except OSError as e: # noqa: BLE001
|
||
self._log(T("Не удалось открыть скриншот") + ": "
|
||
+ str(e), "err")
|
||
|
||
def remove():
|
||
if not messagebox.askyesno(
|
||
"PixelLauncher",
|
||
T("Удалить скриншот {p}?").format(
|
||
p=os.path.basename(path)), parent=v):
|
||
return
|
||
try:
|
||
os.remove(path)
|
||
self._log(T("Скриншот удалён: ")
|
||
+ os.path.basename(path), "ok")
|
||
except OSError as e: # noqa: BLE001
|
||
self._log(T("Не удалось удалить скриншот") + ": "
|
||
+ str(e), "err")
|
||
v.destroy()
|
||
refresh()
|
||
|
||
self._btn(btns, T("Открыть файл"), open_file).pack(side="left")
|
||
self._btn(btns, T("Удалить"), remove).pack(side="left",
|
||
padx=(6, 0))
|
||
self._btn(btns, T("Закрыть"), v.destroy).pack(side="right")
|
||
|
||
def cell(parent, path):
|
||
wrap = tk.Frame(parent, bg=INPUT, highlightthickness=1,
|
||
highlightbackground=BAR_BD, cursor="hand2")
|
||
wrap.pack(side="left", padx=5, pady=5)
|
||
cnv = tk.Canvas(wrap, width=tw, height=th, bg="#0c0c0c",
|
||
highlightthickness=0)
|
||
cnv.pack()
|
||
tk.Label(wrap, text=os.path.basename(path), bg=INPUT, fg=DIM,
|
||
width=22, anchor="center",
|
||
font=("Segoe UI", 7)).pack()
|
||
for wgt in (wrap, cnv):
|
||
wgt.bind("<Button-1>", lambda e, p=path: open_view(p))
|
||
return cnv
|
||
|
||
def refresh():
|
||
for w in grid.winfo_children():
|
||
w.destroy()
|
||
files = list_shots()
|
||
if not files:
|
||
tk.Label(grid, text=T("Скриншотов пока нет"), bg=BG, fg=DIM,
|
||
font=("Segoe UI", 10)).pack(pady=(24, 0))
|
||
self._btn(grid, T("Открыть папку"),
|
||
lambda: self._open_explorer(shot_dir)).pack(
|
||
pady=(10, 0))
|
||
return
|
||
row = None
|
||
for i, p in enumerate(files):
|
||
if i % cols == 0:
|
||
row = tk.Frame(grid, bg=BG)
|
||
row.pack(fill="x")
|
||
cnv = cell(row, p)
|
||
# превью грузим по одному — окно не подвисает
|
||
grid.after(40 + i * 35,
|
||
lambda c=cnv, pp=p: load_thumb(c, pp))
|
||
|
||
refresh()
|
||
self._finish_dialog(d)
|
||
return d
|
||
|
||
# ------------------------------------------------------ перетаскивание
|
||
def _enable_dnd(self):
|
||
"""Приём файлов, брошенных в окно: .mrpack, .jar, .py."""
|
||
if os.name != "nt":
|
||
return
|
||
try:
|
||
WM_DROPFILES = 0x0233
|
||
GWLP_WNDPROC = -4
|
||
WNDPROC = ctypes.WINFUNCTYPE(
|
||
ctypes.c_ssize_t, wintypes.HWND, wintypes.UINT,
|
||
wintypes.WPARAM, wintypes.LPARAM)
|
||
user32 = ctypes.windll.user32
|
||
shell32 = ctypes.windll.shell32
|
||
user32.SetWindowLongPtrW.argtypes = (
|
||
wintypes.HWND, ctypes.c_int, ctypes.c_void_p)
|
||
user32.SetWindowLongPtrW.restype = ctypes.c_ssize_t
|
||
user32.CallWindowProcW.argtypes = (
|
||
ctypes.c_void_p, wintypes.HWND, wintypes.UINT,
|
||
wintypes.WPARAM, wintypes.LPARAM)
|
||
user32.CallWindowProcW.restype = ctypes.c_ssize_t
|
||
shell32.DragQueryFileW.argtypes = (
|
||
ctypes.c_void_p, wintypes.UINT, wintypes.LPCWSTR,
|
||
wintypes.UINT)
|
||
shell32.DragQueryFileW.restype = wintypes.UINT
|
||
shell32.DragFinish.argtypes = (ctypes.c_void_p,)
|
||
shell32.DragAcceptFiles.argtypes = (wintypes.HWND, wintypes.BOOL)
|
||
hwnd = wintypes.HWND(self.winfo_id())
|
||
|
||
def take_paths(hglobal):
|
||
out = []
|
||
try:
|
||
n = shell32.DragQueryFileW(hglobal, 0xffffffff, None, 0)
|
||
buf = ctypes.create_unicode_buffer(32768)
|
||
for i in range(n):
|
||
shell32.DragQueryFileW(hglobal, i, buf, 32768)
|
||
out.append(buf.value)
|
||
finally:
|
||
shell32.DragFinish(hglobal)
|
||
return out
|
||
|
||
def wnd_proc(h, msg, w, l):
|
||
if msg == WM_DROPFILES:
|
||
# максимум осторожности: только ctypes, без вызовов
|
||
# tkinter — иначе CPython падает (GIL) внутри цикла tcl
|
||
try:
|
||
files = take_paths(w)
|
||
if files:
|
||
self._dnd_queue.append(files)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return 0
|
||
return user32.CallWindowProcW(self._dnd_old, h, msg, w, l)
|
||
|
||
self._dnd_proc = WNDPROC(wnd_proc)
|
||
self._dnd_queue = []
|
||
self._dnd_old = user32.SetWindowLongPtrW(
|
||
hwnd, GWLP_WNDPROC, self._dnd_proc)
|
||
shell32.DragAcceptFiles(hwnd, True)
|
||
|
||
def poll_dnd():
|
||
if self._dnd_queue:
|
||
files = self._dnd_queue.pop(0)
|
||
self._on_drop(files)
|
||
self.after(200, poll_dnd)
|
||
self.after(200, poll_dnd)
|
||
except Exception as e: # noqa: BLE001
|
||
try:
|
||
self._log("DnD: " + str(e), "warn")
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
def _on_drop(self, files):
|
||
"""Что делать с файлами, брошенными в окно."""
|
||
for p in files:
|
||
name = os.path.basename(p)
|
||
low = p.lower()
|
||
if low.endswith(".mrpack"):
|
||
self._log(T("Импорт модпака: ") + name)
|
||
self._import_pack(p)
|
||
elif low.endswith(".jar"):
|
||
self._add_mod_file(p)
|
||
elif low.endswith(".py"):
|
||
self._add_plugin_file(p)
|
||
else:
|
||
self._log(T("Файл не подходит для лаунчера: ") + name,
|
||
"warn")
|
||
|
||
def _add_mod_file(self, path):
|
||
try:
|
||
shutil.copy2(path, os.path.join(
|
||
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")
|
||
|
||
def _add_plugin_file(self, path):
|
||
try:
|
||
shutil.copy2(path, os.path.join(plugins_mod.PLUGIN_DIR,
|
||
os.path.basename(path)))
|
||
self._log(T("Плагин добавлен: ") + os.path.basename(path), "ok")
|
||
plugins_mod.scan()
|
||
except OSError as e: # noqa: BLE001
|
||
self._log(T("Не удалось добавить плагин") + ": " + str(e), "err")
|
||
|
||
# ------------------------------------------------------------ версии
|
||
def _load_versions_bg(self):
|
||
try:
|
||
gd = self._game_dir()
|
||
m = manifest_mod.get_manifest(gd, net=True)
|
||
latest = m.get("latest", {})
|
||
ids = []
|
||
if latest.get("release"):
|
||
ids.append("latest-release (" + latest["release"] + ")")
|
||
if latest.get("snapshot"):
|
||
ids.append("latest-snapshot (" + latest["snapshot"] + ")")
|
||
releases, snaps, other = [], [], []
|
||
for v in m.get("versions", []):
|
||
vid, vtype = v.get("id"), v.get("type", "release")
|
||
date = (v.get("releaseTime") or "")[:10]
|
||
self.ver_meta[vid] = (vtype, date)
|
||
(releases if vtype == "release" else
|
||
snaps if vtype == "snapshot" else other).append(vid)
|
||
# локальные версии (Fabric/Quilt) — сразу после latest-записей
|
||
known = set(releases + snaps + other)
|
||
extra = [v for v in manifest_mod.local_versions(gd) if v not in known]
|
||
for v in extra:
|
||
self.ver_meta[v] = ("local", "")
|
||
pos = len([x for x in ids if x.startswith("latest-")])
|
||
ids[pos:pos] = extra
|
||
ids += releases + snaps + other
|
||
self.versions = ids
|
||
news = [(v.get("id"), v.get("type", "release"),
|
||
(v.get("releaseTime") or "")[:10])
|
||
for v in m.get("versions", [])[:3]]
|
||
self.ui_q.put(("versions", ids, latest, news))
|
||
except Exception as e: # noqa: BLE001
|
||
self.ui_q.put(("log", T("Не удалось загрузить список версий: ")
|
||
+ str(e), "err"))
|
||
|
||
def _apply_versions(self, ids, latest, news=None):
|
||
self._news = news
|
||
cur = self.var_ver.get()
|
||
if self._force_version:
|
||
if self._force_version in ids:
|
||
cur = self._force_version
|
||
self.var_ver.set(cur)
|
||
self._save_settings()
|
||
self._force_version = None
|
||
if not cur or (cur not in ids and not cur.startswith("latest-")):
|
||
self.var_ver.set(ids[0] if ids else "")
|
||
self._fill_news(news)
|
||
self._update_pill()
|
||
self._log(T("Загружено версий: ") + str(len(ids)), "ok")
|
||
|
||
def _reload_versions(self):
|
||
if self.busy:
|
||
return
|
||
manifest_mod.invalidate() # следующая загрузка пойдёт в сеть
|
||
threading.Thread(target=self._load_versions_bg, daemon=True).start()
|
||
|
||
def _update_pill(self):
|
||
vid = self._selected_version_id()
|
||
prof = self.settings.get("active_profile") or ""
|
||
text = f"Minecraft {vid}"
|
||
if prof:
|
||
text = f"{prof} · {text}"
|
||
self.pill.set_text(text)
|
||
if hasattr(self, "quick_ver_chip") and self.quick_ver_chip.winfo_exists():
|
||
try:
|
||
self.quick_ver_chip.configure(text=self._ver_chip_text())
|
||
except tk.TclError:
|
||
pass
|
||
|
||
|
||
def _selected_version_id(self):
|
||
if hasattr(self, "var_ver") and self.var_ver is not None:
|
||
try:
|
||
vsel = self.var_ver.get().strip()
|
||
except Exception:
|
||
vsel = ""
|
||
else:
|
||
vsel = str(self.settings.get("version", "latest-release") or "")
|
||
latest = self._latest or {}
|
||
if vsel.startswith("latest-release"):
|
||
return latest.get("release", "latest-release")
|
||
if vsel.startswith("latest-snapshot"):
|
||
return latest.get("snapshot", "latest-snapshot")
|
||
return vsel or "…"
|
||
|
||
def _fill_news(self, news):
|
||
"""Заполняет ленту новостей последними версиями из манифеста."""
|
||
for w in self.news_rows.winfo_children():
|
||
w.destroy()
|
||
if not news:
|
||
tk.Label(self.news_rows, text=T("загрузка…"), bg=BG, fg=FAINT,
|
||
font=("Segoe UI", 8)).pack()
|
||
return
|
||
for vid, vtype, date in news:
|
||
row = tk.Frame(self.news_rows, bg=BG, cursor="hand2")
|
||
row.pack(pady=1)
|
||
color = {"release": OK, "snapshot": WARN}.get(vtype, FAINT)
|
||
cells = [
|
||
tk.Label(row, text=vid, bg=BG, fg=TEXT,
|
||
font=("Segoe UI", 9, "bold")),
|
||
tk.Label(row, text=T(TYPE_RU.get(vtype, vtype)), bg=BG, fg=color,
|
||
font=("Segoe UI", 8)),
|
||
tk.Label(row, text=date, bg=BG, fg=FAINT,
|
||
font=("Segoe UI", 8)),
|
||
]
|
||
for i, c in enumerate(cells):
|
||
c.pack(side="left", padx=(0 if i == 0 else 10, 0))
|
||
c.bind("<Button-1>", lambda e, v=vid: self._pick_version(v))
|
||
|
||
def _pick_version(self, vid):
|
||
"""Выбор версии из ленты новостей или диалога."""
|
||
if self.busy:
|
||
return
|
||
self.var_ver.set(vid)
|
||
self._save_settings()
|
||
self._update_pill()
|
||
self._log(T("Версия выбрана: ") + vid, "ok")
|
||
|
||
# ------------------------------------------------------------ запуск
|
||
def _on_play(self):
|
||
if self.busy:
|
||
return
|
||
nick = self.var_nick.get().strip()
|
||
if not re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick):
|
||
messagebox.showwarning("PixelLauncher",
|
||
T("Ник: 3-16 символов, латиница, цифры и _"))
|
||
return
|
||
self._save_settings()
|
||
self._begin_work(nick, do_launch=True)
|
||
|
||
def _on_download(self):
|
||
if self.busy:
|
||
return
|
||
self._save_settings()
|
||
self._begin_work()
|
||
|
||
def _on_verify(self):
|
||
"""Полная проверка SHA-1 всех файлов выбранной версии."""
|
||
if self.busy:
|
||
return
|
||
self._save_settings()
|
||
self._begin_work(verify=True)
|
||
|
||
def _on_cancel(self):
|
||
"""Останавливает идущую загрузку."""
|
||
if not self.busy or self._cancel is None:
|
||
return
|
||
self._cancel.set()
|
||
self.btn_cancel.configure(state="disabled")
|
||
self._log(T("Отмена..."), "warn")
|
||
|
||
def _auto_ram(self):
|
||
"""ОЗУ по умолчанию: половина памяти системы (512-кратно)."""
|
||
total = self._ram_max()
|
||
return int(max(2048, min(total // 2, 16384)) // 512 * 512)
|
||
|
||
def _begin_work(self, nick=None, do_launch=False, verify=False):
|
||
self.busy = True
|
||
self._cancel = threading.Event()
|
||
self.btn_play.set_state("disabled")
|
||
self.btn_cancel.configure(state="normal")
|
||
self.btn_cancel.pack()
|
||
self.progress.set(0)
|
||
threading.Thread(target=self._work_bg, args=(nick, do_launch, verify),
|
||
daemon=True).start()
|
||
|
||
def _work_bg(self, nick, do_launch, verify=False):
|
||
log = lambda m, t=None: self.ui_q.put(("log", m, t)) # noqa: E731
|
||
prog = lambda d, t, title="": self.ui_q.put(("progress", d, t, title)) # noqa: E731
|
||
cancel = self._cancel
|
||
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)
|
||
vid = self._selected_version_id()
|
||
log(T("Версия: ") + vid)
|
||
|
||
# авто-бэкап мира перед запуском (по настройке)
|
||
if do_launch and self.settings.get("auto_backup"):
|
||
try:
|
||
self._backup_worlds(play_dir, log)
|
||
except Exception as be: # noqa: BLE001
|
||
log(T("Ошибка: ") + str(be), "warn")
|
||
|
||
ver = version_mod.load(gd, vid, net=True)
|
||
if not ver.main_class:
|
||
raise RuntimeError("Не удалось разобрать JSON версии")
|
||
|
||
required = max(ver.java_major, 8)
|
||
preferred = self.settings.get("java_path", "")
|
||
if not preferred:
|
||
# уже скачанная нами Java (Adoptium) — приоритетнее прочих
|
||
preferred = java_fetch.find_installed(
|
||
os.path.join(gd, ".launcher", "runtime"), required)
|
||
jpath, jver, ok = java_mod.pick_java(
|
||
required, preferred,
|
||
cache_path=os.path.join(gd, ".launcher", "java_cache.json"))
|
||
if not jpath:
|
||
log(T("Java не найдена, скачиваю..."), "warn")
|
||
jpath = java_fetch.ensure_java(
|
||
required, os.path.join(gd, ".launcher", "runtime"),
|
||
progress=lambda d, t: prog(d, t, "Java"),
|
||
log=lambda m, t=None: self.ui_q.put(("log", m, t)),
|
||
cancel=cancel)
|
||
jver = java_mod.java_major(jpath) or required
|
||
ok = jver >= required
|
||
if ok:
|
||
log(T("Java {jver} найдена").format(jver=jver), "ok")
|
||
else:
|
||
log(T("Нужна Java {r}+, найдена {j}. Возможны проблемы.")
|
||
.format(r=required, j=jver), "warn")
|
||
|
||
if verify:
|
||
log(T("Проверка целостности файлов (SHA-1)..."))
|
||
else:
|
||
log(T("Скачивание файлов игры..."))
|
||
errors, natives = downloader.ensure(
|
||
ver,
|
||
progress=lambda d, t: prog(d, t, T("Файлы")),
|
||
asset_progress=lambda d, t: prog(d, t, T("Ассеты")),
|
||
log=lambda m: self.ui_q.put(("log", m, None)),
|
||
verify=verify,
|
||
cancel=cancel,
|
||
asset_mirrors=self.settings.get("asset_mirrors") or None,
|
||
lib_mirrors=self.settings.get("lib_mirrors") or None)
|
||
if errors:
|
||
log(T("Ошибок скачивания: ") + str(len(errors)) + " ("
|
||
+ "; ".join(errors[:2]) + ")", "err")
|
||
|
||
if do_launch:
|
||
uuid_hex = launch_mod.offline_uuid(nick)
|
||
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", ""),
|
||
game_directory=play_dir)
|
||
# размер окна/полный экран из профиля
|
||
try:
|
||
ww = int(self.settings.get("win_w") or 0)
|
||
wh = int(self.settings.get("win_h") or 0)
|
||
except (TypeError, ValueError):
|
||
ww = wh = 0
|
||
if ww > 0:
|
||
cmd += ["--width", str(ww)]
|
||
if wh > 0:
|
||
cmd += ["--height", str(wh)]
|
||
if self.settings.get("fullscreen"):
|
||
cmd += ["--fullscreen"]
|
||
log_path = os.path.join(gd, ".launcher", "logs",
|
||
"game-" + time.strftime("%Y%m%d-%H%M%S")
|
||
+ ".log")
|
||
|
||
def on_line(line):
|
||
# не раздуваем очередь, если журнал закрыт и игра шумит
|
||
if self.ui_q.qsize() < 3000:
|
||
self.ui_q.put(("game_line", line))
|
||
|
||
def on_exit(code):
|
||
self.ui_q.put(("game_exit", code, log_path, vid,
|
||
int(time.time() - t0)))
|
||
|
||
prof = str(self.settings.get("active_profile") or "") or nick
|
||
self._emit("before_launch", profile_name=prof,
|
||
version=str(vid))
|
||
t0 = time.time()
|
||
stats_mod.record_start(vid)
|
||
proc = launch_mod.launch(
|
||
play_dir, cmd,
|
||
log=lambda m: self.ui_q.put(
|
||
("log", m, "ok"
|
||
if m.startswith(T("Игра запущена, PID ")) else None)),
|
||
log_path=log_path, on_line=on_line, on_exit=on_exit)
|
||
self._game_proc = proc
|
||
if self.settings.get("minimize_to_tray_on_launch"):
|
||
self.ui_q.put(("minimize",))
|
||
self._emit("after_launch", profile_name=prof,
|
||
version=str(vid), pid=proc.pid)
|
||
self.ui_q.put(("log", T("Вывод игры пишется в ") + log_path,
|
||
None))
|
||
self.ui_q.put(("tray_notify", T("Игра запущена"), vid))
|
||
else:
|
||
if verify and not errors:
|
||
log(T("Все файлы на месте, повреждений не найдено."), "ok")
|
||
else:
|
||
log(T("Версия скачана и готова к запуску."), "ok")
|
||
except http.Cancelled:
|
||
log(T("Загрузка отменена"), "warn")
|
||
except Exception as e: # noqa: BLE001
|
||
log(T("Ошибка: ") + str(e), "err")
|
||
finally:
|
||
self.ui_q.put(("done",))
|
||
|
||
# ------------------------------------------------------------ бэкап
|
||
def _on_backup(self):
|
||
if self.busy:
|
||
return
|
||
self.busy = True
|
||
self.btn_play.set_state("disabled")
|
||
self.progress.set(0)
|
||
threading.Thread(target=self._backup_bg, daemon=True).start()
|
||
|
||
def _backup_worlds(self, gd, log, progress=None):
|
||
"""ZIP-копия папки saves. Возвращает каталог backups."""
|
||
import zipfile
|
||
saves = os.path.join(gd, "saves")
|
||
if not os.path.isdir(saves):
|
||
raise RuntimeError(T("Папка миров (saves) не найдена"))
|
||
out_dir = os.path.join(gd, "backups")
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
path = os.path.join(out_dir,
|
||
"saves-" + time.strftime("%Y%m%d-%H%M%S") + ".zip")
|
||
files = []
|
||
for root, _, fnames in os.walk(saves):
|
||
for f in fnames:
|
||
fp = os.path.join(root, f)
|
||
files.append((fp, os.path.relpath(fp, saves)))
|
||
log(T("Создание резервной копии: ") + str(len(files))
|
||
+ T(" файлов..."))
|
||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
|
||
for i, (fp, rel) in enumerate(files, 1):
|
||
try:
|
||
z.write(fp, rel)
|
||
except OSError:
|
||
continue
|
||
if progress and (i % 10 == 0 or i == len(files)):
|
||
progress(i, len(files))
|
||
size_mb = os.path.getsize(path) / (1024 * 1024)
|
||
log(T("Копия создана: ") + os.path.basename(path) + f" ({size_mb:.1f} "
|
||
+ T("МБ") + ")", "ok")
|
||
return out_dir
|
||
|
||
def _backup_bg(self):
|
||
log = lambda m, t=None: self.ui_q.put(("log", m, t)) # noqa: E731
|
||
try:
|
||
out_dir = self._backup_worlds(
|
||
self._game_dir(), log,
|
||
progress=lambda i, n: self.ui_q.put(
|
||
("progress", i, n, T("Копирование"))))
|
||
self.ui_q.put(("open_dir", out_dir))
|
||
except Exception as e: # noqa: BLE001
|
||
log(T("Ошибка: ") + str(e), "err")
|
||
finally:
|
||
self.ui_q.put(("done",))
|
||
|
||
def _on_close(self):
|
||
if self._close_menu_if_open():
|
||
# меню модальное: tk_popup вернётся после unpost,
|
||
# и только тогда уничтожаем окно
|
||
self._close_after_menu = True
|
||
return
|
||
if self._tray is not None:
|
||
try:
|
||
self._tray.stop()
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
self._tray = None
|
||
self._emit("launcher_closing")
|
||
protocol_mod.clear_instance()
|
||
try:
|
||
self._save_settings()
|
||
except Exception:
|
||
pass
|
||
self._cancel_afters(self)
|
||
self.destroy()
|
||
|
||
# ------------------------------------------------------------ poll
|
||
def _poll_ui(self):
|
||
try:
|
||
while True:
|
||
msg = self.ui_q.get_nowait()
|
||
kind = msg[0]
|
||
if kind == "log":
|
||
self._log(msg[1], msg[2] if len(msg) > 2 else None)
|
||
elif kind == "versions":
|
||
self._latest = msg[2]
|
||
self._apply_versions(msg[1], msg[2],
|
||
msg[3] if len(msg) > 3 else None)
|
||
if not getattr(self, "_mod_upd_done", False):
|
||
self._mod_upd_done = True
|
||
self._startup_mod_updates()
|
||
elif kind == "progress":
|
||
_, done, total, title = msg
|
||
pct = (done / total * 100) if total else 0
|
||
self.progress.set(pct)
|
||
if title:
|
||
self.lbl_status.configure(text=f"{title} {done}/{total}",
|
||
fg=DIM)
|
||
elif kind == "head":
|
||
self._show_head(msg[1], msg[2])
|
||
elif kind == "open_dir":
|
||
self._open_explorer(msg[1])
|
||
elif kind == "minimize":
|
||
self._minimize()
|
||
elif kind == "game_line":
|
||
self._append_game_line(msg[1])
|
||
elif kind == "game_exit":
|
||
self._on_game_exit(
|
||
msg[1], msg[2] if len(msg) > 2 else "",
|
||
msg[3] if len(msg) > 3 else None,
|
||
msg[4] if len(msg) > 4 else 0)
|
||
elif kind == "tray":
|
||
self._tray_event(msg[1])
|
||
elif kind == "tray_notify":
|
||
self._tray_notify(msg[1], msg[2],
|
||
msg[3] if len(msg) > 3 else False)
|
||
elif kind == "skin_path":
|
||
render = getattr(self, "_skin_render", None)
|
||
if render:
|
||
render(msg[1])
|
||
elif kind == "use_version":
|
||
self.var_ver.set(msg[1])
|
||
self._save_settings()
|
||
self._update_pill()
|
||
elif kind == "reload_versions":
|
||
self._reload_versions()
|
||
elif kind == "update_found":
|
||
self._on_update_found(msg[1], msg[2], msg[3])
|
||
elif kind == "update_progress":
|
||
_, done, total = msg
|
||
pct = (done / total * 100) if total else 0
|
||
self.progress.set(pct)
|
||
self.lbl_status.configure(
|
||
text=f"{T('Скачивание обновления')} "
|
||
f"{pct:.0f}%", fg=DIM)
|
||
elif kind == "update_ready":
|
||
self._on_update_ready(msg[1])
|
||
elif kind == "update_fail":
|
||
self._on_update_fail(msg[1])
|
||
elif kind == "done":
|
||
self.busy = False
|
||
self._cancel = None
|
||
self.btn_play.set_state("normal")
|
||
try:
|
||
self.btn_cancel.configure(state="normal")
|
||
self.btn_cancel.pack_forget()
|
||
except tk.TclError:
|
||
pass
|
||
self.progress.set(100)
|
||
self.after(2500, self._reset_progress)
|
||
except queue.Empty:
|
||
pass
|
||
except tk.TclError:
|
||
return # окно уничтожено — не перепланируем
|
||
try:
|
||
self.after(100, self._poll_ui)
|
||
except tk.TclError:
|
||
pass
|
||
|
||
@staticmethod
|
||
def _cancel_afters(app):
|
||
"""Снимает все отложенные callback'и Tk (чтобы не сыпались bgerror
|
||
уже после destroy)."""
|
||
try:
|
||
for aid in app.tk.splitlist(app.tk.call("after", "info")):
|
||
try:
|
||
app.tk.call("after", "cancel", aid)
|
||
except tk.TclError:
|
||
pass
|
||
except tk.TclError:
|
||
pass
|
||
|
||
def _reset_progress(self):
|
||
if not self.busy:
|
||
self.progress.set(0)
|
||
self.lbl_status.configure(text=T("Готово к запуску"), fg=DIM)
|
||
|
||
|
||
def run(select_version=None, protocol_url=None, select_profile=None):
|
||
if os.name == "nt":
|
||
try:
|
||
import ctypes
|
||
ctypes.windll.shcore.SetProcessDpiAwareness(1)
|
||
except Exception:
|
||
pass
|
||
settings = config.load_settings()
|
||
if not settings.get("onboarding_done"):
|
||
from . import onboarding
|
||
onboarding.run(settings) # первый запуск: язык, ник, ОЗУ
|
||
config.save_settings(settings)
|
||
if protocol_url and protocol_mod.other_instance_running():
|
||
protocol_mod.queue_url(protocol_url) # лаунчер уже открыт — в очередь
|
||
return
|
||
protocol_mod.mark_instance()
|
||
app = App(select_version=select_version, protocol_url=protocol_url,
|
||
select_profile=select_profile)
|
||
app.mainloop()
|
||
protocol_mod.clear_instance()
|