"""Main window: Adw.NavigationSplitView with the mod list on the left and details on the right.""" from __future__ import annotations from pathlib import Path import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk, Pango from ..core import nexus from ..core.deploy import ApplyResult, DeployError from ..core.library import LibraryError from ..core.manager import Manager from .details import DetailsPane from .downloads import DownloadManager from .dialogs import (DESTRUCTIVE, GameSetupWindow, PreferencesWindow, TextDialog, alert, confirm, preview_dialog, prompt_text) from .tasks import run_async ARCHIVE_PATTERNS = ("*.zip", "*.7z", "*.rar", "*.ZIP", "*.7Z", "*.RAR", "*.pak", "*.PAK", "*.dll", "*.DLL", "*.lua", "*.LUA") class MainWindow(Adw.ApplicationWindow): def __init__(self, app: Adw.Application, manager: Manager): super().__init__(application=app, title="MH Wilds Mod Manager", default_width=1120, default_height=740) self.m = manager self.keystore = nexus.KeyStore(manager.paths.secrets_file) self.rows: dict[str, Gtk.ListBoxRow] = {} self._search = "" self._category = "" self._busy = 0 self._syncing_profiles = False self._syncing_categories = False self.toasts = Adw.ToastOverlay() self.set_content(self.toasts) self.root_stack = Gtk.Stack() self.toasts.set_child(self.root_stack) self.root_stack.add_named(self._build_no_game(), "no-game") self.root_stack.add_named(self._build_main(), "main") self._install_actions() self._install_file_drop() GLib.idle_add(self._startup) # -- construction ------------------------------------------------------------- def _build_no_game(self) -> Gtk.Widget: page = Adw.StatusPage(icon_name="dialog-question-symbolic", title="Monster Hunter Wilds not found", description="Steam does not list the game (app 2246340). Install it, or point the " "manager at the game folder.") box = Gtk.Box(spacing=12, halign=Gtk.Align.CENTER) pick = Gtk.Button(label="Choose Game Folder…") pick.add_css_class("suggested-action") pick.add_css_class("pill") pick.connect("clicked", lambda *_: self._choose_game_folder()) retry = Gtk.Button(label="Retry") retry.add_css_class("pill") retry.connect("clicked", lambda *_: self._startup()) box.append(pick) box.append(retry) page.set_child(box) tv = Adw.ToolbarView(content=page) tv.add_top_bar(Adw.HeaderBar()) return tv def _build_main(self) -> Gtk.Widget: self.split = Adw.NavigationSplitView(min_sidebar_width=300, max_sidebar_width=420, sidebar_width_fraction=0.32) # sidebar header = Adw.HeaderBar() self.dl_manager = DownloadManager() dl_btn = Gtk.MenuButton(icon_name="folder-download-symbolic", tooltip_text="Downloads", popover=self.dl_manager.popover) header.pack_start(dl_btn) imp = Gtk.Button(icon_name="list-add-symbolic", tooltip_text="Import Mod…", action_name="win.import") header.pack_start(imp) menu = Gio.Menu() section = Gio.Menu() for label, action in (("Game Setup…", "win.setup"), ("Conflicts…", "win.conflicts"), ("Undeploy All Mods", "win.undeploy-all")): section.append(label, action) menu.append_section(None, section) prof = Gio.Menu() for label, action in (("New Profile…", "win.new-profile"), ("Rename Profile…", "win.rename-profile"), ("Delete Profile", "win.delete-profile"), ("Export Modpack…", "win.export-modpack"), ("Import Modpack…", "win.import-modpack")): prof.append(label, action) menu.append_section("Profiles", prof) tail = Gio.Menu() tail.append("Preferences", "win.prefs") tail.append("Quit", "app.quit") menu.append_section(None, tail) mb = Gtk.MenuButton(icon_name="open-menu-symbolic", menu_model=menu, tooltip_text="Main menu") header.pack_end(mb) self.update_all_btn = Gtk.Button(label="Check Updates", tooltip_text="Check Nexus Mods for updates", action_name="win.update_all") header.pack_end(self.update_all_btn) self.profile_dd = Gtk.DropDown(model=Gtk.StringList.new([]), hexpand=True) self.profile_dd.connect("notify::selected", self._on_profile_selected) self.cat_dd = Gtk.DropDown(model=Gtk.StringList.new(["All categories"])) self.cat_dd.connect("notify::selected", self._on_category_selected) self.search = Gtk.SearchEntry(placeholder_text="Search mods", hexpand=True) self.search.connect("search-changed", self._on_search) controls = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, margin_start=8, margin_end=8, margin_top=6, margin_bottom=6) controls.append(self.profile_dd) controls.append(self.search) controls.append(self.cat_dd) self.listbox = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE) self.listbox.add_css_class("navigation-sidebar") self.listbox.set_filter_func(self._filter) self.listbox.connect("row-selected", self._on_row_selected) self.hint = Gtk.Label(label="Load order: later mods win conflicts. Drag ⠿ or Alt+↑/↓ to reorder.", wrap=True, xalign=0, margin_start=12, margin_end=12, margin_bottom=8) self.hint.add_css_class("caption") self.hint.add_css_class("dim-label") side_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) side_box.append(controls) side_box.append(Gtk.ScrolledWindow(child=self.listbox, vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)) side_box.append(self.hint) stv = Adw.ToolbarView(content=side_box) stv.add_top_bar(header) self.split.set_sidebar(Adw.NavigationPage.new(stv, "Mods")) # content self.details = DetailsPane(self.m, self._on_mod_edited, self._delete_mod, self._choose_dll, lambda: self.activate_action("win.import", None)) chead = Adw.HeaderBar() up = Gtk.Button(icon_name="go-up-symbolic", tooltip_text="Move up (lower priority) Alt+↑", action_name="win.move-up") down = Gtk.Button(icon_name="go-down-symbolic", tooltip_text="Move down (higher priority) Alt+↓", action_name="win.move-down") chead.pack_end(down) chead.pack_end(up) ctv = Adw.ToolbarView(content=self.details) ctv.add_top_bar(chead) self.split.set_content(Adw.NavigationPage.new(ctv, "Details")) return self.split def _install_actions(self) -> None: def add(name, cb): a = Gio.SimpleAction.new(name, None) a.connect("activate", lambda *_: cb()) self.add_action(a) add("import", self._import_dialog) add("update_all", self._check_updates_all) add("undeploy-all", self._undeploy_all) add("setup", self._open_setup) add("conflicts", self._show_conflicts) add("prefs", self._open_prefs) add("new-profile", self._new_profile) add("rename-profile", self._rename_profile) add("delete-profile", self._delete_profile) add("export-modpack", self._export_modpack) add("import-modpack", self._import_modpack) add("move-up", lambda: self._move_selected(-1)) add("move-down", lambda: self._move_selected(+1)) app = self.get_application() app.set_accels_for_action("win.move-up", ["Up"]) app.set_accels_for_action("win.move-down", ["Down"]) app.set_accels_for_action("win.import", ["o"]) def _install_file_drop(self) -> None: target = Gtk.DropTarget.new(Gdk.FileList, Gdk.DragAction.COPY) target.connect("drop", self._on_files_dropped) self.add_controller(target) # -- startup / refresh ----------------------------------------------------------------- def toast(self, msg: str, timeout: int = 4) -> None: t = Adw.Toast.new(msg) t.set_timeout(timeout) self.toasts.add_toast(t) def _startup(self) -> bool: try: rep = self.m.startup() except Exception as e: self.toast(f"Startup problem: {e}", 10) rep = None if rep is None or not rep.game_found: self.root_stack.set_visible_child_name("no-game") return False self.root_stack.set_visible_child_name("main") for n in rep.notes: self.toast(n, 12) self.refresh_all() return False def refresh_all(self) -> None: self._recompute() self._fill_profiles() self._fill_categories() self._fill_list() def _recompute(self) -> None: try: self.m.desired() except DeployError: pass def _fill_profiles(self) -> None: self._syncing_profiles = True names = self.m.profiles.names self.profile_dd.set_model(Gtk.StringList.new(names)) self.profile_dd.set_selected(names.index(self.m.profiles.active)) self._syncing_profiles = False def _fill_categories(self) -> None: self._syncing_categories = True cats = ["All categories", *self.m.library.categories()] self.cat_dd.set_model(Gtk.StringList.new(cats)) self.cat_dd.set_selected(cats.index(self._category) if self._category in cats else 0) if self._category not in cats: self._category = "" self._syncing_categories = False def _fill_list(self) -> None: keep = self.details.mod_id while (child := self.listbox.get_first_child()) is not None: self.listbox.remove(child) self.rows.clear() for mid in self.m.profiles.order(): mod = self.m.library.get(mid) if mod: row = self._make_row(mod) self.rows[mid] = row self.listbox.append(row) if keep and keep in self.rows: self.listbox.select_row(self.rows[keep]) else: self.details.clear() def _make_row(self, mod) -> Gtk.ListBoxRow: row = Gtk.ListBoxRow() row.mod_id = mod.id box = Gtk.Box(spacing=8, margin_start=4, margin_end=4, margin_top=6, margin_bottom=6) handle = Gtk.Image.new_from_icon_name("list-drag-handle-symbolic") handle.set_tooltip_text("Drag to change load order") src = Gtk.DragSource(actions=Gdk.DragAction.MOVE) src.connect("prepare", lambda _s, _x, _y, mid=mod.id: Gdk.ContentProvider.new_for_value(mid)) handle.add_controller(src) box.append(handle) check = Gtk.CheckButton(active=self.m.profiles.is_enabled(mod.id), valign=Gtk.Align.CENTER) check.connect("toggled", self._on_toggled, mod.id) box.append(check) row.check = check text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, hexpand=True) name = Gtk.Label(label=mod.name, xalign=0, ellipsize=Pango.EllipsizeMode.END) sub = Gtk.Label(label=f"v{mod.version}" if mod.version else " ", xalign=0) sub.add_css_class("caption") sub.add_css_class("dim-label") text.append(name) text.append(sub) box.append(text) if mod.category: badge = Gtk.Label(label=mod.category) badge.add_css_class("caption") badge.add_css_class("accent") box.append(badge) warn = Gtk.Image.new_from_icon_name("dialog-warning-symbolic") warn.add_css_class("warning") box.append(warn) row.warn = warn self._update_warn(row) drop = Gtk.DropTarget.new(GObject.TYPE_STRING, Gdk.DragAction.MOVE) drop.connect("drop", lambda _t, value, _x, _y, mid=mod.id: self._on_row_drop(value, mid)) row.add_controller(drop) row.set_child(box) return row def _update_warn(self, row) -> None: confs = self.m.conflicts_for(row.mod_id) if self.m.profiles.is_enabled(row.mod_id) else [] row.warn.set_visible(bool(confs)) if confs: others = sorted({self.m.library.get(i).name for c in confs for i in c.mods if i != row.mod_id and self.m.library.get(i)}) row.warn.set_tooltip_text(f"Conflicts with: {', '.join(others)}") def _update_all_warns(self) -> None: for row in self.rows.values(): self._update_warn(row) # -- filtering ----------------------------------------------------------------------------- def _filter(self, row) -> bool: mod = self.m.library.get(row.mod_id) if mod is None: return False if self._category and mod.category != self._category: return False q = self._search return not q or q in mod.name.lower() or q in mod.author.lower() or q in mod.category.lower() def _on_search(self, entry) -> None: self._search = entry.get_text().strip().lower() self.listbox.invalidate_filter() def _on_category_selected(self, dd, _p) -> None: if self._syncing_categories: return item = dd.get_selected_item() cat = item.get_string() if item else "" self._category = "" if cat == "All categories" else cat self.listbox.invalidate_filter() # -- selection / edits ------------------------------------------------------------------------ def _on_row_selected(self, _lb, row) -> None: if row is None: return mod = self.m.library.get(row.mod_id) if mod: self.details.show_mod(mod) self.split.set_show_content(True) def _on_mod_edited(self, mod_id: str) -> None: self._fill_categories() row = self.rows.get(mod_id) mod = self.m.library.get(mod_id) if row and mod: self._fill_list() self.listbox.invalidate_filter() def _selected_id(self) -> str | None: row = self.listbox.get_selected_row() return row.mod_id if row else None # -- enabling / ordering ------------------------------------------------------------------------- def _after_change(self, res: ApplyResult | None) -> None: self._recompute() self._update_all_warns() mid = self.details.mod_id if mid and self.m.library.get(mid): self.details.show_mod(self.m.library.get(mid)) if res is not None: for w in res.warnings[:3]: self.toast(w, 8) def _on_toggled(self, check, mod_id: str) -> None: on = check.get_active() try: res = self.m.set_enabled(mod_id, on) except (DeployError, OSError) as e: self.m.profiles.set_enabled(mod_id, not on) self.m.profiles.save() check.handler_block_by_func(self._on_toggled) check.set_active(not on) check.handler_unblock_by_func(self._on_toggled) self.toast(f"Deploy failed: {e}", 10) return name = self.m.library.get(mod_id).name if res is not None and (res.added or res.removed): self.toast(f"{'Enabled' if on else 'Disabled'} {name}") self._after_change(res) def _move_selected(self, delta: int) -> None: mid = self._selected_id() if not mid: return order = self.m.profiles.order() idx = order.index(mid) new = idx + delta if 0 <= new < len(order): self._reorder(mid, new) def _reorder(self, mod_id: str, new_index: int) -> None: try: res = self.m.move(mod_id, new_index) except (DeployError, OSError) as e: self.toast(f"Reorder failed: {e}", 10) return self._fill_list() self._after_change(res) def _on_row_drop(self, value, target_id: str) -> bool: order = self.m.profiles.order() if not isinstance(value, str) or value == target_id or value not in order or target_id not in order: return False self._reorder(value, order.index(target_id)) return True # -- profiles ------------------------------------------------------------------------------------------ def _on_profile_selected(self, dd, _p) -> None: if self._syncing_profiles: return item = dd.get_selected_item() if not item or item.get_string() == self.m.profiles.active: return try: self.m.switch_profile(item.get_string()) except (DeployError, OSError) as e: self.toast(f"Could not switch profile: {e}", 10) self.refresh_all() self.toast(f"Profile: {self.m.profiles.active}") def _new_profile(self) -> None: def make(name: str): try: self.m.profiles.create(name, copy_from=self.m.profiles.active) self.m.profiles.save() self.m.switch_profile(name) except Exception as e: self.toast(str(e)) self.refresh_all() prompt_text(self, "New profile", "", "Create", make) def _rename_profile(self) -> None: def rename(name: str): try: self.m.profiles.rename(self.m.profiles.active, name) self.m.profiles.save() except Exception as e: self.toast(str(e)) self._fill_profiles() prompt_text(self, "Rename profile", self.m.profiles.active, "Rename", rename) def _delete_profile(self) -> None: name = self.m.profiles.active def go(): try: self.m.profiles.delete(name) self.m.profiles.save() self.m.switch_profile(self.m.profiles.active) except Exception as e: self.toast(str(e)) self.refresh_all() if len(self.m.profiles.names) <= 1: self.toast("Cannot delete the last profile") return confirm(self, f"Delete profile “{name}”?", "Mods themselves are kept.", "Delete", go, destructive=True) def _export_modpack(self) -> None: d = Gtk.FileDialog(title="Export Modpack", initial_name=f"{self.m.profiles.active}.zip") def done(dlg, res): try: f = dlg.save_finish(res) except GLib.Error: return path = Path(f.get_path()) if not path.name.endswith(".zip"): path = path.with_name(path.name + ".zip") self._busy += 1 self.toast(f"Exporting modpack {self.m.profiles.active}...", 2) def work(): try: self.m.export_modpack(path) return True, None except Exception as e: return False, str(e) def finish(res, err): self._busy -= 1 success, error = res if error: self.toast(f"Export failed: {error}", 10) else: self.toast(f"Modpack exported to {path.name}", 8) run_async(work, finish) d.save(self, None, done) def _import_modpack(self) -> None: d = Gtk.FileDialog(title="Import Modpack") flt = Gtk.FileFilter() flt.set_name("Modpacks (.zip)") flt.add_pattern("*.zip") filters = Gio.ListStore.new(Gtk.FileFilter) filters.append(flt) d.set_filters(filters) d.set_default_filter(flt) def done(dlg, res): try: f = dlg.open_finish(res) except GLib.Error: return path = Path(f.get_path()) self._busy += 1 self.toast(f"Importing modpack...", 2) def work(): try: mods = self.m.import_modpack(path) return mods, None except Exception as e: return None, str(e) def finish(res, err): self._busy -= 1 mods, error = res if error: self.toast(f"Import failed: {error}", 10) else: self.toast(f"Imported modpack with {len(mods)} mods.", 8) self.refresh_all() run_async(work, finish) d.open(self, None, done) # -- deploy --------------------------------------------------------------------------------------------- def _check_updates_all(self) -> None: def work(): from ..core.nexus import KeyStore, NexusClient, NxmLink client = NexusClient(KeyStore(self.m.paths.config_dir / "nexus.json").get()) updates = [] for mod in self.m.library.mods.values(): if mod.nexus_mod_id: try: info = client.mod_info(NxmLink(game="monsterhunterwilds", mod_id=int(mod.nexus_mod_id), file_id=0)) if info.get("version") and info["version"] != mod.version: updates.append(mod.name) except Exception: pass return updates def done(updates, err): if err: self.toast(f"Update check failed: {err}") elif updates: self.toast("Updates available for: " + ", ".join(updates), 10) else: self.toast("All Nexus mods are up to date.") self.toast("Checking for updates...") from .tasks import run_async run_async(work, done) def _undeploy_all(self) -> None: def go(): try: res = self.m.undeploy_all() except (DeployError, OSError) as e: self.toast(f"Undeploy failed: {e}", 10) return self.toast(f"Removed {res.removed} managed file(s); profile selection is unchanged") self._after_change(res) confirm(self, "Undeploy all mods?", "Every file the manager placed in the game folder is removed and any files it backed up are restored. " "Your library and profiles are kept.", "Undeploy", go, destructive=True) def _open_setup(self) -> None: GameSetupWindow(self, self.m, self.refresh_all).present() def _open_prefs(self) -> None: PreferencesWindow(self, self.m, self.keystore, self._prefs_changed).present() def _prefs_changed(self) -> None: if self.m.settings.game_dir and (not self.m.game_dir or str(self.m.game_dir) != self.m.settings.game_dir): self._startup() def _choose_game_folder(self) -> None: d = Gtk.FileDialog(title="Select the MonsterHunterWilds folder") def done(dlg, res): try: f = dlg.select_folder_finish(res) except GLib.Error: return path = Path(f.get_path()) if not (path / "MonsterHunterWilds.exe").exists(): self.toast("That folder does not contain MonsterHunterWilds.exe") return self.m.set_game_dir(str(path)) self._startup() d.select_folder(self, None, done) def _show_conflicts(self) -> None: confs = self.m.conflicts() name = lambda i: self.m.library.get(i).name if self.m.library.get(i) else i kinds = {"loose": "Loose file", "pak-pak": "PAK vs PAK", "pak-loose": "PAK vs loose file"} text = "\n\n".join(f"{kinds[c.kind]}: {c.key}\n winner: {name(c.winner)}\n overridden: " f"{', '.join(name(i) for i in c.losers)}" for c in confs) \ or "No conflicts between enabled mods." TextDialog(self, f"Conflicts ({len(confs)})", text).present() # -- import --------------------------------------------------------------------------------------------------- def _import_dialog(self) -> None: d = Gtk.FileDialog(title="Import mod archives") flt = Gtk.FileFilter() flt.set_name("Mod archives") for p in ARCHIVE_PATTERNS: flt.add_pattern(p) allf = Gtk.FileFilter() allf.set_name("All files") allf.add_pattern("*") filters = Gio.ListStore.new(Gtk.FileFilter) filters.append(flt) filters.append(allf) d.set_filters(filters) d.set_default_filter(flt) def done(dlg, res): try: files = dlg.open_multiple_finish(res) except GLib.Error: return self.import_paths([Path(files.get_item(i).get_path()) for i in range(files.get_n_items())]) d.open_multiple(self, None, done) def _on_files_dropped(self, _t, value, _x, _y) -> bool: paths = [Path(f.get_path()) for f in value.get_files() if f.get_path()] if not paths: return False self.import_paths(paths) return True def import_paths(self, paths: list[Path]) -> None: if not self.m.deployer: self.toast("Set up the game folder first") return self._busy += 1 self.toast(f"Importing {len(paths)} item(s)…", 2) def work(): mods, errors = [], [] for p in paths: try: mods.append(self.m.import_source(p)) except (LibraryError, OSError) as e: errors.append(f"{p.name}: {e}") return mods, errors def done(result, err): self._busy -= 1 if err: self.toast(f"Import failed: {err}", 10) return mods, errors = result self.refresh_all() if mods: self.toast(f"Imported {', '.join(m.name for m in mods[:3])}" + (" …" if len(mods) > 3 else "")) self.listbox.select_row(self.rows[mods[-1].id]) for e in errors[:3]: self.toast(e, 10) run_async(work, done) # -- dll decision / delete ----------------------------------------------------------------------------------------- def _choose_dll(self, mod_id: str) -> None: def chosen(rid: str): if rid == "cancel": return self.m.update_mod(mod_id, dll_choice=rid) try: res = self.m.deploy() if self.m.profiles.is_enabled(mod_id) and self.m.settings.auto_deploy else None except (DeployError, OSError) as e: self.toast(f"Deploy failed: {e}", 10) res = None self._after_change(res) alert(self, "What is this .dll?", "A .dll at the top level of a mod could be a REFramework plugin (goes in reframework/plugins) " "or a game-root DLL such as a proxy loader. The manager will not guess.", [("cancel", "Cancel", None), ("skip", "Ignore it", None), ("root", "Game root", None), ("plugin", "REFramework plugin", None)], chosen) def _delete_mod(self, mod_id: str) -> None: mod = self.m.library.get(mod_id) if not mod: return def go(): try: self.m.delete_mod(mod_id) except (DeployError, OSError, LibraryError) as e: self.toast(f"Could not delete: {e}", 10) return self.refresh_all() self.toast(f"Deleted {mod.name}") confirm(self, f"Delete “{mod.name}”?", "It is undeployed and removed from the library, including its files.", "Delete", go, destructive=True) # -- nxm:// -------------------------------------------------------------------------------------------------------------- def handle_nxm(self, url: str) -> None: try: link = nexus.parse_nxm(url) except nexus.NexusError as e: self.toast(str(e), 8) return key = self.keystore.get() if not key: self.toast("Set your Nexus API key in Preferences first", 8) self._open_prefs() return if not self.m.deployer: self.toast("Set up the game folder first") return client = nexus.NexusClient(key) self.toast(f"Downloading mod {link.mod_id} from Nexus…", 3) self._busy += 1 dl_id = f"{link.mod_id}-{link.file_id}" dl_row = self.dl_manager.add_download(dl_id, f"Mod {link.mod_id}") def work(): info = {} try: info = nexus.prefill(link, client.mod_info(link)) except nexus.NexusError: info = nexus.prefill(link, {}) # metadata is a nicety; the download is what matters if info.get("name"): GLib.idle_add(dl_row.label.set_text, info["name"]) links = client.download_links(link) if not links: raise nexus.NexusError("Nexus returned no download servers") def progress_cb(downloaded, total): GLib.idle_add(dl_row.update_progress, downloaded, total) archive = nexus.download(links[0]["URI"], self.m.paths.downloads_dir, progress=progress_cb) GLib.idle_add(dl_row.set_status, "Importing...") mod = self.m.import_source(archive, info) pic = info.get("picture_url") if pic and not mod.screenshot: dest = mod.files_root / "nexus-thumbnail.jpg" if nexus.fetch_thumbnail(pic, dest): self.m.update_mod(mod.id, screenshot="nexus-thumbnail.jpg") return mod, client.limits.summary() def done(result, err): self._busy -= 1 if err: dl_row.set_status("Failed") err_str = GLib.markup_escape_text(str(err)) self.toast(f"Nexus download failed: {err_str}", 10) return dl_row.set_status("Complete") mod, quota = result self.refresh_all() self.toast(f"Imported {mod.name}" + (f" · {quota}" if quota else ""), 8) if mod.id in self.rows: self.listbox.select_row(self.rows[mod.id]) run_async(work, done)