PixelLauncher 2.1.2 — секции настроек, панель быстрых настроек, сборки Modrinth/CurseForge

This commit is contained in:
kosta committed 2026-10-20 17:36:35 +07:00
1 parent 80dc1826a1
commit 39cf752b7b
13 files changed
+972 -49

No files matched your search

+381 -1
View File
@@ -24,12 +24,14 @@ from ctypes import wintypes
from tkinter import filedialog, messagebox, ttk
from . import config, crash as crash_mod, downloader, forge as forge_mod, http
from . import curseforge as curseforge_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
@@ -952,6 +954,12 @@ class App(tk.Tk):
self.news_rows.pack()
self._fill_news(self._news)
# --- Быстрые настройки прямо на главном экране: частые переключатели
# не требуют ни меню ⚙, ни диалога «Настройки».
self.quick_bar = tk.Frame(main, bg=BG)
self.quick_bar.pack(pady=(10, 0))
self._build_quick_bar(self.quick_bar)
self.lbl_status = tk.Label(main, text=T("Готово к запуску"), bg=BG, fg=DIM,
font=("Segoe UI", 8))
self.lbl_status.pack(pady=(12, 0))
@@ -1657,6 +1665,8 @@ class App(tk.Tk):
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)
@@ -2469,6 +2479,117 @@ class App(tk.Tk):
self._finish_dialog(d)
return d
def _build_quick_bar(self, parent):
"""Кликабельные чипы быстрых настроек на главном экране.
Дублируют подменю ⚙ → «Быстрые настройки»: язык, масштаб,
автозагрузка, трей и авто-бэкап меняются одним кликом.
"""
def chip(text, command, active=False):
bg = GREEN_BD if active else PILL
fg = TEXT if active else DIM
lbl = tk.Label(parent, text=text, bg=bg, fg=fg,
font=("Segoe UI", 8), padx=9, pady=3,
cursor="hand2")
lbl.pack(side="left", padx=3)
lbl.bind("<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")))
@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):
"""Подменю быстрых настроек в главном меню.
@@ -2810,6 +2931,253 @@ class App(tk.Tk):
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))
lb = tk.Listbox(body, height=9, bg=INPUT, fg=TEXT, selectbackground=GREEN,
selectforeground="#0d1a0d", relief="solid", bd=1,
highlightthickness=0, font=("Segoe UI", 9),
exportselection=False)
lb.pack(fill="both", expand=True, pady=(4, 0))
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
lb.delete(0, "end")
for it in out:
title = it.get("title") or it.get("name") or "?"
desc = it.get("desc") or it.get("summary") or ""
bits = []
if it.get("downloads"):
bits.append(str(it["downloads"]))
if it.get("loaders"):
bits.append(", ".join(it["loaders"][:2]))
tail = (" [" + " · ".join(bits) + "]") if bits else ""
lb.insert("end", "%s — %s%s" % (title, desc, tail))
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 = lb.curselection()
lb_f.delete(0, "end")
files.clear()
if not sel or sel[0] >= len(packs):
lbl_files.configure(text=T("Файлы сборки"), fg=DIM)
return
p = packs[sel[0]]
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 = lb.curselection()
if not sel or sel[0] >= len(packs):
status.configure(text=T("Выберите сборку"), fg=ERR)
return
p = packs[sel[0]]
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._game_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._game_dir(), log=log,
progress=prog, cancel=cancel)
self._install_pack_async(
worker, T("Установка сборки: ") + str(title))
var_q.trace_add("write", schedule_search)
lb.bind("<<ListboxSelect>>", load_files)
lb_f.bind("<Double-Button-1>", lambda e: install_sel())
lb.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():
@@ -4854,9 +5222,21 @@ class App(tk.Tk):
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):
vsel = self.var_ver.get().strip()
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")