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