diff --git a/CHANGELOG.md b/CHANGELOG.md index 59243cc..188c9ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ - Preboot progress dialog - Choose to jump into splash screen instead of server - Collapsible connection panel +- Play offline (load mods directly) ## Changed - Conform to PEP 440 versioning for beta versions diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 944df15..81b6d1c 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -1,6 +1,6 @@ import hashlib import logging -import shlex +import re from concurrent.futures import wait from concurrent.futures import ThreadPoolExecutor @@ -12,6 +12,7 @@ from dzgui.api.servers import get_rules, fqip_to_record from dzgui.const.constants import ( APP_NAME, APPID_DAYZ, + DAYZ_COMMUNITY_ROOT, LIBRARYFOLDERS_PATH, WORKSHOP_PATH, ) @@ -48,36 +49,32 @@ def get_local_mods(workshop_path: Path) -> list[Path]: mods = [file for file in workshop_path.iterdir() if file.is_dir()] return mods +def is_mission(path: Path) -> bool: + # TODO: parse integrity of other files + parent = path.parent.name + if parent != DAYZ_COMMUNITY_ROOT: + return False + file = path / "init.c" + return file.exists() -def parse_meta(file: Path) -> ModMeta | None: - mod = file / "meta.cpp" - if mod.exists() is False: +def tokenize(mod: Path) -> dict[str, Any] | None: + file = mod.joinpath("meta.cpp") + delimiter=r"\s*=\s*" + modmeta = {} + try: + with open(file, "r", encoding="utf-8") as f: + for line in f: + line = line.strip().rstrip(";") + if not line: + continue + els = re.split(delimiter, line, maxsplit=1) + if len(els) == 2: + key, value = els + modmeta[key.strip()] = value.strip('"') + return modmeta + except Exception as e: + logger.critical(e) return None - with open(file / "meta.cpp", "r") as f: - st = f.read() - lex = shlex.shlex(st) - lex.whitespace += "=;" - v = [] - while True: - tok = lex.get_token() - if not tok: - break - if tok == "protocol" or tok == "publishedid": - ntok = lex.get_token() - elif tok == "timestamp": - # NOTE: some malformed .NET tick conversions result in numbers < 0 - ntok = lex.get_token() - if ntok == "-": - ntok += str(lex.get_token()) - elif tok == "name": - ntok = lex.get_token() - if ntok is not None: - ntok = ntok.split('"')[1] - if ntok is not None: - v.append(ntok) - meta = ModMeta(*v) - return meta - def get_mod_size(path: Path) -> float: s = 0 @@ -87,33 +84,43 @@ def get_mod_size(path: Path) -> float: return size -def get_delimited_mods(steam_path: Path) -> list[Any]: - workshop_path = get_local_mod_path(steam_path) - mods = get_local_mods(workshop_path) +def get_custom_mods(path: Path) -> list[Any]: + # TODO: rename this function for clarity ("get local vs. get custom") + mods = get_local_mods(path) + # TODO: error handling + return parse_mods(mods, use_custom=True) + + +def parse_mods(mods: list[Path], use_custom: bool = False) -> list[Any]: clean = [] for mod in mods: mod_dir = mod.name - symlink = _hash(mod_dir) - # FIXME: malformed .cpp files could break this - # mention that mods may be downloading - meta = parse_meta(mod) + symlink = _hash(mod_dir, use_custom) + meta = tokenize(mod) if meta is None: continue size = get_mod_size(mod) # NOTE: final col is cell renderer highlight toggle - clean.append([meta.name, symlink, mod_dir, size, False]) + clean.append([meta["name"], symlink, mod_dir, size, False]) clean.sort(key=lambda row: str(row[0]).casefold()) return clean +def get_delimited_mods(steam_path: Path) -> list[Any]: + workshop_path = get_local_mod_path(steam_path) + mods = get_local_mods(workshop_path) + return parse_mods(mods) + + def get_missing_mods(local: list, remote: list) -> list: return [mod for mod in remote if mod not in local] -def _hash(uid: str) -> str: +def _hash(uid: str, use_custom: bool = False) -> str: md5 = hashlib.md5() md5.update(uid.encode("ascii")) - return "@" + md5.hexdigest()[:8] + prefix = "@C" if use_custom else "@" + return prefix + md5.hexdigest()[:8] def remove_stale_signatures(config: Path, versions: Path) -> None: diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index f079bf8..63ca05f 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -158,6 +158,26 @@ def load_to_menu(client: str, appid: int, name: str, mods: list[str]) -> int: return proc.returncode +def launch_offline( + client: str, appid: int, name: str, mods: list[str], mission: str +) -> int: + """Launch offline with specific mods/missions""" + symlinks = ";".join(mods) + client_args = concat_bash_args(client) + params = [ + "-applaunch", + str(appid), + "-nolauncher", + "-nosplash", + "-skipintro", + f"-name={name}", + 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 4f0bf2e..d9689d2 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -32,9 +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" @@ -80,3 +83,5 @@ LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG") TMP_PATH = "/tmp" TMP_TARBALL = "/tmp/dzgui.tar.gz" TMP_EXE = "/tmp/dzgui/dzgui" + +DAYZ_COMMUNITY_ROOT = "Missions" diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index aad7698..9e84cc5 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -103,6 +103,7 @@ class Preferences(EnumWithAttrs): class NotebookPage(EnumWithAttrs): + OFFLINE = {"crumbs": strings.crumbs.offline, "statusbar": True} CHANGELOG = {"crumbs": strings.crumbs.changelog, "statusbar": True} DEVELOPERS = {"crumbs": strings.crumbs.developers, "statusbar": False} HELP = {"crumbs": strings.crumbs._help, "statusbar": True} @@ -184,6 +185,7 @@ class ContextMenuGroup(Enum): SERVER_MOD = (ContextMenu.OPEN_WORKSHOP,) MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD) + MOD_OFFLINE = (None,) LOG = (ContextMenu.COPY_LOG_CLIPBOARD,) SERVER_BROWSER = ( ContextMenu.CONNECT, diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index d22d585..c90aef7 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -8,6 +8,8 @@ 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 @@ -154,3 +156,19 @@ class Emitter(GObject.GObject): @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) def lan_page_initialized(self) -> None: pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, str)) + 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/controllers/mc.py b/dzgui/controllers/mc.py index e0c57c0..c7168ab 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -20,7 +20,6 @@ from dzgui.controllers.emitter import Emitter from dzgui.managers.config import ConfigManager from dzgui.managers.connection import ConnectionManager from dzgui.managers.contextmenu import ContextMenuManager -from dzgui.managers.mods import ModManager from dzgui.managers.notes import NoteManager from dzgui.model.servers import ServerModelManager from dzgui.util.diag import write_diagnostic @@ -47,12 +46,13 @@ if TYPE_CHECKING: from dzgui.views.components.filter_panel import FilterPanel from dzgui.views.components.right_panel import RightPanel from dzgui.views.components.statusbar import Statusbar + from dzgui.views.pages.offline import OfflineLoader from dzgui.views.pages.options import Options from dzgui.views.pages.preconnect import PreConnectionAssistant from dzgui.views.pages.servers import ServerNotebook from dzgui.views.trees.tree_log import LogTreeView from dzgui.views.trees.tree_menu import MenuTreeView - from dzgui.views.trees.tree_mods import ModTreeView + from dzgui.views.trees.tree_mods import ModTreeView, OfflineModTreeView from dzgui.views.trees.tree_servers import ServerTreeView @@ -72,6 +72,7 @@ class AppNavigation: filters: "FilterPanel" preconnect: "PreConnectionAssistant" options: "Options" + offline_loader: "OfflineLoader" class Controller(GObject.GObject): @@ -218,19 +219,29 @@ class Controller(GObject.GObject): open_user_workshop(uid, client) def load_mods(self) -> None: - self.mod_man = ModManager(self) + self.mediator.modtreeview.load_mods() def uncolorize_mods(self) -> None: - self.mod_man.uncolorize_mods() + mod_man = self.mediator.modtreeview.get_mod_man() + mod_man.uncolorize_mods() def highlight_stale(self) -> None: - self.mod_man.highlight_stale() + mod_man = self.mediator.modtreeview.get_mod_man() + mod_man.highlight_stale() def toggle_mod_selection(self, state: bool) -> None: - self.mod_man.toggle_mod_selection(state) + mod_man = self.mediator.modtreeview.get_mod_man() + mod_man.toggle_mod_selection(state) - def delete_mods(self) -> None: - self.mod_man.delete_mods() + def delete_mods( + self, treeview: Union["ModTreeView", "OfflineModTreeView", None] = None + ) -> None: + if treeview is None: + view = self.mediator.modtreeview + else: + view = treeview + mod_man = view.get_mod_man() + mod_man.delete_mods() def get_mod_store(self) -> Gtk.TreeModel | None: return self.mediator.modtreeview.get_model() @@ -257,7 +268,8 @@ class Controller(GObject.GObject): dialog.run() def select_colorized(self) -> None: - self.mod_man.select_colorized() + mod_man = self.mediator.modtreeview.get_mod_man() + mod_man.select_colorized() def get_all_tree_filters(self) -> dict[str, dict[str, bool]]: servers = self.get_servers() @@ -533,3 +545,9 @@ class Controller(GObject.GObject): self.clear_cancel_event() return True return False + + def open_offline(self) -> None: + self.open_page(NotebookPage.OFFLINE) + mod_man = self.mediator.modtreeview.get_mod_man() + store = mod_man.store + self.mediator.offline_loader.populate(store) diff --git a/dzgui/init/dayz.py b/dzgui/init/dayz.py index 1dbbdbc..66f60d9 100644 --- a/dzgui/init/dayz.py +++ b/dzgui/init/dayz.py @@ -1,12 +1,10 @@ import logging -import sys from pathlib import Path from dzgui.config.query import lookup from dzgui.const.constants import APPID_DAYZ, APP_NAME, LIBRARYFOLDERS_PATH from dzgui.const.enum import Preferences -from dzgui.views.dialogs.early_alert import EarlyAlertDialog import dzgui.api.pefile as PeFile @@ -19,5 +17,4 @@ def is_dayz_installed(config: Path) -> None: PeFile.get_app_path(Path(path) / LIBRARYFOLDERS_PATH, APPID_DAYZ) except Exception as e: logger.critical(e) - EarlyAlertDialog(str(e)) - sys.exit(1) + raise e diff --git a/dzgui/main.py b/dzgui/main.py index 45edf9d..7a05637 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -11,7 +11,6 @@ from dzgui.const.enum import Preferences from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs from dzgui.config.xdg import get_xdg_paths, parse_filepaths -from dzgui.init.dayz import is_dayz_installed from dzgui.init.flock import lock_acquire from dzgui.init.migrate import ( has_new_config, @@ -124,18 +123,9 @@ def main() -> None: if has_steam_client() is False: EarlyAlertDialog(init.requires_steam) - is_dayz_installed(XDG.config) - bootwin = BootWindow(XDG, version) local_coords, latest_release = bootwin.get_results() - # rebuild_symlinks(XDG.config) - # remove_stale_signatures(XDG.config, XDG.version) - - ## TODO: handle IP DB failure and use coords fallback - # local_coords = get_local_coords(XDG.ips) - # latest_release = check_updates(version) - use_miles = lookup(XDG.config, Preferences.DIST) prefs = UserPrefs( is_steam_deck=_is_steam_deck, diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index e8784be..c0d092e 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -278,6 +278,7 @@ class ConnectionManager: self.thread_man.show_cancel(False) self.thread_man.update_dialog(waiting_for_launch) while True: + # FIXME: cancel should not be visible here per setting above if self.controller.is_cancel_pending(): # TODO: some facility to also close spawned steam process return diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py index 33aea9a..7701b1c 100644 --- a/dzgui/managers/contextmenu.py +++ b/dzgui/managers/contextmenu.py @@ -8,7 +8,7 @@ from dzgui.util.clip import copy_clipboard from dzgui.util.open_links import open_workshop_page from dzgui.views.dialogs.note import NoteDialog -from dzgui.views.trees.tree_mods import ModTreeView +from dzgui.views.trees.tree_mods import ModTreeView, OfflineModTreeView from dzgui.views.trees.tree_log import LogTreeView from dzgui.views.trees.tree_servers import ServerTreeView from dzgui.views.trees.tree_server_mods import ServerModTreeView @@ -27,7 +27,13 @@ if TYPE_CHECKING: class ContextMenuManager: def __init__( self, - treeview: LogTreeView | ModTreeView | ServerTreeView | ServerModTreeView, + treeview: ( + LogTreeView + | ModTreeView + | ServerTreeView + | ServerModTreeView + | OfflineModTreeView + ), controller: "Controller", ) -> None: self.controller = controller @@ -72,10 +78,10 @@ class ContextMenuManager: case ContextMenu.SHOW_MODS: self.controller.get_modlist(record) - if isinstance(self.treeview, ModTreeView): + if isinstance(self.treeview, (ModTreeView, OfflineModTreeView)): match action: case ContextMenu.DELETE_MOD: - self.controller.delete_mods() + self.controller.delete_mods(self.treeview) case ContextMenu.OPEN_WORKSHOP: self.open_mod_page() diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index fdb0c50..05e9996 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -2,7 +2,7 @@ import logging import shutil from pathlib import Path -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING from dzgui.api.mods import ( get_delimited_mods, @@ -40,20 +40,20 @@ class ModManager: this manager is instantiated each time the Mods page is opened """ - def __init__(self, controller: "Controller") -> None: + def __init__(self, tree: Gtk.TreeView, controller: "Controller") -> None: + self.treeview = tree self.controller = controller + self.emitter = controller.get_emitter() self.prefs = controller.get_prefs() self.path = controller.query_config(Preferences.DEFAULT) - self.treeview = self.controller.get_modtreeview() - self.store: FastInsertListStore + self.store: FastInsertListStore | None = None self.thread_man = ThreadingManager(controller) - self._get_mods() @call_on_thread(dialogs.fetching_mods) - def _get_mods(self) -> None: + def load_mods(self) -> None: mods = get_delimited_mods(self.path) if len(mods) < 1: msg = self.format_mod_statusbar() @@ -61,13 +61,18 @@ class ModManager: self.thread_man.set_cleanup_func(func) return - func = StoredFunc(self._on_mods_loaded, mods) - self.thread_man.set_cleanup_func(func) - - def _on_mods_loaded(self, mods: list[list[Any]]) -> None: self.store = ModelFactory().make_mod_store() self.store.extend(mods) + func = StoredFunc(self._on_mods_loaded) + self.thread_man.set_cleanup_func(func) + + def set_store(self, store: "FastInsertListStore") -> None: + self.store = store + + def _on_mods_loaded(self) -> None: self.treeview.set_model(self.store) + if self.store is None: + return msg = self.format_mod_statusbar() total_mods = len(self.store) self.emitter.emit("mods_updated", msg, total_mods) @@ -124,6 +129,8 @@ class ModManager: pass def _on_mods_deleted(self, iters: list[Gtk.TreeIter]) -> None: + if self.store is None: + return for _iter in iters: self.store.remove(_iter) remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version) diff --git a/dzgui/managers/offline.py b/dzgui/managers/offline.py new file mode 100644 index 0000000..5f49f09 --- /dev/null +++ b/dzgui/managers/offline.py @@ -0,0 +1,124 @@ +import logging +import time +from pathlib import Path +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, APPID_DAYZ_EXP +from dzgui.const.enum import Preferences +from dzgui.init.proc import is_dayz_running +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 create_custom_symlinks, symlink_mission +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: + def __init__( + self, + controller: "Controller", + ) -> None: + super().__init__() + + self.controller = controller + self.emitter = controller.get_emitter() + self.thread_man = ThreadingManager(controller) + + self.appid: int + self.mission_folder: str + self.local_mods: list[str] | None + self.custom_mods: list[str] | None + + def get_mission(self) -> None: + folder = self.open_folderpicker(dialogs.mission_dialog) + if folder is None: + return + is_valid = is_mission(folder) + self.emitter.emit("custom_mission_loaded", str(folder), is_valid) + + 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 + # NOTE: starts spinner in calling UI + callback() + self.parse_custom_mods(folder) + + @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(self.post_mod_loading, store, folder) + self.thread_man.set_cleanup_func(func) + + 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, + 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) + + combined_mods: list[str] = [] + + 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) + + relative_link = "" + if len(mission) > 0: + relative_link = symlink_mission(Path(steam_path), mission) + + launch_offline(client, appid, name, combined_mods, relative_link) + while True: + if is_dayz_running(): + break + time.sleep(1) + + 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/model/model_factory.py b/dzgui/model/model_factory.py index ed1ccdc..daefeb3 100644 --- a/dzgui/model/model_factory.py +++ b/dzgui/model/model_factory.py @@ -165,3 +165,6 @@ class ModelFactory: def make_server_store(self) -> FastInsertListStore: return self.new_model_from_class(ServerCols) + + # def convert_model_to_list(self, model: "FastInsertListStore") -> list: + # return [[el for el in row] for row in model] diff --git a/dzgui/strings/dialogs.py b/dzgui/strings/dialogs.py index 4732557..5df7bb7 100644 --- a/dzgui/strings/dialogs.py +++ b/dzgui/strings/dialogs.py @@ -9,5 +9,13 @@ load_error_lan = "Failed to find any servers on your network.\nCheck the server fetching_mods = "Fetching mod metadata" deleting_mods = "Deleting mods" scanning_mods = "Scanning mods" +parsing_mods = "Parsing mods" checking_api = "Checking API key" + +running = "Running" +failed = "FAILED" +ok = "OK" + +mission_dialog = "Set a mission folder location" +custom_mod_dialog = "Set location to a custom mods folder" diff --git a/dzgui/strings/errors.py b/dzgui/strings/errors.py index 32d8741..005a93b 100644 --- a/dzgui/strings/errors.py +++ b/dzgui/strings/errors.py @@ -3,3 +3,4 @@ api_validation_error = ( ) api_popover = "API key validation failed." +dayz_not_found = "Could not find a valid DayZ installation." diff --git a/dzgui/strings/generic.py b/dzgui/strings/generic.py new file mode 100644 index 0000000..1aff78a --- /dev/null +++ b/dzgui/strings/generic.py @@ -0,0 +1 @@ +selectable_tree = "Ctrl-click to select multiple; Shift-click to select a range." diff --git a/dzgui/strings/offline.py b/dzgui/strings/offline.py new file mode 100644 index 0000000..875c574 --- /dev/null +++ b/dzgui/strings/offline.py @@ -0,0 +1,21 @@ +heading = "Play offline" + +mission_button = "Select mission folder" +mission_frame = "Mission" + +version = "DayZ Version" + +custom_frame = "Custom mods" +custom_button = "Set custom mod folder" + +local_frame = "Installed mods" + +no_local_mods = "No local mods found" +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/strings/preboot.py b/dzgui/strings/preboot.py index 01a7ad6..69fffe2 100644 --- a/dzgui/strings/preboot.py +++ b/dzgui/strings/preboot.py @@ -3,3 +3,4 @@ signatures = "Updating mod signatures" geo = "Checking geolocation records" coords = "Checking local coordinates" updates = "Checking for updates" +dayz = "Looking for DayZ" diff --git a/dzgui/util/format.py b/dzgui/util/format.py index d5420cb..e7b4d07 100644 --- a/dzgui/util/format.py +++ b/dzgui/util/format.py @@ -1,6 +1,7 @@ import re from dzgui.util.localize import number +from dzgui.strings.generic import selectable_tree from dzgui.util.strings import no_mods, no_servers, workshop import gi @@ -46,8 +47,7 @@ def format_mods(size: int, mods: int) -> str: return no_mods l_size = number(size) plural = pluralize("mods", mods) - # TODO: strings - suffix = "Ctrl-click to select multiple; Shift-click to select a range." + suffix = selectable_tree return f"Found {mods:n} {plural} taking up {l_size} MiB. {suffix}" diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index dad40fa..3d1a0e4 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -64,7 +64,7 @@ connect = "Connect" # Columns server_mod_cols = ["Mod", "ID", "Installed"] -mod_cols = ["Mod", "Symlink", "Dir", "Size (MiB)", "Color"] +mod_cols = ["Mod", "Symlink", "ID", "Size (MiB)", "Color"] log_cols = ["Timestamp", "Flag", "Traceback", "Message"] browser_cols = [ "Name", @@ -273,6 +273,7 @@ class Crumbs: thanks: str developers: str default: str + offline: str @dataclass(slots=True, frozen=True) @@ -462,6 +463,7 @@ crumbs = Crumbs( thanks="Help > Special thanks", developers="Options > Developers", default="Servers > ", + offline="Mods > Play offline" ) no_mods = "No local mods found." diff --git a/dzgui/util/symlink.py b/dzgui/util/symlink.py index 50253b8..a686dd2 100644 --- a/dzgui/util/symlink.py +++ b/dzgui/util/symlink.py @@ -2,7 +2,7 @@ import logging from pathlib import Path from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash -from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME +from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME, DAYZ_COMMUNITY_ROOT from dzgui.const.enum import Preferences from dzgui.config.query import lookup @@ -11,14 +11,23 @@ import dzgui.api.pefile as PeFile logger = logging.getLogger(APP_NAME) +def expunge_link(file: Path) -> None: + if not file.is_symlink(): + return + # NOTE: unlink stale symlinks + if file.exists() is False: + file.unlink() + if str(file.stem)[:2] == "@C": + file.unlink() + 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 for file in dayz_path.iterdir(): - if file.is_symlink() and file.exists() is False: - file.unlink() + expunge_link(file) + workshop = get_local_mod_path(steam_path) # NOTE: create symlinks for missing mods for mod_id in get_local_mod_ids(steam_path): @@ -30,6 +39,37 @@ def rebuild_symlinks(config: Path) -> None: clone_symlinks(steam_path) +def create_custom_symlinks( + steam_path: Path, custom_dir: Path, uids: list[str] +) -> list[str]: + 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 symlink_mission(steam_path: Path, target: str) -> str: + dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) + for file in dayz_path.iterdir(): + expunge_link(file) + path = Path(target) + stem = path.name + parent = path.parent + source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT) + try: + source.symlink_to(parent) + except Exception: + return "" + return f"{DAYZ_COMMUNITY_ROOT}/{stem}" + + def clone_symlinks(steam_path: Path) -> None: """ Shares symlinks between builds. Used after any symlink operation diff --git a/dzgui/views/base.py b/dzgui/views/base.py index f9b7a00..c6451b5 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -23,6 +23,8 @@ from dzgui.views.pages.help import Help from dzgui.views.pages.keys import Keybindings from dzgui.views.pages.log import Log from dzgui.views.pages.mods import Mods + +from dzgui.views.pages.offline import OfflineLoader from dzgui.views.pages.options import Options from dzgui.views.pages.preconnect import PreConnectionAssistant from dzgui.views.pages.servers import ServerNotebook @@ -68,7 +70,6 @@ class OuterWindow(Gtk.Window): MainController.set_start_tab() self.grid.hide_widgets_on_init() - # TODO: POC, trigger page change here MainController.loaded = True MainController.populate_model(MainController.get_active_treeview()) @@ -110,8 +111,11 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore self.connection = PreConnectionAssistant(MainController) + self.offline = OfflineLoader(MainController) + self.pages = { self.help: NotebookPage.HELP, + self.offline: NotebookPage.OFFLINE, self.clog: NotebookPage.CHANGELOG, self.keys: NotebookPage.KEYS, self.settings: NotebookPage.OPTIONS, diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index affcc5b..63b1986 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -31,40 +31,61 @@ if TYPE_CHECKING: class Icon(Gtk.Image): - def __init__(self, name: str, l_margin: int = 0) -> None: + def __init__(self, name: str, margin_start: int = 0, margin_end: int = 0) -> None: super().__init__( icon_name=name, icon_size=Gtk.IconSize.BUTTON, - margin_start=l_margin, + margin_start=margin_start, + margin_end=margin_end, ypad=2, ) class LargeIcon(Gtk.Image): - def __init__(self, name: str, l_margin: int = 5) -> None: + def __init__(self, name: str, margin_start: int = 5, margin_end: int = 0) -> None: super().__init__( - icon_name=name, icon_size=Gtk.IconSize.LARGE_TOOLBAR, margin_start=l_margin + icon_name=name, + icon_size=Gtk.IconSize.LARGE_TOOLBAR, + margin_start=margin_start, + margin_end=margin_end, ) class IconButton(Gtk.Button): - def __init__(self, icon: str, margin: int = 0) -> None: + def __init__( + self, + icon: str, + position: Gtk.PositionType = Gtk.PositionType.RIGHT, + margin_start: int = 0, + margin_end: int = 0, + ) -> None: super().__init__() - self.icon = Icon(icon, l_margin=margin) + self.icon = Icon(icon, margin_start=margin_start, margin_end=margin_end) self.set_image(self.icon) - self.set_image_position(Gtk.PositionType.RIGHT) + self.set_image_position(position) + # self.set_image_position(Gtk.PositionType.RIGHT) self.set_focus_on_click(False) class IconTextButton(IconButton): - def __init__(self, icon: str, label: str) -> None: - super().__init__(icon, margin=5) + def __init__( + self, icon: str, label: str, position: Gtk.PositionType = Gtk.PositionType.RIGHT + ) -> None: + match position: + case Gtk.PositionType.RIGHT: + super().__init__(icon, position, margin_start=5) + case Gtk.PositionType.LEFT: + super().__init__(icon, position, margin_end=5) + case _: + raise ValueError("Unsupported position type") self.set_label(label) class LargeIconTextButton(IconButton): - def __init__(self, icon: str, label: str) -> None: - super().__init__(icon) + def __init__( + self, icon: str, label: str, position: Gtk.PositionType = Gtk.PositionType.RIGHT + ) -> None: + super().__init__(icon, position) self.set_label(label) self.set_image(LargeIcon(icon)) diff --git a/dzgui/views/components/connect_box.py b/dzgui/views/components/connect_box.py new file mode 100644 index 0000000..3f1200b --- /dev/null +++ b/dzgui/views/components/connect_box.py @@ -0,0 +1,68 @@ +from dzgui.strings import preconnect +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gdk, Gtk # noqa E402 + + +class ConnectBox(Gtk.Box): + def __init__(self) -> None: + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + + self.back = Gtk.Button( + label=preconnect.back, halign=Gtk.Align.END, hexpand=True + ) + + # TODO: dynamically update this + self.ok = Gtk.Button(label="Launch", halign=Gtk.Align.END) + + for button in self.back, self.ok: + self.add(button) + + def get_back_button(self) -> Gtk.Button: + return self.back + + def get_ok_button(self) -> Gtk.Button: + return self.ok + + +class PreconnectBox(Gtk.Box): + def __init__(self) -> None: + super().__init__( + orientation=Gtk.Orientation.VERTICAL, + valign=Gtk.Align.END, + vexpand=True, + spacing=5, + ) + + self.button_hbox = ConnectBox() + self.ok = self.button_hbox.get_ok_button() + self.ok.set_label(preconnect.update_mods) + + self.connect_last = Gtk.Button( + label=preconnect.connect_last, + halign=Gtk.Align.END, + tooltip_text=preconnect.connect_last_tooltip, + ) + self.button_hbox.add(self.connect_last) + + self.raise_window = Gtk.CheckButton( + # TODO: strings + label="Foreground DZGUI while downloading", + halign=Gtk.Align.END, + hexpand=True, + valign=Gtk.Align.END, + visible=False, + has_tooltip=True, + sensitive=False, + tooltip_text="Foreground the DZGUI window after mod downloads are queued", + active=True, + ) + self.add(self.button_hbox) + self.add(self.raise_window) + + def get_back_button(self) -> Gtk.Button: + return self.button_hbox.get_back_button() + + def get_ok_button(self) -> Gtk.Button: + return self.ok diff --git a/dzgui/views/components/frame.py b/dzgui/views/components/frame.py index c782d5f..dab2671 100644 --- a/dzgui/views/components/frame.py +++ b/dzgui/views/components/frame.py @@ -1,3 +1,4 @@ +from typing import Self from dzgui.util import css import gi @@ -6,16 +7,23 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa +# TODO: subclass Gtk.Frame directly class HeadingFrame(Gtk.Box): - def __init__(self, widget: Gtk.Widget, heading: str) -> None: + def __init__(self, heading: str = "") -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL) - label = Gtk.Label(label=heading) - label.set_halign(Gtk.Align.START) - css.add_class(label, "settings-subheading") + self.label = Gtk.Label(label=heading) + self.label.set_halign(Gtk.Align.START) + css.add_class(self.label, "settings-subheading") - frame = Gtk.Frame(hexpand=True) - frame.add(widget) - frame.set_label_widget(label) + self.frame = Gtk.Frame(hexpand=True) + self.frame.set_label_widget(self.label) - self.add(frame) + self.add(self.frame) + + @classmethod + def new_with_widget_and_label(cls, widget: Gtk.Widget, label: str) -> Self: + n = cls() + n.frame.add(widget) + n.label.set_label(label) + return n diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 54dae5d..7eb75a3 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -71,6 +71,7 @@ class Statusbar(Gtk.Grid): NotebookPage.CHANGELOG, NotebookPage.LOG, NotebookPage.CONNECTION, + NotebookPage.OFFLINE, ): self.prior_enum = enum self.set_by_context(enum, esc_to_return) @@ -106,6 +107,7 @@ class Statusbar(Gtk.Grid): self.prior_status = "" # FIXME: CalcDist is being called when table is not loaded if dist is None: + self.set_by_context(context, "") return else: pretty = self.append_distance(dist) diff --git a/dzgui/views/dialogs/boot.py b/dzgui/views/dialogs/boot.py index 4dc5821..8491530 100644 --- a/dzgui/views/dialogs/boot.py +++ b/dzgui/views/dialogs/boot.py @@ -1,19 +1,19 @@ import sys -from typing import Literal, TYPE_CHECKING +from typing import Any, Literal, Self, TYPE_CHECKING # import time from enum import Enum -from typing import Any, Self # TODO: import dialog titles from dzgui.api.mods import remove_stale_signatures as remove_stale from dzgui.config.ipdb import get_ipdb from dzgui.const.constants import HEX_GREEN, HEX_RED from dzgui.init.coords import get_local_coords +from dzgui.init.dayz import is_dayz_installed from dzgui.init.update import check_updates from dzgui.const.constants import EXPAND, FILL from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager -from dzgui.strings import preboot +from dzgui.strings import preboot, dialogs from dzgui.util.format import format_exception from dzgui.util.strings import dialog_header from dzgui.util.symlink import rebuild_symlinks @@ -58,6 +58,9 @@ class BootDialog(Gtk.Dialog): self.view = Gtk.TreeView(enable_search=False, headers_visible=False) self.view.set_model(self.store) + # TODO: capture sigint in early dialogs + self.connect("key-press-event", self._on_keypress) + for i, column_title in enumerate(["Task", "State"]): renderer = Gtk.CellRendererText() column = Gtk.TreeViewColumn(column_title, renderer, text=i) @@ -119,6 +122,7 @@ class BootDialog(Gtk.Dialog): self.error_box.hide() steps = [ + (StoredFunc(is_dayz_installed, self.xdg.config), preboot.dayz, False), (StoredFunc(rebuild_symlinks, self.xdg.config), preboot.symlinks, False), ( StoredFunc(remove_stale, self.xdg.config, self.xdg.version), @@ -126,6 +130,7 @@ class BootDialog(Gtk.Dialog): False, ), (StoredFunc(get_ipdb, self.xdg.ips), preboot.geo, False), + # TODO: handle IP DB failure and use coords fallback (StoredFunc(get_local_coords, self.xdg.ips), preboot.coords, True), (StoredFunc(check_updates, self.version), preboot.updates, True), ] @@ -135,6 +140,10 @@ class BootDialog(Gtk.Dialog): GLib.timeout_add(100, self.pulse_spinner) + def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: + if event.keyval == Gdk.KEY_Escape: + self.exit_button.emit("clicked") + def pulse_spinner(self) -> Literal[True]: for row in self.store: if row[2]: @@ -181,10 +190,10 @@ class BootDialog(Gtk.Dialog): self.thread_man.set_cleanup_func(callback) def update_task(self, task: str) -> None: - self.store.append((task, "Running", True, 0)) + self.store.append((task, dialogs.running, True, 0)) def update_status(self, state: Success) -> None: - d = {Success.OK: "OK", Success.FAIL: "FAILED"} + d = {Success.OK: dialogs.ok, Success.FAIL: dialogs.failed} msg = d[state] self.store[len(self.store) - 1][1] = msg self.iter_step() @@ -197,7 +206,7 @@ class BootDialog(Gtk.Dialog): it: Gtk.TreeIter, data: Any, ) -> None: - if model[it][1] == "Running": + if model[it][1] == dialogs.running: cell.set_property("visible", True) else: cell.set_property("visible", False) @@ -214,9 +223,9 @@ class BootDialog(Gtk.Dialog): state = model[it][1] if column.get_sort_column_id() != 1: return - if state == "OK": + if state == dialogs.ok: cell.set_property(prop, HEX_GREEN) - elif state == "FAILED": + elif state == dialogs.failed: cell.set_property(prop, HEX_RED) else: cell.set_property(prop, None) diff --git a/dzgui/views/dialogs/filepicker.py b/dzgui/views/dialogs/filepicker.py index 0691bc8..1f9e0b2 100644 --- a/dzgui/views/dialogs/filepicker.py +++ b/dzgui/views/dialogs/filepicker.py @@ -33,3 +33,36 @@ class FilePicker(Gtk.FileChooserDialog): self.destroy() return Path(file) return None + + +class FolderPicker(Gtk.FileChooserDialog): + def __init__(self, parent: Gtk.Window, title: str) -> None: + super().__init__( + title=title, + action=Gtk.FileChooserAction.SELECT_FOLDER, + parent=parent, + resizable=True, + ) + self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL) + self.add_buttons("_OK", Gtk.ResponseType.OK) + self.set_default_response(Gtk.ResponseType.OK) + + def pick_folder(self) -> Path | None: + res = self.run() + if res in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT): + self.destroy() + return None + if res == Gtk.ResponseType.OK: + folder = self.get_current_folder() + if folder is None: + try: + uri = self.get_uri() + if uri is None: + return None + return Path.from_uri(uri) + except Exception: + # FIXME: edge cases + return Path("/") + self.destroy() + return Path(folder) + return None diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index f8c4b83..19c0f42 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -44,6 +44,8 @@ class ContextMixin(TreeView): self.context_menu.connect("key-press-event", self._on_key) for row in group.value: + if row is None: + return False item = self._process_dynamic_row(row) self.context_menu.append(item) diff --git a/dzgui/views/pages/mods.py b/dzgui/views/pages/mods.py index f87d993..fdd1e7b 100644 --- a/dzgui/views/pages/mods.py +++ b/dzgui/views/pages/mods.py @@ -1,4 +1,5 @@ from typing import Self, TYPE_CHECKING +from dzgui.const.enum import NotebookPage from dzgui.views.components.scrollable import NoOverlayScrolledWindow from dzgui.views.trees.tree_mods import ModTreeView @@ -11,26 +12,50 @@ if TYPE_CHECKING: from dzgui.controllers.mc import Controller - -class Mods(NoOverlayScrolledWindow): +class Mods(Gtk.Box): def __init__(self, controller: "Controller") -> None: - super().__init__() + super().__init__(orientation=Gtk.Orientation.VERTICAL) - self.controller = controller - self.emitter = controller.get_emitter() + self.box = NoOverlayScrolledWindow() self.tree = ModTreeView(controller) + self.tree.set_vexpand(True) + self.box.add(self.tree) - self.add(self.tree) + self.statusbar_cache = "" + self.controller = controller self.controller.register_widget("modtreeview", self.tree) + self.emitter = controller.get_emitter() + + self.offline_button = Gtk.Button( + label="Play offline", + halign=Gtk.Align.START, + margin_top=10, + margin_bottom=10, + ) + self.offline_button.connect("clicked", self._on_offline_clicked) + + self.add(self.offline_button) + self.add(self.box) self.connect("map", self._on_map) self.connect("unmap", self._on_unmap) + def _on_offline_clicked(self, button: Gtk.Button) -> None: + self.statusbar_cache = self.controller.get_statusbar().get_cache() + self.controller.open_offline() + def _on_unmap(self, widget: Self) -> None: self.emitter.emit("mod_page_toggled", False) def _on_map(self, widget: Self) -> None: self.emitter.emit("mod_page_toggled", True) + # TODO: delegation + # NOTE: handles going back from offline mods page + # more generic cache restoration method + if len(self.statusbar_cache) > 0: + self.controller.get_statusbar().set_by_context( + NotebookPage.MODS, self.statusbar_cache + ) def grab_content_area(self) -> None: self.tree.grab_focus() diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py new file mode 100644 index 0000000..ff618a9 --- /dev/null +++ b/dzgui/views/pages/offline.py @@ -0,0 +1,503 @@ +from __future__ import annotations +from enum import Enum +from typing import Self, Sequence, TYPE_CHECKING, Union + +from dzgui.util import css +from dzgui.const.constants import ( + APPID_DAYZ, + APPID_DAYZ_EXP, + APPNAME_DAYZ, + APPNAME_DAYZ_EXP_HUMAN, + EDIT_DELETE, + ERROR, + FOLDER, + WARNING, +) +from dzgui.const.enum import ContextMenuGroup, NotebookPage +from dzgui.managers.offline import OfflineManager +from dzgui.strings import generic, offline +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 + + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Gdk, GObject # noqa + +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter + 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) + + def extend(self, els: Sequence[Gtk.Widget]) -> None: + for el in els: + self.add(el) + + +class HBox(GenericBox): + def __init__(self, spacing: int = 0) -> None: + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=spacing) + + +class VBox(GenericBox): + def __init__(self, spacing: int = 0) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing) + + +class PageHeading(Gtk.Label): + def __init__(self, label: str) -> None: + super().__init__(label=label, halign=Gtk.Align.CENTER) + + 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, controller: "Controller", btn_label: str, eb_text: str) -> None: + super().__init__(spacing=10) + + self.set_margin_start(5) + self.set_margin_end(10) + self.set_margin_bottom(5) + + 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) + + self.set_can_focus(True) + + self.scrolled_label = Gtk.ScrolledWindow( + propagate_natural_width=True, halign=Gtk.Align.START + ) + self.label = Gtk.Label() + self.scrolled_label.add(self.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.pop.connect("unmap", lambda _: self.grab_focus()) + + 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 _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.folder = "" + 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 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.folder = "" + self.label.set_text("") + self.unset_button.hide() + self.pop.set_label(error, msg) + self.pop.popup() + + +class ModFrame(HeadingFrame): + def __init__( + self, parent: OfflineLoader, controller: "Controller", label: str + ) -> None: + super().__init__(heading=label) + + self.parent = parent + self.vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + self.tree = OfflineModTreeView(controller) + self.tree.set_model(None) + self.tree.set_valign(Gtk.Align.FILL) + + self.scrolled = NoOverlayScrolledWindow() + self.scrolled.set_margin_end(5) + self.scrolled.set_vexpand(True) + self.scrolled.add(self.tree) + + self.status = Gtk.Label( + halign=Gtk.Align.START, margin_start=5, margin_top=3, margin_bottom=3 + ) + + self.tree_vbox = VBox() + self.tree_vbox.extend([self.scrolled, self.status]) + + self.no_mods = HBox(spacing=10) + no_mods_icon = Icon(WARNING, margin_start=10) + no_mods_label = Gtk.Label(label=offline.no_local_mods, halign=Gtk.Align.START) + self.no_mods.extend([no_mods_icon, no_mods_label]) + + self.vbox.pack_end(self.no_mods, expand=True, fill=True, padding=3) + self.vbox.pack_end(self.tree_vbox, expand=True, fill=True, padding=3) + self.frame.add(self.vbox) + + sel = self.tree.get_selection() + sel.connect("changed", self._on_selection_changed) + + self.connect("map", self._on_map) + self.connect("unmap", self._on_unmap) + + def start_empty(self) -> None: + self.tree_vbox.hide() + self.no_mods.show() + + def _on_map(self, widget: Self) -> None: + self.no_mods.hide() + self.tree.set_model(None) + self.tree_vbox.hide() + + def _on_unmap(self, widget: Self) -> None: + self.tree.set_model(None) + self.tree_vbox.hide() + self.no_mods.hide() + + def start(self, store: "FastInsertListStore") -> None: + # TODO: this manipulates mod man store out of band + self.tree.set_model(store) + self.tree.mod_man.set_store(store) + self.show_tree() + + def get_mods(self) -> list[str]: + model, treeiters = self.tree.get_selection().get_selected_rows() + if model is None: + return [] + if type(self) is CustomModFrame: + # NOTE: custom mod dirs to be hashed + 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() + + def get_tree(self) -> OfflineModTreeView: + return self.tree + + def pack(self, widget: Gtk.Widget) -> None: + self.vbox.pack_start(widget, expand=False, fill=False, padding=5) + + def set_model(self, model: Union["FastInsertListStore", None]) -> None: + self.tree.set_model(model) + self.tree.mod_man.store = model + self.set_cursor() + + def _on_selection_changed(self, sel: Gtk.TreeSelection) -> None: + model, rows = sel.get_selected_rows() + if len(rows) == 0: + status = generic.selectable_tree + 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: + path = Gtk.TreePath.new_from_indices([0]) + self.tree.set_cursor(path) + self.tree.get_selection().unselect_all() + + +class CustomModFrame(ModFrame): + def __init__( + self, parent: OfflineLoader, controller: "Controller", heading: str + ) -> None: + super().__init__(parent, controller, heading) + + self.tree.set_menu(ContextMenuGroup.MOD_OFFLINE) + self.parent = parent + + self.controller = controller + self.emitter = controller.get_emitter() + + 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: + self.custom_hbox.hide_label() + self.tree.set_model(None) + self.tree_vbox.hide() + self.no_mods.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_folder(folder) + self.tree.set_model(store) + self.tree_vbox.show() + + def _on_custom_mods_loaded( + self, + emitter: "Emitter", + store: "FastInsertListStore", + folder: str, + ) -> None: + self.custom_hbox.stop_spinner() + if len(store) == 0: + self.present_error(folder) + else: + self.present_tree(store, folder) + + def _on_custom_button_clicked(self, button: Gtk.Button) -> None: + 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): + def __init__(self, parent: OfflineLoader, controller: "Controller") -> None: + super().__init__(heading=offline.mission_frame) + + self.parent = parent + self.controller = controller + self.emitter = controller.get_emitter() + + 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.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: + if is_valid: + self.mission_hbox.set_folder(folder) + else: + self.mission_hbox.present_error(FolderError.NO_VALID_MISSION, folder) + self.parent.check_button() + + 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, parent: OfflineLoader, controller: "Controller") -> None: + super().__init__(heading=offline.version) + + self.controller = controller + + self.id_map = {APPNAME_DAYZ: APPID_DAYZ, APPNAME_DAYZ_EXP_HUMAN: APPID_DAYZ_EXP} + self.appid = APPID_DAYZ + + self.dayz = Gtk.RadioButton.new_with_label(None, APPNAME_DAYZ) + self.dayz_exp = Gtk.RadioButton.new_with_label_from_widget( + self.dayz, APPNAME_DAYZ_EXP_HUMAN + ) + + self.radio_box = HBox() + self.radio_box.extend([self.dayz, self.dayz_exp]) + self.radio_box.set_margin_start(10) + self.radio_box.set_margin_bottom(5) + + self.frame.add(self.radio_box) + + # TODO: cleaner delegation + has_dayz_exp = parent.offline_man.has_dayz_exp() + self.dayz.connect("toggled", self._on_radio_toggled) + if has_dayz_exp is False: + self.dayz_exp.set_sensitive(False) + + def _on_radio_toggled(self, radio: Gtk.RadioButton) -> None: + for el in self.dayz, self.dayz_exp: + if el.get_active(): + label = el.get_label() + self.appid = self.id_map[label] + + def get_appid(self) -> int: + return self.appid + + +class OfflineLoader(Gtk.Box): + def __init__(self, controller: "Controller"): + super().__init__( + orientation=Gtk.Orientation.VERTICAL, + margin_start=10, + margin_end=10, + ) + + 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)) + + self.local_frame = ModFrame(self, controller, offline.local_frame) + self.custom_frame = CustomModFrame(self, controller, offline.custom_frame) + + self.custom_tree = OfflineModTreeView(controller) + self.mission_frame = MissionFrame(self, controller) + self.radio_frame = RadioFrame(self, controller) + + self.scrollable = Gtk.ScrolledWindow( + vexpand=True, propagate_natural_height=True + ) + + self.content_box = VBox(spacing=5) + self.content_box.extend( + [ + self.local_frame, + self.custom_frame, + self.mission_frame, + self.radio_frame, + ] + ) + + # 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(15) + self.back = Gtk.Button(label="Back") + self.ok = Gtk.Button(label="Launch", sensitive=False) + self.back.connect("clicked", self._on_back_clicked) + self.ok.connect("clicked", self._on_ok_clicked) + self.connect("key-press-event", self._on_keypress) + self.emitter.connect("custom_mods_unloaded", lambda _, __: self.check_button()) + + self.button_box.extend([self.back, self.ok]) + + self.scrollable.add(self.content_box) + self.add(self.scrollable) + self.add(self.button_box) + + def check_button(self) -> None: + local_mods = self.local_frame.get_mods() + custom_mods = self.custom_frame.get_mods() + mission = self.mission_frame.get_mission() + if len(local_mods) == 0 and len(custom_mods) == 0 and len(mission) == 0: + self.ok.set_sensitive(False) + else: + self.ok.set_sensitive(True) + + def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: + if event.keyval == Gdk.KEY_Escape: + self.back.emit("clicked") + + def populate(self, store: Union["FastInsertListStore", None]) -> None: + if store is None: + self.local_frame.start_empty() + return + self.local_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: + 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) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index e38d6ea..29b5130 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -187,12 +187,14 @@ class Options(Gtk.Box): if prefs.is_debug: grid.attach(developers, 1, 0, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT) - for frame in [ - HeadingFrame(api_box, strings.options.api_keys), - HeadingFrame(prefs_grid, strings.options.prefs), - HeadingFrame(mods_grid, strings.options.mods), - HeadingFrame(version_grid, strings.options.version), + for pair in [ + (api_box, strings.options.api_keys), + (prefs_grid, strings.options.prefs), + (mods_grid, strings.options.mods), + (version_grid, strings.options.version), ]: + + frame = HeadingFrame.new_with_widget_and_label(*pair) grid.attach(frame, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT) row += 1 diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 964f27b..aa1dc15 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -154,7 +154,7 @@ class PreConnectionAssistant(Gtk.Box): self.progress_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=15) self.progress_box.add(self.mod_count) - self.scrolled = Gtk.ScrolledWindow() + self.scrolled = Gtk.ScrolledWindow(margin_end=10) self.scrolled.add(self.tree) self.scrolled.set_size_request(600, 400) @@ -166,7 +166,9 @@ class PreConnectionAssistant(Gtk.Box): self.mods_placeholder = Placeholder("This server has no mods.") self.tree_box.add(self.mods_placeholder) - self.tree_frame = HeadingFrame(self.tree_box, preconnect.mods) + self.tree_frame = HeadingFrame.new_with_widget_and_label( + self.tree_box, preconnect.mods + ) # TODO: abstract into components self.warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) @@ -175,7 +177,9 @@ class PreConnectionAssistant(Gtk.Box): self.warning_placeholder = Placeholder("No warnings.") self.warning_box.add(self.warning_tree) self.warning_box.add(self.warning_placeholder) - self.warning_frame = HeadingFrame(self.warning_box, preconnect.warnings) + self.warning_frame = HeadingFrame.new_with_widget_and_label( + self.warning_box, preconnect.warnings + ) self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.error_tree = MaskedTree(ERROR) @@ -183,7 +187,9 @@ class PreConnectionAssistant(Gtk.Box): self.error_placeholder = Placeholder("No errors.") self.error_box.add(self.error_tree) self.error_box.add(self.error_placeholder) - self.error_frame = HeadingFrame(self.error_box, preconnect.errors) + self.error_frame = HeadingFrame.new_with_widget_and_label( + self.error_box, preconnect.errors + ) self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) self.box.add(self.title) diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index b39daf0..64dd55c 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -3,6 +3,7 @@ from typing import Any, TYPE_CHECKING from dzgui.const.constants import APP_NAME, HEX_RED from dzgui.const.enum import ContextMenuGroup +from dzgui.managers.mods import ModManager from dzgui.util import strings, localize from dzgui.views.mixins.context_mixin import ContextMixin from dzgui.views.mixins.mods_mixin import ModsMixin @@ -22,9 +23,14 @@ logger = logging.getLogger(APP_NAME) class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore - def __init__(self, controller: "Controller") -> None: - super().__init__(controller, menu=ContextMenuGroup.MOD) + def __init__( + self, controller: "Controller", menu: ContextMenuGroup = ContextMenuGroup.MOD + ) -> None: + super().__init__(controller, menu=menu) + self.controller = controller + self.mod_man = ModManager(self, controller) + emitter = self.controller.get_emitter() emitter.connect("mods_updated", self._on_mods_updated) emitter.connect("mods_highlighted", self._on_mods_highlighted) @@ -56,6 +62,33 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore self.connect("button-press-event", self.present_menu) self.connect("key-press-event", self.present_menu) + self.connect("button-press-event", self.on_row_click) + + def on_row_click(self, widget: Gtk.TreeView, event: Gdk.EventButton) -> bool: + if event.state is Gdk.ModifierType.CONTROL_MASK and event.button == 1: + path_info = widget.get_path_at_pos(int(event.x), int(event.y)) + if path_info is None: + return False + path, column, cellx, celly = path_info + model = self.get_model() + if model is None: + return False + if path is None: + return False + _iter = model.get_iter(path) + if self.get_selection().iter_is_selected(_iter): + self.get_selection().unselect_iter(_iter) + else: + self.get_selection().select_iter(_iter) + return True + return False + + def get_mod_man(self) -> ModManager: + return self.mod_man + + def load_mods(self) -> None: + self.mod_man.load_mods() + def _on_mods_highlighted(self, emitter: "Emitter") -> None: self.get_selection().unselect_all() @@ -105,3 +138,13 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore val = model[it][3] formatted = localize.number(val) cell.set_property("text", formatted) + # NOTE: reapply color property after float conversion + self._format_color(column, cell, model, it, data) + + def set_menu(self, context: ContextMenuGroup) -> None: + self.menu = context + + +class OfflineModTreeView(ModTreeView): + def __init__(self, controller: "Controller") -> None: + super().__init__(controller) diff --git a/tests/fixtures/cpp/meta1.cpp b/tests/fixtures/cpp/meta1.cpp new file mode 100644 index 0000000..b43c25f --- /dev/null +++ b/tests/fixtures/cpp/meta1.cpp @@ -0,0 +1,5 @@ +protocol = 1; +publishedid = 1234567890; +name = "ModName"; +timestamp = 638278452000000000; +hash = "a1b2c3d4e5f6g7h8i9j0"; diff --git a/tests/fixtures/cpp/meta2.cpp b/tests/fixtures/cpp/meta2.cpp new file mode 100644 index 0000000..4308b4f --- /dev/null +++ b/tests/fixtures/cpp/meta2.cpp @@ -0,0 +1,5 @@ +protocol = 1 +publishedid = 1234567890 +name = "ModName" +timestamp = 638278452000000000 +hash = "a1b2c3d4e5f6g7h8i9j0" diff --git a/tests/fixtures/cpp/meta3.cpp b/tests/fixtures/cpp/meta3.cpp new file mode 100644 index 0000000..2b32c3d --- /dev/null +++ b/tests/fixtures/cpp/meta3.cpp @@ -0,0 +1,5 @@ +protocol=1 +publishedid=1234567890 +name="ModName" +timestamp=638278452000000000 +hash="a1b2c3d4e5f6g7h8i9j0" diff --git a/tests/fixtures/cpp/meta4.cpp b/tests/fixtures/cpp/meta4.cpp new file mode 100644 index 0000000..1a8d18a --- /dev/null +++ b/tests/fixtures/cpp/meta4.cpp @@ -0,0 +1,5 @@ +protocol=1 +publishedid=1234567890 +name=ModName +timestamp=638278452000000000 +hash=a1b2c3d4e5f6g7h8i9j0 diff --git a/tests/fixtures/cpp/meta5.cpp b/tests/fixtures/cpp/meta5.cpp new file mode 100644 index 0000000..bfd7976 --- /dev/null +++ b/tests/fixtures/cpp/meta5.cpp @@ -0,0 +1,5 @@ +protocol=1; +publishedid=1234567890; +name=ModName; +timestamp=638278452000000000; +hash=a1b2c3d4e5f6g7h8i9j0; diff --git a/tests/fixtures/cpp/meta6.cpp b/tests/fixtures/cpp/meta6.cpp new file mode 100644 index 0000000..4991854 --- /dev/null +++ b/tests/fixtures/cpp/meta6.cpp @@ -0,0 +1,5 @@ +protocol=1 +publishedid=1234567890; +name="ModName"; +timestamp=638278452000000000; +hash= a1b2c3d4e5f6g7h8i9j0; diff --git a/tests/fixtures/cpp/test.py b/tests/fixtures/cpp/test.py new file mode 100644 index 0000000..453b6d7 --- /dev/null +++ b/tests/fixtures/cpp/test.py @@ -0,0 +1,3 @@ +from dzgui.api.mods import tokenize + +print(tokenize("meta4.cpp")) diff --git a/tests/test_modmeta.py b/tests/test_modmeta.py new file mode 100644 index 0000000..f2f4ca4 --- /dev/null +++ b/tests/test_modmeta.py @@ -0,0 +1,17 @@ +import pytest +from dzgui.api.mods import tokenize +from tests.fixtures import fixture_path + +@pytest.mark.mods +@pytest.mark.parametrize("fixture", [ + fixture_path("cpp/meta1.cpp"), + fixture_path("cpp/meta2.cpp"), + fixture_path("cpp/meta3.cpp"), + fixture_path("cpp/meta4.cpp"), + fixture_path("cpp/meta5.cpp"), + fixture_path("cpp/meta6.cpp"), + ] +) +def test_modmeta(fixture: str) -> None: + meta = tokenize(fixture) + assert meta["name"] == "ModName"