From 07ea73ef2e64c84543a365f99d90c25ba45b44e3 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 17 May 2026 03:00:12 +0900 Subject: [PATCH] feat: setup wizard (WIP) --- CHANGELOG.md | 1 + dzgui/api/steam.py | 40 +- dzgui/config/update.py | 4 + dzgui/config/userprefs.py | 1 - dzgui/const/boilerplate.py | 16 + dzgui/const/constants.py | 8 +- dzgui/init/migrate.py | 12 +- dzgui/init/update.py | 23 +- dzgui/main.py | 35 +- dzgui/managers/threading.py | 6 +- dzgui/strings/errors.py | 2 + dzgui/strings/wizard.py | 59 +++ dzgui/util/clip.py | 6 +- dzgui/views/components/buttons.py | 2 +- dzgui/views/components/entry.py | 99 ++++- dzgui/views/dialogs/wizard.py | 584 ++++++++++++++++++++++++++++++ dzgui/views/pages/options.py | 2 +- 17 files changed, 839 insertions(+), 61 deletions(-) create mode 100644 dzgui/const/boilerplate.py create mode 100644 dzgui/strings/wizard.py create mode 100644 dzgui/views/dialogs/wizard.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 30c1729..8e1898e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## Added +- Setup wizard - Changelog text wrapping and formatting - Changelog ships with source - Documentation ships with source diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 4ede7cb..5bb1736 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -1,19 +1,57 @@ import json import logging +import os import requests import subprocess from shlex import shlex from pathlib import Path -from dzgui.const.constants import APP_NAME +from dzgui.init.prereqs import has_steam_client +from dzgui.const.constants import ( + APP_NAME, + DEBIAN_STEAM_PATH, + DEFAULT_STEAM_PATH, + FLATPAK_STEAM_PATH, + UBUNTU_STEAM_PATH, + VDF_PATH, +) from dzgui.const.endpoints import STEAM_PUBLISHED_FILES +from dzgui.strings import wizard from dzgui.util.bash import concat_bash_args logger = logging.getLogger(APP_NAME) +def get_steam_paths() -> list[tuple[Path, str]]: + paths = [] + if has_steam_client(): + HOME = Path.home() + env = os.environ.get("XDG_DATA_HOME") + if env is not None: + XDG_DATA_HOME = env + else: + XDG_DATA_HOME = DEFAULT_STEAM_PATH + + DEFAULT_PATH = HOME.joinpath(XDG_DATA_HOME) + FLATPAK_PATH = HOME.joinpath(FLATPAK_STEAM_PATH) + UBUNTU_PATH = HOME.joinpath(UBUNTU_STEAM_PATH) + DEBIAN_PATH = HOME.joinpath(DEBIAN_STEAM_PATH) + + human = { + DEFAULT_PATH: wizard.desc_default_path, + FLATPAK_PATH: wizard.desc_flatpak_path, + UBUNTU_PATH: wizard.desc_ubuntu_path, + DEBIAN_PATH: wizard.desc_debian_path, + } + + for path in DEFAULT_PATH, FLATPAK_PATH, UBUNTU_PATH, DEBIAN_PATH: + if path.joinpath(VDF_PATH).is_file(): + paths.append((path, human[path])) + return paths + + def concat_mods(mods: list[str]) -> str: from dzgui.util.symlink import _hash diff --git a/dzgui/config/update.py b/dzgui/config/update.py index fbb05d9..b5a54ea 100644 --- a/dzgui/config/update.py +++ b/dzgui/config/update.py @@ -4,6 +4,9 @@ from dzgui.const.enum import Preferences from dzgui.config.query import get_config, enum_to_key from dzgui.util._json import write_json +# TODO: drop deprecated + + # TEST: parametrize writing config values and checking config output in test def toggle_config(path: Path, key: Preferences) -> None: real_key = enum_to_key(key) @@ -15,6 +18,7 @@ def toggle_config(path: Path, key: Preferences) -> None: except Exception as e: raise e + def write_config(path: Path, key: Preferences, value: str) -> None: real_key = enum_to_key(key) try: diff --git a/dzgui/config/userprefs.py b/dzgui/config/userprefs.py index a06094c..f99245e 100644 --- a/dzgui/config/userprefs.py +++ b/dzgui/config/userprefs.py @@ -14,6 +14,5 @@ class UserPrefs: is_debug: bool coords: Union["Coords", None] version: str - allow_updates: bool paths: "Xdg" use_miles: bool diff --git a/dzgui/const/boilerplate.py b/dzgui/const/boilerplate.py new file mode 100644 index 0000000..03ac14e --- /dev/null +++ b/dzgui/const/boilerplate.py @@ -0,0 +1,16 @@ +""" +Generic defaults used when initializing a config file from scratch +""" +config_boilerplate = { + "bm_api": "", + "fav_server": "", + "fav_label": "", + "name": "", + "fullscreen": False, + "steam_api": "", + "default_steam_path": "", + "client": "", + "ip_list": [], + "use_miles": False, + "start_tab": 0, +} diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index f536ec4..8304fd4 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -64,8 +64,12 @@ LEGACY_IPS_PATH = ".local/share/dzgui/helpers/ips.csv" DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG" SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG" CHANGELOG_PATH = "data/CHANGELOG.md" +HERO_PATH = "data/images/hero.png" CSS_PATH = "data/app.css" +VDF_PATH = "steamapps/libraryfolders.vdf" +DEFAULT_STEAM_PATH = ".local/share/Steam" +FLATPAK_STEAM_PATH = ".var/app/com.valvesoftware.Steam/data/Steam" +UBUNTU_STEAM_PATH = ".steam/steam" +DEBIAN_STEAM_PATH = ".steam/debian-installation" LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG") -# TODO: drop -FOREGROUND_CMDS = ("wmctrl", "xdotool") diff --git a/dzgui/init/migrate.py b/dzgui/init/migrate.py index 554e793..140289c 100644 --- a/dzgui/init/migrate.py +++ b/dzgui/init/migrate.py @@ -1,5 +1,4 @@ import shutil -import sys from pathlib import Path from dzgui.const.constants import LEGACY_CONFIG_PATH, LEGACY_COLS_PATH, LEGACY_IPS_PATH @@ -7,16 +6,11 @@ from dzgui.config.convert import rc2json from dzgui.util._json import read_json, write_json -# TODO: move to setup wizard def migrate_legacy_conf(config: Path) -> None: old_conf = Path.home() / LEGACY_CONFIG_PATH - if old_conf.is_file(): - j = rc2json(old_conf) - config.parent.mkdir(parents=True, exist_ok=True) - config.write_text(j) - else: - print("Unimplemented. You must have a working dztuirc.") - sys.exit(1) + j = rc2json(old_conf) + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text(j) def has_new_config(config: Path) -> bool: diff --git a/dzgui/init/update.py b/dzgui/init/update.py index eb6d7db..eac0bee 100644 --- a/dzgui/init/update.py +++ b/dzgui/init/update.py @@ -20,6 +20,7 @@ def get_latest_release() -> str | None: try: res = requests.get(url, timeout=REQUEST_TIMEOUT) if res.status_code == 200: + print(res.json()) tag = res.json()["tag_name"] break except Exception as e: @@ -28,31 +29,13 @@ def get_latest_release() -> str | None: return tag -def allow_updates(allow: bool) -> bool: - if allow is False: - return False - if allow is True: - return is_prefix_writeable() - - -def check_updates(version: str) -> None: +def check_updates(version: str) -> str | None: try: latest = get_latest_release() - prefix = sys.prefix if latest is None: return if Version(version) >= Version(latest): return - - # TODO: test update logic - print("UNIMPLEMENTED: fetches in-app updates") - return - - with resources.path(APP_NAME_LOWER, "scripts/update.sh") as path: - proc = subprocess.Popen(["/usr/bin/env", "bash", path, latest, prefix]) - if proc != 0: - # TODO: pop a dialog - pass - sys.exit(proc) + return latest except Exception: return diff --git a/dzgui/main.py b/dzgui/main.py index 516d5d5..a1b557c 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -13,7 +13,6 @@ 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.const.update import ALLOW_UPDATES from dzgui.init.coords import get_local_coords from dzgui.init.dayz import is_dayz_installed from dzgui.init.flock import lock_acquire @@ -25,7 +24,7 @@ from dzgui.init.migrate import ( ) from dzgui.init.prefix import get_version from dzgui.init.prereqs import has_steam_client -from dzgui.init.update import allow_updates, check_updates +from dzgui.init.update import check_updates from dzgui.strings import boot @@ -36,6 +35,7 @@ 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.early_alert import EarlyAlertDialog if TYPE_CHECKING: @@ -97,33 +97,31 @@ def main() -> None: if XDG.resolution.parent.is_dir() is False: make_parents(XDG.resolution) + # TODO: test if XDG.debug.is_file() is False: make_parents(XDG.debug) - if has_new_config(XDG.config) is False: - # TODO: handle this in assistant - migrate_legacy_conf(XDG.config) - migrate_cols_file(XDG.columns) - # TODO: copy notes file - copy_state_files(xdg_paths["XDG_STATE_HOME"]) - - setup_logger(XDG.debug) - with open(XDG.debug, "w") as f: - f.truncate(0) - _is_steam_deck = is_steam_deck() _is_game_mode = is_game_mode() if _is_steam_deck else False if _is_game_mode: # NOTE: this may no longer be necessary on newer versions of SteamOS del os.environ["GTK_IM_MODULE"] - # TODO: test spamming timeout - allow = allow_updates(ALLOW_UPDATES) - if allow is True: - check_updates(version) + if has_new_config(XDG.config) is False: + # TODO: add logging inside wizard + # TODO: copy notes file, version file, etc. + # migrate_cols_file(XDG.columns) + # copy_state_files(xdg_paths["XDG_STATE_HOME"]) + SetupWizard(version, _is_steam_deck, XDG.config) + return - # TODO: config wizard: check has_steam_client() prior to VDF exploration + setup_logger(XDG.debug) + with open(XDG.debug, "w") as f: + f.truncate(0) + + # TODO: update area in gutter + # new_version = check_updates(version) if _is_steam_deck is False: # TODO: sudo escalation dialog @@ -148,7 +146,6 @@ def main() -> None: is_debug=args.debug, coords=local_coords, version=version, - allow_updates=allow, paths=XDG, use_miles=use_miles, ) diff --git a/dzgui/managers/threading.py b/dzgui/managers/threading.py index 6c6e20f..b27a409 100644 --- a/dzgui/managers/threading.py +++ b/dzgui/managers/threading.py @@ -1,7 +1,7 @@ import inspect import logging import threading -from typing import Any, Literal, TYPE_CHECKING +from typing import Any, Literal, TYPE_CHECKING, Union from functools import wraps from typing import Callable @@ -52,7 +52,7 @@ class StoredFunc: class ThreadingManager: - def __init__(self, controller: "Controller") -> None: + def __init__(self, controller: Union["Controller", None]) -> None: self.controller = controller self.jobs = 1 self.cleanup_func: StoredFunc | None = None @@ -69,7 +69,7 @@ class ThreadingManager: func.call() GLib.idle_add(self._destroy_on_idle) - if show_dialog: + if show_dialog is True and self.controller is not None: self.wait_dialog = WaitDialog( self.controller, dialog_str, jobs=self.jobs, show_cancel=show_cancel ) diff --git a/dzgui/strings/errors.py b/dzgui/strings/errors.py index bc5c5fd..32d8741 100644 --- a/dzgui/strings/errors.py +++ b/dzgui/strings/errors.py @@ -1,3 +1,5 @@ api_validation_error = ( "API key validation error. Key was typed incorrectly or is defunct." ) + +api_popover = "API key validation failed." diff --git a/dzgui/strings/wizard.py b/dzgui/strings/wizard.py new file mode 100644 index 0000000..ffc34fa --- /dev/null +++ b/dzgui/strings/wizard.py @@ -0,0 +1,59 @@ +### IntroductionPage +title_intro = "Welcome" +blurb_intro = """ +This wizard is going to help you set up some common config options before launching the application. +""" + +### SteamPathPage +# TODO +error_steam_path = "ERROR TEXT HERE" +heading_steam_path = "Steam path" +blurb_steam_path = """ +DZGUI needs to find the location to your default Steam installation. +This will be used to determine whether (and where) DayZ is installed. +""" +desc_default_path = "This is the default Steam path on most distributions." +desc_flatpak_path = ( + "This is the default Steam path if you are using Flatpak Steam." +) +desc_ubuntu_path = "This is the default Steam path on Ubuntu-based systems." +desc_debian_path = "This is the default Steam path on Debian-based systems." +no_valid_paths = ( + "No valid Steam paths found on system. Please install Steam to continue." +) +button_scan = "Scan for Steam" + +### ConfigMigrationPage +heading_config = "Import files" +blurb_config = """ +It looks like you have a DZGUI 6 configuration file on the system.\n +Would you like to import this into DZGUI 7, keeping your existing preferences?\n +In both cases, your DZGUI 6 file will persist separately from DZGUI 7. +""" +config_import_button = "Import DZGUI 6 config to DZGUI 7" +config_import_box = ( + "Configuration data imported successfully. Proceed to the next step." +) +config_new_button = "Create new DZGUI 7 config from scratch" +config_new_box = "A new config file will be created. Proceed to the next step." + +### APIValidationPage +api_success = "API key set successfully. Please proceed to the next step." +heading_steam_api = "Steam Web API key" +button_web_api = "Web API setup link" +blurb_steam_api = """ +You will need to set up a Steam Web API key in order to browse the global server list. +\nIf you don't have one already, it can be set up via the page below. +\nPlease refer to the DZGUI documentation for more instructions. +""" +heading_bm_api = "Battlemetrics Web API key" +blurb_bm_api = """A Battlemetrics key is optional, but allows you to add/search for servers\n +by numeric ID on the web. For example, in the URL https://www.battlemetrics.net/servers/dayz/24819107,\n +the ID would be 24819107. +""" + +entry_placeholder = "Enter API key here" + +### Completion page +heading_completion = "Setup complete" +blurb_completion = "Configuration completed successfully. Please exit and restart DZGUI to apply changes." diff --git a/dzgui/util/clip.py b/dzgui/util/clip.py index 58bf68e..971515c 100644 --- a/dzgui/util/clip.py +++ b/dzgui/util/clip.py @@ -1,10 +1,10 @@ import gi gi.require_version("Gtk", "3.0") -from gi.repository.Gdk import SELECTION_CLIPBOARD # noqa E402 -from gi.repository.Gtk import Clipboard # noqa E402 +gi.require_version("Gdk", "3.0") +from gi.repository import Gtk, Gdk # noqa E402 def copy_clipboard(text: str) -> None: - clipboard = Clipboard.get(SELECTION_CLIPBOARD) + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) clipboard.set_text(text, -1) diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 6f60814..e15ab5b 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -21,7 +21,7 @@ from dzgui.const.constants import ( import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib # noqa E402 +from gi.repository import Gtk, Gdk, GLib # noqa E402 if TYPE_CHECKING: from gi.repository import GLib diff --git a/dzgui/views/components/entry.py b/dzgui/views/components/entry.py index 8dcd3f3..8bdc62c 100644 --- a/dzgui/views/components/entry.py +++ b/dzgui/views/components/entry.py @@ -1,8 +1,10 @@ -from typing import Callable, TYPE_CHECKING +from typing import Any, Callable, TYPE_CHECKING from dzgui.api.servers import validate_ip +from dzgui.const.constants import VIEW_CONCEAL, VIEW_REVEAL from dzgui.util.css import add_class, remove_class from dzgui.util.strings import connect_panel, lan_panel +from dzgui.strings.errors import api_popover import gi @@ -140,3 +142,98 @@ class PortEntry(ValidatedEntry): ) self.set_placeholder_text(lan_panel.placeholder) self.set_tooltip_text(lan_panel.entry_tooltip) + + +# TODO: backport to Options page +class APIEntry(Gtk.Box): + def __init__(self) -> None: + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + + self.func: Callable | None = None + + # TODO: strings + self.entry = Gtk.Entry( + width_chars=60, hexpand=True, placeholder_text="Enter API key" + ) + self.entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL) + self.entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) + self.entry.set_visibility(False) + self.entry.connect("changed", self._on_text_changed) + self.entry.connect("icon-release", self._on_icon_release) + self.entry.connect("activate", self._on_field_activated) + + self.submit = Gtk.Button(label="Submit") + self.submit.set_sensitive(False) + self.submit.connect("clicked", self._on_submit) + + self.pop = Gtk.Popover() + self.pop_label = Gtk.Label( + label=api_popover, + margin_start=10, + margin_end=10, + ) + self.pop.add(self.pop_label) + # NOTE: render once to draw text in bubble + self.pop.show_all() + self.pop.set_margin_start(10) + self.pop.set_relative_to(self.entry) + self.pop.popdown() + + for el in self.entry, self.submit: + self.add(el) + + def get_entry(self) -> None: + return self.entry + + def popup(self) -> None: + self.pop.popup() + + def get_submit(self) -> Gtk.Button: + return self.submit + + def _is_valid_text(self, text: str) -> bool: + if text.isspace(): + return False + if len(text) == 0: + return False + return True + + def _on_text_changed(self, entry: Gtk.Entry) -> None: + text = entry.get_text() + if self._is_valid_text(text): + self.submit.set_sensitive(True) + else: + self.submit.set_sensitive(False) + + def _on_icon_release( + self, + widget: Gtk.Entry, + icon_pos: Gtk.EntryIconPosition, + event: Gdk.Event, + ) -> None: + visible = widget.get_visibility() + if visible: + icon, state = VIEW_REVEAL, False + else: + icon, state = VIEW_CONCEAL, True + widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + widget.set_visibility(state) + + def _on_field_activated(self, entry: Gtk.Entry) -> None: + self.submit.emit("clicked") + + def _on_submit(self, button: Gtk.Button) -> Any: + if self.func is None: + return + text = self.entry.get_text() + res = self.func(text) + return res + + def set_validation_func(self, func: Callable | None) -> None: + self.func = func + + def disable_button(self) -> None: + self.submit.set_sensitive(False) + + def enable_button(self) -> None: + self.submit.set_sensitive(True) diff --git a/dzgui/views/dialogs/wizard.py b/dzgui/views/dialogs/wizard.py new file mode 100644 index 0000000..f990baa --- /dev/null +++ b/dzgui/views/dialogs/wizard.py @@ -0,0 +1,584 @@ +import json +import textwrap + +from enum import Enum +from importlib import resources +from pathlib import Path +from typing import Any, Callable, Self + +from dzgui.api.probe import test_steam_api, test_bm_api +from dzgui.api.steam import get_steam_paths +from dzgui.const.constants import ( + APP_NAME, + APP_NAME_LOWER, + HERO_PATH, + LEGACY_CONFIG_PATH, +) +from dzgui.const.endpoints import BM_API_SETUP, STEAM_API_SETUP + +from dzgui.init.migrate import migrate_legacy_conf +from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager +from dzgui.strings import wizard +from dzgui.util._json import write_json +from dzgui.util.open_links import open_link_by_url +from dzgui.util.css import add_class +from dzgui.views.components.buttons import WebButton +from dzgui.views.components.entry import APIEntry + +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +from gi.repository import Gdk, Gtk, GLib, GObject, GdkPixbuf # noqa E402 + + +class PageNum(Enum): + INTRO = 1 + HAS_CONFIG = 2 + STEAM_PATH = 3 + STEAM_API = 4 + BM_API = 5 + USER_PREFS = 6 + FINAL = 7 + + +class DescriptionArea(Gtk.Box): + def __init__(self, text: str): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + + wrapped = textwrap.fill(text, width=80) + self.description = Gtk.Label(justify=Gtk.Justification.CENTER) + self.description.set_markup(wrapped) + self.add(self.description) + + +class Progress(Gtk.ProgressBar): + def __init__(self) -> None: + super().__init__(show_text=True) + + +class ScrolledWizardPage(Gtk.ScrolledWindow): + def __init__(self, enum: PageNum, heading: str, description: str): + super().__init__() + + self.enum = enum + self.page_type: Gtk.AssistantPageType + self.title = heading + self.heading = Heading(heading) + self.description = DescriptionArea(description) + + hero = resources.files(APP_NAME_LOWER).joinpath(HERO_PATH) + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + filename=str(hero), + width=600, + height=600, + preserve_aspect_ratio=True, + ) + image = Gtk.Image.new_from_pixbuf(pixbuf) + + self.box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + margin_start=100, + margin_end=100, + margin_top=50, + spacing=20, + ) + self.add(self.box) + self.prog = Progress() + self.box.pack_end(self.prog, expand=False, fill=False, padding=0) + self.box.pack_start(image, expand=False, fill=True, padding=0) + self.box.pack_start(self.heading, expand=False, fill=True, padding=0) + self.box.pack_start(self.description, expand=False, fill=True, padding=0) + + self.connect("map", self._on_map) + + def get_progress_bar(self) -> Progress: + return self.prog + + def get_page_type(self) -> Gtk.AssistantPageType: + return self.page_type + + def set_title(self, title: str) -> None: + self.title = title + + def get_title(self) -> str: + return self.title + + def add_start(self, content: Gtk.Widget) -> None: + self.box.add(content) + + def add_end(self, content: Gtk.Widget) -> None: + self.box.pack_end(content, expand=False, fill=False, padding=50) + + def get_box(self) -> Gtk.Box: + return self.box + + def _on_map(self, page: "ScrolledWizardPage") -> None: + pass + + +class NotificationFrame(Gtk.Frame): + def __init__(self, label: str, error: bool = False) -> None: + super().__init__(halign=Gtk.Align.CENTER) + + self.box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + margin_start=50, + margin_end=50, + ) + wrapped = textwrap.fill(label, width=80) + self.label = Gtk.Label( + label=wrapped, + justify=Gtk.Justification.CENTER, + margin_top=50, + margin_bottom=50, + ) + self.box.add(self.label) + self.add(self.box) + + if error: + # TODO: custom css file only for wizard + add_class(self, "error-frame") + + +class APIValidationPage(ScrolledWizardPage): + def __init__( + self, enum: PageNum, heading: str, description: str, link: str, func: Callable + ) -> None: + super().__init__( + enum=enum, + heading=heading, + description=description, + ) + + self.key = "" + self.link = link + self.thread_man = ThreadingManager(None) + self.page_type = Gtk.AssistantPageType.INTRO + + self.validation_func = func + + self.validation_box = APIEntry() + self.validation_box.set_halign(Gtk.Align.CENTER) + self.validation_box.set_validation_func(self._pre_validate) + + self.link_button = WebButton(label=wizard.button_web_api) + self.link_button.set_halign(Gtk.Align.CENTER) + self.link_button.connect("clicked", self._on_link_clicked) + + self.spinner = Gtk.Spinner() + self.success_box = NotificationFrame(wizard.api_success) + + self.add_start(self.link_button) + self.add_start(self.validation_box) + self.add_start(self.spinner) + self.add_start(self.success_box) + + self.connect("map", lambda _: self.success_box.set_visible(False)) + + def get_api_key(self) -> str: + return self.key + + def _on_link_clicked(self, button: Gtk.Button) -> None: + if self.link == "": + return + open_link_by_url(self.link) + + def _pre_validate(self, key: str) -> None: + self.spinner.start() + self.validation_box.disable_button() + self.validation_func(key) + + def _cleanup(self, state: bool, key: str) -> None: + if state: + self.key = key + self.validation_box.disable_button() + self.success_box.set_visible(True) + EMITTER.emit("step_complete") + else: + self.validation_box.popup() + self.validation_box.enable_button() + self.spinner.stop() + + +class BMValidationPage(APIValidationPage): + def __init__(self) -> None: + super().__init__( + enum=PageNum.BM_API, + heading=wizard.heading_bm_api, + description=wizard.blurb_bm_api, + link=BM_API_SETUP, + func=self._validate, + ) + + @call_on_thread("", show_dialog=False) + def _validate(self, key: str) -> None: + is_valid = test_bm_api(key.strip()) + cleanup = StoredFunc(self._cleanup, is_valid, key) + self.thread_man.set_cleanup_func(cleanup) + + +class SteamValidationPage(APIValidationPage): + def __init__(self) -> None: + super().__init__( + enum=PageNum.STEAM_API, + heading=wizard.heading_steam_api, + description=wizard.blurb_steam_api, + link=STEAM_API_SETUP, + func=self._validate, + ) + + @call_on_thread("", show_dialog=False) + def _validate(self, key: str) -> None: + is_valid = test_steam_api(key) + cleanup = StoredFunc(self._cleanup, is_valid, key) + self.thread_man.set_cleanup_func(cleanup) + + +class IntroductionPage(ScrolledWizardPage): + def __init__(self, version: str): + super().__init__( + enum=PageNum.INTRO, + heading=f"Welcome to {APP_NAME} {version}!", + description=wizard.blurb_intro, + ) + self.page_type = Gtk.AssistantPageType.INTRO + self.set_title(wizard.title_intro) + + +class Heading(Gtk.Label): + def __init__(self, label: str): + super().__init__(label=label) + + # add_class(self, "heading") + # font weight + # TODO: set css em size + # TODO: bold text + + +class ChunkyButton(Gtk.Button): + def __init__(self, text: str) -> None: + super().__init__() + self.set_size_request(80, 80) + + wrapped = textwrap.fill(text, width=40) + label = Gtk.Label(label=wrapped, justify=Gtk.Justification.CENTER) + self.add(label) + + +class RadioFrame(Gtk.Frame): + def __init__( + self, parent: Gtk.RadioButton | None, button_path: tuple[Path, str] + ) -> None: + super().__init__() + + self.vbox = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + margin_top=15, + margin_start=10, + margin_end=10, + margin_bottom=15, + ) + path, pretty = button_path + if parent is None: + self.button = Gtk.RadioButton.new_with_label(None, str(path)) + else: + self.button = Gtk.RadioButton.new_with_label_from_widget(parent, str(path)) + self.vbox.add(self.button) + label = Gtk.Label(halign=Gtk.Align.START) + label.set_markup(pretty) + self.vbox.add(label) + self.add(self.vbox) + + def get_button(self) -> Gtk.RadioButton: + return self.button + + +class ConfigMigrationPage(ScrolledWizardPage): + def __init__(self, config: Path) -> None: + super().__init__( + enum=PageNum.HAS_CONFIG, + heading=wizard.heading_config, + description=wizard.blurb_config, + ) + + self.migrated = False + self.config = config + self.page_type = Gtk.AssistantPageType.INTRO + + self.import_button = ChunkyButton(wizard.config_import_button) + self.new_button = ChunkyButton(wizard.config_new_button) + + self.grid = Gtk.Grid(column_spacing=30, halign=Gtk.Align.CENTER) + self.grid.set_column_homogeneous(True) + self.grid.attach(self.import_button, 0, 0, 1, 1) + self.grid.attach(self.new_button, 1, 0, 1, 1) + + self.add_start(self.grid) + + self.success_box = NotificationFrame(wizard.config_import_box) + self.from_scratch_box = NotificationFrame(wizard.config_new_box) + self.add_start(self.success_box) + self.add_start(self.from_scratch_box) + + self.connect("map", self._hide_boxes) + self.import_button.connect("clicked", self._on_import_clicked) + self.new_button.connect("clicked", self._on_new_clicked) + + def _hide_boxes(self, page: Self) -> None: + for box in self.success_box, self.from_scratch_box: + box.set_visible(False) + + def _on_new_clicked(self, button: Gtk.Button) -> None: + self.grid.set_sensitive(False) + self.from_scratch_box.set_visible(True) + EMITTER.emit("step_complete") + EMITTER.emit("config", False) + + def get_migrated(self) -> bool: + return self.migrated + + def _on_import_clicked(self, button: Gtk.Button) -> None: + self.grid.set_sensitive(False) + try: + migrate_legacy_conf(self.config) + self.migrated = True + self.success_box.set_visible(True) + except Exception: + pass + EMITTER.emit("step_complete") + EMITTER.emit("config", True) + + +class CompletionPage(ScrolledWizardPage): + def __init__(self) -> None: + super().__init__( + enum=PageNum.FINAL, + heading=wizard.heading_completion, + description=wizard.blurb_completion, + ) + # TODO: show collapsible config file tree + self.page_type = Gtk.AssistantPageType.SUMMARY + + self.connect("map", lambda _: EMITTER.emit("step_complete")) + + +class Assistant(Gtk.Assistant): + def __init__(self, version: str, is_deck: bool, config: Path): + super().__init__() + if is_deck: + self.fullscreen() + else: + self.set_default_size(1500, 900) + + self.config_path = config + # TODO: read in from boilerplate file + from dzgui.const.boilerplate import config_boilerplate + + self.config_values: dict[str, Any] = config_boilerplate + + self.page1 = IntroductionPage(version) + self.page2 = ConfigMigrationPage(config) + self.page3 = SteamPathPage() + self.page4 = SteamValidationPage() + self.page5 = BMValidationPage() + + # self.page6 = PreferencesPage() + # contains name, miles, and steam client choice + # self.name = Gtk.Entry() + # self.miles = Gtk.RadioButton() + # TODO: use dual column model, recycle into options + # TODO: update client_combo in options page + # self.client = Gtk.ComboBox() + # TODO: write to config if not present + + self.page7 = CompletionPage() + + self.set_forward_page_func(self._advance_page) + + EMITTER.connect("step_complete", self._mark_page_complete) + EMITTER.connect("step_pending", self._mark_page_incomplete) + EMITTER.connect("config", self._set_config_state) + + legacy_path = Path.home().joinpath(LEGACY_CONFIG_PATH) + self.has_legacy_config = legacy_path.is_file() + for page in ( + self.page1, + self.page2, + self.page3, + self.page4, + self.page5, + self.page7, + ): + # NOTE: skip config migration page if no legacy config file + if page == self.page2 and self.has_legacy_config is False: + continue + self._add_page(page, page.get_page_type()) + + self.connect("prepare", self._on_page_prepare) + self.connect("cancel", self.destroy_and_quit) + self.connect("close", self.destroy_and_quit) + self.show_all() + + def write_config(self) -> None: + # NOTE: implies that file was already migrated on page 3 + if self.has_legacy_config: + return + write_json(self.config_values, self.config_path) + + def _advance_page(self, index: int) -> int: + page = self.get_nth_page(index) + match page: + case self.page1: + pass + case self.page2: + if self.page2.get_migrated(): + return self.get_n_pages() - 1 + case self.page3: + self.config_values["default_steam_path"] = page.get_path_from_radio() + case self.page4: + self.config_values["steam_api"] = page.get_api_key() + case self.page5: + self.config_values["bm_api"] = page.get_api_key() + # case self.page6: + # self.write_config() + case _: + raise AttributeError("Trying to advance a non-canonical page") + print(self.config_values) + return index + 1 + + def destroy_and_quit(self, widget: Self) -> None: + self.destroy() + Gtk.main_quit() + + def _mark_page_incomplete(self, emitter: "Emitter") -> None: + page_id = self.get_current_page() + page = self.get_nth_page(page_id) + if page is None: + return + self.set_page_complete(page, False) + + def _mark_page_complete(self, emitter: "Emitter") -> None: + page_id = self.get_current_page() + page = self.get_nth_page(page_id) + if page is None: + return + self.set_page_complete(page, True) + + def _add_page(self, page: ScrolledWizardPage, ptype: Gtk.AssistantPageType) -> None: + self.append_page(page) + self.set_page_type(page, ptype) + self.set_page_title(page, page.get_title()) + self.set_page_complete(page, True) + + def _set_config_state(self, emitter: "Emitter", state: bool) -> None: + self.config = state + + def _on_page_prepare(self: Self, wizard: Self, page: ScrolledWizardPage) -> None: + page_num = self.get_current_page() + 1 + total = self.get_n_pages() + fraction = page_num / total + + bar = page.get_progress_bar() + bar.set_fraction(fraction) + bar.set_text(f"{page_num}/{total}") + + # NOTE: disable forward action + if page != self.page1: + EMITTER.emit("step_pending") + + +class SteamPathPage(ScrolledWizardPage): + def __init__(self) -> None: + super().__init__( + enum=PageNum.USER_PREFS, + heading=wizard.heading_steam_path, + description=wizard.blurb_steam_path, + ) + + self.page_type = Gtk.AssistantPageType.INTRO + + # TODO: add custom CSS class to Gtk.Frame so that only this one is styled + err_box = NotificationFrame(wizard.error_steam_path, error=True) + self.err = err_box + + self.scan_button = Gtk.Button(label=wizard.button_scan, halign=Gtk.Align.CENTER) + self.scan_button.connect("clicked", self._on_scan_clicked) + + self.add_start(self.scan_button) + self.connect("map", self._start_incomplete) + + def _on_scan_clicked(self, button: Gtk.Button) -> None: + self.scan_button.set_sensitive(False) + paths = get_steam_paths() + + button_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + halign=Gtk.Align.CENTER, + spacing=10, + ) + total = len(paths) + err_box = NotificationFrame(wizard.no_valid_paths) + if total == 0: + self.add_start(err_box) + else: + button_box.add(Gtk.Label(label=f"Steam paths found: {total} total.")) + for i, button_path in enumerate(paths): + if i == 0: + frame = RadioFrame(None, button_path) + self.first_button = frame.get_button() + button_box.add(frame) + else: + frame = RadioFrame(self.first_button, button_path) + button_box.add(frame) + self.add_start(button_box) + EMITTER.emit("step_complete") + self.show_all() + + def get_path_from_radio(self) -> str: + active = next(r for r in self.first_button.get_group() if r.get_active()) + return active.get_label() + + def _start_incomplete(self, page: Self) -> None: + self.err.set_visible(False) + + def _test_error_func(self, button: Gtk.CheckButton) -> None: + self.err.set_visible(True) + EMITTER.emit("step_incomplete") + + +class SetupWizard(Gtk.Application): + def __init__(self, version: str, is_deck: bool, config: Path) -> None: + super().__init__() + GLib.set_prgname(APP_NAME) + Window(version, is_deck, config) + Gtk.main() + + +class Window(Gtk.Window): + def __init__(self, version: str, is_deck: bool, config: Path) -> None: + super().__init__(title=APP_NAME, icon_name=APP_NAME) + Assistant(version, is_deck, config) + + +class Emitter(GObject.GObject): + def __init__(self) -> None: + super().__init__() + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def step_complete(self) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def step_pending(self) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,)) + def config(self, state: bool) -> None: + pass + + +EMITTER = Emitter() + +# TODO: Ctrl-q +# TODO: change behavior of global emitter diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 0ae777e..83b03cd 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -481,7 +481,7 @@ class Options(Gtk.Box): self.dayz_version_label.set_text(dayz_version) self.dayz_exp_version_label.set_text(dayz_exp_version) - # TODO: not happy with this + # TODO: bicolumn list store with no cell renderer on index 1, use raw command names active_combo = query.get_client_index(config["client"]) self.client_combo.set_active(active_combo)