750 lines
30 KiB
Python
750 lines
30 KiB
Python
"""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", ["<Alt>Up"])
|
|
app.set_accels_for_action("win.move-down", ["<Alt>Down"])
|
|
app.set_accels_for_action("win.import", ["<Primary>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)
|