diff --git a/.gitignore b/.gitignore index 456bad6..73744d0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ build/ notes.otl uv.lock .coverage +pyapp-latest/ +dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 30c1729..6cddc42 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 @@ -36,6 +37,7 @@ ## Changed +- Conform to PEP 440 versioning for beta versions - Reduce padding on keys button - Boldface breadcrumbs - Bold labels inside frames 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..79f13c5 100644 --- a/dzgui/config/userprefs.py +++ b/dzgui/config/userprefs.py @@ -14,6 +14,6 @@ class UserPrefs: is_debug: bool coords: Union["Coords", None] version: str - allow_updates: bool paths: "Xdg" + update_available: bool use_miles: bool diff --git a/dzgui/config/xdg.py b/dzgui/config/xdg.py index 2826a0b..137d143 100644 --- a/dzgui/config/xdg.py +++ b/dzgui/config/xdg.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from dzgui.const.constants import APP_NAME_LOWER, DEBUG_LOG, SYSTEM_LOG + @dataclass class Xdg: config: Path @@ -73,7 +74,7 @@ def parse_filepaths(xdg: dict) -> Xdg: system = state / "logs" / SYSTEM_LOG debug = state / "logs" / DEBUG_LOG - columns = state / "dzg.cols.json" + columns = state / "dzg.columns.json" notes = state / "dzg.notes.json" resolution = state / "dzg.res.json" history = state / "dzg.history" 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/const/endpoints.py b/dzgui/const/endpoints.py index cb1ca1c..621f233 100644 --- a/dzgui/const/endpoints.py +++ b/dzgui/const/endpoints.py @@ -5,6 +5,7 @@ BM_SERVERS = "https://api.battlemetrics.com/servers?" GITHUB = "https://github.com/aclist" GITHUB_RELEASES = "https://api.github.com/repos/aclist/dztui/releases/latest" CODEBERG_RELEASES = "https://codeberg.org/api/v1/repos/aclist/dzgui/releases/latest" +GITHUB_USER_RELEASES = "https://github.com/aclist/dztui/releases" DB_IP = "https://db-ip.com/db/download/ip-to-city-lite" IP_ECHO = "https://ipecho.net/plain" COORDS_API = "http://ip-api.com/json" diff --git a/dzgui/const/update.py b/dzgui/const/update.py deleted file mode 100644 index 2841083..0000000 --- a/dzgui/const/update.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -This file is intended to be patched by package maintainers -repackaging DZGUI for use in different distributions. - -If DZGUI is going to be installed globally via a package manager -or other means and will reside in an immutable location, or needs to be -explicitly bound to a certain version, set the flag below to False. - -This will disable the following: - -- Version update checks at startup -- Ability to toggle between Stable/Testing branches in the Options menu -""" - -ALLOW_UPDATES = True diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 0b03c65..6cddc42 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -1,4 +1,5 @@ ## Added +- Setup wizard - Changelog text wrapping and formatting - Changelog ships with source - Documentation ships with source @@ -32,20 +33,27 @@ - Refresh servers button - Early load alerts button - Preconnect dialog +- Preconnect warnings/failsafes like filesize + ## Changed +- Conform to PEP 440 versioning for beta versions - Reduce padding on keys button - Boldface breadcrumbs - Bold labels inside frames -- Sidebar buttos do not steal focus +- Sidebar buttons do not steal focus - Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method - Load new model into view without flushing - Cull servers with abnormal queue values (integer overflow: 2147483647) - Packaging structure +- Suppress log messages from imported modules +- Embed Workshop link in Options menu ## Dropped - Debug mode - Branch switching +- Manual mod install mode (describe rationale) +- Force update mods ## Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input @@ -57,12 +65,10 @@ - Load offline mods - Choose to jump into splash screen instead of server - Setup wizard -- Move debug mode to developers only - Local documentation +- Raw debug command in context menu ## Developer-facing - Add pyproject.toml file - Show deprecation warnings - Options -> Dev page -- Raw debug command -- Moved debug log to this mode diff --git a/dzgui/data/app.css b/dzgui/data/app.css index 40eb4e6..9075dec 100644 --- a/dzgui/data/app.css +++ b/dzgui/data/app.css @@ -35,3 +35,6 @@ .masked-tree { background-color: transparent; } +.error-frame border { + border: 2px solid red; +} diff --git a/dzgui/init/migrate.py b/dzgui/init/migrate.py index 554e793..55af183 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: @@ -24,6 +18,7 @@ def has_new_config(config: Path) -> bool: def migrate_cols_file(res: Path) -> None: + # NOTE: dzg.columns.json is API 7 spec old_res = Path.home() / LEGACY_COLS_PATH if old_res.is_file(): j = read_json(old_res) @@ -38,11 +33,20 @@ def migrate_cols_file(res: Path) -> None: def copy_state_files(state_path: Path) -> None: home = Path.home() legacy = home / ".local/state/dzgui" + to_copy = [ + "dzg.res.json", + "dzg.notes.json", + "dzg.history", + "dzg.versions", + "ips.csv", + ".month", + ] if state_path == legacy: # TODO: log this return for file in legacy.iterdir(): - shutil.copy(file, state_path / file.name) + if file.name in to_copy: + shutil.copy(file, state_path / file.name) def copy_ipdb(ips_path: Path) -> None: diff --git a/dzgui/init/scan.py b/dzgui/init/scan.py deleted file mode 100644 index 891f2cc..0000000 --- a/dzgui/init/scan.py +++ /dev/null @@ -1,51 +0,0 @@ -import shutil -import subprocess - -from pathlib import Path - -""" -Find possible locations of default steam path -""" - -def get_steam_dirs() -> list[Path]: - dirs = [] - # pass 1 - if shutil.which("locate") is not None: - # TODO: config or steamapps path? cf. LIBRARYFOLDERS_PATH - proc = subprocess.run( - ["/usr/bin/locate", "Steam/config/libraryfolders.vdf"], - capture_output=True, - text=True - ) - - if proc.returncode == 0: - for d in proc.stdout.splitlines(): - dirs.append(Path(d)) - return dirs - - # pass 2 - for d in Path.home().rglob("Steam/config/libraryfolders.vdf"): - dirs.append(d) - - # pass 3 - # if still nothing, let them select it - # prompt what the dir should look like - if len(dirs) == 0: - print("none found") - return dirs - - -dirs = get_steam_dirs() -print(dirs) - -# TODO: present these in a group of radio buttons -""" -when user toggles radio button, updates text saying -"this is the standard steam location on debian" etc. - -if None or if not satisfied, pop a filepicker and choose -if selection was custom, it has to have a libraryfolders.vdf -keeps next button grayed out until requirements are satisfied - -Cancel Next -""" diff --git a/dzgui/init/update.py b/dzgui/init/update.py index eb6d7db..7ec9b91 100644 --- a/dzgui/init/update.py +++ b/dzgui/init/update.py @@ -1,14 +1,10 @@ import logging import requests -import subprocess -import sys -from importlib import resources from packaging.version import Version -from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, REQUEST_TIMEOUT +from dzgui.const.constants import APP_NAME, REQUEST_TIMEOUT from dzgui.const.endpoints import GITHUB_RELEASES, CODEBERG_RELEASES -from dzgui.init.prefix import is_prefix_writeable logger = logging.getLogger(APP_NAME) @@ -28,31 +24,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) -> bool: try: latest = get_latest_release() - prefix = sys.prefix if latest is None: - return + return False 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 False + return True except Exception: - return + return False diff --git a/dzgui/lib/dayzquery b/dzgui/lib/dayzquery index a22a9f4..07483b8 160000 --- a/dzgui/lib/dayzquery +++ b/dzgui/lib/dayzquery @@ -1 +1 @@ -Subproject commit a22a9f428cbe075d7dda62f78000296955eea92a +Subproject commit 07483b88ed096327ebca752f5e177011b604d7fe diff --git a/dzgui/main.py b/dzgui/main.py index 516d5d5..94e1236 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -13,29 +13,27 @@ 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 from dzgui.init.migrate import ( has_new_config, - migrate_cols_file, - migrate_legacy_conf, copy_state_files, + migrate_cols_file, ) 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.strings import boot +from dzgui.init.update import check_updates -from dzgui.util.map_count import get_map_count +# 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.early_alert import EarlyAlertDialog if TYPE_CHECKING: @@ -97,33 +95,28 @@ 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: + migrate_cols_file(XDG.columns) + copy_state_files(xdg_paths["XDG_STATE_HOME"]) + # TODO: add logging inside wizard + SetupWizard(_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) + + update_available = check_updates(version) if _is_steam_deck is False: # TODO: sudo escalation dialog @@ -148,10 +141,9 @@ def main() -> None: is_debug=args.debug, coords=local_coords, version=version, - allow_updates=allow, paths=XDG, + update_available=update_available, use_miles=use_miles, ) - # TODO: drop allow updates print(boot.all_ok) App(prefs) 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/scripts/update.sh b/dzgui/scripts/update.sh deleted file mode 100644 index 7146dcc..0000000 --- a/dzgui/scripts/update.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# TODO: move this into package data dir -prefix="dzgui" -tag="$1" -prefix="$2" -dir="/tmp/dzgui-${tag}" - -mkdir -p "$dir" - -git clone -C "$dir" git@github.com:aclist/dzgui.git --branch "${tag}" -git -C "$dir" submodule update --recursive --init - -if [[ ! $(which python3.13) ]]; then - echo "Missing python3.13" - exit 1 -fi - -python3.13 -m venv "${prefix}" -source "$prefix/bin/activate" - -rm -rf "$dir" -if [[ $(which uv) ]]; then - uv --directory "$dir" pip install . -else - cd "$dir" - pip install -r requirements.txt - echo "Setup complete. Re-launch DZGUI via the command 'dzgui'" -fi 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..56c9053 --- /dev/null +++ b/dzgui/strings/wizard.py @@ -0,0 +1,70 @@ +### IntroductionPage +title_intro = "Welcome" +blurb_intro = """ +This wizard will help you set up some common config options before launching the application. +""" + +### SteamPathPage +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." +config_error_box = "Something went wrong while writing a new config file to the system." + +### APIValidationPage +entry_placeholder = "Enter API key here" +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 must 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. +""" + +### PreferencesPage +heading_prefs = "User preferences" +blurb_prefs = """Here you can set up some basic settings. Additional preferences can be\n +configured via the Options menu once DZGUI launches. +""" +label_player = "Player name" +placeholder_player = "Set an in-game player name" +radio_km = "km (kilometers)" +radio_mi = "mi (miles)" +label_dist = "Distance display" +label_client = "Steam client" + + +### 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/base.py b/dzgui/views/base.py index 082d218..2778439 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -44,30 +44,6 @@ logger = logging.getLogger(APP_NAME) warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) -## TODO: move to configs/servers -# def query_history() -> list | None: -# history_file = MainController.get_prefs().paths.history -# try: -# with open(history_file, "r") as f: -# rows = [row.rstrip("\n") for row in f] -# except OSError: -# rows = None -# return rows -# -## TODO: belongs in model -# def str_to_record(record: str) -> Record | None: -# r = record.split(":") -# if len(r) != 3: -# return None -# return Record(r[0], int(r[1]), int(r[2])) -# -## TODO: ibid -# def record_to_str(record: Record) -> str: -# return f"{record.ip}:{record.gameport}:{record.qport}" -# -# - - class OuterWindow(Gtk.Window): def __init__(self) -> None: super().__init__(title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER) 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..88fd7e2 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) -> Gtk.Entry: + 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/components/icon.py b/dzgui/views/components/icon.py index 0ade27e..a8abd11 100644 --- a/dzgui/views/components/icon.py +++ b/dzgui/views/components/icon.py @@ -1,10 +1,11 @@ import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 + class Icon(Gtk.Image): - def __init__(self, name: str, l_margin=0) -> None: - super().__init__(icon_name=name, - icon_size=Gtk.IconSize.BUTTON, - margin_start=l_margin + def __init__(self, name: str, l_margin: int = 0) -> None: + super().__init__( + icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin ) diff --git a/dzgui/views/components/misc.py b/dzgui/views/components/misc.py new file mode 100644 index 0000000..7b9fc0f --- /dev/null +++ b/dzgui/views/components/misc.py @@ -0,0 +1,35 @@ +from dzgui.strings import options +from dzgui.const.constants import FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk # noqa + + +class ClientCombo(Gtk.ComboBox): + def __init__(self) -> None: + super().__init__() + + self.client_store = Gtk.ListStore(str, str) + clients = ( + ( + options.steam_combo, + STEAM_CMD, + ), + ( + options.flatpak_combo, + FLATPAK_RUN_CMD, + ), + ( + options.flatpak_container_combo, + FLATPAK_SANDBOX, + ), + ) + for client in clients: + self.client_store.append(client) + self.set_model(self.client_store) + renderer_text = Gtk.CellRendererText() + self.pack_start(renderer_text, True) + self.add_attribute(renderer_text, "text", 0) + self.set_active(0) diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index a0566fc..cea2521 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -1,12 +1,14 @@ from typing import Literal, TYPE_CHECKING +from dzgui.const.constants import NO_EXPAND, NO_FILL, FILL, NO_PADDING +from dzgui.const.endpoints import GITHUB_USER_RELEASES from dzgui.const.enum import ServerTab from dzgui.util.clip import copy_clipboard +from dzgui.util.open_links import open_link_by_url from dzgui.views.components.buttonbox import ButtonBox from dzgui.views.components.filter_panel import FilterPanel from dzgui.views.components.mod_panel import ModSelectionPanel -from dzgui.views.components.buttons import RefreshButton, KeysButton -from dzgui.const.constants import NO_EXPAND, NO_FILL, FILL, NO_PADDING +from dzgui.views.components.buttons import IconTextButton, RefreshButton, KeysButton import gi @@ -40,14 +42,17 @@ class RightPanel(Gtk.Box): self.copying = False - # TODO: strings - version = self.controller.get_prefs().version + prefs = self.controller.get_prefs() + version = prefs.version + update = prefs.update_available + self.version_label = Gtk.Label( label=version, hexpand=True, vexpand=True, halign=Gtk.Align.END, valign=Gtk.Align.END, + # TODO: strings tooltip_text="Click to copy to clipboard", ) eb = Gtk.EventBox(halign=Gtk.Align.END, valign=Gtk.Align.END) @@ -58,7 +63,26 @@ class RightPanel(Gtk.Box): self.pack_start(el, NO_EXPAND, FILL, NO_PADDING) self.pack_start(self.sel_panel, NO_EXPAND, NO_FILL, NO_PADDING) - self.pack_start(eb, NO_EXPAND, FILL, NO_PADDING) + + self.version_button = IconTextButton( + "dialog-information-symbolic", label="Updates available" + ) + + self.gutter_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.END, + valign=Gtk.Align.END, + spacing=10, + ) + if update: + self.version_button.set_halign(Gtk.Align.END) + self.gutter_box.add(self.version_button) + self.version_button.connect("clicked", self._on_version_button_clicked) + self.gutter_box.add(eb) + self.pack_start(self.gutter_box, NO_EXPAND, FILL, NO_PADDING) + + def _on_version_button_clicked(self, button: Gtk.Button) -> None: + open_link_by_url(GITHUB_USER_RELEASES) def _on_server_page_changed( self, emitter: "Emitter", page: "ServerTreeView" diff --git a/dzgui/views/dialogs/watcher.py b/dzgui/views/dialogs/watcher.py deleted file mode 100644 index 2b56d30..0000000 --- a/dzgui/views/dialogs/watcher.py +++ /dev/null @@ -1,18 +0,0 @@ -import gi -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk # noqa - -class Watcher(Gtk.Dialog): - def __init__(): - super().__init__() - pass - - def bump_progress(self): - self.set_secondary_text("FOO") - -#w = Watcher() -#thread = threading() -## compute here -#GLib.idle_add(self._bump_progress) -## raise window: -#Gdk.present_with_time(Gdk.CURRENT_TIME) diff --git a/dzgui/views/dialogs/wizard.py b/dzgui/views/dialogs/wizard.py new file mode 100644 index 0000000..1c118cb --- /dev/null +++ b/dzgui/views/dialogs/wizard.py @@ -0,0 +1,627 @@ +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.boilerplate import config_boilerplate +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, load_css +from dzgui.views.components.buttons import WebButton +from dzgui.views.components.entry import APIEntry +from dzgui.views.components.misc import ClientCombo + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gdk, Gtk, GLib, GObject, GdkPixbuf # noqa E402 + + +# TODO: currently unused, corresponds to linear index +# TODO: use of pagenum enum and page_type attribute is redundant +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: + 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): + super().__init__( + enum=PageNum.INTRO, + heading=f"Welcome to {APP_NAME}!", + 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, "settings-subheading") + + +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.err_box = NotificationFrame(wizard.config_error_box, error=True) + self.success_box = NotificationFrame(wizard.config_import_box) + self.from_scratch_box = NotificationFrame(wizard.config_new_box) + for box in self.err_box, self.success_box, self.from_scratch_box: + self.add_start(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, self.err_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 is_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: + self.err_box.set_visible(True) + EMITTER.emit("step_pending") + return + EMITTER.emit("step_complete") + EMITTER.emit("config", True) + + +class PreferencesPage(ScrolledWizardPage): + def __init__(self) -> None: + super().__init__( + enum=PageNum.USER_PREFS, + heading=wizard.heading_prefs, + description=wizard.blurb_prefs, + ) + self.page_type = Gtk.AssistantPageType.INTRO + + # TODO: widgets and strings are largely a reimplementation of options page, consolidate + name_label = Gtk.Label(label=wizard.label_player, halign=Gtk.Align.START) + self.name_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + self.name_entry = Gtk.Entry( + placeholder_text=wizard.placeholder_player, + halign=Gtk.Align.END, + width_chars=40, + ) + self.name_entry.connect("changed", self._on_entry_changed) + self.name_box.add(name_label) + self.name_box.add(self.name_entry) + + self.dist_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + self.radio_km = Gtk.RadioButton.new_with_label(None, wizard.radio_km) + self.radio_miles = Gtk.RadioButton.new_with_label_from_widget( + self.radio_km, wizard.radio_mi + ) + dist_label = Gtk.Label(label=wizard.label_dist, halign=Gtk.Align.START) + self.dist_box.add(dist_label) + self.dist_box.add(self.radio_km) + self.dist_box.add(self.radio_miles) + + client_label = Gtk.Label(label=wizard.label_client, halign=Gtk.Align.START) + self.client_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + self.client_combo = ClientCombo() + self.client_box.add(client_label) + self.client_box.add(self.client_combo) + + outer_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER, spacing=10 + ) + for el in self.name_box, self.client_box, self.dist_box: + outer_box.add(el) + self.add_start(outer_box) + + def get_prefs(self) -> tuple[str, bool, str]: + name = self.name_entry.get_text().strip() + use_miles = self.radio_miles.get_active() + model = self.client_combo.get_model() + ind = self.client_combo.get_active() + client = model[ind][1] + return name, use_miles, client + + def _on_entry_changed(self, entry: Gtk.Entry) -> None: + text = entry.get_text() + if text.isspace(): + EMITTER.emit("step_pending") + return + EMITTER.emit("step_complete") + + +class CompletionPage(ScrolledWizardPage): + def __init__(self) -> None: + super().__init__( + enum=PageNum.FINAL, + heading=wizard.heading_completion, + description=wizard.blurb_completion, + ) + self.page_type = Gtk.AssistantPageType.SUMMARY + + self.connect("map", lambda _: EMITTER.emit("step_complete")) + + +class Assistant(Gtk.Assistant): + def __init__(self, is_deck: bool, config: Path): + super().__init__() + if is_deck: + self.fullscreen() + else: + self.set_default_size(1500, 900) + + self.config_path = config + + self.config_values: dict[str, Any] = config_boilerplate + + self.page1 = IntroductionPage() + self.page2 = ConfigMigrationPage(config) + self.page3 = SteamPathPage() + self.page4 = SteamValidationPage() + self.page5 = BMValidationPage() + self.page6 = PreferencesPage() + 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.page6, + 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() + load_css() + + def write_config(self) -> None: + 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.is_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() + # NOTE: collects config values before advancing to last page + case self.page6: + name, use_miles, client = self.page6.get_prefs() + self.config_values["name"] = name + self.config_values["use_miles"] = use_miles + self.config_values["client"] = client + self.write_config() + case _: + raise AttributeError("Trying to advance a non-canonical page") + 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 + self.err_box = NotificationFrame(wizard.no_valid_paths, error=True) + + 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.add_start(self.err_box) + 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) + if total == 0: + show_errors = True + 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) + show_errors = False + EMITTER.emit("step_complete") + self.show_all() + # TODO: more robust approach + self.err_box.set_visible(show_errors) + + 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_box.set_visible(False) + + +class SetupWizard(Gtk.Application): + def __init__(self, is_deck: bool, config: Path) -> None: + super().__init__() + GLib.set_prgname(APP_NAME) + Window(is_deck, config) + Gtk.main() + + +class Window(Gtk.Window): + def __init__(self, is_deck: bool, config: Path) -> None: + super().__init__(title=APP_NAME, icon_name=APP_NAME) + Assistant(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() diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 0ae777e..e38d6ea 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -9,12 +9,9 @@ from dzgui.const.constants import ( APPID_DAYZ_EXP, APPNAME_DAYZ, APPNAME_DAYZ_EXP_HUMAN, - FLATPAK_RUN_CMD, - FLATPAK_SANDBOX, NO_EXPAND, NO_FILL, NO_PADDING, - STEAM_CMD, VIEW_CONCEAL, VIEW_REVEAL, ) @@ -27,6 +24,7 @@ from dzgui.views.components.labels import LeftLabel from dzgui.views.components.eventbox import InfoEventBox from dzgui.views.components.buttons import WebButton from dzgui.views.components.frame import HeadingFrame +from dzgui.views.components.misc import ClientCombo from dzgui.views.dialogs.generic import ExceptionDialog @@ -101,29 +99,7 @@ class Options(Gtk.Box): Preferences.WINDOW, ) - # TODO: make this an abstract class - client_store = Gtk.ListStore(str, str) - clients = ( - ( - options.steam_combo, - STEAM_CMD, - ), - ( - options.flatpak_combo, - FLATPAK_RUN_CMD, - ), - ( - options.flatpak_container_combo, - FLATPAK_SANDBOX, - ), - ) - for client in clients: - client_store.append(client) - self.client_combo = Gtk.ComboBox.new_with_model(client_store) # Text() - renderer_text = Gtk.CellRendererText() - self.client_combo.pack_start(renderer_text, True) - self.client_combo.add_attribute(renderer_text, "text", 0) - self.client_combo.set_active(0) + self.client_combo = ClientCombo() self.client_combo.connect("changed", self._on_client_changed) client_hbox = ShortHBox(self.client_combo) @@ -481,7 +457,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) diff --git a/pyproject.toml b/pyproject.toml index 1bf83b6..cc2d4c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux" authors = [ {name = "aclist"} ] -version = "7.0.0" +version = "7.0.0b1" license = "GPL-3.0-or-later" license-files = ["LICENSE"] readme = "README.md" @@ -54,8 +54,12 @@ lint = [ "types-requests>=2.32.4", "types-psutil==7.2.2.20260508", ] +build = [ + "build==1.5.0", +] + dev = [ - "dzgui[docs,test,lint]" + "dzgui[docs,test,lint,build]" ] [build-system] @@ -79,6 +83,7 @@ line-length = 88 python_version = "3.13" disallow_untyped_defs = true warn_return_any = true +exclude = ["lib/*"] [[tool.mypy.overrides]] module = [