diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index b773a57..6cecbf5 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -162,9 +162,12 @@ def load_to_menu(client: str, appid: int, name: str, mods: list[str]) -> int: proc = subprocess.run([*client_args, *params]) return proc.returncode -def launch_offline(client: str, appid: int, name: str, mods: list[str], mission: str) -> int: + +def launch_offline( + client: str, appid: int, name: str, mods: list[str], mission: str +) -> int: """Launch offline with specific mods/missions""" - concat = concat_mods(mods) + symlinks = ";".join(mods) client_args = concat_bash_args(client) params = [ "-applaunch", @@ -173,12 +176,13 @@ def launch_offline(client: str, appid: int, name: str, mods: list[str], mission: "-nosplash", "-skipintro", f"-name={name}", - f"-mod={concat}", - f"-mission={mission}" + f"-mod={symlinks}", + f"-mission={mission}", ] proc = subprocess.run([*client_args, *params]) return proc.returncode + def find_user_id(path: Path) -> str | None: resolved_path = path / "config" / "loginusers.vdf" try: diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 35b5656..78362f7 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -32,10 +32,12 @@ HEX_GREEN = "#32CD32" HEX_RED = "#FF0000" HEX_ORANGE = "#FFAC1C" +# FIXME: normalize icon names CARET_DOWN = "go-down-symbolic" CARET_UP = "go-up-symbolic" CLIPBOARD = "edit-copy-symbolic" FOLDER = "folder-symbolic" +EDIT_DELETE = "edit-delete-symbolic" ERROR = "dialog-error-symbolic" HELP_BUBBLE = "help-about-symbolic" INPUT_KEYBOARD = "input-keyboard-symbolic" diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index c43756c..d70c47d 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union import gi @@ -9,6 +9,7 @@ from gi.repository import Gdk, GObject # noqa E402 if TYPE_CHECKING: from dzgui.const.enum import NotebookPage, ServerTab from dzgui.model.model_factory import FastInsertListStore + from dzgui.views.pages.offline import FolderHBox # TODO: if servers fail to load, may leave dangling widgets waiting for a signal @@ -160,6 +161,14 @@ class Emitter(GObject.GObject): def custom_mods_loaded(self, store: "FastInsertListStore", folder: str) -> None: pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) + def custom_mods_unloaded(self, widget: "FolderHBox") -> None: + pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str, bool)) def custom_mission_loaded(self, folder: str, is_valid: bool) -> None: pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def invalid_custom_mods(self) -> None: + pass diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py index a870402..891bbdd 100644 --- a/dzgui/managers/offline.py +++ b/dzgui/managers/offline.py @@ -1,17 +1,23 @@ +import logging from pathlib import Path -from typing import TYPE_CHECKING, Union +from typing import Callable, TYPE_CHECKING, Union +import dzgui.api.pefile as PeFile from dzgui.api.mods import is_mission, get_custom_mods from dzgui.api.steam import launch_offline +from dzgui.const.constants import APP_NAME from dzgui.const.enum import Preferences from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.model.model_factory import ModelFactory from dzgui.strings import dialogs -from dzgui.util.symlink import clone_symlinks +from dzgui.util.symlink import create_custom_symlinks from dzgui.views.dialogs.filepicker import FolderPicker if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.model.model_factory import FastInsertListStore + +logger = logging.getLogger(APP_NAME) class OfflineManager: @@ -35,65 +41,73 @@ class OfflineManager: if folder is None: return is_valid = is_mission(folder) - # TODO: stop storing values in here - if is_valid: - # TODO: str - self.mission_folder = str(folder) self.emitter.emit("custom_mission_loaded", str(folder), is_valid) - def get_custom_mods(self, local_mods: list[str]) -> None: + def find_custom_mods(self, callback: Callable) -> None: folder = self.open_folderpicker(dialogs.custom_mod_dialog) + window = self.controller.get_window() + window.set_sensitive(False) if folder is None: + window.set_sensitive(True) return - self.parse_custom_mods(local_mods, folder) + # NOTE: starts spinner in calling UI + callback() + self.parse_custom_mods(folder) - @call_on_thread(dialogs.parsing_mods) - def parse_custom_mods(self, local_mods: list[str], folder: str) -> None: + @call_on_thread(dialogs.parsing_mods, show_dialog=False) + def parse_custom_mods(self, folder: str) -> None: mods = get_custom_mods(Path(folder)) store = ModelFactory().make_mod_store() store.extend(mods) - - func = StoredFunc( - lambda: self.emitter.emit("custom_mods_loaded", store, folder) - ) + func = StoredFunc(self.post_mod_loading, store, folder) self.thread_man.set_cleanup_func(func) - def setup( - self, - appid: int, - mission: str = "", - local_mods: list[str] | None = None, - custom_mods: list[str] | None = None, - ) -> None: - - self.appid = appid - self.local_mods = local_mods - self.custom_mods = custom_mods - - self.launch() + def post_mod_loading(self, store: "FastInsertListStore", folder: str) -> None: + window = self.controller.get_window() + window.set_sensitive(True) + self.emitter.emit("custom_mods_loaded", store, folder) @call_on_thread(dialogs.waiting_for_launch) - def launch(self) -> None: + def launch( + self, + appid: int, + mission: str, + local_mods: list[str], + custom_folder: str, + custom_mods: list[str], + ) -> None: + + # NOTE: local_mods and custom_mods are lists of symlinks client = self.controller.query_config(Preferences.CLIENT) name = self.controller.query_config(Preferences.NAME) steam_path = self.controller.query_config(Preferences.DEFAULT) - new_symlinks: list[str] = [] - combined_mods: list[str] = [] - if self.local_mods is not None: - combined_mods.extend(self.local_mods) - if self.custom_mods is not None: - # TODO: new function that creates symlinks in game path - # based on selected mods - clone_symlinks(Path(steam_path)) + if len(local_mods) > 0: + combined_mods.extend(local_mods) + + if len(custom_mods) > 0: + new_symlinks = create_custom_symlinks( + Path(steam_path), Path(custom_folder), custom_mods + ) combined_mods.extend(new_symlinks) - - launch_offline(client, self.appid, name, combined_mods, self.mission_folder) + launch_offline(client, appid, name, combined_mods, mission) def open_folderpicker(self, title: str) -> Union["Path", None]: picker = FolderPicker(self.controller.get_window(), title) folder = picker.pick_folder() picker.destroy() return folder + + def has_dayz_exp(self) -> bool: + try: + default_steam_path = self.controller.query_config(Preferences.DEFAULT) + steam_path = Path(default_steam_path) + dayz_exp = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) + if dayz_exp is None: + return False + return True + except Exception as e: + logger.warning(e) + return False diff --git a/dzgui/strings/offline.py b/dzgui/strings/offline.py index 5cb5410..1c52d50 100644 --- a/dzgui/strings/offline.py +++ b/dzgui/strings/offline.py @@ -12,3 +12,9 @@ local_frame = "Installed mods" no_mods = "No valid mods found" no_mission = "Not a valid mission" + +custom_eventbox = "Set the root folder. Mods must be in atomic folders and have a meta.cpp file at a minimum." +mission_eventbox = "Select a single mission folder containing an init.c file." + +folder_prefix = "Current folder: " +unset_button = "Unset folder" diff --git a/dzgui/util/symlink.py b/dzgui/util/symlink.py index 50253b8..802597f 100644 --- a/dzgui/util/symlink.py +++ b/dzgui/util/symlink.py @@ -12,10 +12,13 @@ logger = logging.getLogger(APP_NAME) def rebuild_symlinks(config: Path) -> None: + # TODO: pass direct path as argument path = lookup(config, Preferences.DEFAULT) steam_path = Path(path) dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) # NOTE: unlink stale symlinks + # TODO: expunge ephemeral (@C) links (possibility of name collisions) + # if str(file)[:2] == "@C": file.unlink() for file in dayz_path.iterdir(): if file.is_symlink() and file.exists() is False: file.unlink() @@ -30,6 +33,21 @@ def rebuild_symlinks(config: Path) -> None: clone_symlinks(steam_path) +def create_custom_symlinks(steam_path: Path, custom_dir: Path, uids: list[str]) -> None: + dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) + links = [] + for uid in uids: + md5sum = _hash(uid, use_custom=True) + source = dayz_path.joinpath(md5sum) + target = custom_dir.joinpath(uid) + links.append(md5sum) + if source.exists() is False: + source.symlink_to(target) + clone_symlinks(steam_path) + return links + + + def clone_symlinks(steam_path: Path) -> None: """ Shares symlinks between builds. Used after any symlink operation diff --git a/dzgui/views/dialogs/filepicker.py b/dzgui/views/dialogs/filepicker.py index c8dfa93..a219f49 100644 --- a/dzgui/views/dialogs/filepicker.py +++ b/dzgui/views/dialogs/filepicker.py @@ -54,7 +54,12 @@ class FolderPicker(Gtk.FileChooserDialog): return None if res == Gtk.ResponseType.OK: folder = self.get_current_folder() - if folder is not None: - self.destroy() - return Path(folder) + if folder is None: + try: + folder = Path.from_uri(self.get_uri()) + except Exception: + # FIXME + return Path("/") + self.destroy() + return Path(folder) return None diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index 99d6d81..377c369 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -1,20 +1,22 @@ from __future__ import annotations -from pathlib import Path +from enum import Enum from typing import Self, Sequence, TYPE_CHECKING, Union from dzgui.util import css -import dzgui.api.pefile as PeFile from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, APPNAME_DAYZ, APPNAME_DAYZ_EXP_HUMAN, + EDIT_DELETE, + ERROR, FOLDER, ) from dzgui.const.enum import NotebookPage, Preferences from dzgui.managers.offline import OfflineManager from dzgui.strings import generic, offline -from dzgui.views.components.buttons import IconTextButton +from dzgui.views.components.buttons import Icon, IconTextButton +from dzgui.views.components.eventbox import InfoEventBox from dzgui.views.components.frame import HeadingFrame from dzgui.views.components.scrollable import NoOverlayScrolledWindow from dzgui.views.trees.tree_mods import OfflineModTreeView @@ -23,7 +25,7 @@ from dzgui.views.trees.tree_mods import OfflineModTreeView import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, Gdk # noqa +from gi.repository import Gtk, Gdk, GObject # noqa if TYPE_CHECKING: from dzgui.controllers.mc import Controller @@ -31,6 +33,11 @@ if TYPE_CHECKING: from dzgui.model.model_factory import FastInsertListStore +class FolderError(Enum): + NO_VALID_MODS = 1 + NO_VALID_MISSION = 2 + + class GenericBox(Gtk.Box): def __init__(self, orientation: Gtk.Orientation, spacing: int = 0) -> None: super().__init__(orientation=orientation, spacing=spacing) @@ -57,15 +64,40 @@ class PageHeading(Gtk.Label): css.add_class(self, "page-heading") +class ErrorPopover(Gtk.Popover): + def __init__(self) -> None: + super().__init__(position=Gtk.PositionType.RIGHT) + + self.hbox = HBox() + self.label = Gtk.Label(label="", margin_start=10, margin_end=10) + error_icon = Icon(ERROR, margin_start=10) + self.hbox.extend([error_icon, self.label]) + self.add(self.hbox) + self.show_all() + self.popdown() + + def set_label(self, error: FolderError, msg: str) -> None: + match error: + case FolderError.NO_VALID_MODS: + prefix = offline.no_mods + case FolderError.NO_VALID_MISSION: + prefix = offline.no_mission + self.label.set_label(f"{prefix}: '{msg}'") + + class FolderHBox(HBox): - def __init__(self, btn_label: str) -> None: + def __init__(self, controller: "Controller", btn_label: str, eb_text: str) -> None: super().__init__(spacing=10) - self.set_margin_start(10) + self.set_margin_start(5) self.set_margin_end(10) self.set_margin_bottom(5) - # TODO: alternate class for left-aligned icons + self.folder = "" + self.controller = controller + self.emitter = controller.get_emitter() + + self.eb = InfoEventBox(eb_text, controller) self.button = IconTextButton(FOLDER, btn_label, Gtk.PositionType.LEFT) self.button.set_halign(Gtk.Align.START) self.button.set_image_position(Gtk.PositionType.LEFT) @@ -75,19 +107,69 @@ class FolderHBox(HBox): ) self.label = Gtk.Label() self.scrolled_label.add(self.label) - self.extend([self.button, self.scrolled_label]) + + self.unset_button = IconTextButton(EDIT_DELETE, offline.unset_button) + self.unset_button.connect("clicked", self._on_unset_clicked) + + self.spinner = Gtk.Spinner() + self.extend( + [self.eb, self.button, self.spinner, self.scrolled_label, self.unset_button] + ) + + self.pop = ErrorPopover() + self.pop.set_relative_to(self.button) + + self.connect("map", self._on_map) + self.connect("unmap", self._on_unmap) + + def start_spinner(self) -> None: + self.spinner.show() + self.spinner.start() + + def stop_spinner(self) -> None: + self.spinner.hide() + self.spinner.stop() + + def get_spinner(self) -> None: + return self.spinner + + def _on_unmap(self, widget: Self) -> None: + self.unset_button.hide() + self.label.set_label("") + self.scrolled_label.hide() + + def _on_map(self, widget: Self) -> None: + self.spinner.hide() + self.scrolled_label.show() + self.unset_button.hide() + + def _on_unset_clicked(self, button: Gtk.Button) -> None: + self.label.set_label("") + self.unset_button.hide() + # FIXME: rename signal + self.emitter.emit("custom_mods_unloaded", self) def get_button(self) -> Gtk.Button: return self.button - def set_label(self, label: str) -> None: - # TODO: strings - prefix = "Current folder: " - self.label.set_label(prefix + label) - def hide_label(self) -> None: self.label.hide() + def get_folder(self) -> str: + return self.folder + + def set_folder(self, folder: str) -> None: + prefix = offline.folder_prefix + self.folder = folder + self.label.set_markup(prefix + folder) + self.label.show() + self.unset_button.show() + + def present_error(self, error: FolderError, msg: str) -> None: + self.unset_button.hide() + self.pop.set_label(error, msg) + self.pop.popup() + class ModFrame(HeadingFrame): def __init__( @@ -120,39 +202,29 @@ class ModFrame(HeadingFrame): sel = self.tree.get_selection() sel.connect("changed", self._on_selection_changed) - # TODO: inherit icons from warnings area in preconnect dialog - # TODO: strings - self.error_label = Gtk.Label( - label="No mods found", - halign=Gtk.Align.START, - margin_start=10, - margin_bottom=5, - ) - self.vbox.pack_end(self.error_label, expand=True, fill=True, padding=3) + self.connect("unmap", self._on_unmap) - def set_error(self, msg: str) -> None: - self.error_label.set_label(msg) - self.error_label.show() + def _on_unmap(self, widget: Self) -> None: + self.tree.set_model(None) + self.tree_vbox.hide() - def hide_errors(self) -> None: - self.error_label.hide() - - def start_empty(self) -> None: - self.error_label.show() - self.collapse_tree() - - def hide_all(self) -> None: - self.error_label.hide() - self.collapse_tree() - - # TODO: warnings area - # name collision within custom mods + def start(self, store: "FastInsertListStore") -> None: + self.tree.set_model(store) + self.show_tree() def get_mods(self) -> list[str]: model, treeiters = self.tree.get_selection().get_selected_rows() if model is None: return [] - return [model[_iter][1] for _iter in treeiters] + # TODO: include mod paths for custom mods + if type(self) is CustomModFrame: + return [model[_iter][2] for _iter in treeiters] + else: + # NOTE: pre-existing, canonical symlinks to published mods + return [model[_iter][1] for _iter in treeiters] + + def show_tree(self) -> None: + self.tree_vbox.show() def collapse_tree(self) -> None: self.tree_vbox.hide() @@ -175,6 +247,8 @@ class ModFrame(HeadingFrame): else: status = f"Mods selected: {len(rows)}" self.status.set_label(status) + # TODO: cleaner delegation + # simply send int value or use emitter self.parent.check_button() def set_cursor(self) -> None: @@ -194,25 +268,39 @@ class CustomModFrame(ModFrame): self.controller = controller self.emitter = controller.get_emitter() - # TODO: descriptive text here explaining how this area works - self.custom_hbox = FolderHBox(offline.custom_button) + self.custom_hbox = FolderHBox( + controller, offline.custom_button, offline.custom_eventbox + ) self.custom_hbox.get_button().connect("clicked", self._on_custom_button_clicked) self.pack(self.custom_hbox) self.emitter.connect("custom_mods_loaded", self._on_custom_mods_loaded) + self.emitter.connect("custom_mods_unloaded", self._on_custom_mods_unloaded) + + self.connect("map", self._on_map) + + def _on_map(self, widget: Self) -> None: + self.hide_tree() + + def _on_custom_mods_unloaded(self, emitter: "Emitter", widget: FolderHBox) -> None: + # TODO: kludgy workaround for generic button emitting global signal + if widget == self.custom_hbox: + self.hide_tree() def hide_tree(self) -> None: - # TODO: no mods message is different from collision message - self.set_error(offline.no_mods) self.custom_hbox.hide_label() + self.tree.set_model(None) self.tree_vbox.hide() + def present_error(self, folder: str) -> None: + self.hide_tree() + self.custom_hbox.present_error(FolderError.NO_VALID_MODS, folder) + def present_tree(self, store: "FastInsertListStore", folder: str) -> None: - self.custom_hbox.set_label(folder) + self.custom_hbox.set_folder(folder) self.tree.set_model(store) self.tree_vbox.show() - self.hide_errors() def _on_custom_mods_loaded( self, @@ -220,14 +308,19 @@ class CustomModFrame(ModFrame): store: "FastInsertListStore", folder: str, ) -> None: + self.custom_hbox.stop_spinner() if len(store) == 0: - self.hide_tree() + self.present_error(folder) else: self.present_tree(store, folder) def _on_custom_button_clicked(self, button: Gtk.Button) -> None: - local_mods = self.get_mods() - self.parent.offline_man.get_custom_mods(local_mods) + callback = self.custom_hbox.start_spinner + # TODO: cleaner delegation + self.parent.offline_man.find_custom_mods(callback) + + def get_folder(self) -> str: + return self.custom_hbox.get_folder() class MissionFrame(HeadingFrame): @@ -238,35 +331,33 @@ class MissionFrame(HeadingFrame): self.controller = controller self.emitter = controller.get_emitter() - self.mission_hbox = FolderHBox(offline.mission_button) + self.mission_hbox = FolderHBox( + controller, offline.mission_button, offline.mission_eventbox + ) self.mission_button = self.mission_hbox.get_button() self.mission_button.connect("clicked", self._on_mission_button_clicked) - self.warning = Gtk.Label( - halign=Gtk.Align.START, margin_start=10, margin_bottom=5 - ) - self.vbox = VBox() - self.vbox.extend([self.mission_hbox, self.warning]) - self.frame.add(self.vbox) + self.frame.add(self.mission_hbox) self.emitter.connect("custom_mission_loaded", self._on_mission_loaded) def _on_mission_loaded( self, emitter: "Emitter", folder: str, is_valid: bool ) -> None: - self.mission_hbox.set_label(folder) - if not is_valid: - self.warning.show() - self.warning.set_label(offline.no_mission) + if is_valid: + self.mission_hbox.set_folder(folder) else: - self.warning.hide() + self.mission_hbox.present_error(FolderError.NO_VALID_MISSION, folder) def _on_mission_button_clicked(self, button: Gtk.Button) -> None: self.parent.offline_man.get_mission() + def get_mission(self) -> str: + return self.mission_hbox.get_folder() + class RadioFrame(HeadingFrame): - def __init__(self, controller: "Controller") -> None: + def __init__(self, parent: OfflineLoader, controller: "Controller") -> None: super().__init__(heading=offline.version) self.controller = controller @@ -286,13 +377,10 @@ class RadioFrame(HeadingFrame): self.frame.add(self.radio_box) - # TODO: abstract out of here - default_steam_path = self.controller.query_config(Preferences.DEFAULT) - steam_path = Path(default_steam_path) - dayz_exp = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) - + # TODO: cleaner delegation + has_dayz_exp = parent.offline_man.has_dayz_exp() self.dayz.connect("toggled", self._on_radio_toggled) - if dayz_exp is None: + if has_dayz_exp is False: self.dayz_exp.set_sensitive(False) def _on_radio_toggled(self, radio: Gtk.RadioButton) -> None: @@ -315,6 +403,7 @@ class OfflineLoader(Gtk.Box): self.controller = controller self.controller.register_widget("offline_loader", self) + self.emitter = controller.get_emitter() self.offline_man = OfflineManager(controller) self.add(PageHeading(offline.heading)) @@ -324,7 +413,7 @@ class OfflineLoader(Gtk.Box): self.custom_tree = OfflineModTreeView(controller) self.mission_frame = MissionFrame(self, controller) - self.radio_frame = RadioFrame(controller) + self.radio_frame = RadioFrame(self, controller) self.scrollable = Gtk.ScrolledWindow( vexpand=True, propagate_natural_height=True @@ -343,7 +432,7 @@ class OfflineLoader(Gtk.Box): # TODO: share ConnectBox class with preconnect dialog? self.button_box = HBox(spacing=5) self.button_box.set_halign(Gtk.Align.END) - self.button_box.set_margin_top(5) + self.button_box.set_margin_top(15) self.back = Gtk.Button(label="Back") self.ok = Gtk.Button(label="Launch", sensitive=False) self.back.connect("clicked", self._on_back_clicked) @@ -365,28 +454,24 @@ class OfflineLoader(Gtk.Box): self.ok.set_sensitive(True) def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: + # FIXME: widget is not always in focus if event.keyval == Gdk.KEY_Escape: self.back.emit("clicked") def populate(self, store: Union["FastInsertListStore", None]) -> None: - self.local_frame.set_model(store) - if store is None: - self.local_frame.start_empty() - else: - self.local_frame.hide_errors() # NOTE: suppress custom tree until explicitly loaded - self.custom_frame.hide_all() + if store is None: + return + self.local_frame.start(store) + # self.custom_frame.start(store) def _on_back_clicked(self, button: Gtk.Button) -> None: self.controller.open_page(NotebookPage.MODS) def _on_ok_clicked(self, button: Gtk.Button) -> None: - # TODO: grab all values in one pass - # appid = self.radio_frame.get_appid() - # mission = self.mission_frame.get_mission() - # local_mods = self.local_frame.get_mods() - # custom_mods = self.custom_frame.get_mods() - # cf. api.mods._hash(uid, use_custom=True) - # TODO: set up all symlinks prior to launch, including custom ones - # self.offline_man.setup(appid, mission, local_mods, custom_mods) - pass + appid = self.radio_frame.get_appid() + mission = self.mission_frame.get_mission() + local_mods = self.local_frame.get_mods() + custom_folder = self.custom_frame.get_folder() + custom_mods = self.custom_frame.get_mods() + self.offline_man.launch(appid, mission, local_mods, custom_folder, custom_mods)