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,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
|
||||
Reference in new issue
Block a user