Files
MHW-Mod-Manager/squashfs-root/usr/share/mhwmm/ui/dialogs.py
T

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}"))