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 = [