Compare commits

..

21 Commits

Author SHA1 Message Date
aclist
ad85bb7343 drop collision check for custom mods
Some checks are pending
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
Mod hashes are taken from the directory names, which cannot be the same
at the filesystem level. Custom mod hashes are also unique from local
mods because they are given a different namespace.
2026-06-11 16:17:02 +09:00
aclist
77df66979a chore: cleanup 2026-06-11 15:40:09 +09:00
aclist
d4825390f7 chore: update strings 2026-06-11 15:39:52 +09:00
aclist
d5a857fe3a fix: parse tree iters correctly 2026-06-11 15:31:49 +09:00
aclist
241df72bf3 fix: pass custom flag 2026-06-11 15:31:17 +09:00
aclist
4f51d962e7 fix: parse filepath correctly 2026-06-11 15:15:43 +09:00
aclist
98b550cec6 chore: dedicated mission frame 2026-06-11 15:13:09 +09:00
aclist
20c850a977 chore: drop print statement 2026-06-11 15:12:13 +09:00
aclist
5a8d85ac92 fix: less brittle meta parsing 2026-06-11 15:11:38 +09:00
aclist
450d54d7fd fix: swap logic 2026-06-11 12:37:27 +09:00
aclist
65fb07c8da chore: clear type annotation errors 2026-06-11 12:10:37 +09:00
aclist
8123893a90 chore: clean up offline mod manager 2026-06-11 12:03:44 +09:00
aclist
3c9b5c9ed3 chore: update strings 2026-06-11 12:03:22 +09:00
aclist
f9be6dfae4 chore: drop unused method 2026-06-11 12:02:44 +09:00
aclist
586f7e6c66 feat: parse mission and mods 2026-06-11 12:02:28 +09:00
aclist
49eb3caf0a fix: handle empty store 2026-06-11 12:02:05 +09:00
aclist
56da5717fa feat: add emitter signal 2026-06-11 12:01:32 +09:00
aclist
9983e91526 feat: test mission integrity 2026-06-11 12:01:17 +09:00
aclist
820deb2955 fix: incorrect error state 2026-06-11 10:52:49 +09:00
aclist
0137bf4610 fix: process escape key 2026-06-11 10:47:32 +09:00
aclist
687b1a915e feat: capture escape signal in BootDialog 2026-06-11 10:43:52 +09:00
17 changed files with 241 additions and 135 deletions

View File

@ -1,6 +1,6 @@
import hashlib import hashlib
import logging import logging
import shlex import re
from concurrent.futures import wait from concurrent.futures import wait
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@ -48,36 +48,29 @@ def get_local_mods(workshop_path: Path) -> list[Path]:
mods = [file for file in workshop_path.iterdir() if file.is_dir()] mods = [file for file in workshop_path.iterdir() if file.is_dir()]
return mods return mods
def is_mission(path: Path) -> bool:
# TODO: parse integrity of other files
file = path / "init.c"
return file.exists()
def parse_meta(file: Path) -> ModMeta | None: def tokenize(mod: Path) -> dict[Any] | None:
mod = file / "meta.cpp" file = mod.joinpath("meta.cpp")
if mod.exists() is False: delimiter=r"\s*=\s*"
modmeta = {}
try:
with open(file, "r", encoding="utf-8") as file:
for line in file:
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 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: def get_mod_size(path: Path) -> float:
s = 0 s = 0
@ -90,21 +83,20 @@ def get_mod_size(path: Path) -> float:
def get_custom_mods(path: Path) -> list[Any]: def get_custom_mods(path: Path) -> list[Any]:
mods = get_local_mods(path) mods = get_local_mods(path)
# TODO: error handling # TODO: error handling
return parse_mods(mods) return parse_mods(mods, use_custom=True)
def parse_mods(mods: list[Path]) -> list[Any]: def parse_mods(mods: list[Path], use_custom: bool = False) -> list[Any]:
clean = [] clean = []
for mod in mods: for mod in mods:
mod_dir = mod.name mod_dir = mod.name
symlink = _hash(mod_dir) symlink = _hash(mod_dir, use_custom)
# FIXME: malformed .cpp files could break this meta = tokenize(mod)
meta = parse_meta(mod)
if meta is None: if meta is None:
continue continue
size = get_mod_size(mod) size = get_mod_size(mod)
# NOTE: final col is cell renderer highlight toggle # 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()) clean.sort(key=lambda row: str(row[0]).casefold())
return clean return clean

View File

@ -156,5 +156,9 @@ class Emitter(GObject.GObject):
pass pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, str, bool)) @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, str, bool))
def custom_mods_loaded(self, store: "FastInsertListStore", folder: str, has_duplicates: bool) -> None: def custom_mods_loaded(self, store: "FastInsertListStore", folder: str) -> 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 pass

View File

@ -25,7 +25,7 @@ from dzgui.model.servers import ServerModelManager
from dzgui.util.diag import write_diagnostic from dzgui.util.diag import write_diagnostic
from dzgui.util.format import format_player_count from dzgui.util.format import format_player_count
from dzgui.util.open_links import open_user_workshop, open_workshop_page from dzgui.util.open_links import open_user_workshop, open_workshop_page
from dzgui.views.dialogs.filepicker import FilePicker, FolderPicker from dzgui.views.dialogs.filepicker import FilePicker
from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.generic import ExceptionDialog
import gi import gi
@ -36,7 +36,6 @@ from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path
from dzgui.api.servers import Record from dzgui.api.servers import Record
from dzgui.const.enum import ServerTab from dzgui.const.enum import ServerTab
from dzgui.managers.connection import Prerequisites from dzgui.managers.connection import Prerequisites
@ -301,12 +300,6 @@ class Controller(GObject.GObject):
dialog = ExceptionDialog(self, str(e)) dialog = ExceptionDialog(self, str(e))
dialog.run() dialog.run()
def set_custom_folder(self) -> Union["Path", None]:
picker = FolderPicker(self.mediator.window)
folder = picker.pick_folder()
picker.destroy()
return folder
def update_api_key(self, key: Preferences, text: str) -> None: def update_api_key(self, key: Preferences, text: str) -> None:
self.config_man.update_api_key(key, text) self.config_man.update_api_key(key, text)

View File

@ -48,7 +48,7 @@ class ModManager:
self.prefs = controller.get_prefs() self.prefs = controller.get_prefs()
self.path = controller.query_config(Preferences.DEFAULT) self.path = controller.query_config(Preferences.DEFAULT)
self.store: FastInsertListStore self.store: FastInsertListStore | None = None
self.thread_man = ThreadingManager(controller) self.thread_man = ThreadingManager(controller)
@ -68,6 +68,8 @@ class ModManager:
def _on_mods_loaded(self) -> None: def _on_mods_loaded(self) -> None:
self.treeview.set_model(self.store) self.treeview.set_model(self.store)
if self.store is None:
return
msg = self.format_mod_statusbar() msg = self.format_mod_statusbar()
total_mods = len(self.store) total_mods = len(self.store)
self.emitter.emit("mods_updated", msg, total_mods) self.emitter.emit("mods_updated", msg, total_mods)
@ -124,6 +126,8 @@ class ModManager:
pass pass
def _on_mods_deleted(self, iters: list[Gtk.TreeIter]) -> None: def _on_mods_deleted(self, iters: list[Gtk.TreeIter]) -> None:
if self.store is None:
return
for _iter in iters: for _iter in iters:
self.store.remove(_iter) self.store.remove(_iter)
remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version) remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version)

View File

@ -1,13 +1,14 @@
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Union
from dzgui.api.mods import get_custom_mods from dzgui.api.mods import is_mission, get_custom_mods
from dzgui.api.steam import launch_offline from dzgui.api.steam import launch_offline
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.model.model_factory import ModelFactory from dzgui.model.model_factory import ModelFactory
from dzgui.strings import dialogs from dzgui.strings import dialogs
from dzgui.util.symlink import clone_symlinks from dzgui.util.symlink import clone_symlinks
from dzgui.views.dialogs.filepicker import FolderPicker
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@ -29,20 +30,31 @@ class OfflineManager:
self.local_mods: list[str] | None self.local_mods: list[str] | None
self.custom_mods: list[str] | None self.custom_mods: list[str] | None
# TODO: strings
@call_on_thread("parsing") def get_mission(self) -> None:
folder = self.open_folderpicker()
if folder is None:
return
is_valid = is_mission(folder)
# TODO: stop storing values in here
if is_valid:
# TODO: str
self.mission_folder = str(folder)
self.emitter.emit("custom_mission_loaded", str(folder), is_valid)
def get_custom_mods(self, local_mods: list[str]) -> None:
folder = self.open_folderpicker()
if folder is None:
return
self.parse_custom_mods(local_mods, folder)
@call_on_thread(dialogs.parsing_mods)
def parse_custom_mods(self, local_mods: list[str], folder: str) -> None: def parse_custom_mods(self, local_mods: list[str], folder: str) -> None:
mods = get_custom_mods(Path(folder)) mods = get_custom_mods(Path(folder))
store = ModelFactory().make_mod_store() store = ModelFactory().make_mod_store()
store.extend(mods) store.extend(mods)
has_duplicates = False func = StoredFunc(lambda: self.emitter.emit("custom_mods_loaded", store, folder))
for row in store:
if row[1] in local_mods:
row[-1]=True
has_duplicates = True
func = StoredFunc(lambda: self.emitter.emit("custom_mods_loaded", store, folder, has_duplicates))
self.thread_man.set_cleanup_func(func) self.thread_man.set_cleanup_func(func)
def setup( def setup(
@ -54,7 +66,6 @@ class OfflineManager:
) -> None: ) -> None:
self.appid = appid self.appid = appid
self.mission_folder = mission
self.local_mods = local_mods self.local_mods = local_mods
self.custom_mods = custom_mods self.custom_mods = custom_mods
@ -79,3 +90,9 @@ class OfflineManager:
combined_mods.extend(new_symlinks) combined_mods.extend(new_symlinks)
launch_offline(client, self.appid, name, combined_mods, self.mission_folder) launch_offline(client, self.appid, name, combined_mods, self.mission_folder)
def open_folderpicker(self) -> Union["Path", None]:
picker = FolderPicker(self.controller.get_window())
folder = picker.pick_folder()
picker.destroy()
return folder

View File

@ -9,5 +9,10 @@ load_error_lan = "Failed to find any servers on your network.\nCheck the server
fetching_mods = "Fetching mod metadata" fetching_mods = "Fetching mod metadata"
deleting_mods = "Deleting mods" deleting_mods = "Deleting mods"
scanning_mods = "Scanning mods" scanning_mods = "Scanning mods"
parsing_mods = "Parsing mods"
checking_api = "Checking API key" checking_api = "Checking API key"
running = "Running"
failed = "FAILED"
ok = "OK"

View File

@ -9,3 +9,6 @@ custom_frame = "Custom mods"
custom_button = "Set custom mod folder" custom_button = "Set custom mod folder"
local_frame = "Installed mods" local_frame = "Installed mods"
no_mods = "No valid mods found"
no_mission = "Not a valid mission"

View File

@ -1,9 +1,8 @@
import sys import sys
from typing import Literal, TYPE_CHECKING from typing import Any, Literal, Self, TYPE_CHECKING
# import time # import time
from enum import Enum from enum import Enum
from typing import Any, Self
# TODO: import dialog titles # TODO: import dialog titles
from dzgui.api.mods import remove_stale_signatures as remove_stale from dzgui.api.mods import remove_stale_signatures as remove_stale
@ -14,7 +13,7 @@ from dzgui.init.dayz import is_dayz_installed
from dzgui.init.update import check_updates from dzgui.init.update import check_updates
from dzgui.const.constants import EXPAND, FILL from dzgui.const.constants import EXPAND, FILL
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager 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.format import format_exception
from dzgui.util.strings import dialog_header from dzgui.util.strings import dialog_header
from dzgui.util.symlink import rebuild_symlinks from dzgui.util.symlink import rebuild_symlinks
@ -59,6 +58,9 @@ class BootDialog(Gtk.Dialog):
self.view = Gtk.TreeView(enable_search=False, headers_visible=False) self.view = Gtk.TreeView(enable_search=False, headers_visible=False)
self.view.set_model(self.store) 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"]): for i, column_title in enumerate(["Task", "State"]):
renderer = Gtk.CellRendererText() renderer = Gtk.CellRendererText()
column = Gtk.TreeViewColumn(column_title, renderer, text=i) column = Gtk.TreeViewColumn(column_title, renderer, text=i)
@ -138,6 +140,10 @@ class BootDialog(Gtk.Dialog):
GLib.timeout_add(100, self.pulse_spinner) 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]: def pulse_spinner(self) -> Literal[True]:
for row in self.store: for row in self.store:
if row[2]: if row[2]:
@ -184,10 +190,10 @@ class BootDialog(Gtk.Dialog):
self.thread_man.set_cleanup_func(callback) self.thread_man.set_cleanup_func(callback)
def update_task(self, task: str) -> None: 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: 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] msg = d[state]
self.store[len(self.store) - 1][1] = msg self.store[len(self.store) - 1][1] = msg
self.iter_step() self.iter_step()
@ -200,7 +206,7 @@ class BootDialog(Gtk.Dialog):
it: Gtk.TreeIter, it: Gtk.TreeIter,
data: Any, data: Any,
) -> None: ) -> None:
if model[it][1] == "Running": if model[it][1] == dialogs.running:
cell.set_property("visible", True) cell.set_property("visible", True)
else: else:
cell.set_property("visible", False) cell.set_property("visible", False)
@ -217,9 +223,9 @@ class BootDialog(Gtk.Dialog):
state = model[it][1] state = model[it][1]
if column.get_sort_column_id() != 1: if column.get_sort_column_id() != 1:
return return
if state == "OK": if state == dialogs.ok:
cell.set_property(prop, HEX_GREEN) cell.set_property(prop, HEX_GREEN)
elif state == "FAILED": elif state == dialogs.failed:
cell.set_property(prop, HEX_RED) cell.set_property(prop, HEX_RED)
else: else:
cell.set_property(prop, None) cell.set_property(prop, None)

View File

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Self, Sequence, TYPE_CHECKING from typing import Self, Sequence, TYPE_CHECKING, Union
from dzgui.util import css from dzgui.util import css
import dzgui.api.pefile as PeFile import dzgui.api.pefile as PeFile
@ -62,27 +62,36 @@ class FolderHBox(HBox):
super().__init__(spacing=10) super().__init__(spacing=10)
self.set_margin_start(10) self.set_margin_start(10)
self.set_margin_bottom(10) self.set_margin_end(10)
self.set_margin_bottom(5)
# TODO: alternate class for left-aligned icons # TODO: alternate class for left-aligned icons
self.button = IconTextButton(FOLDER, btn_label) self.button = IconTextButton(FOLDER, btn_label)
self.button.set_halign(Gtk.Align.START) self.button.set_halign(Gtk.Align.START)
self.button.set_image_position(Gtk.PositionType.LEFT) self.button.set_image_position(Gtk.PositionType.LEFT)
self.scrolled_label = Gtk.ScrolledWindow(propagate_natural_width=True, halign=Gtk.Align.START)
self.label = Gtk.Label() self.label = Gtk.Label()
self.extend([self.button, self.label]) self.scrolled_label.add(self.label)
self.extend([self.button, self.scrolled_label])
def get_button(self) -> Gtk.Button: def get_button(self) -> Gtk.Button:
return self.button return self.button
def set_label(self, label: str) -> None: def set_label(self, label: str) -> None:
self.label.set_label(label) # TODO: strings
prefix = "Current folder: "
self.label.set_label(prefix + label)
def hide_label(self) -> None:
self.label.hide()
class ModFrame(HeadingFrame): class ModFrame(HeadingFrame):
def __init__(self, controller: "Controller", label: str) -> None: def __init__(self, parent: OfflineLoader, controller: "Controller", label: str) -> None:
super().__init__(heading=label) super().__init__(heading=label)
self.parent = parent
self.vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.tree = OfflineModTreeView(controller) self.tree = OfflineModTreeView(controller)
@ -103,26 +112,38 @@ class ModFrame(HeadingFrame):
self.vbox.pack_end(self.tree_vbox, expand=True, fill=True, padding=3) self.vbox.pack_end(self.tree_vbox, expand=True, fill=True, padding=3)
self.frame.add(self.vbox) self.frame.add(self.vbox)
# self.vbox.add(self.tree_vbox)
# self.none_label = Gtk.Label(label="No mods found", halign=Gtk.Align.START)
# self.vbox.pack_end(self.none_label, expand=True, fill=True, padding=3)
# self.none_label.hide()
sel = self.tree.get_selection() sel = self.tree.get_selection()
sel.connect("changed", self._on_selection_changed) sel.connect("changed", self._on_selection_changed)
# TODO: inherit icons from warnings area in preconnect dialog
# TODO: strings
self.error_label = Gtk.Label(label="No mods found", halign=Gtk.Align.START, margin_start=10, margin_bottom=5)
self.vbox.pack_end(self.error_label, expand=True, fill=True, padding=3)
def set_error(self, msg: str) -> None:
self.error_label.set_label(msg)
self.error_label.show()
def hide_errors(self) -> None:
self.error_label.hide()
def start_empty(self) -> None:
self.error_label.show()
self.collapse_tree()
def hide_all(self) -> None:
self.error_label.hide()
self.collapse_tree()
# TODO: warnings area # TODO: warnings area
# valid warnings (one at a time): # name collision within custom mods
# - name collision within custom mods
# - name collision b/w custom and local (permissible)
# - no mods
# - not a valid mission
def get_mods(self) -> list[str]: def get_mods(self) -> list[str]:
model = self.tree.get_model() model, treeiters = self.tree.get_selection().get_selected_rows()
if model is None: if model is None:
return [] return []
return [row[1] for row in model] return [model[_iter][1] for _iter in treeiters]
def collapse_tree(self) -> None: def collapse_tree(self) -> None:
self.tree_vbox.hide() self.tree_vbox.hide()
@ -133,7 +154,7 @@ class ModFrame(HeadingFrame):
def pack(self, widget: Gtk.Widget) -> None: def pack(self, widget: Gtk.Widget) -> None:
self.vbox.pack_start(widget, expand=False, fill=False, padding=5) self.vbox.pack_start(widget, expand=False, fill=False, padding=5)
def set_model(self, model: "FastInsertListStore") -> None: def set_model(self, model: Union["FastInsertListStore", None]) -> None:
self.tree.set_model(model) self.tree.set_model(model)
self.tree.mod_man.store = model self.tree.mod_man.store = model
self.set_cursor() self.set_cursor()
@ -145,6 +166,7 @@ class ModFrame(HeadingFrame):
else: else:
status = f"Mods selected: {len(rows)}" status = f"Mods selected: {len(rows)}"
self.status.set_label(status) self.status.set_label(status)
self.parent.check_button()
def set_cursor(self) -> None: def set_cursor(self) -> None:
path = Gtk.TreePath.new_from_indices([0]) path = Gtk.TreePath.new_from_indices([0])
@ -156,7 +178,7 @@ class CustomModFrame(ModFrame):
def __init__( def __init__(
self, parent: OfflineLoader, controller: "Controller", heading: str self, parent: OfflineLoader, controller: "Controller", heading: str
) -> None: ) -> None:
super().__init__(controller, heading) super().__init__(parent, controller, heading)
self.parent = parent self.parent = parent
@ -171,34 +193,62 @@ class CustomModFrame(ModFrame):
self.emitter.connect("custom_mods_loaded", self._on_custom_mods_loaded) self.emitter.connect("custom_mods_loaded", self._on_custom_mods_loaded)
def hide_tree(self) -> None:
# TODO: no mods message is different from collision message
self.set_error(offline.no_mods)
self.custom_hbox.hide_label()
self.tree_vbox.hide()
def present_tree(self, folder: str, store: "FastInsertListStore") -> None:
self.custom_hbox.set_label(folder)
self.tree.set_model(store)
self.tree_vbox.show()
self.hide_errors()
def _on_custom_mods_loaded( def _on_custom_mods_loaded(
self, self,
emitter: "Emitter", emitter: "Emitter",
store: "FastInsertListStore", store: "FastInsertListStore",
folder: str, folder: str,
has_duplicates: bool,
) -> None: ) -> None:
if len(store) == 0: if len(store) == 0:
# TODO: pop error dialog area self.hide_tree()
# block button access
pass
else: else:
self.custom_hbox.set_label(folder) self.present_tree()
self.tree.set_model(store)
self.tree_vbox.show()
if has_duplicates:
# TODO: pop relevant error
# duplicates should block button access
pass
def _on_custom_button_clicked(self, button: Gtk.Button) -> None: def _on_custom_button_clicked(self, button: Gtk.Button) -> None:
self.parent.parse_mods() local_mods = self.get_mods()
self.parent.offline_man.get_custom_mods(local_mods)
def get_mods(self) -> list[str]: class MissionFrame(HeadingFrame):
rows = self.tree.get_selection().get_selected_rows() def __init__(self, parent: OfflineLoader, controller: "Controller") -> None:
dirs = [str(col[1]) for col in rows] super().__init__(heading=offline.mission_frame)
return dirs
self.parent = parent
self.controller = controller
self.emitter = controller.get_emitter()
self.mission_hbox = FolderHBox(offline.mission_button)
self.mission_button = self.mission_hbox.get_button()
self.mission_button.connect("clicked", self._on_mission_button_clicked)
self.warning = Gtk.Label(halign=Gtk.Align.START, margin_start=10, margin_bottom=5)
self.vbox = VBox()
self.vbox.extend([self.mission_hbox, self.warning])
self.frame.add(self.vbox)
self.emitter.connect("custom_mission_loaded", self._on_mission_loaded)
def _on_mission_loaded(self, emitter: "Emitter", folder: str, is_valid: bool) -> None:
self.mission_hbox.set_label(folder)
if not is_valid:
self.warning.show()
self.warning.set_label(offline.no_mission)
else:
self.warning.hide()
def _on_mission_button_clicked(self, button: Gtk.Button) -> None:
self.parent.offline_man.get_mission()
class RadioFrame(HeadingFrame): class RadioFrame(HeadingFrame):
@ -255,20 +305,11 @@ class OfflineLoader(Gtk.Box):
self.add(PageHeading(offline.heading)) self.add(PageHeading(offline.heading))
self.local_frame = ModFrame(controller, offline.local_frame) self.local_frame = ModFrame(self, controller, offline.local_frame)
self.custom_frame = CustomModFrame(self, controller, offline.custom_frame) self.custom_frame = CustomModFrame(self, controller, offline.custom_frame)
# TODO: suppress symlink column
self.custom_tree = OfflineModTreeView(controller) self.custom_tree = OfflineModTreeView(controller)
self.mission_frame = MissionFrame(self, controller)
# TODO: custom class
self.mission_hbox = FolderHBox(offline.mission_button)
self.mission_frame = HeadingFrame.new_with_widget_and_label(
self.mission_hbox, offline.mission_frame
)
self.mission_button = self.mission_hbox.get_button()
self.mission_button.connect("clicked", self._on_mission_clicked)
self.radio_frame = RadioFrame(controller) self.radio_frame = RadioFrame(controller)
self.scrollable = Gtk.ScrolledWindow( self.scrollable = Gtk.ScrolledWindow(
@ -290,7 +331,7 @@ class OfflineLoader(Gtk.Box):
self.button_box.set_halign(Gtk.Align.END) self.button_box.set_halign(Gtk.Align.END)
self.button_box.set_margin_top(5) self.button_box.set_margin_top(5)
self.back = Gtk.Button(label="Back") self.back = Gtk.Button(label="Back")
self.ok = Gtk.Button(label="Launch") self.ok = Gtk.Button(label="Launch", sensitive=False)
self.back.connect("clicked", self._on_back_clicked) self.back.connect("clicked", self._on_back_clicked)
self.ok.connect("clicked", self._on_ok_clicked) self.ok.connect("clicked", self._on_ok_clicked)
self.connect("key-press-event", self._on_keypress) self.connect("key-press-event", self._on_keypress)
@ -301,46 +342,37 @@ class OfflineLoader(Gtk.Box):
self.add(self.scrollable) self.add(self.scrollable)
self.add(self.button_box) self.add(self.button_box)
def _on_mission_clicked(self, button: Gtk.Button) -> None: def check_button(self) -> None:
# process path, cf. set_custom_folder()
# file = path / "init.c"
# if not file.exists():
# pop warning
pass
def parse_mods(self) -> None:
# TODO: check method on custom frame
# TODO: delegate folderpicker to offline manager
local_mods = self.local_frame.get_mods() local_mods = self.local_frame.get_mods()
folder = self.controller.set_custom_folder() custom_mods = self.custom_frame.get_mods()
if folder is not None: if len(local_mods) == 0 and len(custom_mods) == 0:
# TODO: self.ok.set_sensitive(False)
self.offline_man.parse_custom_mods(local_mods, folder) else:
self.ok.set_sensitive(True)
def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None:
if event.keyval == Gdk.KEY_Escape: if event.keyval == Gdk.KEY_Escape:
self.back.emit("clicked") self.back.emit("clicked")
def populate(self, store: "FastInsertListStore") -> None: def populate(self, store: Union["FastInsertListStore", None]) -> None:
self.local_frame.set_model(store) self.local_frame.set_model(store)
if store is None: if store is None:
self.local_frame.collapse_tree() self.local_frame.start_empty()
# TODO: toggle if empty model, show warning label else:
# NOTE: there may be no local mods self.local_frame.hide_errors()
# TODO: suppress trees if there are no mods # NOTE: suppress custom tree until explicitly loaded
self.custom_frame.collapse_tree() self.custom_frame.hide_all()
def _on_back_clicked(self, button: Gtk.Button) -> None: def _on_back_clicked(self, button: Gtk.Button) -> None:
self.controller.open_page(NotebookPage.MODS) self.controller.open_page(NotebookPage.MODS)
def _on_ok_clicked(self, button: Gtk.Button) -> None: def _on_ok_clicked(self, button: Gtk.Button) -> None:
# TODO; block button access if no mods are selected # TODO: grab all values in one pass
# TODO: consider hooking up to emitter and changing button state whenever mods are selected
# appid = self.radio_frame.get_appid() # appid = self.radio_frame.get_appid()
# mission = self.mission_frame.get_mission() # mission = self.mission_frame.get_mission()
# local_mods = self.local_frame.get_mods() # local_mods = self.local_frame.get_mods()
# custom_mods = self.custom_frame.get_mods() # custom_mods = self.custom_frame.get_mods()
# cf. api.mods._hash(uid, use_custom=True) # cf. api.mods._hash(uid, use_custom=True)
# TODO: set up all symlinks prior to launch, including custom ones
# self.offline_man.setup(appid, mission, local_mods, custom_mods) # self.offline_man.setup(appid, mission, local_mods, custom_mods)
pass pass

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"