feat: setup wizard (WIP)

This commit is contained in:
aclist 2026-05-17 03:00:12 +09:00
parent f0e51fa758
commit 07ea73ef2e
17 changed files with 839 additions and 61 deletions

View File

@ -1,4 +1,5 @@
## Added
- Setup wizard
- Changelog text wrapping and formatting
- Changelog ships with source
- Documentation ships with source

View File

@ -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

View File

@ -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:

View File

@ -14,6 +14,5 @@ class UserPrefs:
is_debug: bool
coords: Union["Coords", None]
version: str
allow_updates: bool
paths: "Xdg"
use_miles: bool

View File

@ -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,
}

View File

@ -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")

View File

@ -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:

View File

@ -20,6 +20,7 @@ def get_latest_release() -> str | None:
try:
res = requests.get(url, timeout=REQUEST_TIMEOUT)
if res.status_code == 200:
print(res.json())
tag = res.json()["tag_name"]
break
except Exception as e:
@ -28,31 +29,13 @@ def get_latest_release() -> str | None:
return tag
def allow_updates(allow: bool) -> bool:
if allow is False:
return False
if allow is True:
return is_prefix_writeable()
def check_updates(version: str) -> None:
def check_updates(version: str) -> str | None:
try:
latest = get_latest_release()
prefix = sys.prefix
if latest is None:
return
if Version(version) >= Version(latest):
return
# TODO: test update logic
print("UNIMPLEMENTED: fetches in-app updates")
return
with resources.path(APP_NAME_LOWER, "scripts/update.sh") as path:
proc = subprocess.Popen(["/usr/bin/env", "bash", path, latest, prefix])
if proc != 0:
# TODO: pop a dialog
pass
sys.exit(proc)
return latest
except Exception:
return

View File

@ -13,7 +13,6 @@ from dzgui.config.ipdb import get_ipdb
from dzgui.config.query import lookup
from dzgui.config.userprefs import UserPrefs
from dzgui.config.xdg import get_xdg_paths, parse_filepaths
from dzgui.const.update import ALLOW_UPDATES
from dzgui.init.coords import get_local_coords
from dzgui.init.dayz import is_dayz_installed
from dzgui.init.flock import lock_acquire
@ -25,7 +24,7 @@ from dzgui.init.migrate import (
)
from dzgui.init.prefix import get_version
from dzgui.init.prereqs import has_steam_client
from dzgui.init.update import allow_updates, check_updates
from dzgui.init.update import check_updates
from dzgui.strings import boot
@ -36,6 +35,7 @@ from dzgui.util.symlink import rebuild_symlinks
from dzgui.util.strings import init, flags
from dzgui.views.base import App
from dzgui.views.dialogs.wizard import SetupWizard
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
if TYPE_CHECKING:
@ -97,33 +97,31 @@ def main() -> None:
if XDG.resolution.parent.is_dir() is False:
make_parents(XDG.resolution)
# TODO: test
if XDG.debug.is_file() is False:
make_parents(XDG.debug)
if has_new_config(XDG.config) is False:
# TODO: handle this in assistant
migrate_legacy_conf(XDG.config)
migrate_cols_file(XDG.columns)
# TODO: copy notes file
copy_state_files(xdg_paths["XDG_STATE_HOME"])
setup_logger(XDG.debug)
with open(XDG.debug, "w") as f:
f.truncate(0)
_is_steam_deck = is_steam_deck()
_is_game_mode = is_game_mode() if _is_steam_deck else False
if _is_game_mode:
# NOTE: this may no longer be necessary on newer versions of SteamOS
del os.environ["GTK_IM_MODULE"]
# TODO: test spamming timeout
allow = allow_updates(ALLOW_UPDATES)
if allow is True:
check_updates(version)
if has_new_config(XDG.config) is False:
# TODO: add logging inside wizard
# TODO: copy notes file, version file, etc.
# migrate_cols_file(XDG.columns)
# copy_state_files(xdg_paths["XDG_STATE_HOME"])
SetupWizard(version, _is_steam_deck, XDG.config)
return
# TODO: config wizard: check has_steam_client() prior to VDF exploration
setup_logger(XDG.debug)
with open(XDG.debug, "w") as f:
f.truncate(0)
# TODO: update area in gutter
# new_version = check_updates(version)
if _is_steam_deck is False:
# TODO: sudo escalation dialog
@ -148,7 +146,6 @@ def main() -> None:
is_debug=args.debug,
coords=local_coords,
version=version,
allow_updates=allow,
paths=XDG,
use_miles=use_miles,
)

View File

@ -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
)

View File

@ -1,3 +1,5 @@
api_validation_error = (
"API key validation error. Key was typed incorrectly or is defunct."
)
api_popover = "API key validation failed."

59
dzgui/strings/wizard.py Normal file
View File

@ -0,0 +1,59 @@
### IntroductionPage
title_intro = "Welcome"
blurb_intro = """
This wizard is going to help you set up some common config options before launching the application.
"""
### SteamPathPage
# TODO
error_steam_path = "ERROR TEXT HERE"
heading_steam_path = "Steam path"
blurb_steam_path = """
DZGUI needs to find the location to your default Steam installation.
This will be used to determine whether (and where) DayZ is installed.
"""
desc_default_path = "This is the default Steam path on <b>most distributions</b>."
desc_flatpak_path = (
"This is the default Steam path if you are using <b>Flatpak Steam</b>."
)
desc_ubuntu_path = "This is the default Steam path on <b>Ubuntu-based systems</b>."
desc_debian_path = "This is the default Steam path on <b>Debian-based systems</b>."
no_valid_paths = (
"No valid Steam paths found on system. Please install Steam to continue."
)
button_scan = "Scan for Steam"
### ConfigMigrationPage
heading_config = "Import files"
blurb_config = """
It looks like you have a DZGUI 6 configuration file on the system.\n
Would you like to import this into DZGUI 7, keeping your existing preferences?\n
In both cases, your DZGUI 6 file will persist separately from DZGUI 7.
"""
config_import_button = "Import DZGUI 6 config to DZGUI 7"
config_import_box = (
"Configuration data imported successfully. Proceed to the next step."
)
config_new_button = "Create new DZGUI 7 config from scratch"
config_new_box = "A new config file will be created. Proceed to the next step."
### APIValidationPage
api_success = "API key set successfully. Please proceed to the next step."
heading_steam_api = "Steam Web API key"
button_web_api = "Web API setup link"
blurb_steam_api = """
You will need to set up a Steam Web API key in order to browse the global server list.
\nIf you don't have one already, it can be set up via the page below.
\nPlease refer to the DZGUI documentation for more instructions.
"""
heading_bm_api = "Battlemetrics Web API key"
blurb_bm_api = """A Battlemetrics key is <b>optional</b>, 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 <b>24819107</b>.
"""
entry_placeholder = "Enter API key here"
### Completion page
heading_completion = "Setup complete"
blurb_completion = "Configuration completed successfully. Please exit and restart DZGUI to apply changes."

View File

@ -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)

View File

@ -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

View File

@ -1,8 +1,10 @@
from typing import Callable, TYPE_CHECKING
from typing import Any, Callable, TYPE_CHECKING
from dzgui.api.servers import validate_ip
from dzgui.const.constants import VIEW_CONCEAL, VIEW_REVEAL
from dzgui.util.css import add_class, remove_class
from dzgui.util.strings import connect_panel, lan_panel
from dzgui.strings.errors import api_popover
import gi
@ -140,3 +142,98 @@ class PortEntry(ValidatedEntry):
)
self.set_placeholder_text(lan_panel.placeholder)
self.set_tooltip_text(lan_panel.entry_tooltip)
# TODO: backport to Options page
class APIEntry(Gtk.Box):
def __init__(self) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self.func: Callable | None = None
# TODO: strings
self.entry = Gtk.Entry(
width_chars=60, hexpand=True, placeholder_text="Enter API key"
)
self.entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL)
self.entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True)
self.entry.set_visibility(False)
self.entry.connect("changed", self._on_text_changed)
self.entry.connect("icon-release", self._on_icon_release)
self.entry.connect("activate", self._on_field_activated)
self.submit = Gtk.Button(label="Submit")
self.submit.set_sensitive(False)
self.submit.connect("clicked", self._on_submit)
self.pop = Gtk.Popover()
self.pop_label = Gtk.Label(
label=api_popover,
margin_start=10,
margin_end=10,
)
self.pop.add(self.pop_label)
# NOTE: render once to draw text in bubble
self.pop.show_all()
self.pop.set_margin_start(10)
self.pop.set_relative_to(self.entry)
self.pop.popdown()
for el in self.entry, self.submit:
self.add(el)
def get_entry(self) -> None:
return self.entry
def popup(self) -> None:
self.pop.popup()
def get_submit(self) -> Gtk.Button:
return self.submit
def _is_valid_text(self, text: str) -> bool:
if text.isspace():
return False
if len(text) == 0:
return False
return True
def _on_text_changed(self, entry: Gtk.Entry) -> None:
text = entry.get_text()
if self._is_valid_text(text):
self.submit.set_sensitive(True)
else:
self.submit.set_sensitive(False)
def _on_icon_release(
self,
widget: Gtk.Entry,
icon_pos: Gtk.EntryIconPosition,
event: Gdk.Event,
) -> None:
visible = widget.get_visibility()
if visible:
icon, state = VIEW_REVEAL, False
else:
icon, state = VIEW_CONCEAL, True
widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon)
widget.set_visibility(state)
def _on_field_activated(self, entry: Gtk.Entry) -> None:
self.submit.emit("clicked")
def _on_submit(self, button: Gtk.Button) -> Any:
if self.func is None:
return
text = self.entry.get_text()
res = self.func(text)
return res
def set_validation_func(self, func: Callable | None) -> None:
self.func = func
def disable_button(self) -> None:
self.submit.set_sensitive(False)
def enable_button(self) -> None:
self.submit.set_sensitive(True)

View File

@ -0,0 +1,584 @@
import json
import textwrap
from enum import Enum
from importlib import resources
from pathlib import Path
from typing import Any, Callable, Self
from dzgui.api.probe import test_steam_api, test_bm_api
from dzgui.api.steam import get_steam_paths
from dzgui.const.constants import (
APP_NAME,
APP_NAME_LOWER,
HERO_PATH,
LEGACY_CONFIG_PATH,
)
from dzgui.const.endpoints import BM_API_SETUP, STEAM_API_SETUP
from dzgui.init.migrate import migrate_legacy_conf
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import wizard
from dzgui.util._json import write_json
from dzgui.util.open_links import open_link_by_url
from dzgui.util.css import add_class
from dzgui.views.components.buttons import WebButton
from dzgui.views.components.entry import APIEntry
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
from gi.repository import Gdk, Gtk, GLib, GObject, GdkPixbuf # noqa E402
class PageNum(Enum):
INTRO = 1
HAS_CONFIG = 2
STEAM_PATH = 3
STEAM_API = 4
BM_API = 5
USER_PREFS = 6
FINAL = 7
class DescriptionArea(Gtk.Box):
def __init__(self, text: str):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
wrapped = textwrap.fill(text, width=80)
self.description = Gtk.Label(justify=Gtk.Justification.CENTER)
self.description.set_markup(wrapped)
self.add(self.description)
class Progress(Gtk.ProgressBar):
def __init__(self) -> None:
super().__init__(show_text=True)
class ScrolledWizardPage(Gtk.ScrolledWindow):
def __init__(self, enum: PageNum, heading: str, description: str):
super().__init__()
self.enum = enum
self.page_type: Gtk.AssistantPageType
self.title = heading
self.heading = Heading(heading)
self.description = DescriptionArea(description)
hero = resources.files(APP_NAME_LOWER).joinpath(HERO_PATH)
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
filename=str(hero),
width=600,
height=600,
preserve_aspect_ratio=True,
)
image = Gtk.Image.new_from_pixbuf(pixbuf)
self.box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
margin_start=100,
margin_end=100,
margin_top=50,
spacing=20,
)
self.add(self.box)
self.prog = Progress()
self.box.pack_end(self.prog, expand=False, fill=False, padding=0)
self.box.pack_start(image, expand=False, fill=True, padding=0)
self.box.pack_start(self.heading, expand=False, fill=True, padding=0)
self.box.pack_start(self.description, expand=False, fill=True, padding=0)
self.connect("map", self._on_map)
def get_progress_bar(self) -> Progress:
return self.prog
def get_page_type(self) -> Gtk.AssistantPageType:
return self.page_type
def set_title(self, title: str) -> None:
self.title = title
def get_title(self) -> str:
return self.title
def add_start(self, content: Gtk.Widget) -> None:
self.box.add(content)
def add_end(self, content: Gtk.Widget) -> None:
self.box.pack_end(content, expand=False, fill=False, padding=50)
def get_box(self) -> Gtk.Box:
return self.box
def _on_map(self, page: "ScrolledWizardPage") -> None:
pass
class NotificationFrame(Gtk.Frame):
def __init__(self, label: str, error: bool = False) -> None:
super().__init__(halign=Gtk.Align.CENTER)
self.box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
margin_start=50,
margin_end=50,
)
wrapped = textwrap.fill(label, width=80)
self.label = Gtk.Label(
label=wrapped,
justify=Gtk.Justification.CENTER,
margin_top=50,
margin_bottom=50,
)
self.box.add(self.label)
self.add(self.box)
if error:
# TODO: custom css file only for wizard
add_class(self, "error-frame")
class APIValidationPage(ScrolledWizardPage):
def __init__(
self, enum: PageNum, heading: str, description: str, link: str, func: Callable
) -> None:
super().__init__(
enum=enum,
heading=heading,
description=description,
)
self.key = ""
self.link = link
self.thread_man = ThreadingManager(None)
self.page_type = Gtk.AssistantPageType.INTRO
self.validation_func = func
self.validation_box = APIEntry()
self.validation_box.set_halign(Gtk.Align.CENTER)
self.validation_box.set_validation_func(self._pre_validate)
self.link_button = WebButton(label=wizard.button_web_api)
self.link_button.set_halign(Gtk.Align.CENTER)
self.link_button.connect("clicked", self._on_link_clicked)
self.spinner = Gtk.Spinner()
self.success_box = NotificationFrame(wizard.api_success)
self.add_start(self.link_button)
self.add_start(self.validation_box)
self.add_start(self.spinner)
self.add_start(self.success_box)
self.connect("map", lambda _: self.success_box.set_visible(False))
def get_api_key(self) -> str:
return self.key
def _on_link_clicked(self, button: Gtk.Button) -> None:
if self.link == "":
return
open_link_by_url(self.link)
def _pre_validate(self, key: str) -> None:
self.spinner.start()
self.validation_box.disable_button()
self.validation_func(key)
def _cleanup(self, state: bool, key: str) -> None:
if state:
self.key = key
self.validation_box.disable_button()
self.success_box.set_visible(True)
EMITTER.emit("step_complete")
else:
self.validation_box.popup()
self.validation_box.enable_button()
self.spinner.stop()
class BMValidationPage(APIValidationPage):
def __init__(self) -> None:
super().__init__(
enum=PageNum.BM_API,
heading=wizard.heading_bm_api,
description=wizard.blurb_bm_api,
link=BM_API_SETUP,
func=self._validate,
)
@call_on_thread("", show_dialog=False)
def _validate(self, key: str) -> None:
is_valid = test_bm_api(key.strip())
cleanup = StoredFunc(self._cleanup, is_valid, key)
self.thread_man.set_cleanup_func(cleanup)
class SteamValidationPage(APIValidationPage):
def __init__(self) -> None:
super().__init__(
enum=PageNum.STEAM_API,
heading=wizard.heading_steam_api,
description=wizard.blurb_steam_api,
link=STEAM_API_SETUP,
func=self._validate,
)
@call_on_thread("", show_dialog=False)
def _validate(self, key: str) -> None:
is_valid = test_steam_api(key)
cleanup = StoredFunc(self._cleanup, is_valid, key)
self.thread_man.set_cleanup_func(cleanup)
class IntroductionPage(ScrolledWizardPage):
def __init__(self, version: str):
super().__init__(
enum=PageNum.INTRO,
heading=f"Welcome to {APP_NAME} {version}!",
description=wizard.blurb_intro,
)
self.page_type = Gtk.AssistantPageType.INTRO
self.set_title(wizard.title_intro)
class Heading(Gtk.Label):
def __init__(self, label: str):
super().__init__(label=label)
# add_class(self, "heading")
# font weight
# TODO: set css em size
# TODO: bold text
class ChunkyButton(Gtk.Button):
def __init__(self, text: str) -> None:
super().__init__()
self.set_size_request(80, 80)
wrapped = textwrap.fill(text, width=40)
label = Gtk.Label(label=wrapped, justify=Gtk.Justification.CENTER)
self.add(label)
class RadioFrame(Gtk.Frame):
def __init__(
self, parent: Gtk.RadioButton | None, button_path: tuple[Path, str]
) -> None:
super().__init__()
self.vbox = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
margin_top=15,
margin_start=10,
margin_end=10,
margin_bottom=15,
)
path, pretty = button_path
if parent is None:
self.button = Gtk.RadioButton.new_with_label(None, str(path))
else:
self.button = Gtk.RadioButton.new_with_label_from_widget(parent, str(path))
self.vbox.add(self.button)
label = Gtk.Label(halign=Gtk.Align.START)
label.set_markup(pretty)
self.vbox.add(label)
self.add(self.vbox)
def get_button(self) -> Gtk.RadioButton:
return self.button
class ConfigMigrationPage(ScrolledWizardPage):
def __init__(self, config: Path) -> None:
super().__init__(
enum=PageNum.HAS_CONFIG,
heading=wizard.heading_config,
description=wizard.blurb_config,
)
self.migrated = False
self.config = config
self.page_type = Gtk.AssistantPageType.INTRO
self.import_button = ChunkyButton(wizard.config_import_button)
self.new_button = ChunkyButton(wizard.config_new_button)
self.grid = Gtk.Grid(column_spacing=30, halign=Gtk.Align.CENTER)
self.grid.set_column_homogeneous(True)
self.grid.attach(self.import_button, 0, 0, 1, 1)
self.grid.attach(self.new_button, 1, 0, 1, 1)
self.add_start(self.grid)
self.success_box = NotificationFrame(wizard.config_import_box)
self.from_scratch_box = NotificationFrame(wizard.config_new_box)
self.add_start(self.success_box)
self.add_start(self.from_scratch_box)
self.connect("map", self._hide_boxes)
self.import_button.connect("clicked", self._on_import_clicked)
self.new_button.connect("clicked", self._on_new_clicked)
def _hide_boxes(self, page: Self) -> None:
for box in self.success_box, self.from_scratch_box:
box.set_visible(False)
def _on_new_clicked(self, button: Gtk.Button) -> None:
self.grid.set_sensitive(False)
self.from_scratch_box.set_visible(True)
EMITTER.emit("step_complete")
EMITTER.emit("config", False)
def get_migrated(self) -> bool:
return self.migrated
def _on_import_clicked(self, button: Gtk.Button) -> None:
self.grid.set_sensitive(False)
try:
migrate_legacy_conf(self.config)
self.migrated = True
self.success_box.set_visible(True)
except Exception:
pass
EMITTER.emit("step_complete")
EMITTER.emit("config", True)
class CompletionPage(ScrolledWizardPage):
def __init__(self) -> None:
super().__init__(
enum=PageNum.FINAL,
heading=wizard.heading_completion,
description=wizard.blurb_completion,
)
# TODO: show collapsible config file tree
self.page_type = Gtk.AssistantPageType.SUMMARY
self.connect("map", lambda _: EMITTER.emit("step_complete"))
class Assistant(Gtk.Assistant):
def __init__(self, version: str, is_deck: bool, config: Path):
super().__init__()
if is_deck:
self.fullscreen()
else:
self.set_default_size(1500, 900)
self.config_path = config
# TODO: read in from boilerplate file
from dzgui.const.boilerplate import config_boilerplate
self.config_values: dict[str, Any] = config_boilerplate
self.page1 = IntroductionPage(version)
self.page2 = ConfigMigrationPage(config)
self.page3 = SteamPathPage()
self.page4 = SteamValidationPage()
self.page5 = BMValidationPage()
# self.page6 = PreferencesPage()
# contains name, miles, and steam client choice
# self.name = Gtk.Entry()
# self.miles = Gtk.RadioButton()
# TODO: use dual column model, recycle into options
# TODO: update client_combo in options page
# self.client = Gtk.ComboBox()
# TODO: write to config if not present
self.page7 = CompletionPage()
self.set_forward_page_func(self._advance_page)
EMITTER.connect("step_complete", self._mark_page_complete)
EMITTER.connect("step_pending", self._mark_page_incomplete)
EMITTER.connect("config", self._set_config_state)
legacy_path = Path.home().joinpath(LEGACY_CONFIG_PATH)
self.has_legacy_config = legacy_path.is_file()
for page in (
self.page1,
self.page2,
self.page3,
self.page4,
self.page5,
self.page7,
):
# NOTE: skip config migration page if no legacy config file
if page == self.page2 and self.has_legacy_config is False:
continue
self._add_page(page, page.get_page_type())
self.connect("prepare", self._on_page_prepare)
self.connect("cancel", self.destroy_and_quit)
self.connect("close", self.destroy_and_quit)
self.show_all()
def write_config(self) -> None:
# NOTE: implies that file was already migrated on page 3
if self.has_legacy_config:
return
write_json(self.config_values, self.config_path)
def _advance_page(self, index: int) -> int:
page = self.get_nth_page(index)
match page:
case self.page1:
pass
case self.page2:
if self.page2.get_migrated():
return self.get_n_pages() - 1
case self.page3:
self.config_values["default_steam_path"] = page.get_path_from_radio()
case self.page4:
self.config_values["steam_api"] = page.get_api_key()
case self.page5:
self.config_values["bm_api"] = page.get_api_key()
# case self.page6:
# self.write_config()
case _:
raise AttributeError("Trying to advance a non-canonical page")
print(self.config_values)
return index + 1
def destroy_and_quit(self, widget: Self) -> None:
self.destroy()
Gtk.main_quit()
def _mark_page_incomplete(self, emitter: "Emitter") -> None:
page_id = self.get_current_page()
page = self.get_nth_page(page_id)
if page is None:
return
self.set_page_complete(page, False)
def _mark_page_complete(self, emitter: "Emitter") -> None:
page_id = self.get_current_page()
page = self.get_nth_page(page_id)
if page is None:
return
self.set_page_complete(page, True)
def _add_page(self, page: ScrolledWizardPage, ptype: Gtk.AssistantPageType) -> None:
self.append_page(page)
self.set_page_type(page, ptype)
self.set_page_title(page, page.get_title())
self.set_page_complete(page, True)
def _set_config_state(self, emitter: "Emitter", state: bool) -> None:
self.config = state
def _on_page_prepare(self: Self, wizard: Self, page: ScrolledWizardPage) -> None:
page_num = self.get_current_page() + 1
total = self.get_n_pages()
fraction = page_num / total
bar = page.get_progress_bar()
bar.set_fraction(fraction)
bar.set_text(f"{page_num}/{total}")
# NOTE: disable forward action
if page != self.page1:
EMITTER.emit("step_pending")
class SteamPathPage(ScrolledWizardPage):
def __init__(self) -> None:
super().__init__(
enum=PageNum.USER_PREFS,
heading=wizard.heading_steam_path,
description=wizard.blurb_steam_path,
)
self.page_type = Gtk.AssistantPageType.INTRO
# TODO: add custom CSS class to Gtk.Frame so that only this one is styled
err_box = NotificationFrame(wizard.error_steam_path, error=True)
self.err = err_box
self.scan_button = Gtk.Button(label=wizard.button_scan, halign=Gtk.Align.CENTER)
self.scan_button.connect("clicked", self._on_scan_clicked)
self.add_start(self.scan_button)
self.connect("map", self._start_incomplete)
def _on_scan_clicked(self, button: Gtk.Button) -> None:
self.scan_button.set_sensitive(False)
paths = get_steam_paths()
button_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
halign=Gtk.Align.CENTER,
spacing=10,
)
total = len(paths)
err_box = NotificationFrame(wizard.no_valid_paths)
if total == 0:
self.add_start(err_box)
else:
button_box.add(Gtk.Label(label=f"Steam paths found: {total} total."))
for i, button_path in enumerate(paths):
if i == 0:
frame = RadioFrame(None, button_path)
self.first_button = frame.get_button()
button_box.add(frame)
else:
frame = RadioFrame(self.first_button, button_path)
button_box.add(frame)
self.add_start(button_box)
EMITTER.emit("step_complete")
self.show_all()
def get_path_from_radio(self) -> str:
active = next(r for r in self.first_button.get_group() if r.get_active())
return active.get_label()
def _start_incomplete(self, page: Self) -> None:
self.err.set_visible(False)
def _test_error_func(self, button: Gtk.CheckButton) -> None:
self.err.set_visible(True)
EMITTER.emit("step_incomplete")
class SetupWizard(Gtk.Application):
def __init__(self, version: str, is_deck: bool, config: Path) -> None:
super().__init__()
GLib.set_prgname(APP_NAME)
Window(version, is_deck, config)
Gtk.main()
class Window(Gtk.Window):
def __init__(self, version: str, is_deck: bool, config: Path) -> None:
super().__init__(title=APP_NAME, icon_name=APP_NAME)
Assistant(version, is_deck, config)
class Emitter(GObject.GObject):
def __init__(self) -> None:
super().__init__()
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def step_complete(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def step_pending(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,))
def config(self, state: bool) -> None:
pass
EMITTER = Emitter()
# TODO: Ctrl-q
# TODO: change behavior of global emitter

View File

@ -481,7 +481,7 @@ class Options(Gtk.Box):
self.dayz_version_label.set_text(dayz_version)
self.dayz_exp_version_label.set_text(dayz_exp_version)
# TODO: not happy with this
# TODO: bicolumn list store with no cell renderer on index 1, use raw command names
active_combo = query.get_client_index(config["client"])
self.client_combo.set_active(active_combo)