From 4face6f11517f02e3775729908a90d85cae43270 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 27 May 2026 03:59:57 +0900 Subject: [PATCH] feat: preboot dialog (WIP) --- dzgui/const/constants.py | 1 + dzgui/main.py | 37 ++--- dzgui/managers/threading.py | 4 +- dzgui/views/dialogs/boot.py | 282 ++++++++++++++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 20 deletions(-) create mode 100644 dzgui/views/dialogs/boot.py diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 6818fef..7620bc8 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -28,6 +28,7 @@ APP_NAME = "DZGUI" APP_NAME_LOWER = "dzgui" APP_NAME_ABBR = "dzg" +HEX_GREEN = "#32CD32" HEX_RED = "#FF0000" HEX_ORANGE = "#FFAC1C" diff --git a/dzgui/main.py b/dzgui/main.py index c5d74d4..59e19b9 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -6,14 +6,11 @@ import warnings from typing import TYPE_CHECKING -from dzgui.api.mods import remove_stale_signatures from dzgui.const.constants import APP_NAME from dzgui.const.enum import Preferences -from dzgui.config.ipdb import get_ipdb 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.coords import get_local_coords from dzgui.init.dayz import is_dayz_installed from dzgui.init.flock import lock_acquire from dzgui.init.migrate import ( @@ -24,17 +21,16 @@ from dzgui.init.migrate import ( from dzgui.init.prefix import get_version from dzgui.init.prereqs import has_steam_client from dzgui.strings import boot -from dzgui.init.update import check_updates # from dzgui.util.map_count import get_map_count from dzgui.util.deck import is_steam_deck, is_game_mode from dzgui.util.localize import set_locale -from dzgui.util.symlink import rebuild_symlinks from dzgui.util.strings import init, flags from dzgui.views.base import App -from dzgui.views.dialogs.wizard import SetupWizard +from dzgui.views.dialogs.boot import BootWindow from dzgui.views.dialogs.early_alert import EarlyAlertDialog +from dzgui.views.dialogs.wizard import SetupWizard if TYPE_CHECKING: from pathlib import Path @@ -47,6 +43,11 @@ parser.add_argument("-u", "--uninstall", action="store_true", help=flags.uninsta parser.add_argument("-d", "--debug", action="store_true", help=flags.debug) args = parser.parse_args() +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib, Gdk # noqa + # TODO: profile load time def make_parents(path: "Path") -> None: @@ -130,20 +131,20 @@ def main() -> None: is_dayz_installed(XDG.config) - # TODO: slow running procs here--needs dialog - # NOTE: clears versions file of unlinked mods - rebuild_symlinks(XDG.config) - remove_stale_signatures(XDG.config, XDG.version) + 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 + ## TODO: drop this after dialog is complete + # print("Fetching geolocation data, may take some time...") + # get_ipdb(XDG.ips) + # local_coords = get_local_coords(XDG.ips) + # latest_release = check_updates(version) - # TODO: handle IP DB failure and use coords fallback - # TODO: drop this after dialog is complete - print("Fetching geolocation data, may take some time...") - get_ipdb(XDG.ips) - local_coords = get_local_coords(XDG.ips) use_miles = lookup(XDG.config, Preferences.DIST) - - latest_release = check_updates(version) - prefs = UserPrefs( is_steam_deck=_is_steam_deck, is_game_mode=_is_game_mode, diff --git a/dzgui/managers/threading.py b/dzgui/managers/threading.py index 91db2e9..d972487 100644 --- a/dzgui/managers/threading.py +++ b/dzgui/managers/threading.py @@ -48,7 +48,7 @@ class StoredFunc: self.bindings = sig.bind(*args, **kwargs) def call(self) -> None: - self.func(*self.bindings.args, *self.bindings.kwargs) + return self.func(*self.bindings.args, *self.bindings.kwargs) class ThreadingManager: @@ -113,8 +113,8 @@ class ThreadingManager: func = self.get_cleanup_func() if func is not None: - func.call() self.set_cleanup_func(None) + func.call() if not self.destroy_first: self.destroy_dialog() diff --git a/dzgui/views/dialogs/boot.py b/dzgui/views/dialogs/boot.py new file mode 100644 index 0000000..39b5142 --- /dev/null +++ b/dzgui/views/dialogs/boot.py @@ -0,0 +1,282 @@ +import sys +import time +from enum import Enum +from typing import Any, Self, TYPE_CHECKING + +# TODO: import dialog titles +from dzgui.api.mods import remove_stale_signatures +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.update import check_updates +from dzgui.const.constants import EXPAND, FILL +from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager +from dzgui.util.symlink import rebuild_symlinks +from dzgui.views.components.buttons import ClipboardButton + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib, Gdk # noqa + +if TYPE_CHECKING: + from dzgui.config.xdg import Xdg + + +class Success(Enum): + OK = 1 + FAIL = 2 + + +# TODO: add margins to tree +class BootDialog(Gtk.Dialog): + def __init__(self, parent: Gtk.Widget, xdg: "Xdg", version: str) -> None: + super().__init__( + # text="TEST", + # buttons=Gtk.ButtonsType.NONE, + # message_type=Gtk.MessageType.INFO, + # secondary_text="BOOTING UP", + # TODO: strings + title="TEST TITLE", + parent=parent, + flags=Gtk.DialogFlags.MODAL, + ) + + self.parent = parent + self.set_modal(True) + self.xdg = xdg + self.version = version + + self.thread_man = ThreadingManager(None) + self.set_size_request(700, 500) + + self.store = Gtk.ListStore(str, str, bool, int) + + self.view = Gtk.TreeView(enable_search=False, headers_visible=False) + self.view.set_model(self.store) + # self.view.set_fixed_height_mode(True) + + for i, column_title in enumerate(["Task", "State"]): # , "Spinner"]): + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn(column_title, renderer, text=i) + column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + column.set_resizable(False) + # column.set_expand(True) + column.set_sort_column_id(i) + column.set_cell_data_func(renderer, self._format_color, func_data=None) + self.view.append_column(column) + if i == 0: + column.set_fixed_width(300) + + self.spinner_renderer = Gtk.CellRendererSpinner() + self.spinner_renderer.set_property("size", Gtk.IconSize.LARGE_TOOLBAR) + col_bool = Gtk.TreeViewColumn("Spinner", self.spinner_renderer, active=3) + col_bool.set_alignment(0.0) + col_bool.set_cell_data_func( + self.spinner_renderer, self._set_spinner_vis, func_data=None + ) + self.view.append_column(col_bool) + + col_int = Gtk.TreeViewColumn("Int", Gtk.CellRendererText(), text=3) + col_int.set_visible(False) + self.view.append_column(col_int) + + self.view.get_selection().set_mode(Gtk.SelectionMode.NONE) + self.connect("response", self._on_response) + self.connect("delete-event", self._on_delete) + + self.scrollable_tree = Gtk.ScrolledWindow(overlay_scrolling=False) + self.scrollable_tree.add(self.view) + self.scrollable_tree.set_size_request(700, 400) + + # TODO: pack spinner into dedicated box + self.loading_label = Gtk.Label(label="Loading") + self.spinner = Gtk.Spinner() + self.spinner.start() + + self.error_box = Gtk.Box( + halign=Gtk.Align.CENTER, + orientation=Gtk.Orientation.VERTICAL, + spacing=20, + margin_top=30, + margin_bottom=30, + ) + self.error_label = Gtk.Label() + + # TODO: recycle ClipboardButton from generics + # TODO: abstract class + # self.copy_button = ClipboardButton() + self.copy_button = Gtk.Button(label="Copy") + self.button_hbox = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.CENTER, spacing=10 + ) + self.exit_button = Gtk.Button(label="Exit", halign=Gtk.Align.CENTER) + self.exit_button.connect("clicked", lambda _: sys.exit(1)) + self.button_hbox.add(self.copy_button) + self.button_hbox.add(self.exit_button) + + self.error_box.add(self.error_label) + self.error_box.add(self.button_hbox) + + self.content = self.get_content_area() + self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0) + self.content.pack_start(self.error_box, EXPAND, FILL, 0) + # self.content.pack_start(self.loading_label, EXPAND, FILL, 0) + # self.content.pack_start(self.spinner, EXPAND, FILL, 0) + self.show_all() + + self.error_box.hide() + + symlinks = "Rebuilding symlinks" + signatures = "Updating mod signatures" + geo = "Checking geolocation records" + coords = "Checking local coordinates" + updates = "Checking for updates" + + steps = [ + (StoredFunc(rebuild_symlinks, self.xdg.config), symlinks, False), + ( + StoredFunc(remove_stale_signatures, self.xdg.config, self.xdg.version), + signatures, + False, + ), + (StoredFunc(get_ipdb, self.xdg.ips), geo, False), + (StoredFunc(get_local_coords, self.xdg.ips), coords, True), + (StoredFunc(check_updates, self.version), updates, True), + # (StoredFunc(time.sleep, 5.1), "Sleeping", True), + # (StoredFunc(time.sleep, 0.1), "Sleeping", True), + # (StoredFunc(time.sleep, 0.1), "Sleeping", False), + # (StoredFunc(lambda: 1 / 0), "Broken function", False), + ] + self.results: list[Any] = [] + self.failed = False + self.steps = iter(steps) + + def pulse_spinner(self) -> None: + for row in self.store: + if row[2]: + if row[3] == 150: + row[3] = 0 + else: + row[3] += 1 + # if self.pulse == 100: + # self.pulse = 0 + # else: + # self.pulse += 1 + self.spinner_renderer.set_property("pulse", row[3]) + return True + + def run(self) -> None: + GLib.timeout_add(100, self.pulse_spinner) + self.iter_step() + + def _on_delete(self, widget: Self, event: Gdk.Event) -> None: + return True + + def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: + print("caught response") + if response == Gtk.ResponseType.DELETE_EVENT: + return True + if response == Gtk.ResponseType.CLOSE: + return True + + def iter_step(self) -> None: + try: + step, label, store_output = next(self.steps) + self.update_task(label) + self.background(step, store_output) + except StopIteration: + if self.failed: + # TODO: abstract into method + self.error_box.show() + self.error_label.set_text(self.exception) + self.spinner.stop() + self.loading_label.hide() + else: + self.parent.set_results(self.results) + self.destroy() + + @call_on_thread("", show_dialog=False) + def background(self, func: StoredFunc, store_output: bool) -> None: + # time.sleep(0.1) + try: + if store_output: + res = func.call() + self.results.append(res) + else: + func.call() + callback = StoredFunc(self.update_status, Success.OK) + self.thread_man.set_cleanup_func(callback) + except Exception as e: + self.failed = True + # TODO: generic exception wrapper + self.exception = f"{type(e).__name__}: {e}" + callback = StoredFunc(self.update_status, Success.FAIL) + self.thread_man.set_cleanup_func(callback) + + def get_results(self) -> list[Any]: + return self.results + + def update_task(self, task: str) -> None: + self.store.append((task, "Running", True, 0)) + + # TODO: use enum + def update_status(self, state: Success) -> None: + d = {Success.OK: "OK", Success.FAIL: "FAILED"} + msg = d[state] + model = self.view.get_model() + _iter = model.get_iter(len(model) - 1) + model.set_value(_iter, 1, msg) + self.iter_step() + + def _set_spinner_vis( + self, + column: Gtk.TreeViewColumn, + cell: Gtk.CellRendererSpinner, + model: Gtk.TreeModel, + it: Gtk.TreeIter, + data: Any, + ) -> None: + if model[it][1] == "Running": + cell.set_property("visible", True) + else: + cell.set_property("visible", False) + + def _format_color( + self, + column: Gtk.TreeViewColumn, + cell: Gtk.CellRendererText, + model: Gtk.TreeModel, + it: Gtk.TreeIter, + data: Any, + ) -> None: + state = model[it][1] + if column.get_sort_column_id() != 1: + return + if state == "OK": + cell.set_property("foreground", HEX_GREEN) + elif state == "FAILED": + cell.set_property("foreground", HEX_RED) + else: + cell.set_property("foreground", None) + + +class BootWindow(Gtk.Window): + def __init__(self, xdg: "Xdg", version: str) -> None: + super().__init__() + + self.results: list[Any] = [] + + dialog = BootDialog(self, xdg, version) + dialog.run() + dialog.connect("destroy", self._on_destroy) + Gtk.main() + + def set_results(self, res: list[Any]) -> None: + self.results = res + + def get_results(self) -> str: + return self.results + + def _on_destroy(self, widget) -> None: + Gtk.main_quit()