feat: add various system and theme icons, configuration files, and support assets

This commit is contained in:
jschantli committed 2026-10-03 08:14:42 -07:00
1 parent 848dac4264
commit 54baea79f4
15893 files changed
+781426 -2

No files matched your search

Whitespace-only changes.
+47
View File
@@ -0,0 +1,47 @@
"""Adw.Application with single-instance nxm:// forwarding."""
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, Gio, GLib
from .. import APP_ID
from ..core.manager import Manager
from .window import MainWindow
class App(Adw.Application):
def __init__(self):
# HANDLES_COMMAND_LINE makes a second launch (e.g. the browser opening nxm://) forward its
# arguments to this already-running instance over D-Bus instead of opening a second window.
super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
self.manager: Manager | None = None
self.win: MainWindow | None = None
quit_action = Gio.SimpleAction.new("quit", None)
quit_action.connect("activate", lambda *_: self.quit())
self.add_action(quit_action)
self.set_accels_for_action("app.quit", ["<Primary>q"])
def _window(self) -> MainWindow:
if self.win is None:
self.manager = Manager()
self.win = MainWindow(self, self.manager)
return self.win
def do_activate(self):
self._window().present()
def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int:
args = cmdline.get_arguments()[1:]
win = self._window()
win.present()
for a in args:
if a.startswith("nxm://"):
GLib.idle_add(lambda url=a: (win.handle_nxm(url), False)[1])
elif Path(a).exists():
win.import_paths([Path(a)])
return 0
+207
View File
@@ -0,0 +1,207 @@
"""Content pane: screenshot, metadata, and Files / Conflicts tabs for the selected mod."""
from __future__ import annotations
from typing import Callable
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk
from ..core.library import Mod
from ..core.manager import Manager
MAX_FILE_LINES = 2000
class DetailsPane(Gtk.Stack):
def __init__(self, manager: Manager, on_edit: Callable[[str], None], on_delete: Callable[[str], None],
on_choose_dll: Callable[[str], None], on_import: Callable[[], None]):
super().__init__()
self.m = manager
self.on_edit, self.on_delete, self.on_choose_dll = on_edit, on_delete, on_choose_dll
self.mod_id: str | None = None
self._loading = False
self.empty = Adw.StatusPage(icon_name="package-x-generic-symbolic", title="Select a mod",
description="Pick a mod from the list to see its details.")
self.first_run = Adw.StatusPage(icon_name="folder-download-symbolic", title="No mods yet",
description="Import a mod archive (.zip, .7z, .rar) or drop one onto this window.")
btn = Gtk.Button(label="Import Mod…", halign=Gtk.Align.CENTER)
btn.add_css_class("suggested-action")
btn.add_css_class("pill")
btn.connect("clicked", lambda *_: on_import())
self.first_run.set_child(btn)
self.add_named(self.empty, "empty")
self.add_named(self.first_run, "first-run")
self.add_named(self._build_detail(), "detail")
def _build_detail(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12, margin_top=12, margin_bottom=24,
margin_start=12, margin_end=12)
self.picture = Gtk.Picture(can_shrink=True, content_fit=Gtk.ContentFit.COVER, height_request=260)
self.picture.add_css_class("card")
box.append(self.picture)
self.title = Gtk.Label(xalign=0, wrap=True, selectable=True)
self.title.add_css_class("title-1")
box.append(self.title)
self.meta = Gtk.Label(xalign=0, wrap=True)
self.meta.add_css_class("dim-label")
box.append(self.meta)
self.banner = Adw.Banner.new("")
self.banner.set_button_label("Choose…")
self.banner.connect("button-clicked", lambda *_: self.mod_id and self.on_choose_dll(self.mod_id))
box.append(self.banner)
self.desc = Gtk.Label(xalign=0, wrap=True, selectable=True)
box.append(self.desc)
edit = Adw.PreferencesGroup(title="Details")
self.name_row = Adw.EntryRow(title="Name", show_apply_button=True)
self.cat_row = Adw.EntryRow(title="Category", show_apply_button=True)
self.ver_row = Adw.EntryRow(title="Version", show_apply_button=True)
for key, row in (("name", self.name_row), ("category", self.cat_row), ("version", self.ver_row)):
row.connect("apply", self._apply, key)
edit.add(row)
self.path_row = Adw.EntryRow(title="Installed Path", editable=False)
edit.add(self.path_row)
self.game_path_row = Adw.EntryRow(title="Game Directory", editable=False)
edit.add(self.game_path_row)
self.nexus_row = Adw.ActionRow(title="Nexus Mods", subtitle="Not linked")
self.nexus_link_btn = Gtk.LinkButton(label="Open Page")
self.nexus_row.add_suffix(self.nexus_link_btn)
self.nexus_update_btn = Gtk.Button(label="Check Update", valign=Gtk.Align.CENTER)
self.nexus_update_btn.connect("clicked", self._check_update_clicked)
self.nexus_row.add_suffix(self.nexus_update_btn)
edit.add(self.nexus_row)
box.append(edit)
stack = Adw.ViewStack()
self.files_view = Gtk.TextView(editable=False, cursor_visible=False, monospace=True, top_margin=8,
bottom_margin=8, left_margin=8, right_margin=8)
files_scroll = Gtk.ScrolledWindow(min_content_height=220, child=self.files_view)
self.conflicts_view = Gtk.TextView(editable=False, cursor_visible=False, wrap_mode=Gtk.WrapMode.WORD_CHAR,
top_margin=8, bottom_margin=8, left_margin=8, right_margin=8)
conf_scroll = Gtk.ScrolledWindow(min_content_height=220, child=self.conflicts_view)
self.files_page = stack.add_titled(files_scroll, "files", "Files")
self.conf_page = stack.add_titled(conf_scroll, "conflicts", "Conflicts")
switcher = Adw.ViewSwitcher(stack=stack, policy=Adw.ViewSwitcherPolicy.WIDE, halign=Gtk.Align.START)
box.append(switcher)
box.append(stack)
delete = Gtk.Button(label="Delete Mod…", halign=Gtk.Align.START)
delete.add_css_class("destructive-action")
delete.connect("clicked", lambda *_: self.mod_id and self.on_delete(self.mod_id))
box.append(delete)
clamp = Adw.Clamp(maximum_size=760, child=box)
return Gtk.ScrolledWindow(child=clamp, hscrollbar_policy=Gtk.PolicyType.NEVER)
def _check_update_clicked(self, btn) -> None:
if not self.mod_id: return
mod = self.m.library.get(self.mod_id)
if not mod or not mod.nexus_mod_id: return
def work():
from ..core.nexus import KeyStore, NexusClient, NxmLink
client = NexusClient(KeyStore(self.m.paths.config_dir / "nexus.json").get())
return client.mod_info(NxmLink(game="monsterhunterwilds", mod_id=int(mod.nexus_mod_id), file_id=0))
def done(info, err):
if err:
self.nexus_row.set_subtitle(f"Check failed: {err}")
elif info:
latest = info.get("version", "Unknown")
if latest != mod.version:
self.nexus_row.set_subtitle(f"Update available! Latest: v{latest}")
self.nexus_update_btn.add_css_class("suggested-action")
else:
self.nexus_row.set_subtitle("Up to date")
self.nexus_update_btn.remove_css_class("suggested-action")
self.nexus_row.set_subtitle("Checking...")
from .tasks import run_async
run_async(work, done)
def _apply(self, row: Adw.EntryRow, key: str) -> None:
if self._loading or not self.mod_id:
return
text = row.get_text().strip()
if key == "name" and not text:
return
self.m.update_mod(self.mod_id, **{key: text})
self.on_edit(self.mod_id)
def clear(self) -> None:
self.mod_id = None
self.set_visible_child_name("empty" if self.m.library.mods else "first-run")
def show_mod(self, mod: Mod) -> None:
self._loading = True
self.mod_id = mod.id
shot = mod.screenshot_path
self.picture.set_filename(str(shot) if shot else None)
self.picture.set_visible(bool(shot))
self.title.set_label(mod.name)
bits = [b for b in (f"v{mod.version}" if mod.version else "", f"by {mod.author}" if mod.author else "",
mod.category) if b]
self.meta.set_label(" · ".join(bits) or "No metadata")
self.desc.set_label(mod.description)
self.desc.set_visible(bool(mod.description))
for row, val in ((self.name_row, mod.name), (self.cat_row, mod.category), (self.ver_row, mod.version)):
row.set_text(val)
self.path_row.set_text(str(mod.files_root))
if mod.nexus_url:
self.nexus_row.set_visible(True)
self.nexus_link_btn.set_uri(mod.nexus_url)
self.nexus_row.set_subtitle(f"Linked (ID: {mod.nexus_mod_id})")
self.nexus_update_btn.remove_css_class("suggested-action")
else:
self.nexus_row.set_visible(False)
cls = self.m.classification(mod)
if self.m.game_dir and cls.entries:
import os
paths = [os.path.dirname(e.dest) for e in cls.entries if e.dest]
if not paths:
self.game_path_row.set_text(str(self.m.game_dir))
else:
common = os.path.commonpath(paths) if len(paths) > 1 else paths[0]
self.game_path_row.set_text(str(self.m.game_dir / common))
elif self.m.game_dir:
self.game_path_row.set_text(str(self.m.game_dir))
else:
self.game_path_row.set_text("Not deployed or no files")
self.banner.set_title(f"{len(cls.ambiguous)} .dll file(s) at the top level: plugin or root-level DLL?")
self.banner.set_revealed(bool(cls.ambiguous))
lines = [f"[{e.kind}] {e.dest or e.src.name}" + (f" ({e.chain} chain)" if e.kind == "pak" else "")
for e in cls.entries]
lines += [f"[?] {p.name} (undecided .dll)" for p in cls.ambiguous]
lines += [f"note: {n}" for n in cls.notes]
if len(lines) > MAX_FILE_LINES:
lines = lines[:MAX_FILE_LINES] + [f"… {len(lines) - MAX_FILE_LINES} more"]
self.files_view.get_buffer().set_text("\n".join(lines) or "No deployable files")
confs = self.m.conflicts_for(mod.id)
names = lambda ids: ", ".join(self.m.library.get(i).name if self.m.library.get(i) else i for i in ids)
if not confs:
text = "No conflicts with other enabled mods." if self.m.profiles.is_enabled(mod.id) \
else "Enable this mod to check for conflicts."
else:
text = "\n\n".join(
f"{c.key}\n {'PAK / loose' if c.kind == 'pak-loose' else 'PAK' if c.kind == 'pak-pak' else 'Loose file'}"
f" · winner: {names([c.winner])}\n overridden: {names(c.losers)}" for c in confs)
self.conflicts_view.get_buffer().set_text(text)
self.conf_page.set_badge_number(len(confs)) if hasattr(self.conf_page, "set_badge_number") else None
self.set_visible_child_name("detail")
self._loading = False
+294
View File
@@ -0,0 +1,294 @@
"""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}"))
@@ -0,0 +1,71 @@
from __future__ import annotations
import gi
from gi.repository import GLib, Gtk, Pango, Adw
class DownloadRow(Gtk.ListBoxRow):
def __init__(self, name: str):
super().__init__()
self.set_selectable(False)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
vbox.set_margin_start(12)
vbox.set_margin_end(12)
vbox.set_margin_top(8)
vbox.set_margin_bottom(8)
self.label = Gtk.Label(label=name, xalign=0.0)
self.label.set_ellipsize(Pango.EllipsizeMode.END)
self.label.add_css_class("heading")
vbox.append(self.label)
self.progress_bar = Gtk.ProgressBar()
self.progress_bar.set_show_text(True)
vbox.append(self.progress_bar)
self.set_child(vbox)
def update_progress(self, downloaded: int, total: int) -> None:
if total > 0:
fraction = downloaded / total
self.progress_bar.set_fraction(fraction)
self.progress_bar.set_text(f"{int(fraction * 100)}%")
else:
self.progress_bar.pulse()
self.progress_bar.set_text("Downloading...")
def set_status(self, status: str) -> None:
self.progress_bar.set_text(status)
class DownloadManager:
def __init__(self):
self.popover = Gtk.Popover()
self.scrolled = Gtk.ScrolledWindow()
self.scrolled.set_min_content_width(450)
self.scrolled.set_min_content_height(250)
self.scrolled.set_max_content_height(600)
self.listbox = Gtk.ListBox()
self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
self.listbox.add_css_class("boxed-list")
self.listbox.set_margin_start(12)
self.listbox.set_margin_end(12)
self.listbox.set_margin_top(12)
self.listbox.set_margin_bottom(12)
self.scrolled.set_child(self.listbox)
self.popover.set_child(self.scrolled)
self.active_downloads: dict[str, DownloadRow] = {}
def add_download(self, download_id: str, name: str) -> DownloadRow:
row = DownloadRow(name)
self.active_downloads[download_id] = row
self.listbox.append(row)
return row
def remove_download(self, download_id: str) -> None:
if download_id in self.active_downloads:
row = self.active_downloads.pop(download_id)
self.listbox.remove(row)
+19
View File
@@ -0,0 +1,19 @@
"""Run blocking work off the GTK main loop."""
from __future__ import annotations
import threading
from typing import Callable
from gi.repository import GLib
def run_async(work: Callable[[], object], done: Callable[[object, Exception | None], None]) -> None:
"""Execute `work` in a thread; call `done(result, error)` on the main loop."""
def target():
try:
result, error = work(), None
except Exception as e: # surfaced to the UI as a toast
result, error = None, e
GLib.idle_add(lambda: (done(result, error), False)[1])
threading.Thread(target=target, daemon=True).start()
+749
View File
@@ -0,0 +1,749 @@
"""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)