295 lines
13 KiB
Python
295 lines
13 KiB
Python
"""Dialogs: alerts, text prompts, preview, conflicts, game setup and preferences."""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
import gi
|
|
|
|
gi.require_version("Gtk", "4.0")
|
|
gi.require_version("Adw", "1")
|
|
from gi.repository import Adw, Gdk, Gio, GLib, Gtk
|
|
|
|
from ..core import desktop, loader, nexus, steam
|
|
from ..core.config import LINK_MODES
|
|
from ..core.deploy import Plan
|
|
from ..core.manager import Manager
|
|
from .tasks import run_async
|
|
|
|
DESTRUCTIVE = Adw.ResponseAppearance.DESTRUCTIVE
|
|
SUGGESTED = Adw.ResponseAppearance.SUGGESTED
|
|
|
|
|
|
def alert(parent: Gtk.Window, heading: str, body: str,
|
|
responses: list[tuple[str, str, Adw.ResponseAppearance | None]],
|
|
callback: Callable[[str], None], extra_child: Gtk.Widget | None = None) -> None:
|
|
"""Modal alert. `responses` are (id, label, appearance); the id 'cancel' is the close response."""
|
|
if hasattr(Adw, "AlertDialog"):
|
|
dlg = Adw.AlertDialog.new(heading, body)
|
|
else: # libadwaita < 1.5
|
|
dlg = Adw.MessageDialog.new(parent, heading, body)
|
|
for rid, label, appearance in responses:
|
|
dlg.add_response(rid, label)
|
|
if appearance is not None:
|
|
dlg.set_response_appearance(rid, appearance)
|
|
dlg.set_close_response("cancel")
|
|
if extra_child is not None:
|
|
dlg.set_extra_child(extra_child)
|
|
dlg.connect("response", lambda _d, rid: callback(rid))
|
|
if hasattr(Adw, "AlertDialog"):
|
|
dlg.present(parent)
|
|
else:
|
|
dlg.present()
|
|
|
|
|
|
def confirm(parent, heading: str, body: str, label: str, on_yes: Callable[[], None], destructive=False) -> None:
|
|
alert(parent, heading, body,
|
|
[("cancel", "Cancel", None), ("yes", label, DESTRUCTIVE if destructive else SUGGESTED)],
|
|
lambda rid: on_yes() if rid == "yes" else None)
|
|
|
|
|
|
def prompt_text(parent, heading: str, initial: str, label: str, on_text: Callable[[str], None]) -> None:
|
|
entry = Gtk.Entry(text=initial, activates_default=True)
|
|
alert(parent, heading, "", [("cancel", "Cancel", None), ("ok", label, SUGGESTED)],
|
|
lambda rid: on_text(entry.get_text().strip()) if rid == "ok" and entry.get_text().strip() else None,
|
|
extra_child=entry)
|
|
|
|
|
|
def copy_text(widget: Gtk.Widget, text: str) -> None:
|
|
widget.get_clipboard().set_content(Gdk.ContentProvider.new_for_value(text))
|
|
|
|
|
|
class TextDialog(Adw.Window):
|
|
"""Read-only monospace text with optional confirm action (used for Preview changes / conflicts)."""
|
|
|
|
def __init__(self, parent: Gtk.Window, title: str, text: str, action_label: str | None = None,
|
|
on_action: Callable[[], None] | None = None):
|
|
super().__init__(transient_for=parent, modal=True, title=title, default_width=640, default_height=480)
|
|
view = Gtk.TextView(editable=False, cursor_visible=False, monospace=True, wrap_mode=Gtk.WrapMode.WORD_CHAR,
|
|
left_margin=12, right_margin=12, top_margin=12, bottom_margin=12)
|
|
view.get_buffer().set_text(text)
|
|
scroller = Gtk.ScrolledWindow(vexpand=True, child=view)
|
|
header = Adw.HeaderBar()
|
|
cancel = Gtk.Button(label="Close" if not on_action else "Cancel")
|
|
cancel.connect("clicked", lambda *_: self.close())
|
|
header.pack_start(cancel)
|
|
if on_action:
|
|
go = Gtk.Button(label=action_label or "Apply")
|
|
go.add_css_class("suggested-action")
|
|
go.connect("clicked", lambda *_: (self.close(), on_action()))
|
|
header.pack_end(go)
|
|
tv = Adw.ToolbarView(content=scroller)
|
|
tv.add_top_bar(header)
|
|
self.set_content(tv)
|
|
|
|
|
|
def preview_dialog(parent, plan: Plan, on_apply: Callable[[], None]) -> None:
|
|
lines = plan.describe()
|
|
text = "\n".join(lines) if lines else "Nothing to do — the game folder already matches your profile."
|
|
changes = len(plan.changes)
|
|
TextDialog(parent, f"Preview changes ({changes})", text,
|
|
"Apply" if changes else None, on_apply if changes else None).present()
|
|
|
|
|
|
class GameSetupWindow(Adw.Window):
|
|
"""Game folder, PAK chains and REFramework status with one-click fixes."""
|
|
|
|
def __init__(self, parent: Gtk.Window, manager: Manager, on_change: Callable[[], None]):
|
|
super().__init__(transient_for=parent, modal=True, title="Game setup", default_width=620, default_height=700)
|
|
self.m, self.parent_win, self.on_change = manager, parent, on_change
|
|
self.build()
|
|
|
|
# -- helpers --------------------------------------------------------------
|
|
def toast(self, msg: str) -> None:
|
|
self.overlay.add_toast(Adw.Toast.new(msg))
|
|
|
|
def build(self) -> None:
|
|
page = Adw.PreferencesPage()
|
|
|
|
g = Adw.PreferencesGroup(title="Game")
|
|
row = Adw.ActionRow(title=self.m.install.name if self.m.install else "Monster Hunter Wilds",
|
|
subtitle=str(self.m.game_dir), subtitle_selectable=True)
|
|
g.add(row)
|
|
page.add(g)
|
|
|
|
pg = Adw.PreferencesGroup(title="PAK chains",
|
|
description="Mod PAKs are slotted above the vanilla ceiling and re-slotted "
|
|
"automatically after a game update.")
|
|
for chain, info in self.m.pak_summary().items():
|
|
slots = info["mod_slots"]
|
|
r = Adw.ActionRow(title=f"{chain} chain", subtitle=(
|
|
f"Vanilla ceiling {info['ceiling']:03d} · {info['vanilla']} PAKs · "
|
|
+ (f"mod slots {', '.join(f'{n:03d}' for n in slots)}" if slots else "no mod PAKs deployed")))
|
|
pg.add(r)
|
|
page.add(pg)
|
|
|
|
lg = Adw.PreferencesGroup(title="Mod loader (REFramework)")
|
|
st = self.m.loader_status()
|
|
for chk in st.checks:
|
|
r = Adw.ActionRow(title=chk.label, subtitle=chk.detail)
|
|
r.add_prefix(Gtk.Image.new_from_icon_name("emblem-ok-symbolic" if chk.ok else "dialog-warning-symbolic"))
|
|
lg.add(r)
|
|
page.add(lg)
|
|
|
|
ag = Adw.PreferencesGroup()
|
|
install = Adw.ActionRow(title="Install / update REFramework",
|
|
subtitle="Downloads the latest nightly MHWILDS build and installs dinput8.dll")
|
|
b = Gtk.Button(label="Install", valign=Gtk.Align.CENTER)
|
|
b.connect("clicked", self._install)
|
|
install.add_suffix(b)
|
|
ag.add(install)
|
|
loose = Adw.ActionRow(title="Enable loose file loader",
|
|
subtitle=f"Sets LooseFileLoader_Enabled=true in {loader.CONFIG_NAME} (before launch)")
|
|
b2 = Gtk.Button(label="Enable", valign=Gtk.Align.CENTER)
|
|
b2.connect("clicked", self._enable_loose)
|
|
loose.add_suffix(b2)
|
|
ag.add(loose)
|
|
page.add(ag)
|
|
|
|
og = Adw.PreferencesGroup(
|
|
title="Steam launch options",
|
|
description="Proton must be told to load dinput8.dll. Set this in Steam → Properties → Launch Options, "
|
|
"or let the manager write it while Steam is closed.")
|
|
cur = Adw.ActionRow(title="Current", subtitle=st.launch_options or "(empty)", subtitle_selectable=True)
|
|
og.add(cur)
|
|
need = Adw.ActionRow(title="Required", subtitle=st.suggested_launch_options, subtitle_selectable=True)
|
|
cp = Gtk.Button(icon_name="edit-copy-symbolic", valign=Gtk.Align.CENTER, tooltip_text="Copy")
|
|
cp.connect("clicked", lambda *_: (copy_text(cp, st.suggested_launch_options), self.toast("Copied")))
|
|
need.add_suffix(cp)
|
|
og.add(need)
|
|
write = Adw.ActionRow(title="Write to Steam config",
|
|
subtitle="Backs up localconfig.vdf first. Only works while Steam is not running.")
|
|
wb = Gtk.Button(label="Write…", valign=Gtk.Align.CENTER)
|
|
wb.set_sensitive(not st.all_ok or not steam.launch_options_ok(st.launch_options))
|
|
wb.connect("clicked", lambda *_: self._write_opts(st.suggested_launch_options))
|
|
write.add_suffix(wb)
|
|
og.add(write)
|
|
page.add(og)
|
|
|
|
self.overlay = Adw.ToastOverlay(child=page)
|
|
tv = Adw.ToolbarView(content=self.overlay)
|
|
tv.add_top_bar(Adw.HeaderBar())
|
|
self.set_content(tv)
|
|
|
|
# -- actions --------------------------------------------------------------
|
|
def _refresh(self) -> None:
|
|
self.build()
|
|
self.on_change()
|
|
|
|
def _install(self, btn) -> None:
|
|
btn.set_sensitive(False)
|
|
self.toast("Downloading REFramework…")
|
|
|
|
def done(tag, err):
|
|
if err:
|
|
btn.set_sensitive(True)
|
|
self.toast(f"Install failed: {err}")
|
|
else:
|
|
self._refresh()
|
|
self.toast(f"Installed REFramework {tag}")
|
|
run_async(self.m.install_reframework, done)
|
|
|
|
def _enable_loose(self, *_):
|
|
try:
|
|
loader.setup_all(self.m.game_dir)
|
|
except OSError as e:
|
|
self.toast(f"Could not write config: {e}")
|
|
return
|
|
self._refresh()
|
|
self.toast("Loose file loader enabled")
|
|
|
|
def _write_opts(self, value: str) -> None:
|
|
def go():
|
|
try:
|
|
bak = self.m.write_launch_options(value)
|
|
except steam.SteamRunningError as e:
|
|
self.toast(str(e))
|
|
except (OSError, steam.VDFError) as e:
|
|
self.toast(f"Could not write: {e}")
|
|
else:
|
|
self._refresh()
|
|
self.toast(f"Written. Backup: {bak.name}")
|
|
confirm(self, "Write launch options?", f"This edits Steam's localconfig.vdf:\n\n{value}", "Write", go)
|
|
|
|
|
|
class PreferencesWindow(Adw.PreferencesWindow):
|
|
def __init__(self, parent: Gtk.Window, manager: Manager, keystore: nexus.KeyStore, on_change: Callable[[], None]):
|
|
super().__init__(transient_for=parent, modal=True, title="Preferences")
|
|
self.m, self.keystore, self.on_change = manager, keystore, on_change
|
|
page = Adw.PreferencesPage(title="General")
|
|
|
|
g = Adw.PreferencesGroup(title="Deployment")
|
|
combo = Adw.ComboRow(title="Link mode", subtitle="Symlink is instant and uses no disk space",
|
|
model=Gtk.StringList.new(list(LINK_MODES)))
|
|
combo.set_selected(LINK_MODES.index(manager.settings.link_mode))
|
|
combo.connect("notify::selected", self._link_mode)
|
|
g.add(combo)
|
|
auto = Adw.SwitchRow(title="Deploy on toggle", subtitle="Off: review and apply changes with the Deploy button")
|
|
auto.set_active(manager.settings.auto_deploy)
|
|
auto.connect("notify::active", self._auto)
|
|
g.add(auto)
|
|
page.add(g)
|
|
|
|
gg = Adw.PreferencesGroup(title="Game folder")
|
|
self.folder = Adw.ActionRow(title="Override", subtitle=manager.settings.game_dir or "Auto-detect from Steam")
|
|
pick = Gtk.Button(label="Choose…", valign=Gtk.Align.CENTER)
|
|
pick.connect("clicked", self._pick_folder)
|
|
reset = Gtk.Button(label="Auto", valign=Gtk.Align.CENTER, tooltip_text="Use Steam auto-detection")
|
|
reset.connect("clicked", lambda *_: self._set_folder(""))
|
|
self.folder.add_suffix(pick)
|
|
self.folder.add_suffix(reset)
|
|
gg.add(self.folder)
|
|
page.add(gg)
|
|
|
|
n = Adw.PreferencesGroup(title="Nexus Mods",
|
|
description="An API key is needed for nxm:// downloads. Stored in the system keyring "
|
|
"when available.")
|
|
key = Adw.PasswordEntryRow(title="API key")
|
|
key.set_show_apply_button(True)
|
|
key.connect("apply", lambda r: (keystore.set(r.get_text().strip()), self.add_toast(Adw.Toast.new("API key saved"))))
|
|
n.add(key)
|
|
reg = Adw.ActionRow(title="Handle nxm:// links", subtitle="Register this app for the “Mod Manager Download” button")
|
|
rb = Gtk.Button(label="Register", valign=Gtk.Align.CENTER)
|
|
rb.connect("clicked", self._register)
|
|
reg.add_suffix(rb)
|
|
n.add(reg)
|
|
page.add(n)
|
|
self.add(page)
|
|
|
|
def _link_mode(self, row, _p):
|
|
self.m.settings.link_mode = LINK_MODES[row.get_selected()]
|
|
self.m.apply_settings()
|
|
|
|
def _auto(self, row, _p):
|
|
self.m.settings.auto_deploy = row.get_active()
|
|
self.m.apply_settings()
|
|
self.on_change()
|
|
|
|
def _pick_folder(self, *_):
|
|
d = Gtk.FileDialog(title="Select the MonsterHunterWilds folder")
|
|
|
|
def done(dlg, res):
|
|
try:
|
|
f = dlg.select_folder_finish(res)
|
|
except GLib.Error:
|
|
return
|
|
self._set_folder(f.get_path())
|
|
d.select_folder(self, None, done)
|
|
|
|
def _set_folder(self, path: str):
|
|
self.m.set_game_dir(path)
|
|
self.folder.set_subtitle(path or "Auto-detect from Steam")
|
|
self.on_change()
|
|
|
|
def _register(self, *_):
|
|
argv0 = Path(sys.argv[0]).resolve()
|
|
checkout = Path(__file__).resolve().parents[3] / "bin" / "mhwmm"
|
|
launcher = argv0 if argv0.name != "__main__.py" else (checkout if checkout.exists() else argv0)
|
|
try:
|
|
f = desktop.register_nxm_handler(str(launcher))
|
|
except OSError as e:
|
|
self.add_toast(Adw.Toast.new(f"Could not register: {e}"))
|
|
return
|
|
self.add_toast(Adw.Toast.new(f"Registered {f.name}"))
|