"""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