Merge pull request #354 from aclist/feat/launch-offline
Some checks failed
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled

feat: launch offline
This commit is contained in:
aclist 2026-06-14 22:03:17 +09:00 committed by GitHub
commit e8b18d83a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1177 additions and 128 deletions

View File

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

View File

@ -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:

View File

@ -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:

View File

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

View File

@ -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,

View File

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

View File

@ -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)

View File

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

View File

@ -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,

View File

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

View File

@ -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()

View File

@ -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)

124
dzgui/managers/offline.py Normal file
View File

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

View File

@ -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]

View File

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

View File

@ -3,3 +3,4 @@ api_validation_error = (
)
api_popover = "API key validation failed."
dayz_not_found = "Could not find a valid DayZ installation."

1
dzgui/strings/generic.py Normal file
View File

@ -0,0 +1 @@
selectable_tree = "Ctrl-click to select multiple; Shift-click to select a range."

21
dzgui/strings/offline.py Normal file
View File

@ -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 = "<b>Current folder: </b>"
unset_button = "Unset folder"

View File

@ -3,3 +3,4 @@ signatures = "Updating mod signatures"
geo = "Checking geolocation records"
coords = "Checking local coordinates"
updates = "Checking for updates"
dayz = "Looking for DayZ"

View File

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

View File

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

View File

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

View File

@ -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,

View File

@ -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))

View File

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

View File

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

View File

@ -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)

View File

@ -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)

View File

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

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

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

View File

@ -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)

View File

@ -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)

5
tests/fixtures/cpp/meta1.cpp vendored Normal file
View File

@ -0,0 +1,5 @@
protocol = 1;
publishedid = 1234567890;
name = "ModName";
timestamp = 638278452000000000;
hash = "a1b2c3d4e5f6g7h8i9j0";

5
tests/fixtures/cpp/meta2.cpp vendored Normal file
View File

@ -0,0 +1,5 @@
protocol = 1
publishedid = 1234567890
name = "ModName"
timestamp = 638278452000000000
hash = "a1b2c3d4e5f6g7h8i9j0"

5
tests/fixtures/cpp/meta3.cpp vendored Normal file
View File

@ -0,0 +1,5 @@
protocol=1
publishedid=1234567890
name="ModName"
timestamp=638278452000000000
hash="a1b2c3d4e5f6g7h8i9j0"

5
tests/fixtures/cpp/meta4.cpp vendored Normal file
View File

@ -0,0 +1,5 @@
protocol=1
publishedid=1234567890
name=ModName
timestamp=638278452000000000
hash=a1b2c3d4e5f6g7h8i9j0

5
tests/fixtures/cpp/meta5.cpp vendored Normal file
View File

@ -0,0 +1,5 @@
protocol=1;
publishedid=1234567890;
name=ModName;
timestamp=638278452000000000;
hash=a1b2c3d4e5f6g7h8i9j0;

5
tests/fixtures/cpp/meta6.cpp vendored Normal file
View File

@ -0,0 +1,5 @@
protocol=1
publishedid=1234567890;
name="ModName";
timestamp=638278452000000000;
hash= a1b2c3d4e5f6g7h8i9j0;

3
tests/fixtures/cpp/test.py vendored Normal file
View File

@ -0,0 +1,3 @@
from dzgui.api.mods import tokenize
print(tokenize("meta4.cpp"))

17
tests/test_modmeta.py Normal file
View File

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