Initial commit with nexus updates and streamlined UI
This commit is contained in:
commit
f780daecad
41 files changed
+4668
No files matched your search
@@ -0,0 +1,673 @@
|
||||
"""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")):
|
||||
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("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)
|
||||
|
||||
# -- 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)
|
||||
Reference in new issue
Block a user