Compare commits

..
Author SHA1 Message Date
aclist 557079ce0f Merge fb47564618 into c947bc2c9b 2026-07-31 23:13:10 +09:00
85 changed files with 892 additions and 2619 deletions

No files matched your search

+1 -17
View File
@@ -3,8 +3,6 @@
## [7.0.0] Unreleased ## [7.0.0] Unreleased
### Added ### Added
- Setup wizard - Setup wizard
- Uninstall wizard
- Descriptive details panel to certain error dialogs
- Changelog text wrapping and formatting - Changelog text wrapping and formatting
- Changelog ships with source - Changelog ships with source
- Documentation ships with source - Documentation ships with source
@@ -26,7 +24,6 @@
- Set favorite server from tables - Set favorite server from tables
- Detailed/copyable trace in critical error dialogs - Detailed/copyable trace in critical error dialogs
- Visual icons - Visual icons
- Unset favorite server button
- Integrated server notebook - Integrated server notebook
- Propagate width changes to all tables - Propagate width changes to all tables
- Remember tree position in menus - Remember tree position in menus
@@ -48,19 +45,11 @@
- Generate Steam shortcuts and cover art - Generate Steam shortcuts and cover art
- Warn user if background downloads are disabled - Warn user if background downloads are disabled
- Dynamic copy button - Dynamic copy button
- Update map count (-u flag)
- Filter button tooltips
- Expand/collapse changelog versions
- Support local light/dark mode changes on system theme
- Server time calculator
### Changed ### Changed
- Conform to PEP 440 versioning for beta versions - Conform to PEP 440 versioning for beta versions
- Enforce fullscreen mode on Steam Deck
- Reduce padding on keys button - Reduce padding on keys button
- Boldface breadcrumbs - Boldface breadcrumbs
- Changed some dialog error messages from popups to popovers
- Changed how filter buttons are packed in sidebar
- Bold labels inside frames - Bold labels inside frames
- Sidebar buttons 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 - Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method
@@ -73,25 +62,20 @@
- Reduce size of geolocation DB on disk (~100MB) - Reduce size of geolocation DB on disk (~100MB)
- Enable LAN page Empty/Full filters on first run of app - Enable LAN page Empty/Full filters on first run of app
- Propagate subscribed mods to Steam client - Propagate subscribed mods to Steam client
- Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow
### Dropped ### Dropped
- Battlemetrics API support - Battlemetrics API support
- Unused metadata from system log files - Debug mode
- Branch switching - Branch switching
- Manual mod install mode (describe rationale) - Manual mod install mode (describe rationale)
- Force update mods - Force update mods
### Fixed ### Fixed
- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input
- In-game time field missing certain ranges of time at night
- Wrap long entries in changelog
- Certain server descriptions with special characters not rendering correctly
- Center server title text on server dialogs - Center server title text on server dialogs
- Rare segfaults when changing maps (threading) - Rare segfaults when changing maps (threading)
- Moved dialogs out of threads - Moved dialogs out of threads
- History table not being in lock-step with state file - History table not being in lock-step with state file
- Current keyword filter not being applied when refreshing table
### Unreleased ### Unreleased
- Load offline mods - Load offline mods
-4
View File
@@ -67,7 +67,3 @@ It is recommended to follow
integrates well with tooling and helps the project review your code. integrates well with tooling and helps the project review your code.
Please see DEVELOPERS.md for further details. Please see DEVELOPERS.md for further details.
## License applicability to contributions
Code contributed to this repository becomes subject to the terms of the GPL license defined in the LICENSE file at the project root.
+5 -13
View File
@@ -1,23 +1,15 @@
## What this is ## What this is
DZGUI is a turnkey server browser and mod manager for DayZ on Linux. It allows you to connect to both official and modded/community DayZ servers on Linux via a graphical user interface (GUI). DZGUI is a turnkey browser and mod manager for DayZ on Linux. It allows you to connect to both official and modded/community DayZ servers on Linux via a graphical user interface (GUI).
This overcomes certain limitations in the Linux client and helps prepare the game to launch by providing features like: This overcomes certain limitations in the Linux client and helps prepare the game to launch by providing features like:
- Search for and display global and LAN servers via an interactive table - Search for and display global and LAN servers via an interactive table
- Supports official, third-party, and modded DayZ servers - Supports official, third-party, and modded DayZ servers
- Automatically find and prepare mods being requested by servers - Automatically find and prepare mods being requested by servers
- Add/delete/manage favorite servers by IP or ID
- Quick-connect to favorite/recent servers - Quick-connect to favorite/recent servers
- Manage and delete local mods - Manage local mods and symlinks
- And more - Prepare launch options to pass to Steam
## Design philosophy
DZGUI follows a specific set of guidelines, enumerated below:
- Provide a free and open-source tool under GPL
- Do not use undocumented, third-party servers to aggregate data; no central "DZGUI" server
- All connections go to Steam and DayZ servers directly; the tool should function in perpetuity without intermediary endpoints
- Target Linux desktop and Steam deck specifically; not a cross-platform tool
## Setup and usage ## Setup and usage
@@ -26,7 +18,7 @@ Please refer to the documentation for installation and setup instructions:
- [GitHub](https://aclist.github.io/dzgui/index.html) - [GitHub](https://aclist.github.io/dzgui/index.html)
- [Codeberg (Mirror)](https://aclist.codeberg.page) - [Codeberg (Mirror)](https://aclist.codeberg.page)
![A screenshot of DZGUI](/dzgui/data/images/example.png) ![A screenshot of DZGUI](/images/example.png)
## Attribution ## Attribution
+1 -1
View File
@@ -8,7 +8,7 @@ runtime environment are built-in.
Turnkey installer Turnkey installer
------------------------ ------------------------
- Visit the project's `Releases page <https://github.com/aclist/dztui/releases/latest>`_. - Visit the project's `Releases page <https://github.com/aclist/dztui/releases>`_.
- Extract the DZGUI tarball from the top of the **Assets** list. - Extract the DZGUI tarball from the top of the **Assets** list.
- From a terminal, run the command ``./dzgui`` or double click on the extracted file in - From a terminal, run the command ``./dzgui`` or double click on the extracted file in
a file explorer. a file explorer.
+7 -37
View File
@@ -3,22 +3,17 @@ Knowledge Base
.. _DZG-001: .. _DZG-001:
DZG-001: Timeouts occur while trying to query a specific server DZG-001: Periodically getting dropped from servers, or servers time out in DZGUI
------------------------------------------------------------------------------------ ------------------------------------------------------------------------------------
The leading cause of specific servers periodically timing out is local network configuration. DayZ opens a large number of connections while querying/connected to servers.
Many third-party DayZ servers use a server rental/hosting provider with DDoS protection. In addition, packets sent from server responses are expected to be a standard size (see warning below).
This can cause responses from servers to originate from a server other than the one originally queried.
Consumer-grade routers are likely to drop this traffic as invalid due to how they handle NAT (network address translation). If your network does not have enough headroom or has settings departing from defaults, this may lead to getting dropped from servers,
By contrast, wireless routers and enterprise-grade routers may be less likely to have this issue. unresponsiveness, or a timeout.
If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for
the server's query port.
In addition, a uniform packet size is expected. If your router's MTU setting differs from the default of 1,500,
it could lead to incoming responses being discarded (see warning below).
If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. Consumer Wi-Fi routers
tend to have less headroom than their wired counterparts.
.. important:: .. important::
Ensure that MTU (maximum tranmission unit) on your network is set to the standard size of 1,500 bytes. Ensure that MTU (maximum tranmission unit) on your network is set to the standard size of 1,500 bytes.
@@ -85,28 +80,3 @@ There is some misconception that a Steam Web API key could be used to gain infor
A Steam Web API key is the most strict way of getting authentic, reliable, and consistent server information in a zero-trust model. A Steam Web API key is the most strict way of getting authentic, reliable, and consistent server information in a zero-trust model.
You are responsible for the creation, storage, management, and revocation of your Web API key. You are responsible for the creation, storage, management, and revocation of your Web API key.
DZG-008: Periodically getting dropped from servers while connected
-------------------------------------------------------------------
In some cases, DayZ opens a large number of connections while connected to servers.
If your network does not have enough headroom or has settings departing from defaults, this may lead to getting dropped from servers,
unresponsiveness, or a timeout.
If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. Consumer Wi-Fi routers
tend to have less headroom for simultaneous connections than their wired counterparts.
DZG-009: Floating dialogs appear maximized on tiling window managers
-------------------------------------------------------------------
The main DZGUI window and its child dialogs are expected to be rendered as floating by your window manager.
DZGUI sends window manager hints to this effect, but tiling window managers designed to bisect the screen into quadrants (e.g., i3 window manager)
may try to always launch applications in fullscreen.
To resolve this, set specific exclusions or window hints in your WM's configuration file.
For example, for i3, add the following to your `XDG_CONFIG_HOME/i3/config` file (defaults to `$HOME/.config/i3/config`):
.. code:: console
for_window [instance="DZGUI"] floating enable, move position center
for_window [instance="DZGUI - Dialog"] floating enable, move position center
+14 -4
View File
@@ -4,7 +4,7 @@ Setup
DayZ license DayZ license
----------------- -----------------
Prepare a Steam account with a DayZ license (i.e., own the game). From Steam's right-click menu for the game options, under ``Compatibility``, enable a Proton version ≥ ``6.8`` (or use Proton Prepare a Steam account with a DayZ license (i.e., own the game). From Steam's right-click menu for the game options, under ``Compatibility``, enable a Proton version ≥ ``6.8`` (or use Proton
Experimental for the latest version). Experimental for the latest version).
As of this writing, any recent version of Proton should work, and it is encouraged to use the most recent one. As of this writing, any recent version of Proton should work, and it is encouraged to use the most recent one.
@@ -33,10 +33,20 @@ Once configured, you can insert this key in the app when launching it for the fi
Battlemetrics API key Battlemetrics API key
^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^
.. warning:: **This step is optional, but recommended**. Using this key lets you also connect to and query servers by their shorthand ID instead of by IP.
This feature is no longer supported in DZGUI. Battlemetrics has switched to a paid service. Register for an API key at `BattleMetrics <https://www.battlemetrics.com/account/register?after=%2Fdevelopers>`_ (free).
Battlemetrics queries are expected to fail in DZGUI 6. In DZGUI 7, this feature has been removed entirely.
From the ``Personal Access Tokens area``, select ``New Token``.
Give the token any name in the field at the top.
Leave **all options unchecked** and scroll to the bottom. Select ``Create Token``.
Once configured, you can insert this key in the app when launching it for the first time (optional), or later on when using the connect/query by ID methods in the app.
.. tip::
Each server has a unique ID. This is the string of numbers at the end of the URL. For example, in the URL ``https://www.battlemetrics.com/servers/dayz/8039514``, the ID is ``8039514``.
Next steps Next steps
-------------- --------------
+1 -5
View File
@@ -1,10 +1,6 @@
Steam integration Steam integration
==================== ====================
.. note::
In :doc:`DZGUI 7 <dzgui7>`, Steam shortcut creation and cover art setup are handled automatically during setup. You can skip this step unless you are using DZGUI 6.
DZGUI can be added to Steam as a "non-Steam game" in order to facilitate integration with Steam Deck or desktop environments. DZGUI can be added to Steam as a "non-Steam game" in order to facilitate integration with Steam Deck or desktop environments.
First, launch Steam in the **Large** (default) view. First, launch Steam in the **Large** (default) view.
@@ -78,7 +74,7 @@ The final result will create box art looking like the above.
:scale: 100% :scale: 100%
:alt: Adding a non-Steam game :alt: Adding a non-Steam game
Next, right-click the DZGUI entry in your Library and select **Properties** to open the properties dialog. Next to the **Shortcut** field, you will see a small square box which represents the game’s Next, right-click the DZGUI entry in your Library and select **Properties** to open the properties dialog. Next to the **Shortcut** field, you will see a small square box which represents the game’s
icon. Click this to open a file explorer, navigate to ``$HOME/.local/share/dzgui``, and select ``icon.png``. This will add a small icon to the list view. icon. Click this to open a file explorer, navigate to ``$HOME/.local/share/dzgui``, and select ``icon.png``. This will add a small icon to the list view.
Finally, after you launch DZGUI for the first time, you should quit the application and return to the Library view. Select the **Recent Games** dropdown on the right-hand side. Finally, after you launch DZGUI for the first time, you should quit the application and return to the Library view. Select the **Recent Games** dropdown on the right-hand side.
+29 -29
View File
@@ -54,16 +54,19 @@ class Res:
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
class Details: class Details:
data: list[list[str]] data: Union[list, None]
name: str
description: str description: str
gametime: str success: bool
day_accel: float
night_accel: float
@dataclass(slots=True, frozen=True) @dataclass(slots=True)
class Record: class Record:
# TODO: investigate whether this enum is still being used;
# can this be frozen?
"""
The gameport field is manipulated by the RowType.CONN_BY_IP method
"""
ip: str ip: str
gameport: int gameport: int
qport: int qport: int
@@ -280,41 +283,43 @@ def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] |
info = a2s.info((ip, qport), timeout) info = a2s.info((ip, qport), timeout)
return source_info_to_dict(ip, qport, info) return source_info_to_dict(ip, qport, info)
except Exception as e: except Exception as e:
logger.debug(e) logger.warning(e)
return None return None
def get_details(record: Record) -> Details | None: def get_details(record: Record) -> Details:
# TODO: consolidate try/except statements
ip = record.ip ip = record.ip
qport = record.qport qport = record.qport
default_str = strings.none_provided default_str = strings.none_provided
try: try:
info = a2s.info((ip, qport)) info = a2s.info((ip, qport))
except (TimeoutError, a2s.exceptions.BrokenMessageError):
return Details(None, default_str, False)
try:
rules = dayzquery.dayz_rules((ip, qport)) rules = dayzquery.dayz_rules((ip, qport))
except Exception:
return Details(None, default_str, False)
try:
keywords = info.keywords.split(",") keywords = info.keywords.split(",")
except Exception as e: except AttributeError:
logger.critical(e) return Details(None, default_str, False)
return None
battleye = strings.disabled battleye = strings.disabled
if "battleye" in keywords: if "battleye" in keywords:
battleye = strings.enabled battleye = strings.enabled
name = info.server_name day_accel = 0.0
day_accel = 1.0 night_accel = 0.0
night_accel = 1.0
gametime = "00:00"
for keyword in keywords: for keyword in keywords:
if "etm" in keyword: if "etm" in keyword:
day_accel = float(keyword.lstrip("etm")) day_accel = float(keyword.lstrip("etm"))
day_accel = float(f"{day_accel:g}") day_accel = float(f"{day_accel:g}")
if "entm" in keyword: if "entm" in keywords:
night_accel = float(keyword.lstrip("entm")) night_accel = float(keyword.lstrip("entm"))
night_accel = float(f"{night_accel:g}") night_accel = float(f"{night_accel:g}")
time_reg = r"^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]$)"
if re.match(time_reg, keyword):
gametime = keyword
try: try:
password = info.password_protected password = info.password_protected
@@ -341,15 +346,10 @@ def get_details(record: Record) -> Details | None:
try: try:
dlc = rules.dlc_flags dlc = rules.dlc_flags
match dlc: if dlc == 0:
case 0: dlc = strings.none
dlc = strings.dlc_none if dlc == 2:
case 2: dlc = strings.dlc_frostline
dlc = strings.dlc_frostline
case 3:
dlc = strings.dlc_badlands
case _:
dlc = strings.unspecified
except AttributeError: except AttributeError:
dlc = strings.unspecified dlc = strings.unspecified
@@ -380,7 +380,7 @@ def get_details(record: Record) -> Details | None:
["Version", version], ["Version", version],
] ]
return Details(rows, name, description, gametime, day_accel, night_accel) return Details(rows, description, True)
def ping(ip: str, qport: int) -> int: def ping(ip: str, qport: int) -> int:
-8
View File
@@ -160,14 +160,6 @@ class Shortcuts:
NEW_ENTRY["tags"] = {} NEW_ENTRY["tags"] = {}
return NEW_ENTRY return NEW_ENTRY
def delete_shortcut(self, start_path: Path) -> None:
shortcuts = self.shortcuts["shortcuts"]
for s in shortcuts:
if shortcuts[s]["StartDir"] == str(start_path):
del shortcuts[s]
break
self.save_shortcuts()
def save_shortcuts(self) -> None: def save_shortcuts(self) -> None:
try: try:
backup = self.shortcuts_path.with_suffix(".vdf.bak") backup = self.shortcuts_path.with_suffix(".vdf.bak")
+10 -58
View File
@@ -6,7 +6,6 @@ import subprocess
import vdf # type: ignore import vdf # type: ignore
from pathlib import Path from pathlib import Path
from requests.exceptions import HTTPError
from typing import Any, Union from typing import Any, Union
from warnings import deprecated from warnings import deprecated
@@ -17,7 +16,6 @@ from dzgui.const.constants import (
APPID_DAYZ_EXP, APPID_DAYZ_EXP,
APP_NAME, APP_NAME,
DAYZ_BINARY, DAYZ_BINARY,
DAYZ_LAUNCHER,
DEBIAN_STEAM_PATH, DEBIAN_STEAM_PATH,
DEFAULT_STEAM_PATH, DEFAULT_STEAM_PATH,
FLATPAK_STEAM_PATH, FLATPAK_STEAM_PATH,
@@ -33,7 +31,6 @@ from dzgui.const.endpoints import (
UNSUB_ENDPOINT, UNSUB_ENDPOINT,
) )
from dzgui.strings import wizard from dzgui.strings import wizard
from dzgui.strings.errors import api_validation_error, workshop_endpoint_error
from dzgui.util.bash import concat_bash_args from dzgui.util.bash import concat_bash_args
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
@@ -142,7 +139,6 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]:
j = r.json() j = r.json()
rows = j["response"]["publishedfiledetails"] rows = j["response"]["publishedfiledetails"]
for row in rows: for row in rows:
# NOTE: not a valid published file
if row["result"] == 9: if row["result"] == 9:
continue continue
title = str(row["title"]) title = str(row["title"])
@@ -153,25 +149,6 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]:
return hashes return hashes
def connect_debug(
client: str, addr: str, appid: int, name: str, mods: list[str]
) -> str:
concat = concat_mods(mods)
client_args = concat_bash_args(client)
params = [
"-applaunch",
str(appid),
f"-connect={addr}",
"-nolauncher",
"-nosplash",
"-skipintro",
f"-name={name}",
f"-mod={concat}",
]
client_args.extend(params)
return " ".join(client_args)
# TODO: set config to name=user, use official server and no mods, # TODO: set config to name=user, use official server and no mods,
# ensure that formatted string is identical to fixture with same hash # ensure that formatted string is identical to fixture with same hash
def connect(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int: def connect(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int:
@@ -240,18 +217,14 @@ def find_user_id(path: Path) -> str | None:
try: try:
with open(resolved_path, "r") as f: with open(resolved_path, "r") as f:
v = vdf.load(f) v = vdf.load(f)
users = v["users"] # NOTE: beta client
# NOTE: beta client: /package/beta # /package/beta
last_user = str(next(iter(users))) if len(v["users"]) == 1:
if len(users) == 1: return str(list(v["users"].keys())[0])
return last_user for user in v["users"]:
last_stamp = int(users[last_user]["Timestamp"]) if v["users"][user]["MostRecent"] == "1":
for user in users: return str(user)
stamp = int(users[user]["Timestamp"]) return None
if stamp > last_stamp:
last_stamp = stamp
last_user = user
return last_user
except Exception as e: except Exception as e:
logger.debug(e) logger.debug(e)
return None return None
@@ -264,16 +237,6 @@ def find_user_id_32(path: Path) -> int:
return int(uid) & 0xFFFFFFFF return int(uid) & 0xFFFFFFFF
class SteamAPIKeyError(Exception):
def __init__(self, msg=None):
super().__init__(msg or api_validation_error)
class SteamWorkshopEndpointError(Exception):
def __init__(self, msg=None):
super().__init__(msg or workshop_endpoint_error)
def update_workshop(key: str, mod: int, endpoint: str) -> None: def update_workshop(key: str, mod: int, endpoint: str) -> None:
payload: dict[str, Union[int, str]] = { payload: dict[str, Union[int, str]] = {
"publishedfileid": mod, "publishedfileid": mod,
@@ -285,19 +248,8 @@ def update_workshop(key: str, mod: int, endpoint: str) -> None:
try: try:
res = requests.post(endpoint, params=payload, timeout=REQUEST_TIMEOUT) res = requests.post(endpoint, params=payload, timeout=REQUEST_TIMEOUT)
res.raise_for_status() res.raise_for_status()
except HTTPError as e:
logger.critical(e)
match res.status_code:
case 403:
raise SteamAPIKeyError()
case 404:
raise SteamWorkshopEndpointError()
case _:
# TODO: apply redaction filter
raise e
except Exception as e: except Exception as e:
logger.critical(e) logger.critical(e)
raise e
def subscribe(key: str, mod: int) -> None: def subscribe(key: str, mod: int) -> None:
@@ -429,14 +381,14 @@ def get_client_allows_downloads(path: Path) -> bool:
def is_dayz_running() -> bool: def is_dayz_running() -> bool:
"""Subprocesses spawned from Steam will not show up in regular process tree""" """Subprocesses spawned from Steam will not show up in regular process tree"""
procs = [] procs = []
substring = [DAYZ_BINARY, DAYZ_LAUNCHER] substring = DAYZ_BINARY
for proc in psutil.process_iter(): for proc in psutil.process_iter():
try: try:
procs.append(proc.cmdline()) procs.append(proc.cmdline())
except Exception as e: except Exception as e:
logger.warning(e) logger.warning(e)
continue continue
return any(s in item for sublist in procs for item in sublist for s in substring) return any(substring in item for sublist in procs for item in sublist)
def get_app_path(folders_path: Path, appid: int) -> Path: def get_app_path(folders_path: Path, appid: int) -> Path:
+2 -4
View File
@@ -18,10 +18,10 @@ from dzgui.init.flock import lock_acquire
from dzgui.init.prereqs import has_steam_client from dzgui.init.prereqs import has_steam_client
from dzgui.strings import boot from dzgui.strings import boot
# from dzgui.util.map_count import get_map_count
from dzgui.util.deck import is_steam_deck, is_game_mode from dzgui.util.deck import is_steam_deck, is_game_mode
from dzgui.util.dirs import make_parents from dzgui.util.dirs import make_parents
from dzgui.util.localize import set_locale from dzgui.util.localize import set_locale
from dzgui.util.map_count import test_map_count
from dzgui.util.redact import RedactionFilter, REDACTION_PATTERNS from dzgui.util.redact import RedactionFilter, REDACTION_PATTERNS
from dzgui.util.strings import init from dzgui.util.strings import init
@@ -119,7 +119,6 @@ def load_gui(version: str, is_debug: bool) -> None:
del os.environ["GTK_IM_MODULE"] del os.environ["GTK_IM_MODULE"]
if has_new_config(XDG.config) is False: if has_new_config(XDG.config) is False:
test_map_count()
migrate_cols_file(XDG.columns) migrate_cols_file(XDG.columns)
copy_state_files(xdg_paths["XDG_STATE_HOME"]) copy_state_files(xdg_paths["XDG_STATE_HOME"])
# TODO: add logging inside wizard # TODO: add logging inside wizard
@@ -154,5 +153,4 @@ def load_gui(version: str, is_debug: bool) -> None:
use_miles=use_miles, use_miles=use_miles,
) )
print(boot.all_ok) print(boot.all_ok)
app = App(prefs) App(prefs)
app.run(None)
+1 -12
View File
@@ -12,7 +12,6 @@ APPNAME_DAYZ = "DayZ"
APPNAME_DAYZ_EXP = "DayZ Exp" APPNAME_DAYZ_EXP = "DayZ Exp"
APPNAME_DAYZ_EXP_HUMAN = "DayZ Experimental" APPNAME_DAYZ_EXP_HUMAN = "DayZ Experimental"
DAYZ_BINARY = "DayZ_x64.exe" DAYZ_BINARY = "DayZ_x64.exe"
DAYZ_LAUNCHER = "DayZLauncher.exe"
LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf" LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf"
WORKSHOP_PATH = "steamapps/workshop/content/" + str(APPID_DAYZ) WORKSHOP_PATH = "steamapps/workshop/content/" + str(APPID_DAYZ)
@@ -30,18 +29,15 @@ BETA_REPO = "testing"
APP_NAME = "DZGUI" APP_NAME = "DZGUI"
APP_NAME_LOWER = "dzgui" APP_NAME_LOWER = "dzgui"
APP_NAME_ABBR = "dzg" APP_NAME_ABBR = "dzg"
GTK_APP_ID = "io.github.aclist.dzgui"
HEX_GREEN = "#32CD32" HEX_GREEN = "#32CD32"
HEX_RED = "#FF0000" HEX_RED = "#FF0000"
HEX_ORANGE = "#FFAC1C" HEX_ORANGE = "#FFAC1C"
# TODO: normalize icon names # FIXME: normalize icon names
CALCULATOR = "accessories-calculator-symbolic"
CARET_DOWN = "go-down-symbolic" CARET_DOWN = "go-down-symbolic"
CARET_UP = "go-up-symbolic" CARET_UP = "go-up-symbolic"
CLIPBOARD = "edit-copy-symbolic" CLIPBOARD = "edit-copy-symbolic"
CLOSE = "window-close-symbolic"
FOLDER = "folder-symbolic" FOLDER = "folder-symbolic"
EDIT_DELETE = "edit-delete-symbolic" EDIT_DELETE = "edit-delete-symbolic"
ERROR = "dialog-error-symbolic" ERROR = "dialog-error-symbolic"
@@ -55,8 +51,6 @@ VIEW_CONCEAL = "view-conceal-symbolic"
VIEW_REVEAL = "view-reveal-symbolic" VIEW_REVEAL = "view-reveal-symbolic"
WARNING = "dialog-warning-symbolic" WARNING = "dialog-warning-symbolic"
WEB_BROWSER = "web-browser-symbolic" WEB_BROWSER = "web-browser-symbolic"
DAY_ICON = "weather-clear-symbolic"
NIGHT_ICON = "weather-clear-night-symbolic"
SEPARATOR = "SEPARATOR" SEPARATOR = "SEPARATOR"
@@ -95,8 +89,3 @@ TMP_TARBALL = "/tmp/dzgui.tar.gz"
TMP_EXE = "/tmp/dzgui/dzgui" TMP_EXE = "/tmp/dzgui/dzgui"
DAYZ_COMMUNITY_ROOT = "Missions" DAYZ_COMMUNITY_ROOT = "Missions"
GIO_SETTINGS_INTERFACE = "org.gnome.desktop.interface"
GIO_SETTINGS_PROP = "color-scheme"
GIO_SETTINGS_KEY = "gtk-application-prefer-dark-theme"
GIO_SETTINGS_VAL = "prefer-dark"
+9
View File
@@ -73,6 +73,12 @@ class Preferences(EnumWithAttrs):
NAME = { NAME = {
"key": "name", "key": "name",
} }
INSTALL = {
"key": "auto_install",
}
DEBUG = {
"key": "debug",
}
DEFAULT = { DEFAULT = {
"key": "default_steam_path", "key": "default_steam_path",
} }
@@ -85,6 +91,9 @@ class Preferences(EnumWithAttrs):
IP_LIST = { IP_LIST = {
"key": "ip_list", "key": "ip_list",
} }
BRANCH = {
"key": "branch",
}
START_TAB = { START_TAB = {
"key": "start_tab", "key": "start_tab",
} }
-4
View File
@@ -145,10 +145,6 @@ class Emitter(GObject.GObject):
def api_change_failed(self) -> None: def api_change_failed(self) -> None:
pass pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def api_change_successful(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def already_saved_server(self) -> None: def already_saved_server(self) -> None:
pass pass
+12 -25
View File
@@ -7,12 +7,11 @@ import dzgui.util._json as JSON # noqa
from dzgui.const.enum import ( from dzgui.const.enum import (
FilterMode,
Preferences,
NotebookPage,
ButtonType, ButtonType,
ContextMenu, ContextMenu,
FilterMode,
NotebookPage,
Preferences,
ServerTab,
) )
from dzgui.config.userprefs import UserPrefs from dzgui.config.userprefs import UserPrefs
@@ -38,6 +37,7 @@ logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import Record from dzgui.api.servers import Record
from dzgui.const.enum import ServerTab
from dzgui.managers.connection import Prerequisites from dzgui.managers.connection import Prerequisites
from dzgui.managers.filter import FilterManager from dzgui.managers.filter import FilterManager
from dzgui.util.dist import Haversine from dzgui.util.dist import Haversine
@@ -88,6 +88,7 @@ class Controller(GObject.GObject):
# NOTE: suppress requests until entire UI is loaded # NOTE: suppress requests until entire UI is loaded
self.loaded = False self.loaded = False
self.pending_jobs = 1
self.exit_event = threading.Event() self.exit_event = threading.Event()
self.cancel_event = threading.Event() self.cancel_event = threading.Event()
@@ -114,6 +115,9 @@ class Controller(GObject.GObject):
def query_config(self, key: Preferences) -> Any: def query_config(self, key: Preferences) -> Any:
return self.config_man.lookup(key) return self.config_man.lookup(key)
def is_auto_install(self) -> bool:
return bool(self.query_config(Preferences.INSTALL))
def suppress_signal( def suppress_signal(
self, owner: Any, child: Any, func_name: str, state: bool self, owner: Any, child: Any, func_name: str, state: bool
) -> None: ) -> None:
@@ -263,7 +267,6 @@ class Controller(GObject.GObject):
self.open_page(NotebookPage.LOG) self.open_page(NotebookPage.LOG)
except Exception as e: except Exception as e:
dialog = ExceptionDialog(self, str(e)) dialog = ExceptionDialog(self, str(e))
dialog.show_all()
dialog.run() dialog.run()
def select_colorized(self) -> None: def select_colorized(self) -> None:
@@ -297,11 +300,10 @@ class Controller(GObject.GObject):
write_diagnostic(self.prefs.paths.config, file) write_diagnostic(self.prefs.paths.config, file)
except Exception as e: except Exception as e:
dialog = ExceptionDialog(self, str(e)) dialog = ExceptionDialog(self, str(e))
dialog.show_all()
dialog.run() dialog.run()
def update_steam_api_key(self, text: str) -> None: def update_api_key(self, key: Preferences, text: str) -> None:
self.config_man.update_steam_api_key(text) self.config_man.update_api_key(key, text)
def set_resolution(self, window: "OuterWindow") -> None: def set_resolution(self, window: "OuterWindow") -> None:
self.config_man.set_resolution(window) self.config_man.set_resolution(window)
@@ -400,9 +402,6 @@ class Controller(GObject.GObject):
def get_menu(self) -> "MenuTreeView": def get_menu(self) -> "MenuTreeView":
return self.mediator.menu return self.mediator.menu
def unset_fav(self) -> None:
self.config_man.unset_fav()
def has_favorites(self) -> bool: def has_favorites(self) -> bool:
favs = self.config_man.get_favorites() favs = self.config_man.get_favorites()
if len(favs) < 1: if len(favs) < 1:
@@ -460,9 +459,6 @@ class Controller(GObject.GObject):
open_workshop_page(mod, cmd) open_workshop_page(mod, cmd)
def has_note(self) -> bool: def has_note(self) -> bool:
# TODO: get record string, store in memory
# get note by record, get is in favs, etc. store as a block
# this is only used by context mixin
note = self.get_note() note = self.get_note()
if len(note) > 0: if len(note) > 0:
return True return True
@@ -477,13 +473,11 @@ class Controller(GObject.GObject):
return self.notes_man.get_note(record) return self.notes_man.get_note(record)
def add_note(self, note: str) -> None: def add_note(self, note: str) -> None:
# TODO: record should be cached upon creation of dialog
tv = self.get_active_treeview() tv = self.get_active_treeview()
record = tv.get_record_string() record = tv.get_record_string()
self.notes_man.add_note(record, note) self.notes_man.add_note(record, note)
def delete_note(self) -> None: def delete_note(self) -> None:
# TODO: record should be cached upon creation of dialog
tv = self.get_active_treeview() tv = self.get_active_treeview()
record = tv.get_record_string() record = tv.get_record_string()
self.notes_man.delete_note(record) self.notes_man.delete_note(record)
@@ -498,9 +492,6 @@ class Controller(GObject.GObject):
def set_active_keyword(self, keyword: str) -> None: def set_active_keyword(self, keyword: str) -> None:
self.get_filter_man().set_active_keyword(keyword) self.get_filter_man().set_active_keyword(keyword)
ServerModelManager(self, self.get_active_treeview()).refilter(
FilterMode.TOGGLE_ON
)
def get_modtreeview(self) -> "ModTreeView": def get_modtreeview(self) -> "ModTreeView":
return self.mediator.modtreeview return self.mediator.modtreeview
@@ -526,12 +517,8 @@ class Controller(GObject.GObject):
def set_start_tab(self) -> None: def set_start_tab(self) -> None:
ind = self.config_man.get_start_tab() ind = self.config_man.get_start_tab()
logger.info(f"Setting start tab to index {ind}")
self.get_servers().notebook.set_current_page(ind) self.get_servers().notebook.set_current_page(ind)
def get_debug_args(self) -> str:
return self.connection_man.get_debug_args()
def update_and_load_to_menu(self) -> None: def update_and_load_to_menu(self) -> None:
self.connection_man.update_and_connect(menu_only=True) self.connection_man.update_and_connect(menu_only=True)
@@ -541,8 +528,8 @@ class Controller(GObject.GObject):
def update_status(self) -> None: def update_status(self) -> None:
self.mediator.preconnect.mark_finished() self.mediator.preconnect.mark_finished()
def get_steam_client_name(self, cmd: str) -> str: def get_steam_client_name(self) -> str:
return self.mediator.options.get_client_name(cmd) return self.mediator.options.get_client_name()
def present_window(self) -> None: def present_window(self) -> None:
self.mediator.window.present_with_time(Gdk.CURRENT_TIME) self.mediator.window.present_with_time(Gdk.CURRENT_TIME)
+1 -20
View File
@@ -3,8 +3,6 @@
## [7.0.0] Unreleased ## [7.0.0] Unreleased
### Added ### Added
- Setup wizard - Setup wizard
- Uninstall wizard
- Descriptive details panel to certain error dialogs
- Changelog text wrapping and formatting - Changelog text wrapping and formatting
- Changelog ships with source - Changelog ships with source
- Documentation ships with source - Documentation ships with source
@@ -26,7 +24,6 @@
- Set favorite server from tables - Set favorite server from tables
- Detailed/copyable trace in critical error dialogs - Detailed/copyable trace in critical error dialogs
- Visual icons - Visual icons
- Unset favorite server button
- Integrated server notebook - Integrated server notebook
- Propagate width changes to all tables - Propagate width changes to all tables
- Remember tree position in menus - Remember tree position in menus
@@ -47,20 +44,11 @@
- Play offline (load mods directly) - Play offline (load mods directly)
- Generate Steam shortcuts and cover art - Generate Steam shortcuts and cover art
- Warn user if background downloads are disabled - Warn user if background downloads are disabled
- Dynamic copy button
- Update map count (-u flag)
- Filter button tooltips
- Expand/collapse changelog versions
- Support local light/dark mode changes on system theme
- Server time calculator
### Changed ### Changed
- Conform to PEP 440 versioning for beta versions - Conform to PEP 440 versioning for beta versions
- Enforce fullscreen mode on Steam Deck
- Reduce padding on keys button - Reduce padding on keys button
- Boldface breadcrumbs - Boldface breadcrumbs
- Changed some dialog error messages from popups to popovers
- Changed how filter buttons are packed in sidebar
- Bold labels inside frames - Bold labels inside frames
- Sidebar buttons 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 - Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method
@@ -73,25 +61,18 @@
- Reduce size of geolocation DB on disk (~100MB) - Reduce size of geolocation DB on disk (~100MB)
- Enable LAN page Empty/Full filters on first run of app - Enable LAN page Empty/Full filters on first run of app
- Propagate subscribed mods to Steam client - Propagate subscribed mods to Steam client
- Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow
### Dropped ### Dropped
- Battlemetrics API support - Debug mode
- Unused metadata from system log files
- Branch switching - Branch switching
- Manual mod install mode (describe rationale) - Manual mod install mode (describe rationale)
- Force update mods - Force update mods
### Fixed ### Fixed
- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input
- In-game time field missing certain ranges of time at night
- Wrap long entries in changelog
- Certain server descriptions with special characters not rendering correctly
- Center server title text on server dialogs - Center server title text on server dialogs
- Rare segfaults when changing maps (threading) - Rare segfaults when changing maps (threading)
- Moved dialogs out of threads - Moved dialogs out of threads
- History table not being in lock-step with state file
- Current keyword filter not being applied when refreshing table
### Unreleased ### Unreleased
- Load offline mods - Load offline mods
-4
View File
@@ -24,10 +24,6 @@
.left-label { .left-label {
font-size: 1.3rem; font-size: 1.3rem;
} }
.server-subheading {
font-size: 1.1rem;
font-weight: 700;
}
.details-heading { .details-heading {
font-size: 1.2rem font-size: 1.2rem
} }
+2 -9
View File
@@ -4,24 +4,17 @@ from pathlib import Path
from dzgui.api.steam import get_app_path from dzgui.api.steam import get_app_path
from dzgui.config.query import lookup from dzgui.config.query import lookup
from dzgui.const.constants import APPID_DAYZ, APP_NAME, APPNAME_DAYZ from dzgui.const.constants import APPID_DAYZ, APP_NAME
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.strings.preconnect import resync
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
class VDFSyncError(Exception):
pass
def is_dayz_installed(config: Path) -> None: def is_dayz_installed(config: Path) -> None:
try: try:
path = lookup(config, Preferences.DEFAULT) path = lookup(config, Preferences.DEFAULT)
get_app_path(Path(path), APPID_DAYZ) get_app_path(Path(path), APPID_DAYZ)
except Exception as e: except Exception as e:
logger.critical(e) logger.critical(e)
msg = resync.format(APPNAME_DAYZ) raise e
raise VDFSyncError(msg)
+3 -3
View File
@@ -1,4 +1,3 @@
import json
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -8,10 +7,11 @@ from dzgui.config.convert import rc2json
from dzgui.util._json import read_json, write_json from dzgui.util._json import read_json, write_json
def migrate_legacy_conf(config: Path) -> Any: def migrate_legacy_conf(config: Path) -> None:
old_conf = Path.home() / LEGACY_CONFIG_PATH old_conf = Path.home() / LEGACY_CONFIG_PATH
j = rc2json(old_conf) j = rc2json(old_conf)
return json.loads(j) config.parent.mkdir(parents=True, exist_ok=True)
config.write_text(j)
def has_new_config(config: Path) -> bool: def has_new_config(config: Path) -> bool:
+4 -9
View File
@@ -16,15 +16,12 @@ logger = logging.getLogger(APP_NAME)
def is_steam_running(cmd: str) -> bool: def is_steam_running(cmd: str) -> bool:
is_flatpak_running = is_flatpak_steam_running() if cmd == STEAM_CMD:
if cmd in (FLATPAK_RUN_CMD, FLATPAK_SANDBOX):
return is_flatpak_running
elif cmd == STEAM_CMD:
if is_flatpak_running:
return False
if has_cmd(STEAM_CMD) is False: if has_cmd(STEAM_CMD) is False:
return False return False
return is_running(STEAM_CMD) return is_running(STEAM_CMD)
elif cmd == FLATPAK_RUN_CMD or FLATPAK_SANDBOX:
return is_flatpak_steam_running()
else: else:
raise TypeError("Not a valid Steam client selection") raise TypeError("Not a valid Steam client selection")
@@ -34,9 +31,7 @@ def is_steam_running(cmd: str) -> bool:
def is_flatpak_steam_running() -> bool: def is_flatpak_steam_running() -> bool:
if has_cmd(FLATPAK_CMD) is False: if has_cmd(FLATPAK_CMD) is False:
return False return False
proc = subprocess.check_output( proc = subprocess.check_output([FLATPAK_CMD, "ps"], text=True)
[FLATPAK_CMD, "ps", "--columns=application"], text=True
)
lines = proc.splitlines() lines = proc.splitlines()
if FLATPAK_APPID in lines: if FLATPAK_APPID in lines:
return True return True
+13 -16
View File
@@ -5,36 +5,33 @@ import warnings
from dzgui.const.constants import APP_NAME from dzgui.const.constants import APP_NAME
from dzgui.init.libgi import test_libgi_missing from dzgui.init.libgi import test_libgi_missing
from dzgui.init.prefix import get_version from dzgui.init.prefix import get_version
from dzgui.util.deck import is_steam_deck
from dzgui.util.map_count import set_map_count
from dzgui.util.strings import flags from dzgui.util.strings import flags
from dzgui.views.dialogs.uninstall import UninstallWizard
from dzgui.config.xdg import get_xdg_paths
parser = argparse.ArgumentParser(description=flags.description) parser = argparse.ArgumentParser(description=flags.description)
group = parser.add_mutually_exclusive_group() parser.add_argument("-v", "--version", action="store_true", help=flags.version)
group.add_argument("-d", "--debug", action="store_true", help=flags.debug) parser.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall)
group.add_argument("-m", "--map", action="store_true", help=flags.map_count) parser.add_argument("-d", "--debug", action="store_true", help=flags.debug)
group.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall)
group.add_argument("-v", "--version", action="store_true", help=flags.version)
args = parser.parse_args() args = parser.parse_args()
def uninstall() -> None:
# TODO: uninstall data files (-u)
# -u removes state, log, freedesktop
# XDG_SHARE_HOME/dzgui
# XDG_STATE_HOME/dzgui
# XDG_DATA_HOME/dzgui
pass
def main() -> None: def main() -> None:
if args.version is True: if args.version is True:
print(get_version()) print(get_version())
sys.exit(0) sys.exit(0)
if args.uninstall is True: if args.uninstall is True:
paths = get_xdg_paths() uninstall()
_is_steam_deck = is_steam_deck()
UninstallWizard(_is_steam_deck, paths)
sys.exit(0) sys.exit(0)
if args.debug is True: if args.debug is True:
warnings.filterwarnings("default", category=DeprecationWarning) warnings.filterwarnings("default", category=DeprecationWarning)
if args.map is True:
set_map_count()
sys.exit(0)
version = get_version() version = get_version()
+13 -22
View File
@@ -19,6 +19,11 @@ from dzgui.views.dialogs.generic import ExceptionDialog
from dzgui.strings import dialogs from dzgui.strings import dialogs
from dzgui.util._json import read_json, write_json from dzgui.util._json import read_json, write_json
import gi
gi.require_version("Gtk", "3.0")
from gi.repository.Gtk import main_quit # noqa E402
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.config.userprefs import UserPrefs from dzgui.config.userprefs import UserPrefs
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@@ -93,13 +98,12 @@ class ConfigManager:
logger.critical(e) logger.critical(e)
raise e raise e
@call_on_thread(dialogs.checking_api, show_dialog=False) @call_on_thread(dialogs.checking_api)
def update_steam_api_key(self, text: str) -> None: def update_api_key(self, key: Preferences, text: str) -> None:
res = test_steam_api(text) if key is Preferences.STEAM:
res = test_steam_api(text)
if res is True: if res is True:
self.update_config(Preferences.STEAM, text) self.update_config(key, text)
func = StoredFunc(lambda: self.emitter.emit("api_change_successful"))
self.thread_man.set_cleanup_func(func)
else: else:
self.thread_man.set_cleanup_func( self.thread_man.set_cleanup_func(
StoredFunc(lambda: self.emitter.emit("api_change_failed")) StoredFunc(lambda: self.emitter.emit("api_change_failed"))
@@ -156,13 +160,6 @@ class ConfigManager:
except Exception: except Exception:
return return
def unset_fav(self) -> None:
try:
self.write_config(Preferences.FAV_LBL, "")
self.write_config(Preferences.FAV_SRV, "")
except Exception:
return
def write_config(self, key: Preferences, value: str) -> None: def write_config(self, key: Preferences, value: str) -> None:
try: try:
real_key = self.enum_to_key(key) real_key = self.enum_to_key(key)
@@ -173,8 +170,8 @@ class ConfigManager:
logger.critical(e) logger.critical(e)
trace = traceback.format_exc() trace = traceback.format_exc()
dialog = ExceptionDialog(self.controller, trace) dialog = ExceptionDialog(self.controller, trace)
dialog.show_all()
dialog.run() dialog.run()
raise e
def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None: def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None:
columns = tv.get_columns() columns = tv.get_columns()
@@ -201,10 +198,7 @@ class ConfigManager:
logger.info("Normal user exit") logger.info("Normal user exit")
if window.props.is_maximized: if window.props.is_maximized:
app = window.get_application() main_quit()
if app is None:
return
app.quit()
return return
w, h = window.get_size() w, h = window.get_size()
@@ -216,10 +210,7 @@ class ConfigManager:
except Exception as e: except Exception as e:
logger.critical(e) logger.critical(e)
app = window.get_application() main_quit()
if app is None:
return
app.quit()
def set_resolution(self, window: "OuterWindow") -> None: def set_resolution(self, window: "OuterWindow") -> None:
if self.prefs.is_game_mode: if self.prefs.is_game_mode:
+6 -30
View File
@@ -13,7 +13,6 @@ from dzgui.api.shortcuts import Shortcuts
from dzgui.api.steam import ( from dzgui.api.steam import (
connect, connect,
connect_debug,
get_app_allows_downloads, get_app_allows_downloads,
get_app_name, get_app_name,
get_needs_update, get_needs_update,
@@ -46,9 +45,8 @@ from dzgui.strings.dialogs import (
waiting_for_mods, waiting_for_mods,
waiting_for_directories, waiting_for_directories,
) )
from dzgui.strings import kb
from dzgui.strings.server_mods import checkmark, resync from dzgui.strings.server_mods import checkmark, resync
from dzgui.util.format import format_exception, format_mib from dzgui.util.format import format_mib
from dzgui.util.strings import dialog, server_timeout from dzgui.util.strings import dialog, server_timeout
from dzgui.util.symlink import rebuild_symlinks from dzgui.util.symlink import rebuild_symlinks
from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.generic import ExceptionDialog
@@ -184,8 +182,8 @@ class ConnectionManager:
else: else:
allows_downloads = (True, "") allows_downloads = (True, "")
client_name = self.controller.get_steam_client_name()
client = self.controller.query_config(Preferences.CLIENT) client = self.controller.query_config(Preferences.CLIENT)
client_name = self.controller.get_steam_client_name(client)
running = is_steam_running(client) running = is_steam_running(client)
steam_proc = SteamProcess(client_name, running) steam_proc = SteamProcess(client_name, running)
self.client = client self.client = client
@@ -195,9 +193,7 @@ class ConnectionManager:
is_last = self.is_last_server() is_last = self.is_last_server()
# TODO: strings # TODO: strings
invalid_mods = [ invalid_mods = [(mod[0], mod[1]) for mod in remote_mods if mod[2] == "Invalid mod"]
(mod[0], mod[1]) for mod in remote_mods if mod[2] == "Invalid mod"
]
prereqs = Prerequisites( prereqs = Prerequisites(
name=info.server_name, name=info.server_name,
@@ -235,7 +231,7 @@ class ConnectionManager:
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def query_details(self, record: Servers.Record) -> None: def query_details(self, record: Servers.Record) -> None:
details = Servers.get_details(record) details = Servers.get_details(record)
if details is None: if details.success is False:
self.thread_man.set_cleanup_func( self.thread_man.set_cleanup_func(
StoredFunc(self._server_timeout), destroy_first=True StoredFunc(self._server_timeout), destroy_first=True
) )
@@ -300,24 +296,10 @@ class ConnectionManager:
dialog = ServerDetailsDialog(self.controller, details) dialog = ServerDetailsDialog(self.controller, details)
dialog.run() dialog.run()
def _workshop_timeout(self, msg: str) -> None:
dialog = ExceptionDialog(self.controller, msg)
dialog.show_all()
dialog.run()
def _server_timeout(self) -> None: def _server_timeout(self) -> None:
dialog = ExceptionDialog(self.controller, server_timeout) dialog = ExceptionDialog(self.controller, server_timeout)
dialog.set_details_buffer(kb.DZG_006)
dialog.show_all()
dialog.run() dialog.run()
def get_debug_args(self) -> str:
addr = f"{self.record.ip}:{self.record.gameport}"
playername = self.controller.query_config(Preferences.NAME)
return connect_debug(
self.client, addr, self.appid, playername, self.remote_mod_ids
)
def _connect_steam(self, menu_only: bool) -> None: def _connect_steam(self, menu_only: bool) -> None:
addr = f"{self.record.ip}:{self.record.gameport}" addr = f"{self.record.ip}:{self.record.gameport}"
playername = self.controller.query_config(Preferences.NAME) playername = self.controller.query_config(Preferences.NAME)
@@ -356,14 +338,8 @@ class ConnectionManager:
for title, mod, stamp, size in self.missing_mods: for title, mod, stamp, size in self.missing_mods:
if self.controller.is_cancel_pending(): if self.controller.is_cancel_pending():
return return
try: subscribe(key, int(mod))
subscribe(key, int(mod)) time.sleep(API_RATE_LIMIT)
time.sleep(API_RATE_LIMIT)
except Exception as e:
msg = format_exception(e)
func = StoredFunc(self._workshop_timeout, msg)
self.thread_man.set_cleanup_func(func, destroy_first=True)
return
self.thread_man.update_dialog(waiting_for_directories) self.thread_man.update_dialog(waiting_for_directories)
for title, mod, stamp, size in self.missing_mods: for title, mod, stamp, size in self.missing_mods:
+5
View File
@@ -1,3 +1,4 @@
import gi
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -13,6 +14,10 @@ from dzgui.views.trees.tree_servers import ServerTreeView
from dzgui.views.trees.tree_server_mods import ServerModTreeView from dzgui.views.trees.tree_server_mods import ServerModTreeView
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING: if TYPE_CHECKING:
+3 -23
View File
@@ -21,30 +21,14 @@ class FilterManager:
strings.filter_3pp: True, strings.filter_3pp: True,
strings.filter_night: True, strings.filter_night: True,
strings.filter_full: show_full, strings.filter_full: show_full,
strings.filter_official: True,
strings.filter_nonascii: False,
strings.filter_lowpop: True, strings.filter_lowpop: True,
strings.filter_unofficial: True, strings.filter_nonascii: False,
strings.filter_duplicate: False, strings.filter_duplicate: False,
strings.filter_official: True,
strings.filter_unofficial: True,
strings.filter_modded: True, strings.filter_modded: True,
} }
# TODO: strings
self.tooltips = {
strings.filter_1pp: "First-person perspective",
strings.filter_day: "In-game time between 0600 and 1759",
strings.filter_empty: "Servers with no players",
strings.filter_3pp: "Third-person perspective",
strings.filter_night: "In-game time between 1800 and 0559",
strings.filter_full: "Servers with no open slots",
strings.filter_official: "Bohemia official servers",
strings.filter_nonascii: "Server names using non-standard, complex glyphs",
strings.filter_lowpop: "Current population is under 30% of total",
strings.filter_unofficial: "Third-party servers",
strings.filter_duplicate: "Duplicates of existing servers (usually spoofed)",
strings.filter_modded: "Servers with one or more mods",
}
self.active_keyword = "" self.active_keyword = ""
self.active_map = (0, all_maps) self.active_map = (0, all_maps)
self.prior_map = all_maps self.prior_map = all_maps
@@ -52,9 +36,6 @@ class FilterManager:
self.filters: list self.filters: list
self.enabled_filters = dict(self.default_filters) self.enabled_filters = dict(self.default_filters)
def get_tooltips(self) -> dict[str, str]:
return self.tooltips
def set_prior_map(self, name: str) -> None: def set_prior_map(self, name: str) -> None:
self.prior_map = name self.prior_map = name
@@ -105,7 +86,6 @@ class FilterManager:
self.reinit_map_store() self.reinit_map_store()
maps.sort() maps.sort()
for m in maps: for m in maps:
# TODO: strings
if m == "All maps": if m == "All maps":
continue continue
self.append_map([m]) self.append_map([m])
+7 -5
View File
@@ -22,7 +22,9 @@ from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManage
from dzgui.model.model_factory import FastInsertListStore, ModelFactory from dzgui.model.model_factory import FastInsertListStore, ModelFactory
from dzgui.strings import dialogs from dzgui.strings import dialogs
from dzgui.util.format import format_mods from dzgui.util.format import format_mods
from dzgui.util.strings import server_timeout
from dzgui.util.symlink import rebuild_symlinks from dzgui.util.symlink import rebuild_symlinks
from dzgui.views.dialogs.generic import ExceptionDialog
import dzgui.api.pefile as PeFile import dzgui.api.pefile as PeFile
@@ -122,11 +124,7 @@ class ModManager:
def unsub_atomic_mod(self, mod: str) -> None: def unsub_atomic_mod(self, mod: str) -> None:
config_man = self.controller.get_config_man() config_man = self.controller.get_config_man()
key = config_man.lookup(Preferences.STEAM) key = config_man.lookup(Preferences.STEAM)
try: unsubscribe(key, int(mod))
unsubscribe(key, int(mod))
except Exception as e:
logger.critical(e)
return
steam_path = Path(self.path) steam_path = Path(self.path)
app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
@@ -215,6 +213,10 @@ class ModManager:
func = StoredFunc(self._on_stale_mods_found, stale) func = StoredFunc(self._on_stale_mods_found, stale)
self.thread_man.set_cleanup_func(func) self.thread_man.set_cleanup_func(func)
def _server_timeout(self) -> None:
dialog = ExceptionDialog(self.controller, server_timeout)
dialog.run()
def select_colorized(self) -> None: def select_colorized(self) -> None:
model = self.treeview.get_model() model = self.treeview.get_model()
if model is None: if model is None:
-1
View File
@@ -64,5 +64,4 @@ class UpdateManager:
def _on_update_failure(self, msg: str) -> None: def _on_update_failure(self, msg: str) -> None:
dialog = ExceptionDialog(self.controller, msg) dialog = ExceptionDialog(self.controller, msg)
dialog.show_all()
dialog.run() dialog.run()
+9 -8
View File
@@ -12,9 +12,6 @@ if TYPE_CHECKING:
from dzgui.api.servers import Record from dzgui.api.servers import Record
from dzgui.model.servers import NewPlayerCount from dzgui.model.servers import NewPlayerCount
DAY_REG = r"([0][6-9]|[1][0-7])"
NIGHT_REG = r"([0][0-5]|[1][8-9]|[2][0-3])"
class ProxyModelManager: class ProxyModelManager:
""" """
@@ -234,9 +231,11 @@ class ProxyModelManager:
final.append(row) final.append(row)
rows = final rows = final
case strings.filter_day: case strings.filter_day:
rows = [row for row in rows if not re.match(DAY_REG, row[3])] reg = r"([0][0-9]|[1][0-6])"
rows = [row for row in rows if not re.match(reg, row[3])]
case strings.filter_night: case strings.filter_night:
rows = [row for row in rows if not re.match(NIGHT_REG, row[3])] reg = r"([0][0-4]|[1][8]|[2][0-3])"
rows = [row for row in rows if not re.match(reg, row[3])]
case strings.filter_nonascii: case strings.filter_nonascii:
rows = [row for row in rows if row[0].isascii()] rows = [row for row in rows if row[0].isascii()]
case strings.filter_lowpop: case strings.filter_lowpop:
@@ -259,6 +258,10 @@ class ProxyModelManager:
def set_cache(self, filters: tuple[str], filtered_rows: list[tuple]) -> None: def set_cache(self, filters: tuple[str], filtered_rows: list[tuple]) -> None:
self.filter_cache[filters] = filtered_rows self.filter_cache[filters] = filtered_rows
# @deprecated("Currently unused")
# def convert_model_to_list(self, model: "FastInsertListStore") -> list:
# return [[el for el in row] for row in model]
def set_filtered(self, rows: list | None) -> None: def set_filtered(self, rows: list | None) -> None:
if rows is None: if rows is None:
self.filtered = [] self.filtered = []
@@ -294,6 +297,4 @@ class ProxyModelManager:
def push(self, data: list[Any]) -> None: def push(self, data: list[Any]) -> None:
self.wipe_cache() self.wipe_cache()
self.set_control(data) self.set_control(data)
cur_keyword = self.filter_man.get_active_keyword() self.filter(FilterMode.INITIAL)
mode = FilterMode.TOGGLE_ON if len(cur_keyword) > 0 else FilterMode.INITIAL
self.filter(mode)
+2 -7
View File
@@ -14,14 +14,14 @@ from dzgui.const.constants import (
) )
from dzgui.const.enum import FilterMode, Preferences, ServerTab from dzgui.const.enum import FilterMode, Preferences, ServerTab
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import dialogs, kb from dzgui.strings import dialogs
from dzgui.util.strings import server_timeout, dialog from dzgui.util.strings import server_timeout, dialog
from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.generic import ExceptionDialog
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib # noqa E402 from gi.repository import Gtk # noqa E402
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo, Record from dzgui.api.servers import A2SInfo, Record
@@ -390,8 +390,6 @@ class ServerModelManager:
def _cleanup_on_success(self) -> None: def _cleanup_on_success(self) -> None:
proxy = self.proxy_man.get_proxy_model() proxy = self.proxy_man.get_proxy_model()
self.tv.set_model(proxy) self.tv.set_model(proxy)
if self.tv.enum == ServerTab.BROWSER:
GLib.idle_add(self.tv.wipe_cache)
if self.preserve_on_fail is True: if self.preserve_on_fail is True:
self.proxy_man.wipe_cache() self.proxy_man.wipe_cache()
@@ -407,7 +405,6 @@ class ServerModelManager:
if show_dialog: if show_dialog:
dialog = ExceptionDialog(self.controller, dialogs.load_error_lan) dialog = ExceptionDialog(self.controller, dialogs.load_error_lan)
dialog.show_all()
dialog.run() dialog.run()
def _cleanup_on_failure(self, show_dialog: bool = True) -> None: def _cleanup_on_failure(self, show_dialog: bool = True) -> None:
@@ -423,8 +420,6 @@ class ServerModelManager:
# customize statusbar and dialog accordingly # customize statusbar and dialog accordingly
if show_dialog: if show_dialog:
dialog = ExceptionDialog(self.controller, server_timeout) dialog = ExceptionDialog(self.controller, server_timeout)
dialog.set_details_buffer(kb.DZG_006)
dialog.show_all()
dialog.run() dialog.run()
def _push_data(self, data: list[Any]) -> None: def _push_data(self, data: list[Any]) -> None:
-7
View File
@@ -1,7 +0,0 @@
heading_local_time = "Local time"
heading_server_time = "In-game time"
heading_target_time = "Target time"
heading_remainder_time = "Remainder until target"
heading_adjusted_time = "Adjusted local time"
calculate = "Calculate"
disclaimer = "Times shown are approximations.\nServer time may differ based on time of year in game."
-2
View File
@@ -1,2 +0,0 @@
collapse_all = "Collapse all"
expand_all = "Expand all"
+18 -17
View File
@@ -1,22 +1,23 @@
from dzgui.const.constants import UDP_PORT add_button="Add"
add_con_heading="Add/connect"
add_button = "Add"
add_con_heading = "Add/connect"
add_popover = "This address is already in your Saved Servers" add_popover = "This address is already in your Saved Servers"
add_tooltip = "Add to Saved Servers" add_tooltip="Add to Saved Servers"
connect_button = "Connect" connect_button="Connect"
connect_tooltip = "Connect to this server" connect_tooltip="Connect to this server"
connect_entry_placeholder = f"Enter IP:Query port (e.g., 192.168.1.1:{UDP_PORT})" connect_entry_tooltip=(
"- IP: format as IP:Query port\ne.g. 192.168.1.1:27016"
)
connect_entry_placeholder="Enter IP (IP:Query port)"
fav_heading = "Favorite server" fav_heading="Favorite server"
favs_empty = "None set. Right click a server and select 'Set favorite' to set." favs_empty="None set. Right click a server and select 'Set favorite' to set."
lan_heading = "LAN query port" lan_heading="LAN query port"
lan_default_button = f"Default port ({UDP_PORT})" lan_default_button="Default port (27016)"
lan_custom_button = "Custom port" lan_custom_button="Custom port"
lan_scan_button = "Scan" lan_scan_button="Scan"
lan_abort_tooltip = "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster" lan_abort_tooltip = "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster"
lan_checkbox = "Stop scanning on first hit" lan_checkbox = "Stop scanning on first hit"
lan_entry_placeholder = "Enter the query port (1-65535)" lan_placeholder="Enter the query port (1-65535)"
lan_entry_tooltip = "Specify the port to search for DayZ servers on the local network" lan_entry_tooltip="Specify the port to search for DayZ servers on the local network"
lan_scan_tooltip = "Scan for servers" lan_scan_tooltip="Scan for servers"
-8
View File
@@ -20,11 +20,3 @@ ok = "OK"
mission_dialog = "Set a mission folder location" mission_dialog = "Set a mission folder location"
custom_mod_dialog = "Set location to a custom mods folder" custom_mod_dialog = "Set location to a custom mods folder"
debug_heading = "Debug"
debug_secondary = "Launch arguments"
toggle_calc = "Toggle calculator"
server_details = "Server details"
server_message = "Server message"
-1
View File
@@ -4,4 +4,3 @@ api_validation_error = (
api_popover = "API key validation failed." api_popover = "API key validation failed."
dayz_not_found = "Could not find a valid DayZ installation." dayz_not_found = "Could not find a valid DayZ installation."
workshop_endpoint_error = "Steam Workshop service unavailable, try again later."
-13
View File
@@ -1,13 +0,0 @@
DZG_006 = """The leading cause of specific servers periodically timing out is local network configuration.
Many third-party DayZ servers use a server rental/hosting provider with DDoS protection.
This can cause responses from servers to originate from a server other than the one originally queried.
Consumer-grade routers are likely to drop this traffic as invalid due to how they handle NAT (network address translation).
By contrast, wireless routers and enterprise-grade routers may be less likely to have this issue.
If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for the server's query port.
In addition, a uniform packet size is expected. If your router's MTU (maximum transmission unit) setting differs from the default of 1500, it could lead to incoming responses being discarded."""
-16
View File
@@ -1,16 +0,0 @@
exit_msg = (
"System map count is not high enough to run DayZ. "
"Please exit and run 'dzgui -m' to update map count."
)
failed_to_parse = (
"Failed to parse system map count.\n"
"This usually indicates that systemd is not installed."
)
failed_to_update = (
"Failed to update system map count.\n"
"Please report the issue upstream and provide the following traceback.\n\n"
"{0}"
)
meets_minimum = "System map count of {0} already meets the minimum."
prompt = "Updated map count will be written to the file {0}."
user_exit = "User exit"
+1 -8
View File
@@ -1,19 +1,12 @@
developers = "Developers" developers = "Developers"
start_tab = "Start tab" start_tab = "Start tab"
workshop_label = "Subscribed mods" workshop_label = "Subscribed mods"
workshop_eventbox = "If you manually subscribed to mods on Steam prior to using DZGUI, you can unsubscribe via this link."
steam_combo = "Steam" steam_combo = "Steam"
flatpak_combo = "Flatpak" flatpak_combo = "Flatpak"
flatpak_container_combo = "Flatpak (container)" flatpak_container_combo = "Flatpak (container)"
steam_placeholder = "Enter your Steam API key"
name_placeholder = "Identifies you to other players"
server_combo = "Server browser" server_combo = "Server browser"
saved_combo = "Saved servers" saved_combo = "Saved servers"
recent_combo = "Recent" recent_combo = "Recent"
lan_combo = "LAN" lan_combo = "LAN"
fullscreen_eventbox = "This option is not available on Steam Deck."
save_button = "Save"
api_failed = "API validation failed"
+5 -38
View File
@@ -1,8 +1,9 @@
update_mods = "Update mods and connect" update_mods = "Update mods and connect"
connect = "Connect" connect = "Connect"
connect_last = "Load to menu screen" connect_last = "Load to menu screen"
connect_last_tooltip = "Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting." connect_last_tooltip = (
debug = "Debug" "Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting."
)
back = "Back" back = "Back"
cancel = "Cancel" cancel = "Cancel"
warnings = "Warnings" warnings = "Warnings"
@@ -11,39 +12,5 @@ mods = "Mods"
total_mods = "Total mods: " total_mods = "Total mods: "
up_to_date = "All mods are up to date." up_to_date = "All mods are up to date."
all_updated = "All mods updated." all_updated = "All mods updated."
required_space = " Need to download {0} MiB of mod updates." "If you recently installed {build} or moved it to a different drive, "
"restart Steam to allow these changes to synchronize, then try again."
placeholder_no_mods = "This server has no mods."
placeholder_no_warnings = "No warnings."
# Errors
invalid_mods = (
"Server has invalid mods that are not recognized by Steam.\n"
"Contact the server owner and include these mod IDs in your report:\n"
"{0}"
)
not_enough_space = (
"Need to update {0} MiB of mods, but installation path only has {1} MiB."
)
resync = (
"If you recently installed {0} or moved it to a different drive,\n"
"restart Steam to allow these changes to synchronize, then try again."
)
steam_not_running = "'{0}' is set as the default Steam client, but it is either not installed or not running."
use_desktop_mode = "Use Desktop Mode to download mods on Steam Deck."
version_mismatch = "Local client version '{0}' does not match remote version '{1}'."
version_missing = "Remote server is running the build '{0}', but it is not installed."
no_errors = "No errors."
# Warnings
protected_server = (
"Protected: you will be prompted for a password when connecting to this server."
)
dayz_running = "It looks like DayZ is already running in the background. Exit DayZ before connecting."
running_app = (
"The app '{0}' is currently running in Steam, but background downloads are not enabled.\n"
"Either stop the game first, or update your global Steam settings or the game's local settings.\n"
"Otherwise, mods may be queued for download but never update."
)
-39
View File
@@ -1,39 +0,0 @@
pg1_title = "Choose uninstall targets"
pg1_heading = "Choose uninstall targets"
pg1_blurb = "Here you can choose which DZGUI configuration/state files to remove. "
pg2_title = "Uninstall complete"
pg2_heading = "Uninstall complete"
pg2_blurb = "Finished removing the selected files. See the console for any errors."
config_label = "Configuration files"
config_details = (
"User preferences and API key. "
"Keep this file if you want to import it again later."
)
desktop_label = "Desktop shortcut"
desktop_details = "Shortcut set on the desktop, if applicable."
start_menu_label = "Start menu shortcut"
start_menu_details = (
"Shortcut set under the Games category of the start menu, if applicable."
)
state_label = "Long-term state files"
state_details = (
"UI size, filter preferences, column size, server notes, and mod signatures."
)
steam_shortcut = "Steam shortcut"
steam_details = "Shortcut to DZGUI installed within Steam, if applicable. Requires Steam to be restarted to reflect the change."
path_remove_prefix = "The following path will be removed:"
not_installed = "It looks like DZGUI was not previously set up; nothing to uninstall."
standalone_uninstall = (
"It looks like you are running the standalone version of DZGUI.\n"
"To finalize the uninstall process, invoke './dzgui self remove',\n"
"then remove the 'dzgui' file."
)
system_uninstall = (
"It looks like DZGUI was installed via your system package manager or directly from source code.\n"
"To finalize the uninstall process, follow the standard removal procedure for that context."
)
+4
View File
@@ -54,6 +54,8 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
cpu = get_cpu_model() cpu = get_cpu_model()
version = get_version() version = get_version()
debug = lookup(config, Preferences.DEBUG)
install = lookup(config, Preferences.INSTALL)
default = lookup(config, Preferences.DEFAULT) default = lookup(config, Preferences.DEFAULT)
steam_path = Path(default) steam_path = Path(default)
@@ -77,6 +79,8 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
Kernel: {kernel} Kernel: {kernel}
CPU: {cpu} CPU: {cpu}
Debug: {debug}
Auto-install: {install}
Steam path: {steam_redacted} Steam path: {steam_redacted}
Workshop path: {workshop_redacted} Workshop path: {workshop_redacted}
+1
View File
@@ -84,5 +84,6 @@ class CalcDist:
except Exception: except Exception:
return None return None
# TODO: handle failed remote dist
haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon) haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon)
return haversine return haversine
+9 -1
View File
@@ -2,13 +2,14 @@ import a2s
import requests import requests
import shutil import shutil
import subprocess import subprocess
from warnings import deprecated
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from dzgui.api.servers import Record from dzgui.api.servers import Record
from dzgui.const.constants import REQUEST_TIMEOUT from dzgui.const.constants import REQUEST_TIMEOUT
from dzgui.const.endpoints import IP_ECHO from dzgui.const.endpoints import COORDS_API, IP_ECHO
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -126,3 +127,10 @@ def is_valid_port(port: str) -> bool:
if not port.isdigit() or int(port) == 0 or int(port[0]) == 0 or int(port) > 65535: if not port.isdigit() or int(port) == 0 or int(port[0]) == 0 or int(port) > 65535:
return True return True
return False return False
@deprecated("use ips.csv")
def get_local_coords(ip: str) -> str:
url = COORDS_API + "/" + ip
# local res=$(curl -Ls "$url" | jq -r '"\(.lat)\n\(.lon)"')
return url
+16 -59
View File
@@ -1,71 +1,28 @@
import logging
import subprocess import subprocess
import sys
import tempfile
import traceback
from pathlib import Path from pathlib import Path
from dzgui.const.constants import APP_NAME, VM_FILE, MIN_COUNT from dzgui.const.constants import VM_FILE, MIN_COUNT
from dzgui.util.bash import concat_bash_args
from dzgui.strings import map_count
from dzgui.views.dialogs.early_alert import EarlyIgnoreDialog
logger = logging.getLogger(APP_NAME)
def is_map_count_valid(count: int | None) -> bool:
if count is None:
# NOTE: permit if count was unreadable
return True
return count >= MIN_COUNT
def get_map_count() -> int | None: def get_map_count() -> int | None:
path = Path(VM_FILE) path = Path(VM_FILE)
try: if path.is_file() is False:
count = int(path.read_text())
except Exception as e:
logger.debug(e)
return None return None
count = int(path.read_text())
return count return count
# TODO: unfinished
#if count < MIN_COUNT:
# print("needs sudo escalation")
# # pop prompt
# # get response
# set_map_count()
#return count
def test_map_count() -> None: # TODO: unfinished
count = get_map_count()
if is_map_count_valid(count):
return
msg = map_count.exit_msg
EarlyIgnoreDialog(msg)
def set_map_count() -> None: def set_map_count() -> None:
count = get_map_count()
if count is None:
print(map_count.failed_to_parse)
return
if is_map_count_valid(count):
msg = map_count.meets_minimum.format(count)
print(msg)
return
conf = "/etc/sysctl.d/dayz.conf" conf = "/etc/sysctl.d/dayz.conf"
value = f"vm.max_map_count={MIN_COUNT}" count = f"vm.max_map_count={MIN_COUNT}"
try: with open(conf, "w") as f:
msg = map_count.prompt.format(conf) f.write(count)
print(msg) # TODO: use concat_bash_args()
with tempfile.NamedTemporaryFile(delete=False) as f: subprocess.run(["/usr/bin/sudo", "sysctl", "-p", conf])
tmp = f.name
Path(tmp).write_text(value)
mv_cmd = f"sudo mv {tmp} {conf}"
reload_cmd = f"sudo sysctl -p {conf}"
for cmd in mv_cmd, reload_cmd:
args = concat_bash_args(cmd)
subprocess.run([*args])
except Exception as e:
logger.debug(e)
trace = traceback.format_exc()
print(map_count.failed_to_update.format(trace))
except KeyboardInterrupt:
print(map_count.user_exit)
sys.exit(0)
+86 -9
View File
@@ -1,6 +1,27 @@
from dataclasses import dataclass from dataclasses import dataclass
from dzgui.const.constants import SYSTEM_LOG from dzgui.const.constants import SYSTEM_LOG
# TODO: move to util.format.py
def build_missing(build: str) -> str:
msg = (
f"This server is running {build}. You can install "
f"{build} by searching for it in your Steam library. "
f"If you recently installed {build} or moved it to a different drive, "
"restart Steam to allow these changes to synchronize, then try again."
)
return msg
def build_path_invalid(build: str) -> str:
msg = (
f"Steam is reporting that {build} is installed at a non-existent location. "
f"If you recently installed {build} or moved it to a different drive, "
"restart Steam to allow these changes to synchronize, then try again."
)
return msg
# General # General
dialog_header = "DZGUI - Dialog" dialog_header = "DZGUI - Dialog"
@@ -11,6 +32,7 @@ delimiter = "␞"
# GenericDialog # GenericDialog
main_menu = "Return to main menu" main_menu = "Return to main menu"
exit_app = "Exit" exit_app = "Exit"
server_details = "Server details"
modlist = "Modlist" modlist = "Modlist"
input_required = "User input required" input_required = "User input required"
confirm = "Confirmation" confirm = "Confirmation"
@@ -58,6 +80,7 @@ browser_cols = [
] ]
# DetailsDialog # DetailsDialog
server_message = "Server message"
workshop = "Enter/double click a row to open in Steam Workshop." workshop = "Enter/double click a row to open in Steam Workshop."
# LanDialog # LanDialog
@@ -166,12 +189,11 @@ none_provided = "None provided"
disabled = "Disabled" disabled = "Disabled"
enabled = "Enabled" enabled = "Enabled"
null = "-" null = "-"
none = "None"
unspecified = "not specified" unspecified = "not specified"
# DLC # DLC
dlc_frostline = "Frostline" dlc_frostline = "Frostline"
dlc_badlands = "Badlands"
dlc_none = "None"
# platform # platform
windows = "Windows" windows = "Windows"
@@ -305,8 +327,8 @@ mod_panel = ModPanelStrings(
unhighlight_stale_tooltip="Clears highlight from stale mods", unhighlight_stale_tooltip="Clears highlight from stale mods",
highlight_stale="Highlight stale", highlight_stale="Highlight stale",
highlight_stale_tooltip=( highlight_stale_tooltip=(
"Shows locally-installed mods which are not \n" "Shows locally-installed mods which are not used by any server "
"used by any server in your Saved Servers" "in your Saved Servers"
), ),
unsub_selected="Unsubscribe selected", unsub_selected="Unsubscribe selected",
unsub_selected_tooltip="Unsubscribes from selected mods", unsub_selected_tooltip="Unsubscribes from selected mods",
@@ -445,15 +467,13 @@ class Flags:
version: str version: str
uninstall: str uninstall: str
debug: str debug: str
map_count: str
flags = Flags( flags = Flags(
description="DayZ server browser and mod manager", description="DayZ server browser and mod manager",
version="print version information", version="Print version information",
uninstall="clean up state/config files", uninstall="Uninstall data files (use prior to 'pip uninstall')",
debug="enable developer debugging features", debug="Enables developer debugging features",
map_count="check and update system map count value",
) )
@@ -505,6 +525,63 @@ server_labels = ServerLabels(
) )
@dataclass(slots=True, frozen=True)
class ConnectPanel:
connect: str
add: str
add_con: str
placeholder: str
entry_tooltip: str
add_tooltip: str
connect_tooltip: str
connect_panel = ConnectPanel(
connect="Connect",
add="Add",
add_con="Add/connect",
placeholder="Enter IP (IP:Query port)",
entry_tooltip=(
"- IP: format as IP:Query port\ne.g. 192.168.1.1:27016"
),
add_tooltip="Add to Saved Servers",
connect_tooltip="Connect to this server",
)
@dataclass(slots=True, frozen=True)
class FavPanel:
heading: str
no_fav: str
fav_panel = FavPanel(
heading="Favorite server",
no_fav="None set. Right click a server and select 'Set favorite' to set.",
)
@dataclass(slots=True, frozen=True)
class LanPanel:
heading: str
default_button: str
custom_button: str
scan_button: str
placeholder: str
entry_tooltip: str
scan_tooltip: str
lan_panel = LanPanel(
heading="LAN query port",
default_button="Default port (27016)",
custom_button="Custom port",
scan_button="Scan",
placeholder="Enter the query port (1-65535)",
entry_tooltip="Specify the port to search for DayZ servers on the local network",
scan_tooltip="Scan for servers",
)
distance_suffix = "Distance: calculating..." distance_suffix = "Distance: calculating..."
dialog_error = "ERROR" dialog_error = "ERROR"
+5 -10
View File
@@ -2,12 +2,7 @@ import logging
from pathlib import Path from pathlib import Path
from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash
from dzgui.const.constants import ( from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME, DAYZ_COMMUNITY_ROOT
APPID_DAYZ,
APPID_DAYZ_EXP,
APP_NAME,
DAYZ_COMMUNITY_ROOT,
)
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.config.query import lookup from dzgui.config.query import lookup
@@ -25,7 +20,6 @@ def expunge_link(file: Path) -> None:
if str(file.stem)[:2] == "@C": if str(file.stem)[:2] == "@C":
file.unlink() file.unlink()
def rebuild_symlinks(config: Path) -> None: def rebuild_symlinks(config: Path) -> None:
# TODO: pass direct path as argument # TODO: pass direct path as argument
path = lookup(config, Preferences.DEFAULT) path = lookup(config, Preferences.DEFAULT)
@@ -69,9 +63,10 @@ def symlink_mission(steam_path: Path, target: str) -> str:
stem = path.name stem = path.name
parent = path.parent parent = path.parent
source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT) source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT)
if source.exists(): try:
source.unlink() source.symlink_to(parent)
source.symlink_to(parent) except Exception:
return ""
return f"{DAYZ_COMMUNITY_ROOT}/{stem}" return f"{DAYZ_COMMUNITY_ROOT}/{stem}"
+20 -26
View File
@@ -1,14 +1,10 @@
import logging import logging
import signal import signal
import warnings
from typing import TYPE_CHECKING, Literal, Self from typing import TYPE_CHECKING, Literal
from dzgui.const.constants import ( from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, STEAM_ICON
APP_NAME,
APP_NAME_LOWER,
GTK_APP_ID,
STEAM_ICON,
)
from dzgui.const.enum import NotebookPage from dzgui.const.enum import NotebookPage
from dzgui.controllers.emitter import Emitter from dzgui.controllers.emitter import Emitter
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@@ -18,7 +14,6 @@ from dzgui.views.components.connect_panel import ConnectPanel
from dzgui.views.components.crumbs import Breadcrumbs from dzgui.views.components.crumbs import Breadcrumbs
from dzgui.views.components.right_panel import RightPanel from dzgui.views.components.right_panel import RightPanel
from dzgui.views.components.statusbar import Statusbar from dzgui.views.components.statusbar import Statusbar
from dzgui.views.mixins.colorscheme import ColorAwareApp
from dzgui.views.mixins.scrollable_mixin import ScrollableMixin from dzgui.views.mixins.scrollable_mixin import ScrollableMixin
# TODO: import notebook only and add components there? # TODO: import notebook only and add components there?
@@ -46,21 +41,20 @@ if TYPE_CHECKING:
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
# TODO: drop
# https://bugzilla.gnome.org/show_bug.cgi?id=708676
warnings.filterwarnings("ignore", ".*g_value_get_int", Warning)
class OuterWindow(Gtk.ApplicationWindow):
def __init__(self, app: Gtk.Application) -> None: class OuterWindow(Gtk.Window):
super().__init__( def __init__(self) -> None:
application=app, title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER super().__init__(title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER)
)
self.hb = AppHeaderBar() self.hb = AppHeaderBar()
MainController.register_widget("window", self) MainController.register_widget("window", self)
# NOTE: steam deck taskbar may occlude elements # NOTE: steam deck taskbar may occlude elements
is_deck = MainController.get_prefs().is_steam_deck if MainController.get_prefs().is_steam_deck is False:
if is_deck:
self.maximize()
else:
self.set_titlebar(self.hb) self.set_titlebar(self.hb)
self.connect("delete-event", self._on_delete_event) self.connect("delete-event", self._on_delete_event)
@@ -72,8 +66,8 @@ class OuterWindow(Gtk.ApplicationWindow):
self.show_all() self.show_all()
css.load_css() css.load_css()
MainController.set_start_tab()
MainController.open_page(NotebookPage.SERVERS) MainController.open_page(NotebookPage.SERVERS)
MainController.set_start_tab()
self.grid.hide_widgets_on_init() self.grid.hide_widgets_on_init()
MainController.loaded = True MainController.loaded = True
@@ -282,18 +276,13 @@ class Grid(Gtk.Grid):
self.right_panel.refresh_button.set_visible(state) self.right_panel.refresh_button.set_visible(state)
class App(ColorAwareApp, Gtk.Application): # type: ignore class App(Gtk.Application):
def __init__(self, prefs: "UserPrefs") -> None: def __init__(self, prefs: "UserPrefs") -> None:
super().__init__(application_id=GTK_APP_ID)
GLib.set_prgname(APP_NAME) GLib.set_prgname(APP_NAME)
MainController.set_prefs(prefs) MainController.set_prefs(prefs)
self._setup_signals() self.win = OuterWindow()
self.connect("activate", self._on_activate)
def _on_activate(self, app: Self) -> None:
self.win = OuterWindow(app)
accel = Gtk.AccelGroup() accel = Gtk.AccelGroup()
accel.connect( accel.connect(
@@ -304,6 +293,9 @@ class App(ColorAwareApp, Gtk.Application): # type: ignore
) )
self.win.add_accel_group(accel) self.win.add_accel_group(accel)
self._setup_signals()
Gtk.main()
def _setup_signals(self) -> None: def _setup_signals(self) -> None:
SIGNAL_ADD = "signal_add" SIGNAL_ADD = "signal_add"
SIGNAL_ADD_FULL = "signal_add_full" SIGNAL_ADD_FULL = "signal_add_full"
@@ -339,6 +331,8 @@ try:
theme = Gtk.IconTheme.get_default() theme = Gtk.IconTheme.get_default()
icons = theme.list_icons(None) icons = theme.list_icons(None)
if STEAM_ICON not in icons: if STEAM_ICON not in icons:
logger.warning(strings.steam_icon_missing) logger.warn(strings.steam_icon_missing)
warnings.warn(strings.steam_icon_missing, stacklevel=2)
except Exception as e: except Exception as e:
logger.warning(e) logger.warning(e)
warnings.warn(strings.gtk_theme_missing, stacklevel=2)
-8
View File
@@ -23,11 +23,3 @@ class HBox(GenericBox):
class VBox(GenericBox): class VBox(GenericBox):
def __init__(self, spacing: int = 0) -> None: def __init__(self, spacing: int = 0) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing) super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing)
class ShortHBox(HBox):
def __init__(self, widget: Gtk.Widget) -> None:
super().__init__(spacing=5)
self.set_halign(Gtk.Align.START)
self.pack_start(widget, expand=False, fill=False, padding=0)
+4 -47
View File
@@ -6,10 +6,10 @@ from dzgui.strings import buttons
from dzgui.util.strings import ( from dzgui.util.strings import (
alert_button_tooltip, alert_button_tooltip,
atomic_buttons, atomic_buttons,
connect_panel,
) )
from dzgui.const.constants import ( from dzgui.const.constants import (
CLIPBOARD, CLIPBOARD,
CLOSE,
INPUT_KEYBOARD, INPUT_KEYBOARD,
LIST_ADD, LIST_ADD,
REFRESH_ICON, REFRESH_ICON,
@@ -17,7 +17,6 @@ from dzgui.const.constants import (
WARNING, WARNING,
WEB_BROWSER, WEB_BROWSER,
) )
from dzgui.strings import connect_panel
import gi import gi
@@ -64,6 +63,7 @@ class IconButton(Gtk.Button):
self.icon = Icon(icon, margin_start=margin_start, margin_end=margin_end) self.icon = Icon(icon, margin_start=margin_start, margin_end=margin_end)
self.set_image(self.icon) self.set_image(self.icon)
self.set_image_position(position) self.set_image_position(position)
# self.set_image_position(Gtk.PositionType.RIGHT)
self.set_focus_on_click(False) self.set_focus_on_click(False)
def swap_icon(self, icon: str) -> None: def swap_icon(self, icon: str) -> None:
@@ -128,14 +128,6 @@ class CopyIpButton(ClipboardButton):
self.set_tooltip_text("Copy IP to clipboard") self.set_tooltip_text("Copy IP to clipboard")
class CloseButton(IconTextButton):
def __init__(self, label: str) -> None:
super().__init__(icon=CLOSE, label=label)
# TODO: strings
self.set_tooltip_text("Unset this server as favorite")
class WebButton(IconTextButton): class WebButton(IconTextButton):
def __init__(self, label: str) -> None: def __init__(self, label: str) -> None:
super().__init__(icon=WEB_BROWSER, label=label) super().__init__(icon=WEB_BROWSER, label=label)
@@ -221,7 +213,7 @@ class KeysButton(IconTextButton):
class SteamConnectButton(LargeIconTextButton): class SteamConnectButton(LargeIconTextButton):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(icon=STEAM_ICON, label=connect_panel.connect_button) super().__init__(icon=STEAM_ICON, label=connect_panel.connect)
self.set_tooltip_text(connect_panel.connect_tooltip) self.set_tooltip_text(connect_panel.connect_tooltip)
@@ -241,7 +233,7 @@ class SteamWorkshopButton(SteamTextButton):
class AddButton(IconTextButton): class AddButton(IconTextButton):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(icon=LIST_ADD, label=connect_panel.add_button) super().__init__(icon=LIST_ADD, label=connect_panel.add)
self.set_tooltip_text(connect_panel.add_tooltip) self.set_tooltip_text(connect_panel.add_tooltip)
@@ -266,38 +258,3 @@ class LoggerAlertsButton(IconTextButton):
self.set_halign(Gtk.Align.END) self.set_halign(Gtk.Align.END)
self.set_hexpand(True) self.set_hexpand(True)
self.set_tooltip_text(alert_button_tooltip) self.set_tooltip_text(alert_button_tooltip)
class SpinnerButton(Gtk.Button):
def __init__(self, label: str) -> None:
super().__init__()
self.text = label
self.label = Gtk.Label(label=label, halign=Gtk.Align.CENTER)
self.spinner = Gtk.Spinner()
self.spinner.set_halign(Gtk.Align.END)
self.spinner.set_sensitive(False)
grid = Gtk.Grid(column_spacing=10)
for el in self.label, self.spinner:
grid.add(el)
self.add(grid)
self.connect("clicked", self._on_button_clicked)
self.connect("map", self._on_map)
def _on_map(self, button: Self) -> None:
self.spinner.set_visible(False)
def _on_button_clicked(self, button: Self) -> None:
self.start_spinner()
def stop_spinner(self) -> None:
self.spinner.set_visible(False)
self.spinner.stop()
def start_spinner(self) -> None:
self.spinner.set_visible(True)
self.spinner.start()
+68
View File
@@ -0,0 +1,68 @@
from dzgui.strings import preconnect
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, Gtk # noqa E402
class ConnectBox(Gtk.Box):
def __init__(self) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
self.back = Gtk.Button(
label=preconnect.back, halign=Gtk.Align.END, hexpand=True
)
# TODO: dynamically update this
self.ok = Gtk.Button(label="Launch", halign=Gtk.Align.END)
for button in self.back, self.ok:
self.add(button)
def get_back_button(self) -> Gtk.Button:
return self.back
def get_ok_button(self) -> Gtk.Button:
return self.ok
class PreconnectBox(Gtk.Box):
def __init__(self) -> None:
super().__init__(
orientation=Gtk.Orientation.VERTICAL,
valign=Gtk.Align.END,
vexpand=True,
spacing=5,
)
self.button_hbox = ConnectBox()
self.ok = self.button_hbox.get_ok_button()
self.ok.set_label(preconnect.update_mods)
self.connect_last = Gtk.Button(
label=preconnect.connect_last,
halign=Gtk.Align.END,
tooltip_text=preconnect.connect_last_tooltip,
)
self.button_hbox.add(self.connect_last)
self.raise_window = Gtk.CheckButton(
# TODO: strings
label="Foreground DZGUI while downloading",
halign=Gtk.Align.END,
hexpand=True,
valign=Gtk.Align.END,
visible=False,
has_tooltip=True,
sensitive=False,
tooltip_text="Foreground the DZGUI window after mod downloads are queued",
active=True,
)
self.add(self.button_hbox)
self.add(self.raise_window)
def get_back_button(self) -> Gtk.Button:
return self.button_hbox.get_back_button()
def get_ok_button(self) -> Gtk.Button:
return self.ok
+5 -24
View File
@@ -6,7 +6,6 @@ from dzgui.strings import connect_panel
from dzgui.util.keys import is_ctrl_mask from dzgui.util.keys import is_ctrl_mask
from dzgui.views.components.buttons import ( from dzgui.views.components.buttons import (
AddButton, AddButton,
CloseButton,
CopyIpButton, CopyIpButton,
Icon, Icon,
IconButton, IconButton,
@@ -188,27 +187,17 @@ class FavPanel(Gtk.Frame):
self.fav_button = SteamConnectButton() self.fav_button = SteamConnectButton()
self.fav_button.connect("clicked", self._on_connect_clicked) self.fav_button.connect("clicked", self._on_connect_clicked)
self.copy_button = CopyIpButton(self.controller, self.get_fav_ip) self.copy_button = CopyIpButton(self.controller, self.get_fav_ip)
# TODO: strings
self.unset_button = CloseButton("Unset")
self.unset_button.connect("clicked", self._on_unset_clicked)
if favorite is None: if favorite is None:
self.toggle_buttons(False) self.toggle_buttons(False)
# NOTE: disable vscrollbar to prevent layout jumping behavior # NOTE: disable vscrollbar to prevent layout jumping behavior
self.scrollable_label = Gtk.ScrolledWindow( scrollable_label = Gtk.ScrolledWindow(vscrollbar_policy=Gtk.PolicyType.NEVER)
vscrollbar_policy=Gtk.PolicyType.NEVER, overlay_scrolling=False scrollable_label.add(self.fav_label)
)
self.scrollable_label.add(self.fav_label)
grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5)
grid.attach(self.scrollable_label, 0, 0, 3, ROWS) grid.attach(scrollable_label, 0, 0, 3, ROWS)
grid.attach_next_to( grid.attach_next_to(
self.unset_button, self.scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS self.copy_button, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS
)
grid.attach_next_to(
self.copy_button, self.unset_button, Gtk.PositionType.RIGHT, COLS, ROWS
) )
grid.attach_next_to( grid.attach_next_to(
self.fav_button, self.copy_button, Gtk.PositionType.RIGHT, COLS, ROWS self.fav_button, self.copy_button, Gtk.PositionType.RIGHT, COLS, ROWS
@@ -216,16 +205,8 @@ class FavPanel(Gtk.Frame):
self.add(grid) self.add(grid)
def _on_unset_clicked(self, button: Gtk.Button) -> None:
try:
self.controller.unset_fav()
except Exception:
return
self.fav_label.set_text(connect_panel.favs_empty)
self.toggle_buttons(False)
def toggle_buttons(self, state: bool) -> None: def toggle_buttons(self, state: bool) -> None:
for button in self.fav_button, self.copy_button, self.unset_button: for button in self.fav_button, self.copy_button:
button.set_sensitive(state) button.set_sensitive(state)
def _on_connect_clicked(self, button: Gtk.Button) -> None: def _on_connect_clicked(self, button: Gtk.Button) -> None:
+9 -4
View File
@@ -3,7 +3,7 @@ from typing import Any, Callable, TYPE_CHECKING
from dzgui.api.servers import validate_ip from dzgui.api.servers import validate_ip
from dzgui.const.constants import VIEW_CONCEAL, VIEW_REVEAL from dzgui.const.constants import VIEW_CONCEAL, VIEW_REVEAL
from dzgui.util.css import add_class, remove_class from dzgui.util.css import add_class, remove_class
from dzgui.strings import connect_panel from dzgui.util.strings import connect_panel, lan_panel
from dzgui.strings.errors import api_popover from dzgui.strings.errors import api_popover
import gi import gi
@@ -122,8 +122,11 @@ class IpEntry(ValidatedEntry):
super().__init__( super().__init__(
controller, controller,
func=validate_ip_truthy, func=validate_ip_truthy,
placeholder_text=connect_panel.connect_entry_placeholder, placeholder_text=connect_panel.placeholder,
tooltip_text=connect_panel.entry_tooltip,
) )
self.set_placeholder_text(connect_panel.placeholder)
self.set_tooltip_text(connect_panel.entry_tooltip)
class PortEntry(ValidatedEntry): class PortEntry(ValidatedEntry):
@@ -131,9 +134,11 @@ class PortEntry(ValidatedEntry):
super().__init__( super().__init__(
controller, controller,
func=validate_port, func=validate_port,
placeholder_text=connect_panel.lan_entry_placeholder, placeholder_text=connect_panel.placeholder,
tooltip_text=connect_panel.lan_entry_tooltip, tooltip_text=connect_panel.entry_tooltip,
) )
self.set_placeholder_text(lan_panel.placeholder)
self.set_tooltip_text(lan_panel.entry_tooltip)
# TODO: backport to Options page # TODO: backport to Options page
+17 -22
View File
@@ -9,6 +9,8 @@ from dzgui.const.constants import (
NO_PADDING, NO_PADDING,
SEARCH_ICON, SEARCH_ICON,
) )
from dzgui.const.enum import FilterMode
from dzgui.model.servers import ServerModelManager
from dzgui.util.format import format_exception from dzgui.util.format import format_exception
from dzgui.util.strings import all_maps from dzgui.util.strings import all_maps
from dzgui.views.components.maps_combo import MapsCombo from dzgui.views.components.maps_combo import MapsCombo
@@ -27,33 +29,21 @@ if TYPE_CHECKING:
class ButtonGrid(Gtk.Grid): class ButtonGrid(Gtk.Grid):
def __init__( def __init__(self, controller: "Controller", defaults: dict) -> None:
self,
controller: "Controller",
defaults: dict[str, str],
tooltips: dict[str, str],
) -> None:
super().__init__( super().__init__(
halign=Gtk.Align.CENTER, column_spacing=5, column_homogeneous=True halign=Gtk.Align.CENTER, column_spacing=5, column_homogeneous=True
) )
row = 1
col = 0
self.controller = controller self.controller = controller
self.emitter = controller.get_emitter() self.emitter = controller.get_emitter()
self.checks: list[Gtk.CheckButton] = [] self.checks: list[Gtk.CheckButton] = []
flowbox = Gtk.FlowBox( # TODO: use enumerated checks
halign=Gtk.Align.CENTER, min_children_per_line=3, max_children_per_line=3, selection_mode=Gtk.SelectionMode.NONE
)
for check in defaults.keys(): for check in defaults.keys():
checkbox = Gtk.CheckButton(label=check) checkbox = Gtk.CheckButton(label=check)
try:
tt = tooltips[check]
except Exception as e:
logger.debug(e)
tt = ""
checkbox.set_tooltip_text(tt)
label = checkbox.get_child() label = checkbox.get_child()
if label is not None: if label is not None:
label.set_ellipsize(Pango.EllipsizeMode.END) # type: ignore label.set_ellipsize(Pango.EllipsizeMode.END) # type: ignore
@@ -61,12 +51,14 @@ class ButtonGrid(Gtk.Grid):
if defaults[check]: if defaults[check]:
checkbox.set_active(True) checkbox.set_active(True)
col = col + 1
if col > 3:
row += 1
col = 1
self.attach(checkbox, col, row, 1, 1)
checkbox.connect("toggled", self._on_check_toggled) checkbox.connect("toggled", self._on_check_toggled)
flowbox.add(checkbox)
self.checks.append(checkbox) self.checks.append(checkbox)
self.add(flowbox)
def block_toggles(self, state: bool) -> None: def block_toggles(self, state: bool) -> None:
for check in self.checks: for check in self.checks:
self.controller.suppress_signal( self.controller.suppress_signal(
@@ -146,6 +138,10 @@ class KeywordEntry(Gtk.Entry):
self.controller.set_active_keyword(keyword) self.controller.set_active_keyword(keyword)
logger.info(f"User filtered by keyword '{keyword}'") logger.info(f"User filtered by keyword '{keyword}'")
ServerModelManager(
self.controller, self.controller.get_active_treeview()
).refilter(FilterMode.TOGGLE_ON)
class FilterPanel(Gtk.Box): class FilterPanel(Gtk.Box):
def __init__(self, controller: "Controller") -> None: def __init__(self, controller: "Controller") -> None:
@@ -167,12 +163,11 @@ class FilterPanel(Gtk.Box):
filter_man = self.controller.get_filter_man() filter_man = self.controller.get_filter_man()
defaults = filter_man.get_default_filters() defaults = filter_man.get_default_filters()
tooltips = filter_man.get_tooltips()
self.map_store = filter_man.get_map_store() self.map_store = filter_man.get_map_store()
self.enabled_filters = defaults self.enabled_filters = defaults
self.keyword_entry = KeywordEntry(self.controller) self.keyword_entry = KeywordEntry(self.controller)
self.button_grid = ButtonGrid(self.controller, defaults, tooltips) self.button_grid = ButtonGrid(self.controller, defaults)
# TODO: strings # TODO: strings
self.filters_label = BoldLabel("Filters") self.filters_label = BoldLabel("Filters")
-3
View File
@@ -9,6 +9,3 @@ class Icon(Gtk.Image):
super().__init__( super().__init__(
icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin
) )
def set_icon_name(self, name: str) -> None:
super().set_from_icon_name(name, Gtk.IconSize.BUTTON)
+3 -28
View File
@@ -1,8 +1,5 @@
from dzgui.const.constants import ERROR, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD
from dzgui.strings import options from dzgui.strings import options
from dzgui.views.components.box import HBox from dzgui.const.constants import FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD
from dzgui.views.components.buttons import Icon
import gi import gi
@@ -15,7 +12,7 @@ class ClientCombo(Gtk.ComboBox):
super().__init__() super().__init__()
self.client_store = Gtk.ListStore(str, str) self.client_store = Gtk.ListStore(str, str)
self.clients = ( clients = (
( (
options.steam_combo, options.steam_combo,
STEAM_CMD, STEAM_CMD,
@@ -29,32 +26,10 @@ class ClientCombo(Gtk.ComboBox):
FLATPAK_SANDBOX, FLATPAK_SANDBOX,
), ),
) )
for client in self.clients: for client in clients:
self.client_store.append(client) self.client_store.append(client)
self.set_model(self.client_store) self.set_model(self.client_store)
renderer_text = Gtk.CellRendererText() renderer_text = Gtk.CellRendererText()
self.pack_start(renderer_text, True) self.pack_start(renderer_text, True)
self.add_attribute(renderer_text, "text", 0) self.add_attribute(renderer_text, "text", 0)
self.set_active(0) self.set_active(0)
def get_client_human_readable(self, cmd: str) -> str:
for client in self.clients:
human, command = client
if command == cmd:
return human
raise TypeError("Not a valid Steam client selection")
class ErrorPopover(Gtk.Popover):
def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None:
super().__init__(relative_to=relative_to, position=position)
self.hbox = HBox()
self.label = Gtk.Label(label="", margin_start=10, margin_end=10)
error_icon = Icon(ERROR, margin_start=10)
self.hbox.extend([error_icon, self.label])
self.add(self.hbox)
self.show_all()
self.popdown()
def set_label(self, label: str) -> None:
self.label.set_label(label)
+2 -5
View File
@@ -4,9 +4,6 @@ from typing import Any, Literal, Self, TYPE_CHECKING
# import time # import time
from enum import Enum from enum import Enum
from dzgui.views.mixins.colorscheme import ColorAwareApp
# TODO: import dialog titles # TODO: import dialog titles
from dzgui.api.mods import remove_stale_signatures as remove_stale from dzgui.api.mods import remove_stale_signatures as remove_stale
from dzgui.config.ipdb import get_ipdb from dzgui.config.ipdb import get_ipdb
@@ -40,7 +37,7 @@ class Success(Enum):
# TODO: strings for "Running", "Failed", etc. # TODO: strings for "Running", "Failed", etc.
# TODO: add margins to tree # TODO: add margins to tree
class BootDialog(ColorAwareApp, Gtk.Dialog): # type: ignore class BootDialog(Gtk.Dialog):
def __init__(self, parent: "BootWindow", xdg: "Xdg", version: str) -> None: def __init__(self, parent: "BootWindow", xdg: "Xdg", version: str) -> None:
super().__init__( super().__init__(
title=dialog_header, title=dialog_header,
@@ -102,7 +99,7 @@ class BootDialog(ColorAwareApp, Gtk.Dialog): # type: ignore
margin_top=30, margin_top=30,
margin_bottom=30, margin_bottom=30,
) )
self.error_label = Gtk.Label(margin_start=15, margin_end=15) self.error_label = Gtk.Label()
# TODO: abstract class # TODO: abstract class
self.copy_button = ClipboardButton(None, lambda: self.error_label.get_text()) self.copy_button = ClipboardButton(None, lambda: self.error_label.get_text())
-457
View File
@@ -1,457 +0,0 @@
import gi
from dataclasses import dataclass
from datetime import datetime, time, timedelta
from typing import Literal
from dzgui.const.constants import DAY_ICON, NIGHT_ICON
from dzgui.strings import calc
from dzgui.views.components.icon import Icon
from dzgui.views.components.labels import BoldLabel
from dzgui.views.components.box import VBox, HBox
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, GObject # type: ignore # noqa E402
DAY_START_TIME = time(hour=6, minute=0, second=0)
DAY_END_TIME = time(hour=18, minute=0, second=0)
NIGHT_START_TIME = time(hour=18, minute=0, second=0)
NIGHT_END_TIME = time(hour=6, minute=0, second=0)
EXTENDED_TIME_FORMAT = "%H:%M:%S"
@dataclass(slots=True, frozen=True)
class Time:
time: str
day_accel: float
night_accel: float
class Emitter(GObject.GObject):
def __init__(self) -> None:
super().__init__()
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, object))
def local_time_incremented(self, time_now: datetime, elapsed: timedelta) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def server_time_incremented(self, server_time: datetime) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def calculate_button_clicked(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def target_time_changed(self, time: timedelta) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def target_time_reset(self, time: timedelta) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def remaining_time_changed(self, time: datetime) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def adjusted_local_time_changed(self, time: datetime) -> None:
pass
class LocalClock:
def __init__(self, emitter: Emitter) -> None:
self.emitter = emitter
self.start_time = datetime.now()
# NOTE: highest theoretical accel interval is 24 * 64
GLib.timeout_add(500, self.check_time)
def get_time(self) -> str:
return datetime.now().time().strftime(EXTENDED_TIME_FORMAT)
def get_elapsed_time(self) -> timedelta:
return datetime.now() - self.start_time
def check_time(self) -> Literal[True]:
time_now = datetime.now()
elapsed = datetime.now() - self.start_time
self.emitter.emit("local_time_incremented", time_now, elapsed)
return True
class ServerClock:
def __init__(
self, server_time: Time, emitter: Emitter, local_clock: LocalClock
) -> None:
self.local_clock = local_clock
self.emitter = emitter
hour, minute = self.split_time(server_time)
self.day_accel = server_time.day_accel
self.night_accel = server_time.night_accel
self.start_time = datetime.combine(datetime.today(), time(hour, minute))
self.cur_time = datetime.combine(datetime.today(), time(hour, minute))
self._last_elapsed = self.local_clock.get_elapsed_time()
self.emitter.connect("local_time_incremented", self._on_local_time_incremented)
@staticmethod
def split_time(server_time: Time) -> tuple[int, int]:
split = server_time.time.split(":")
return int(split[0]), int(split[1])
def is_day(self) -> bool:
if DAY_START_TIME <= self.cur_time.time() <= DAY_END_TIME:
return True
return False
def _on_local_time_incremented(
self,
emitter: Emitter,
time: datetime,
elapsed: timedelta,
) -> Literal[True]:
delta = elapsed - self._last_elapsed
self._last_elapsed = elapsed
factor = self.get_accel_factor(self.cur_time)
self.cur_time += delta * factor
self.emitter.emit("server_time_incremented", self.cur_time)
return True
def get_accel_factor(self, dt: datetime) -> float:
return (
self.day_accel
if DAY_START_TIME <= dt.time() < DAY_END_TIME
else (self.night_accel * self.day_accel)
)
def get_values(self) -> tuple[int, int]:
return self.cur_time.hour, self.cur_time.minute
def get_datetime(self) -> datetime:
return self.cur_time
def get_time(self) -> time:
return self.cur_time.time()
def calc_delta(self, end_time: datetime) -> timedelta:
total = 0.0
current_time = self.cur_time
if current_time > end_time:
end_time += timedelta(days=1)
while current_time < end_time:
factor = self.get_accel_factor(current_time)
day_start = datetime.combine(current_time.date(), DAY_START_TIME)
day_end = datetime.combine(current_time.date(), DAY_END_TIME)
if current_time.time() < DAY_START_TIME:
edge = day_start
elif current_time.time() < DAY_END_TIME:
edge = day_end
else:
edge = datetime.combine(
current_time.date() + timedelta(days=1), DAY_START_TIME
)
chunk_end = min(end_time, edge)
total += (chunk_end - current_time).total_seconds() / factor
current_time = chunk_end
return timedelta(seconds=total)
class RemainderClock:
def __init__(self, emitter: Emitter) -> None:
self.emitter = emitter
self.emitter.connect("target_time_changed", self._on_target_time_changed)
self.emitter.connect("target_time_reset", self._on_target_time_reset)
self.emitter.connect("local_time_incremented", self._on_local_time_incremented)
self.reset_time(True)
self.previous = timedelta(0)
def reset_time(self, zero_out: bool = False) -> None:
now = datetime.now()
second, ms = (0, 0) if zero_out else (now.second, now.microsecond)
self.time = datetime.combine(
now, time(hour=0, minute=0, second=second, microsecond=ms)
)
def _on_local_time_incremented(
self, emitter: Emitter, time: datetime, elapsed: timedelta
) -> None:
if self.time == datetime.min.time():
return
delta = elapsed - self.previous
# NOTE: clamp to midnight
self.time = max(
self.time - delta, self.time.replace(hour=0, minute=0, second=0)
)
self.previous = elapsed
self.emitter.emit("remaining_time_changed", self.time)
def _on_target_time_reset(self, emitter: Emitter, time: timedelta) -> None:
self.reset_time(True)
self.emitter.emit("remaining_time_changed", self.time)
# NOTE: restore adjusted time to current time
self.emitter.emit("adjusted_local_time_changed", datetime.now())
def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None:
self.reset_time(True)
self.time += time
self.emitter.emit("remaining_time_changed", self.time)
def get_time(self) -> str:
return self.time.strftime(EXTENDED_TIME_FORMAT)
class AdjustedClock:
def __init__(self, emitter: Emitter) -> None:
self.emitter = emitter
self.emitter.connect("target_time_changed", self._on_target_time_changed)
self.start_time = datetime.now()
def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None:
self.start_time = datetime.now()
adjusted = self.start_time + time
self.emitter.emit("adjusted_local_time_changed", adjusted)
def get_time(self) -> str:
return self.start_time.strftime(EXTENDED_TIME_FORMAT)
class VerticalSpinBox(Gtk.SpinButton):
def __init__(self, value: int, upper: int) -> None:
super().__init__(
adjustment=Gtk.Adjustment(
value=value,
lower=0,
upper=upper,
step_increment=1,
page_increment=1,
page_size=0,
),
orientation=Gtk.Orientation.VERTICAL,
)
class HourSpinBox(VerticalSpinBox):
def __init__(self, hour: int) -> None:
super().__init__(value=hour, upper=23)
class MinuteSpinBox(VerticalSpinBox):
def __init__(self, minute: int) -> None:
super().__init__(value=minute, upper=59)
self.get_adjustment().set_page_increment(10)
class TimePicker(Gtk.Box):
def __init__(self, hour: int, minute: int) -> None:
super().__init__(
orientation=Gtk.Orientation.HORIZONTAL,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
)
self.hour_spin = HourSpinBox(hour)
self.minute_spin = MinuteSpinBox(minute)
self.add(self.hour_spin)
self.add(self.minute_spin)
self.hour_spin.connect("output", self._on_output)
self.minute_spin.connect("output", self._on_output)
def _on_output(self, spin_button: VerticalSpinBox) -> Literal[True]:
val = spin_button.get_value()
spin_button.set_text(f"{int(val):02d}")
# NOTE: indicates that rendering completed
return True
def get_values(self) -> tuple[int, int]:
hour = self.get_hour()
minute = self.get_minute()
return hour, minute
def get_hour(self) -> int:
return int(self.hour_spin.get_adjustment().get_value())
def get_minute(self) -> int:
return int(self.minute_spin.get_adjustment().get_value())
def get_time(self) -> datetime:
"""
NOTE: interpolating seconds/microseconds from the current time
may prevent abrupt visual changes when setting a new TimePicker time
while a clock tick is updating the local remainder label.
However, this can introduce false negatives when running tests due to microsecond drift.
Ideally, tests should be decoupled from this logic.
"""
d = datetime.now()
return datetime.combine(
d,
time(
hour=self.get_hour(),
minute=self.get_minute(),
second=0,
microsecond=0,
),
)
def set_time(self, hour: int, minute: int) -> None:
self.hour_spin.get_adjustment().set_value(hour)
self.minute_spin.get_adjustment().set_value(minute)
class AdjustedTimeFrame(Gtk.Frame):
def __init__(self, emitter: Emitter) -> None:
super().__init__(margin_start=50, margin_end=50)
self.emitter = emitter
self.emitter.connect(
"adjusted_local_time_changed", self._on_adjusted_time_changed
)
self.emitter.connect("remaining_time_changed", self._on_remainder_changed)
self.remainder_clock = RemainderClock(emitter)
self.remainder_heading = BoldLabel(calc.heading_remainder_time)
self.remaining_time = Gtk.Label(label=self.remainder_clock.get_time())
self.adjusted_clock = AdjustedClock(emitter)
self.adjusted_heading = BoldLabel(calc.heading_adjusted_time)
self.adjusted_local_time = Gtk.Label(label=self.adjusted_clock.get_time())
flowbox = Gtk.FlowBox(
row_spacing=20,
column_spacing=50,
min_children_per_line=2,
max_children_per_line=2,
margin=15,
halign=Gtk.Align.CENTER,
selection_mode=Gtk.SelectionMode.NONE,
)
flowbox.add(self.remainder_heading)
flowbox.add(self.adjusted_heading)
flowbox.add(self.remaining_time)
flowbox.add(self.adjusted_local_time)
recalc = Gtk.Button(
label=calc.calculate, halign=Gtk.Align.CENTER, margin_bottom=20
)
recalc.connect("clicked", self._on_recalc_clicked)
vbox = VBox()
vbox.extend([flowbox, recalc])
self.add(vbox)
def _on_remainder_changed(self, emitter: Emitter, time: datetime) -> None:
strtime = str(time.strftime(EXTENDED_TIME_FORMAT))
self.remaining_time.set_text(strtime)
def _on_adjusted_time_changed(self, emitter: Emitter, time: datetime) -> None:
strtime = str(time.strftime(EXTENDED_TIME_FORMAT))
self.adjusted_local_time.set_text(strtime)
def _on_recalc_clicked(self, button: Gtk.Button) -> None:
self.emitter.emit("calculate_button_clicked")
class TimePickerFrame(Gtk.Frame):
def __init__(self, emitter: Emitter, stime: Time) -> None:
super().__init__(margin_start=50, margin_end=50)
self.emitter = emitter
self.emitter.connect("local_time_incremented", self._on_local_clock_increment)
self.emitter.connect("server_time_incremented", self._on_server_clock_increment)
self.emitter.connect(
"calculate_button_clicked", self._on_calculate_button_clicked
)
self.local_clock = LocalClock(self.emitter)
local_clock_time = self.local_clock.get_time()
self.server_time = ServerClock(stime, self.emitter, self.local_clock)
hour, minute = self.server_time.get_values()
self.picker = TimePicker(hour, minute)
self.local_timelabel = Gtk.Label(label=local_clock_time)
self.server_timelabel = Gtk.Label(label=stime.time, halign=Gtk.Align.CENTER)
h0 = BoldLabel(calc.heading_local_time)
h1 = BoldLabel(calc.heading_server_time)
h2 = BoldLabel(calc.heading_target_time)
self.icon_indicator = Icon(DAY_ICON)
self.update_server_icon()
server_time_box = HBox(10)
server_time_box.set_halign(Gtk.Align.CENTER)
server_time_box.extend([self.server_timelabel, self.icon_indicator])
flowbox = Gtk.FlowBox(
row_spacing=10,
column_spacing=50,
min_children_per_line=3,
max_children_per_line=3,
margin=15,
halign=Gtk.Align.CENTER,
selection_mode=Gtk.SelectionMode.NONE,
)
flowbox.add(h0)
flowbox.add(h1)
flowbox.add(h2)
flowbox.add(self.local_timelabel)
flowbox.add(server_time_box)
flowbox.add(self.picker)
self.add(flowbox)
def update_server_icon(self) -> None:
icon = DAY_ICON if self.server_time.is_day() else NIGHT_ICON
self.icon_indicator.set_icon_name(icon)
def _on_server_clock_increment(self, emitter: Emitter, time_now: datetime) -> None:
t = time_now.time().strftime("%H:%M")
self.server_timelabel.set_text(t)
self.update_server_icon()
def _on_local_clock_increment(
self, emitter: Emitter, time_now: datetime, elapsed: timedelta
) -> None:
t = time_now.time().strftime(EXTENDED_TIME_FORMAT)
self.local_timelabel.set_text(t)
def _on_calculate_button_clicked(self, button: Gtk.Button) -> None:
if (self.picker.get_values()) == (self.server_time.get_values()):
total_time = timedelta(seconds=0)
self.emitter.emit("target_time_reset", total_time)
else:
picker_time = self.picker.get_time()
total_time = self.server_time.calc_delta(picker_time)
self.emitter.emit("target_time_changed", total_time)
class ServerTimeCalculator(Gtk.ScrolledWindow):
def __init__(self, stime: Time) -> None:
super().__init__(propagate_natural_width=True)
self.emitter = Emitter()
outerbox = Gtk.Box(
halign=Gtk.Align.CENTER,
valign=Gtk.Align.START,
spacing=20,
orientation=Gtk.Orientation.VERTICAL,
)
time_picker_frame = TimePickerFrame(self.emitter, stime)
adjusted_time_frame = AdjustedTimeFrame(self.emitter)
disclaimer = Gtk.Label(
label=calc.disclaimer,
justify=Gtk.Justification.CENTER,
)
for el in time_picker_frame, adjusted_time_frame, disclaimer:
outerbox.add(el)
self.add(outerbox)
+10 -20
View File
@@ -3,7 +3,6 @@ import sys
from typing import Self from typing import Self
from dzgui.util.strings import dialog_error, dialog_header from dzgui.util.strings import dialog_error, dialog_header
from dzgui.views.mixins.colorscheme import ColorAwareApp
import gi import gi
@@ -11,26 +10,21 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk # noqa E402
class AbortDialog(ColorAwareApp, Gtk.MessageDialog): # type: ignore class EarlyAlertDialog(Gtk.MessageDialog):
def __init__(self, string: str, buttons: Gtk.ButtonsType) -> None: def __init__(self, string: str) -> None:
super().__init__( super().__init__(
title=dialog_header, title=dialog_header,
text=dialog_error, text=dialog_error,
transient_for=None, transient_for=None,
buttons=buttons, buttons=Gtk.ButtonsType.OK,
) )
msg = textwrap.fill(string, 50) msg = textwrap.fill(string, 50)
self.format_secondary_text(msg) self.format_secondary_text(msg)
ma = self.get_message_area()
label = ma.get_children()[1] # type: ignore
label.set_justify(Gtk.Justification.CENTER)
aa = self.get_action_area() aa = self.get_action_area()
aa.set_margin_bottom(20) aa.set_margin_bottom(20)
aa.set_layout(Gtk.ButtonBoxStyle.CENTER) # type: ignore # self.action_area.set_margin_bottom(20)
self.outer = self.get_content_area() self.outer = self.get_content_area()
self.outer.set_margin_start(30) self.outer.set_margin_start(30)
self.outer.set_margin_end(30) self.outer.set_margin_end(30)
@@ -38,30 +32,26 @@ class AbortDialog(ColorAwareApp, Gtk.MessageDialog): # type: ignore
self.set_default_size(250, 100) self.set_default_size(250, 100)
abort = self.get_widget_for_response(Gtk.ResponseType.OK) abort = self.get_widget_for_response(Gtk.ResponseType.OK)
ignore = self.get_widget_for_response(Gtk.ResponseType.CANCEL)
if abort is not None and hasattr(abort, "set_label"): if abort is not None and hasattr(abort, "set_label"):
abort.set_label("Exit") abort.set_label("Exit")
if ignore is not None and hasattr(ignore, "set_label"):
ignore.set_label("Ignore")
self.connect("response", self._on_response) self.connect("response", self._on_response)
self.run() self.run()
self.destroy() self.destroy()
def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None:
match response: match response:
case Gtk.ResponseType.OK | Gtk.ResponseType.DELETE_EVENT: case Gtk.ResponseType.OK:
sys.exit(1) sys.exit(1)
case Gtk.ResponseType.CANCEL: case Gtk.ResponseType.CANCEL:
return return
class EarlyAlertDialog(AbortDialog): class EarlyIgnoreDialog(EarlyAlertDialog):
def __init__(self, string: str) -> None: def __init__(self, string: str) -> None:
super().__init__(string=string, buttons=Gtk.ButtonsType.OK) super().__init__(string=string)
# TODO: reverse order
self.add_button("Ignore", Gtk.ResponseType.CANCEL)
class EarlyIgnoreDialog(AbortDialog): # TODO: if exit, sys.exit(1), else pass
def __init__(self, string: str) -> None:
super().__init__(string=string, buttons=Gtk.ButtonsType.OK_CANCEL)
+45 -143
View File
@@ -1,7 +1,6 @@
from typing import Literal, Self, TYPE_CHECKING from typing import Literal, Self, TYPE_CHECKING
from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING, EXPAND, FILL from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING, EXPAND, FILL
from dzgui.strings import dialogs
from dzgui.util import strings from dzgui.util import strings
from dzgui.views.components.buttons import ClipboardButton from dzgui.views.components.buttons import ClipboardButton
@@ -108,7 +107,7 @@ class WaitDialog(GenericDialog):
self.cur_job = 1 self.cur_job = 1
self.cancel = Gtk.Button(label="Cancel", halign=Gtk.Align.CENTER) self.cancel = Gtk.Button(label="Cancel", halign=Gtk.Align.CENTER)
self.cancel.connect("clicked", self._on_cancel_clicked) self.cancel.connect("clicked", lambda _: self.controller.set_cancel_event())
self.connect("delete-event", lambda widget, event: True) self.connect("delete-event", lambda widget, event: True)
content = self.get_content_area() content = self.get_content_area()
@@ -139,11 +138,6 @@ class WaitDialog(GenericDialog):
self.prog.set_fraction(fraction) self.prog.set_fraction(fraction)
self.cur_job += 1 self.cur_job += 1
def _on_cancel_clicked(self, button: Gtk.Button) -> None:
# TODO: strings
GLib.idle_add(self.update_text, "Caught cancel signal, cleaning up")
self.controller.set_cancel_event()
def show_cancel(self, state: bool) -> None: def show_cancel(self, state: bool) -> None:
self.cancel.set_visible(state) self.cancel.set_visible(state)
@@ -162,7 +156,6 @@ class QuitDialog(GenericDialog):
self.add_button(strings.exit_app, Gtk.ResponseType.OK) self.add_button(strings.exit_app, Gtk.ResponseType.OK)
self.connect("response", self._on_response) self.connect("response", self._on_response)
# TODO: superfluous with the above
self.connect("delete-event", self._on_response) self.connect("delete-event", self._on_response)
def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None:
@@ -170,30 +163,58 @@ class QuitDialog(GenericDialog):
self.controller.save_res_and_quit() self.controller.save_res_and_quit()
class TextBufferDialog(GenericDialog): class ExceptionDialog(GenericDialog):
def __init__( """
self, Error dialog with rich traceback.
controller: "Controller", Usage:
mtype: Gtk.MessageType, try:
heading: str, foo()
secondary: str, except Exception:
text: str, trace = traceback.format_exc()
): dialog = ExceptionDialog(Controller, trace)
dialog.run()
"""
def __init__(self, controller: "Controller", trace: str):
super().__init__( super().__init__(
controller=controller, controller=controller,
text=heading, text=strings.error_heading,
mtype=mtype, mtype=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.NONE, buttons=Gtk.ButtonsType.NONE,
secondary=secondary, secondary=strings.something_wrong,
) )
self.text = text
copy_button = ClipboardButton(controller, self.get_text) self.trace = trace
# NOTE: box expands to end of content area
scrollable = Gtk.ScrolledWindow(
propagate_natural_height=True, max_content_height=500
)
box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL)
# TODO: wrap/truncate long messages
textview = Gtk.TextView(
wrap_mode=Gtk.WrapMode.WORD, editable=False, left_margin=10, right_margin=10
)
textview.set_buffer(Gtk.TextBuffer(text=self.trace))
box.pack_start(textview, EXPAND, FILL, 10)
scrollable.add(box)
content = self.get_content_area()
content.set_spacing(0)
# TODO: padding around top of content area when traceback is long
content.add(scrollable)
copy_button = ClipboardButton(controller, self.get_trace)
self.add_action_widget(copy_button, Gtk.ResponseType.NONE) self.add_action_widget(copy_button, Gtk.ResponseType.NONE)
self.add_button("OK", Gtk.ResponseType.OK) self.add_button("OK", Gtk.ResponseType.OK)
self.show_all()
self.ok = self.get_widget_for_response(Gtk.ResponseType.OK)
if self.ok is not None:
self.ok.grab_focus()
self.connect("response", self._on_response) self.connect("response", self._on_response)
def get_text(self) -> str: def get_trace(self) -> str:
return self.text return self.trace
def _on_response( def _on_response(
self, dialog: Self, response: Gtk.ResponseType self, dialog: Self, response: Gtk.ResponseType
@@ -209,122 +230,3 @@ class TextBufferDialog(GenericDialog):
return None return None
case _: case _:
return None return None
class DebugDialog(TextBufferDialog):
def __init__(self, controller: "Controller", debug: str):
super().__init__(
controller=controller,
mtype=Gtk.MessageType.INFO,
heading=dialogs.debug_heading,
secondary=dialogs.debug_secondary,
text=debug,
)
scrollable = Gtk.ScrolledWindow(
propagate_natural_height=False,
min_content_height=200,
max_content_height=200,
margin_bottom=10,
)
textview = Gtk.TextView(
wrap_mode=Gtk.WrapMode.CHAR, editable=False, left_margin=10, right_margin=10
)
textview.set_buffer(Gtk.TextBuffer(text=debug))
scrollable.add(textview)
content = self.get_content_area()
content.set_spacing(0)
content.add(scrollable)
class ExceptionDialog(TextBufferDialog):
"""
Error dialog with rich traceback.
Usage:
try:
foo()
except Exception:
trace = traceback.format_exc()
dialog = ExceptionDialog(Controller, trace)
dialog.show_all()
dialog.run()
"""
def __init__(self, controller: "Controller", trace: str):
super().__init__(
controller=controller,
heading=strings.error_heading,
secondary=strings.something_wrong,
text=trace,
mtype=Gtk.MessageType.ERROR,
)
# NOTE: box expands to end of content area
scrollable = Gtk.ScrolledWindow(
propagate_natural_height=True, max_content_height=500
)
box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL)
textview = Gtk.TextView(
wrap_mode=Gtk.WrapMode.WORD,
editable=False,
left_margin=10,
right_margin=10,
top_margin=15,
bottom_margin=10,
)
textview.set_buffer(Gtk.TextBuffer(text=trace))
box.pack_start(textview, EXPAND, FILL, 0)
self.error_details = Gtk.ScrolledWindow(
overlay_scrolling=False,
max_content_height=150,
propagate_natural_height=False,
)
details_box = Gtk.Box(
hexpand=True,
vexpand=True,
margin_right=5,
orientation=Gtk.Orientation.VERTICAL,
)
self.details_buffer = Gtk.TextBuffer()
details_textview = Gtk.TextView(
wrap_mode=Gtk.WrapMode.WORD_CHAR,
editable=False,
left_margin=10,
right_margin=10,
top_margin=15,
)
details_textview.set_buffer(self.details_buffer)
details_box.pack_start(details_textview, EXPAND, FILL, 10)
self.error_details.add(details_box)
self.error_notebook = Gtk.Notebook(show_tabs=False, margin_bottom=15)
self.error_notebook.append_page(box, Gtk.Label(label="Error"))
self.error_notebook.append_page(self.error_details, Gtk.Label(label="Details"))
self.error_notebook.connect("switch-page", self._on_page_changed)
scrollable.add(self.error_notebook)
content = self.get_content_area()
content.set_spacing(0)
content.add(scrollable)
self.connect("map", self._on_map)
def _on_map(self, dialog: Self) -> None:
button = self.get_widget_for_response(Gtk.ResponseType.OK)
if button is None:
return
button.grab_focus()
def _on_page_changed(
self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int
) -> None:
if child == self.error_details:
GLib.idle_add(self.error_details.set_propagate_natural_height, True)
else:
GLib.idle_add(self.error_details.set_propagate_natural_height, False)
def set_details_buffer(self, text: str) -> None:
self.details_buffer.set_text(text)
self.error_notebook.set_show_tabs(True)
+25 -48
View File
@@ -1,21 +1,19 @@
import gi
import html
from typing import Self, TYPE_CHECKING from typing import Self, TYPE_CHECKING
from dzgui.const.constants import CALCULATOR from dzgui.const.constants import EXPAND, FILL
from dzgui.const.enum import ContextMenuGroup from dzgui.const.enum import ContextMenuGroup
from dzgui.model.model_factory import ModelFactory from dzgui.model.model_factory import ModelFactory
from dzgui.util import css from dzgui.util import css
from dzgui.util import strings
from dzgui.util.format import format_hyperlinks, format_server_mods from dzgui.util.format import format_hyperlinks, format_server_mods
from dzgui.strings import server_mods, dialogs from dzgui.strings import server_mods
from dzgui.views.components.buttons import IconTextButton
from dzgui.views.components.box import VBox
from dzgui.views.dialogs.calc import Time, ServerTimeCalculator
from dzgui.views.dialogs.generic import GenericDialog from dzgui.views.dialogs.generic import GenericDialog
from dzgui.views.trees.tree_base import TreeView from dzgui.views.trees.tree_base import TreeView
import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, Pango # noqa from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import Details from dzgui.api.servers import Details
@@ -47,19 +45,12 @@ class ServerDialog(GenericDialog):
self.connect("response", self._on_response) self.connect("response", self._on_response)
self.view.connect("key-press-event", self._on_keypress) self.view.connect("key-press-event", self._on_keypress)
self.scrollable_tree = Gtk.ScrolledWindow(vexpand=True) self.scrollable_tree = Gtk.ScrolledWindow()
self.scrollable_tree.add(self.view) self.scrollable_tree.add(self.view)
self.scrollable_tree.set_size_request(700, 500) self.scrollable_tree.set_size_request(700, 400)
self.stack = Gtk.Stack()
self.content_box = VBox(10)
self.content_box.set_valign(Gtk.Align.FILL)
self.content_box.add(self.scrollable_tree)
self.stack.add_named(self.content_box, "METADATA")
self.content = self.get_content_area() self.content = self.get_content_area()
self.content.pack_start(self.stack, expand=True, fill=True, padding=0) self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0)
def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> bool: def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> bool:
# NOTE: ESC normally unfocuses treeview instead of destroying dialog # NOTE: ESC normally unfocuses treeview instead of destroying dialog
@@ -74,7 +65,8 @@ class ServerDialog(GenericDialog):
class ServerDetailsDialog(ServerDialog): class ServerDetailsDialog(ServerDialog):
def __init__(self, controller: "Controller", details: "Details"): def __init__(self, controller: "Controller", details: "Details"):
super().__init__(controller, dialogs.server_details, details.name, menu=None) name = controller.get_server_name()
super().__init__(controller, strings.server_details, name, menu=None)
self.store = Gtk.ListStore(str, str, Pango.Weight) self.store = Gtk.ListStore(str, str, Pango.Weight)
self.view.connect("row-activated", self._on_row_activated) self.view.connect("row-activated", self._on_row_activated)
@@ -91,9 +83,14 @@ class ServerDetailsDialog(ServerDialog):
column.set_sort_column_id(i) column.set_sort_column_id(i)
column.set_expand(True) column.set_expand(True)
scrollable_message = Gtk.ScrolledWindow(vexpand=True) # NOTE: constrain tree area to logical height of metadata
desc = Gtk.Label(label=dialogs.server_message, valign=Gtk.Align.START) # leaving room for server message
css.add_class(desc, "server-subheading") self.scrollable_tree.set_size_request(700, 150)
# TODO: make "Server message" text boldface
scrollable_message = Gtk.ScrolledWindow()
desc = Gtk.Label(label=strings.server_message, valign=Gtk.Align.START)
css.add_class(desc, "details-heading")
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER)
self.description = Gtk.Label(justify=Gtk.Justification.CENTER, wrap=True) self.description = Gtk.Label(justify=Gtk.Justification.CENTER, wrap=True)
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
@@ -101,41 +98,21 @@ class ServerDetailsDialog(ServerDialog):
for el in desc, sep, self.description: for el in desc, sep, self.description:
box.add(el) box.add(el)
scrollable_message.add(box) scrollable_message.add(box)
self.content_box.add(scrollable_message)
self.pack(scrollable_message)
if details.data is None:
return
for row in details.data: for row in details.data:
self.store.append(row + [Pango.Weight.BOLD]) self.store.append(row + [Pango.Weight.BOLD])
self.view.set_model(self.store) self.view.set_model(self.store)
text = details.description text = details.description
text = format_hyperlinks(text) text = format_hyperlinks(text)
self.description.set_markup(html.escape(text)) self.description.set_markup(text)
calc_button = IconTextButton(CALCULATOR, dialogs.toggle_calc)
calc_button.connect("clicked", self._on_calc_pressed)
calc_button.set_halign(Gtk.Align.END)
calc_button.set_valign(Gtk.Align.START)
self.content.pack_start(calc_button, False, False, 0)
self.content.reorder_child(calc_button, 2)
self.scrollable_tree.set_size_request(700, 250)
self.calc = ServerTimeCalculator(
Time(details.gametime, details.day_accel, details.night_accel)
)
self.stack.add_named(self.calc, "CALCULATOR")
self.connect("map", self._on_map)
self.show_all() self.show_all()
def _on_map(self, dialog: Self) -> None:
if widget := self.get_widget_for_response(Gtk.ResponseType.OK):
widget.grab_focus()
def _on_calc_pressed(self, button: Gtk.Button) -> None:
if self.stack.get_visible_child_name() == "METADATA":
self.stack.set_visible_child_name("CALCULATOR")
else:
self.stack.set_visible_child_name("METADATA")
def _on_row_activated( def _on_row_activated(
self, self,
treeview: Gtk.TreeView, treeview: Gtk.TreeView,
-230
View File
@@ -1,230 +0,0 @@
import os
import shutil
import subprocess
from pathlib import Path
from typing import Self
from dzgui.api.shortcuts import Shortcuts
from dzgui.const.constants import APP_NAME
from dzgui.strings import uninstall
from dzgui.util._json import read_json
from dzgui.util.css import load_css
from dzgui.util.format import format_exception
from dzgui.views.components.box import HBox
from dzgui.views.dialogs.wizard import ScrolledWizardPage, CheckboxWithLabel
from dzgui.views.mixins.colorscheme import ColorAwareApp
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Pango # noqa E402
class Assistant(Gtk.Assistant):
def __init__(self, is_deck: bool, paths: dict[str, str]):
super().__init__()
if is_deck:
# NOTE: deemed to be "safe" dimensions that exclude taskbar size
self.set_default_size(1085, 670)
else:
self.set_default_size(1500, 900)
self.set_forward_page_func(self._advance_page)
pyapp = os.getenv("PYAPP")
self.page1 = UninstallPage(paths, pyapp)
self.page2 = CompletionPage(pyapp)
self.append_page(self.page1)
self.set_page_type(self.page1, Gtk.AssistantPageType.INTRO)
self.set_page_title(self.page1, uninstall.pg1_title)
self.set_page_complete(self.page1, True)
self.append_page(self.page2)
self.set_page_title(self.page2, uninstall.pg2_title)
self.set_page_type(self.page2, Gtk.AssistantPageType.SUMMARY)
self.connect("cancel", self.destroy_and_quit)
self.connect("close", self.destroy_and_quit)
self.show_all()
load_css()
def _advance_page(self, index: int) -> int:
cur_page = self.get_nth_page(index)
if cur_page == self.page1:
self.page1.uninstall()
return index + 1
def destroy_and_quit(self, widget: Self) -> None:
self.destroy()
Gtk.main_quit()
class Window(Gtk.Window):
def __init__(self, is_deck: bool, paths: dict[str, str]) -> None:
super().__init__(title=APP_NAME, icon_name=APP_NAME)
self.assistant = Assistant(is_deck, paths)
class CompletionPage(ScrolledWizardPage):
def __init__(self, pyapp: str | None):
super().__init__(heading=uninstall.pg2_heading, description=uninstall.pg2_blurb)
text = (
uninstall.standalone_uninstall
if pyapp is not None
else uninstall.system_uninstall
)
label = Gtk.Label(
label=text,
wrap_mode=Pango.WrapMode.WORD,
margin=10,
max_width_chars=80,
justify=Gtk.Justification.CENTER,
)
frame = Gtk.Frame()
frame.add(label)
self.add_start(frame)
class FileLabel(HBox):
def __init__(self, path: Path) -> None:
super().__init__(spacing=10)
label = Gtk.Label(label=uninstall.path_remove_prefix, halign=Gtk.Align.START)
textview = Gtk.TextView(
editable=False,
halign=Gtk.Align.START,
left_margin=10,
right_margin=10,
)
textview.set_buffer(Gtk.TextBuffer(text=str(path)))
self.extend([label, textview])
class CheckboxWithPath(CheckboxWithLabel):
def __init__(
self, label: str, details: str, path: Path, active: bool = True
) -> None:
super().__init__(text=label, blurb_text=details)
self.conf_path = path
self.set_active(active)
fl = FileLabel(path)
self.indent_below(fl)
def get_conf_path(self) -> Path:
return self.conf_path
class UninstallPage(ScrolledWizardPage):
def __init__(self, paths: dict[str, str], pyapp: str | None):
super().__init__(heading=uninstall.pg1_heading, description=uninstall.pg1_blurb)
self.pyapp = pyapp
config = Path(paths["XDG_CONFIG_HOME"])
state = Path(paths["XDG_STATE_HOME"])
self.share = Path(paths["XDG_DATA_HOME"])
shortcut = self.share.parent.joinpath("applications/dzgui.desktop")
desktop = config.parent.parent.joinpath("Desktop/dzgui.desktop")
self.steam_path = self.parse_steam_path(config, self.share)
self.boxes: list[CheckboxWithPath] = []
self.checks_area = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
halign=Gtk.Align.CENTER,
spacing=20,
margin_top=20,
margin_bottom=20,
)
self.config_box = CheckboxWithPath(
uninstall.config_label, uninstall.config_details, config, active=False
)
self.desktop_box = CheckboxWithPath(
uninstall.desktop_label, uninstall.desktop_details, desktop
)
self.start_menu_box = CheckboxWithPath(
uninstall.start_menu_label, uninstall.start_menu_details, shortcut
)
self.state_box = CheckboxWithPath(
uninstall.state_label, uninstall.state_details, state
)
self.steam_box = CheckboxWithLabel(
uninstall.steam_shortcut, uninstall.steam_details
)
for box in (
self.config_box,
self.state_box,
self.desktop_box,
self.start_menu_box,
):
self.checks_area.add(box)
self.boxes.append(box)
self.checks_area.add(self.steam_box)
self.add_start(self.checks_area)
self.show_all()
def parse_steam_path(self, config: Path, steam: Path) -> Path | None:
try:
file = config.joinpath("config.json")
conf = read_json(file)
steam = conf["default_steam_path"]
self.steam_path = Path(steam)
return Path(steam)
except Exception as e:
print(format_exception(e))
return None
def wipe_conf_file(self, box: CheckboxWithPath) -> None:
if box.get_active():
path = box.get_conf_path()
try:
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
print(f"Deleted '{path}'")
except Exception as e:
print(format_exception(e))
def wipe_steam_shortcut(self) -> None:
if self.steam_path is None:
return
try:
shortcuts = Shortcuts(self.steam_path)
shortcuts.delete_shortcut(self.share)
shutil.rmtree(self.share)
except Exception as e:
print(format_exception(e))
def wipe_pyapp(self) -> None:
if self.pyapp is None:
return
subprocess.run([self.pyapp, "self", "remove"])
def uninstall(self) -> None:
for box in self.boxes:
self.wipe_conf_file(box)
if self.steam_box.get_active():
self.wipe_steam_shortcut()
self.wipe_pyapp()
pass
class UninstallWizard(ColorAwareApp, Gtk.Application): # type: ignore
def __init__(self, is_deck: bool, paths: dict[str, str]) -> None:
super().__init__()
config = Path(paths["XDG_CONFIG_HOME"])
if not config.is_dir() or not config.joinpath("config.json").is_file():
print(uninstall.not_installed)
return
GLib.set_prgname(APP_NAME)
self.win = Window(is_deck, paths)
Gtk.main()
+65 -77
View File
@@ -1,3 +1,4 @@
import os
import textwrap import textwrap
from enum import Enum from enum import Enum
@@ -16,7 +17,9 @@ from dzgui.const.constants import (
) )
from dzgui.const.boilerplate import config_boilerplate from dzgui.const.boilerplate import config_boilerplate
from dzgui.const.endpoints import STEAM_API_SETUP from dzgui.const.endpoints import STEAM_API_SETUP
from dzgui.const.enum import Preferences
from dzgui.config import freedesktop from dzgui.config import freedesktop
from dzgui.config.query import lookup
from dzgui.init.migrate import migrate_legacy_conf from dzgui.init.migrate import migrate_legacy_conf
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import wizard from dzgui.strings import wizard
@@ -26,7 +29,6 @@ from dzgui.util.css import add_class, load_css
from dzgui.views.components.buttons import WebButton from dzgui.views.components.buttons import WebButton
from dzgui.views.components.entry import APIEntry from dzgui.views.components.entry import APIEntry
from dzgui.views.components.misc import ClientCombo from dzgui.views.components.misc import ClientCombo
from dzgui.views.mixins.colorscheme import ColorAwareApp
import gi import gi
@@ -50,7 +52,7 @@ class PageNum(Enum):
class OptionalPageMixin: class OptionalPageMixin:
"""Marks optional pages as advanceable""" """Marks optional pages as advanceable"""
def _on_map(self, page: "EnumeratedWizardPage") -> None: def _on_map(self, page: "ScrolledWizardPage") -> None:
EMITTER.emit("step_complete") EMITTER.emit("step_complete")
@@ -70,9 +72,10 @@ class Progress(Gtk.ProgressBar):
class ScrolledWizardPage(Gtk.ScrolledWindow): class ScrolledWizardPage(Gtk.ScrolledWindow):
def __init__(self, heading: str, description: str): def __init__(self, enum: PageNum, heading: str, description: str):
super().__init__(overlay_scrolling=False) super().__init__(overlay_scrolling=False)
self.enum = enum
self.page_type: Gtk.AssistantPageType self.page_type: Gtk.AssistantPageType
self.title = heading self.title = heading
self.heading = Heading(heading) self.heading = Heading(heading)
@@ -85,6 +88,7 @@ class ScrolledWizardPage(Gtk.ScrolledWindow):
height=600, height=600,
preserve_aspect_ratio=True, preserve_aspect_ratio=True,
) )
image = Gtk.Image.new_from_pixbuf(pixbuf)
self.box = Gtk.Box( self.box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, orientation=Gtk.Orientation.VERTICAL,
@@ -93,13 +97,22 @@ class ScrolledWizardPage(Gtk.ScrolledWindow):
margin_top=50, margin_top=50,
spacing=20, spacing=20,
) )
image = Gtk.Image.new_from_pixbuf(pixbuf)
self.box.pack_start(image, expand=False, fill=True, padding=0)
self.add(self.box) 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.heading, expand=False, fill=True, padding=0)
self.box.pack_start(self.description, 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_enum(self) -> PageNum:
return self.enum
def get_progress_bar(self) -> Progress:
return self.prog
def get_page_type(self) -> Gtk.AssistantPageType: def get_page_type(self) -> Gtk.AssistantPageType:
return self.page_type return self.page_type
@@ -118,20 +131,8 @@ class ScrolledWizardPage(Gtk.ScrolledWindow):
def get_box(self) -> Gtk.Box: def get_box(self) -> Gtk.Box:
return self.box return self.box
def _on_map(self, page: "ScrolledWizardPage") -> None:
class EnumeratedWizardPage(ScrolledWizardPage): pass
def __init__(self, enum: PageNum, heading: str, description: str) -> None:
super().__init__(heading=heading, description=description)
self.enum = enum
self.prog = Progress()
self.box.pack_end(self.prog, expand=False, fill=False, padding=0)
def get_enum(self) -> PageNum:
return self.enum
def get_progress_bar(self) -> Progress:
return self.prog
class NotificationFrame(Gtk.Frame): class NotificationFrame(Gtk.Frame):
@@ -161,7 +162,7 @@ class NotificationFrame(Gtk.Frame):
self.label.set_markup(wrapped) self.label.set_markup(wrapped)
class APIValidationPage(EnumeratedWizardPage): class APIValidationPage(ScrolledWizardPage):
def __init__( def __init__(
self, enum: PageNum, heading: str, description: str, link: str, func: Callable self, enum: PageNum, heading: str, description: str, link: str, func: Callable
) -> None: ) -> None:
@@ -238,7 +239,7 @@ class SteamValidationPage(APIValidationPage):
self.thread_man.set_cleanup_func(cleanup) self.thread_man.set_cleanup_func(cleanup)
class IntroductionPage(EnumeratedWizardPage): class IntroductionPage(ScrolledWizardPage):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
enum=PageNum.INTRO, enum=PageNum.INTRO,
@@ -293,7 +294,7 @@ class RadioFrame(Gtk.Frame):
return self.button return self.button
class ConfigMigrationPage(EnumeratedWizardPage): class ConfigMigrationPage(ScrolledWizardPage):
def __init__(self, config: Path) -> None: def __init__(self, config: Path) -> None:
super().__init__( super().__init__(
enum=PageNum.HAS_CONFIG, enum=PageNum.HAS_CONFIG,
@@ -302,7 +303,6 @@ class ConfigMigrationPage(EnumeratedWizardPage):
) )
self.migrated = False self.migrated = False
self.migrated_conf: dict[str, Any] = {}
self.config = config self.config = config
self.page_type = Gtk.AssistantPageType.INTRO self.page_type = Gtk.AssistantPageType.INTRO
@@ -342,7 +342,8 @@ class ConfigMigrationPage(EnumeratedWizardPage):
def _on_import_clicked(self, button: Gtk.Button) -> None: def _on_import_clicked(self, button: Gtk.Button) -> None:
self.grid.set_sensitive(False) self.grid.set_sensitive(False)
try: try:
self.migrated_conf = migrate_legacy_conf(self.config) # TODO: this could be deferred to the final page (prevents accidental destruction of dialog via ESC)
migrate_legacy_conf(self.config)
self.migrated = True self.migrated = True
self.success_box.set_visible(True) self.success_box.set_visible(True)
except Exception: except Exception:
@@ -353,7 +354,7 @@ class ConfigMigrationPage(EnumeratedWizardPage):
EMITTER.emit("config", True) EMITTER.emit("config", True)
class PreferencesPage(EnumeratedWizardPage): class PreferencesPage(ScrolledWizardPage):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
enum=PageNum.USER_PREFS, enum=PageNum.USER_PREFS,
@@ -413,7 +414,7 @@ class PreferencesPage(EnumeratedWizardPage):
EMITTER.emit("step_complete") EMITTER.emit("step_complete")
class CompletionPage(EnumeratedWizardPage): class CompletionPage(ScrolledWizardPage):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
enum=PageNum.FINAL, enum=PageNum.FINAL,
@@ -434,18 +435,20 @@ class Assistant(Gtk.Assistant):
else: else:
self.set_default_size(1500, 900) self.set_default_size(1500, 900)
self.is_binary = False if os.getenv("PYAPP") is None else True
self.config_path = XDG.config self.config_path = XDG.config
self.config_values: dict[str, Any] = config_boilerplate self.config_values: dict[str, Any] = config_boilerplate
self.setup_complete = False self.setup_complete = False
self.page_intro = IntroductionPage() self.page1 = IntroductionPage()
self.page_migration = ConfigMigrationPage(XDG.config) self.page2 = ConfigMigrationPage(XDG.config)
self.page_paths = SteamPathPage() self.page3 = SteamPathPage()
self.page_api = SteamValidationPage() self.page4 = SteamValidationPage()
self.page_prefs = PreferencesPage() self.page5 = PreferencesPage()
self.page_shortcuts = ShortcutCreationPage(XDG.shortcut) self.page6 = ShortcutCreationPage(XDG.shortcut)
self.page_completion = CompletionPage() self.page7 = CompletionPage()
self.set_forward_page_func(self._advance_page) self.set_forward_page_func(self._advance_page)
@@ -453,18 +456,16 @@ class Assistant(Gtk.Assistant):
EMITTER.connect("step_pending", self._mark_page_incomplete) EMITTER.connect("step_pending", self._mark_page_incomplete)
EMITTER.connect("config", self._set_config_state) EMITTER.connect("config", self._set_config_state)
self.pages: dict[EnumeratedWizardPage, int] = {}
legacy_path = Path.home().joinpath(LEGACY_CONFIG_PATH) legacy_path = Path.home().joinpath(LEGACY_CONFIG_PATH)
self.has_legacy_config = legacy_path.is_file() self.has_legacy_config = legacy_path.is_file()
for page in ( for page in (
self.page_intro, self.page1,
self.page_migration, self.page2,
self.page_paths, self.page3,
self.page_api, self.page4,
self.page_prefs, self.page5,
self.page_shortcuts, self.page6,
self.page_completion, self.page7,
): ):
# NOTE: skip config migration page if no legacy config file # NOTE: skip config migration page if no legacy config file
if ( if (
@@ -472,8 +473,10 @@ class Assistant(Gtk.Assistant):
and self.has_legacy_config is False and self.has_legacy_config is False
): ):
continue continue
ind = self._add_page(page, page.get_page_type()) # NOTE: disabled for now on system-provided packages
self.pages[page] = ind if isinstance(page, ShortcutCreationPage) and not self.is_binary:
continue
self._add_page(page, page.get_page_type())
self.connect("prepare", self._on_page_prepare) self.connect("prepare", self._on_page_prepare)
self.connect("cancel", self.destroy_and_quit) self.connect("cancel", self.destroy_and_quit)
@@ -481,11 +484,8 @@ class Assistant(Gtk.Assistant):
self.show_all() self.show_all()
load_css() load_css()
def write_config(self, config: dict[str, Any]) -> None: def write_config(self) -> None:
write_json(config, self.config_path) write_json(self.config_values, self.config_path)
def get_final_page(self) -> int:
return self.pages[self.page_shortcuts]
def _advance_page(self, index: int) -> int: def _advance_page(self, index: int) -> int:
page = self.get_nth_page(index) page = self.get_nth_page(index)
@@ -494,23 +494,24 @@ class Assistant(Gtk.Assistant):
pass pass
case ConfigMigrationPage(): case ConfigMigrationPage():
if page.is_migrated(): if page.is_migrated():
sp = page.migrated_conf["default_steam_path"] steam_path = lookup(self.config_path, Preferences.DEFAULT)
self.page_shortcuts.set_steam_path(sp) self.page6.set_steam_path(steam_path)
self.config_values = self.page_migration.migrated_conf offset = 1 if not self.is_binary else 2
return self.get_final_page() self.setup_complete = True
return self.get_n_pages() - offset
case SteamPathPage(): case SteamPathPage():
self.config_values["default_steam_path"] = page.get_path_from_radio() self.config_values["default_steam_path"] = page.get_path_from_radio()
case SteamValidationPage(): case SteamValidationPage():
self.config_values["steam_api"] = page.get_api_key() self.config_values["steam_api"] = page.get_api_key()
case PreferencesPage(): case PreferencesPage():
# NOTE: collects config values before advancing to last page # NOTE: collects config values before advancing to last page
name, use_miles, client = self.page_prefs.get_prefs() name, use_miles, client = self.page5.get_prefs()
self.config_values["name"] = name self.config_values["name"] = name
self.config_values["use_miles"] = use_miles self.config_values["use_miles"] = use_miles
self.config_values["client"] = client self.config_values["client"] = client
self.page_shortcuts.set_steam_path( self.write_config()
self.config_values["default_steam_path"] self.page6.set_steam_path(self.config_values["default_steam_path"])
) self.setup_complete = True
case ShortcutCreationPage(): case ShortcutCreationPage():
page.create_shortcuts() page.create_shortcuts()
case _: case _:
@@ -538,19 +539,16 @@ class Assistant(Gtk.Assistant):
return return
self.set_page_complete(page, True) self.set_page_complete(page, True)
def _add_page( def _add_page(self, page: ScrolledWizardPage, ptype: Gtk.AssistantPageType) -> None:
self, page: EnumeratedWizardPage, ptype: Gtk.AssistantPageType self.append_page(page)
) -> int:
ind = self.append_page(page)
self.set_page_type(page, ptype) self.set_page_type(page, ptype)
self.set_page_title(page, page.get_title()) self.set_page_title(page, page.get_title())
self.set_page_complete(page, True) self.set_page_complete(page, True)
return ind
def _set_config_state(self, emitter: "Emitter", state: bool) -> None: def _set_config_state(self, emitter: "Emitter", state: bool) -> None:
self.config = state self.config = state
def _on_page_prepare(self: Self, wizard: Self, page: EnumeratedWizardPage) -> None: def _on_page_prepare(self: Self, wizard: Self, page: ScrolledWizardPage) -> None:
page_num = self.get_current_page() + 1 page_num = self.get_current_page() + 1
total = self.get_n_pages() total = self.get_n_pages()
fraction = page_num / total fraction = page_num / total
@@ -562,30 +560,20 @@ class Assistant(Gtk.Assistant):
if not isinstance(page, IntroductionPage): if not isinstance(page, IntroductionPage):
EMITTER.emit("step_pending") EMITTER.emit("step_pending")
if page == self.page_completion:
self.write_config(self.config_values)
self.setup_complete = True
class CheckboxWithLabel(Gtk.Box): class CheckboxWithLabel(Gtk.Box):
def __init__(self, text: str, blurb_text: str) -> None: def __init__(self, text: str, blurb_text: str) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=5) super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=5)
self.indent = 20
self.button = Gtk.CheckButton(label=text) self.button = Gtk.CheckButton(label=text)
self.button.set_active(True) self.button.set_active(True)
label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=self.indent) label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=20)
wrapped = textwrap.fill(blurb_text, width=100) wrapped = textwrap.fill(blurb_text, width=100)
label.set_markup(f"- {wrapped}") label.set_markup(f"- {wrapped}")
for el in self.button, label: for el in self.button, label:
self.add(el) self.add(el)
def indent_below(self, widget: Gtk.Widget) -> None:
widget.set_margin_start(self.indent)
self.add(widget)
def get_checkbox(self) -> Gtk.CheckButton: def get_checkbox(self) -> Gtk.CheckButton:
return self.button return self.button
@@ -596,7 +584,7 @@ class CheckboxWithLabel(Gtk.Box):
self.button.set_active(state) self.button.set_active(state)
class ShortcutCreationPage(OptionalPageMixin, EnumeratedWizardPage): # type: ignore class ShortcutCreationPage(OptionalPageMixin, ScrolledWizardPage): # type: ignore
def __init__(self, shortcut: Path) -> None: def __init__(self, shortcut: Path) -> None:
super().__init__( super().__init__(
enum=PageNum.SHORTCUTS, enum=PageNum.SHORTCUTS,
@@ -654,7 +642,7 @@ class ShortcutCreationPage(OptionalPageMixin, EnumeratedWizardPage): # type: ig
freedesktop.write_desktop_shortcut(desktop_file) freedesktop.write_desktop_shortcut(desktop_file)
class SteamPathPage(EnumeratedWizardPage): class SteamPathPage(ScrolledWizardPage):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
enum=PageNum.USER_PREFS, enum=PageNum.USER_PREFS,
@@ -709,7 +697,7 @@ class SteamPathPage(EnumeratedWizardPage):
self.err_box.set_visible(False) self.err_box.set_visible(False)
class SetupWizard(ColorAwareApp, Gtk.Application): # type: ignore class SetupWizard(Gtk.Application):
def __init__(self, is_deck: bool, XDG: "Xdg") -> None: def __init__(self, is_deck: bool, XDG: "Xdg") -> None:
super().__init__() super().__init__()
GLib.set_prgname(APP_NAME) GLib.set_prgname(APP_NAME)
-37
View File
@@ -1,37 +0,0 @@
from typing import Any
from dzgui.const.constants import (
GIO_SETTINGS_KEY,
GIO_SETTINGS_INTERFACE,
GIO_SETTINGS_PROP,
GIO_SETTINGS_VAL,
)
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gio # noqa
class ColorAwareApp:
"""
Polls the local theme's color scheme settings.
Intended to be consumed by Gtk.Applications (or ephemeral widgets like EarlyAlertDialogs)
on initialization, prior to being realized. Applying scheme changes too late in the chain
may cause a blinking effect.
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.interface_settings = Gio.Settings.new(GIO_SETTINGS_INTERFACE)
self.interface_settings.connect(
"changed::color-scheme", self._on_color_scheme_changed
)
self.default_settings = Gtk.Settings.get_default()
self._on_color_scheme_changed(self.interface_settings, GIO_SETTINGS_PROP)
def _on_color_scheme_changed(self, settings: Gio.Settings, prop: str) -> None:
state = self.interface_settings.get_string(prop) == GIO_SETTINGS_VAL
if self.default_settings is None:
return
self.default_settings.set_property(GIO_SETTINGS_KEY, state)
+9
View File
@@ -104,11 +104,20 @@ class ContextMixin(TreeView):
return False return False
menu = self.context_menu menu = self.context_menu
children = menu.get_children() children = menu.get_children()
sel = menu.get_selected_item()
for i, child in enumerate(children):
if sel is child:
ind = i
break
match event.keyval: match event.keyval:
case Gdk.KEY_j: case Gdk.KEY_j:
if ind + 1 > len(children) - 1:
return False
menu.emit("move-current", Gtk.MenuDirectionType.NEXT) menu.emit("move-current", Gtk.MenuDirectionType.NEXT)
case Gdk.KEY_k: case Gdk.KEY_k:
if ind - 1 < 0:
return False
menu.emit("move-current", Gtk.MenuDirectionType.PREV) menu.emit("move-current", Gtk.MenuDirectionType.PREV)
case Gdk.KEY_g: case Gdk.KEY_g:
menu.select_item(children[0]) menu.select_item(children[0])
+1 -5
View File
@@ -37,11 +37,7 @@ class CursorMixin:
end = len(model) - 1 end = len(model) - 1
else: else:
return False return False
cur_row = self.get_focused_row_index() # type: ignore
try:
cur_row = self.get_focused_row_index() # type: ignore
except Exception:
return False
if position == CursorPosition.DOWN: if position == CursorPosition.DOWN:
if cur_row == end: if cur_row == end:
+6 -43
View File
@@ -1,12 +1,10 @@
import logging import logging
import re import re
import textwrap
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from importlib import resources from importlib import resources
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, CHANGELOG_PATH from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, CHANGELOG_PATH
from dzgui.strings import changelog as clog
from dzgui.util.strings import missing_changelog from dzgui.util.strings import missing_changelog
from dzgui.util.format import format_pango from dzgui.util.format import format_pango
from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin
@@ -33,39 +31,18 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
logger.critical(e) logger.critical(e)
changelog = missing_changelog changelog = missing_changelog
# TODO: should long text be wrapped?
self.controller = controller self.controller = controller
self.box = Gtk.Box( self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5, margin_top=10)
orientation=Gtk.Orientation.VERTICAL, spacing=5, margin_top=10
)
self.add(self.box) self.add(self.box)
expand_all = Gtk.Button(
label=clog.expand_all, halign=Gtk.Align.START, margin_start=20
)
expand_all.connect("clicked", self._on_expand_all_clicked)
self.box.add(expand_all)
self.expanded = False
self.expanders: list[Gtk.Expander] = []
self.connect("key-press-event", self._on_keypress) self.connect("key-press-event", self._on_keypress)
self.connect("key-press-event", self._on_esc_keypress) self.connect("key-press-event", self._on_esc_keypress)
changes = self._parse(changelog) changes = self._parse(changelog)
self._generate_nodes(changes) self._generate_nodes(changes)
self.show_all() self.show_all()
def _on_expand_all_clicked(self, button: Gtk.Button) -> None:
self.expanded = not self.expanded
for expander in self.expanders:
# NOTE: simply setting set_expanded() does not trigger activate() signal,
# so margins are not applied
if expander.get_expanded() == self.expanded:
continue
expander.activate()
label = clog.collapse_all if self.expanded else clog.expand_all
button.set_label(label)
def grab_content_area(self) -> None: def grab_content_area(self) -> None:
self.grab_focus() self.grab_focus()
@@ -78,6 +55,7 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
return version, date return version, date
raise ValueError("Changelog parse error") raise ValueError("Changelog parse error")
def _generate_nodes(self, releases: list[tuple[str, list[str]]]) -> None: def _generate_nodes(self, releases: list[tuple[str, list[str]]]) -> None:
for header, changes in releases: for header, changes in releases:
for ind in (0, -1): for ind in (0, -1):
@@ -86,10 +64,8 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
text = "\n".join(changes) text = "\n".join(changes)
formatted = format_pango(text) formatted = format_pango(text)
container = Gtk.Box(valign=Gtk.Align.START, halign=Gtk.Align.START) label = Gtk.Label(valign=Gtk.Align.START, margin=15, halign=Gtk.Align.START)
label = Gtk.Label()
label.set_markup(formatted) label.set_markup(formatted)
container.add(label)
version, date = self._parse_version(header) version, date = self._parse_version(header)
button_text = f"{version} ({date})" button_text = f"{version} ({date})"
@@ -97,24 +73,11 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
expander = Gtk.Expander() expander = Gtk.Expander()
expander.set_label_widget(button) expander.set_label_widget(button)
expander.add(container) expander.add(label)
expander.connect("activate", self._on_expand, container)
self.box.add(expander) self.box.add(expander)
self.expanders.append(expander)
def _on_expand(self, expander: Gtk.Box, container: Gtk.Box) -> None:
"""
Wayland/some themes do not expect widgets to have a size allocation until expanded.
Trying to set margins a priori may cause negative allocation,
as the margins are subtracted from zero. In effect, the widget is not realized
until it is expanded for the first time.
"""
container.set_margin_start(15)
container.set_margin_top(15)
container.set_margin_bottom(15)
def _parse(self, changelog: str) -> list[tuple[str, list[str]]]: def _parse(self, changelog: str) -> list[tuple[str, list[str]]]:
release = "" release = ""
releases: list[tuple[str, list[str]]] = [] releases: list[tuple[str, list[str]]] = []
release_notes: list[str] = [] release_notes: list[str] = []
@@ -128,5 +91,5 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
release = "" release = ""
release = line release = line
continue continue
release_notes.append(textwrap.fill(line.rstrip(), width=120)) release_notes.append(line.rstrip())
return releases return releases
+1 -1
View File
@@ -103,4 +103,4 @@ class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
return grid return grid
def grab_content_area(self) -> None: def grab_content_area(self) -> None:
self.grab_focus() self.box.grab_focus()
+21 -12
View File
@@ -9,6 +9,7 @@ from dzgui.const.constants import (
APPNAME_DAYZ, APPNAME_DAYZ,
APPNAME_DAYZ_EXP_HUMAN, APPNAME_DAYZ_EXP_HUMAN,
EDIT_DELETE, EDIT_DELETE,
ERROR,
FOLDER, FOLDER,
WARNING, WARNING,
) )
@@ -20,7 +21,6 @@ from dzgui.views.components.buttons import Icon, IconTextButton
from dzgui.views.components.eventbox import InfoEventBox from dzgui.views.components.eventbox import InfoEventBox
from dzgui.views.components.frame import HeadingFrame from dzgui.views.components.frame import HeadingFrame
from dzgui.views.components.scrollable import NoOverlayScrolledWindow from dzgui.views.components.scrollable import NoOverlayScrolledWindow
from dzgui.views.components.misc import ErrorPopover
from dzgui.views.trees.tree_mods import OfflineModTreeView from dzgui.views.trees.tree_mods import OfflineModTreeView
@@ -57,11 +57,19 @@ class PageHeading(Gtk.Box):
css.add_class(self, "page-heading") css.add_class(self, "page-heading")
class OfflineErrorPopover(ErrorPopover): class ErrorPopover(Gtk.Popover):
def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None: def __init__(self) -> None:
super().__init__(relative_to=relative_to, position=position) super().__init__(position=Gtk.PositionType.RIGHT)
def set_error_label(self, error: FolderError, msg: str) -> None: self.hbox = HBox()
self.label = Gtk.Label(label="", margin_start=10, margin_end=10)
error_icon = Icon(ERROR, margin_start=10)
self.hbox.extend([error_icon, self.label])
self.add(self.hbox)
self.show_all()
self.popdown()
def set_label(self, error: FolderError, msg: str) -> None:
match error: match error:
case FolderError.NO_VALID_MODS: case FolderError.NO_VALID_MODS:
prefix = offline.no_mods prefix = offline.no_mods
@@ -107,10 +115,13 @@ class FolderHBox(HBox):
[self.eb, self.button, self.spinner, self.scrolled_label, self.unset_button] [self.eb, self.button, self.spinner, self.scrolled_label, self.unset_button]
) )
self.pop = OfflineErrorPopover(self.button, Gtk.PositionType.RIGHT) self.pop = ErrorPopover()
self.pop.set_relative_to(self.button)
self.pop.connect("unmap", lambda _: self.grab_focus()) self.pop.connect("unmap", lambda _: self.grab_focus())
self.sidepop = OfflineErrorPopover(self.scrolled_label, Gtk.PositionType.BOTTOM) self.sidepop = ErrorPopover()
self.sidepop.set_position(Gtk.PositionType.BOTTOM)
self.sidepop.set_relative_to(self.scrolled_label)
self.sidepop.connect("unmap", lambda _: self.grab_focus()) self.sidepop.connect("unmap", lambda _: self.grab_focus())
self.connect("map", self._on_map) self.connect("map", self._on_map)
@@ -159,13 +170,13 @@ class FolderHBox(HBox):
def present_error(self, error: FolderError, msg: str) -> None: def present_error(self, error: FolderError, msg: str) -> None:
if error == FolderError.FOLDER_CHANGED: if error == FolderError.FOLDER_CHANGED:
self.sidepop.set_error_label(error, msg) self.sidepop.set_label(error, msg)
self.sidepop.popup() self.sidepop.popup()
return return
self.folder = "" self.folder = ""
self.label.set_text("") self.label.set_text("")
self.unset_button.hide() self.unset_button.hide()
self.pop.set_error_label(error, msg) self.pop.set_label(error, msg)
self.pop.popup() self.pop.popup()
@@ -456,6 +467,7 @@ class OfflineLoader(Gtk.Box):
] ]
) )
# TODO: share ConnectBox class with preconnect dialog?
self.button_box = HBox(spacing=5) self.button_box = HBox(spacing=5)
self.button_box.set_halign(Gtk.Align.END) self.button_box.set_halign(Gtk.Align.END)
self.button_box.set_margin_top(15) self.button_box.set_margin_top(15)
@@ -509,6 +521,3 @@ class OfflineLoader(Gtk.Box):
self.mission_frame.present_folder_changed(mission) self.mission_frame.present_folder_changed(mission)
return return
self.offline_man.launch(appid, mission, local_mods, custom_folder, custom_mods) self.offline_man.launch(appid, mission, local_mods, custom_folder, custom_mods)
def grab_content_area(self) -> None:
self.local_frame.tree.grab_focus()
+209 -252
View File
@@ -1,5 +1,5 @@
from pathlib import Path from pathlib import Path
from typing import Callable, TYPE_CHECKING from typing import TYPE_CHECKING
from dzgui.api import pefile as PeFile from dzgui.api import pefile as PeFile
@@ -17,17 +17,14 @@ from dzgui.const.constants import (
) )
from dzgui.const.endpoints import STEAM_API_SETUP from dzgui.const.endpoints import STEAM_API_SETUP
from dzgui.const.enum import Preferences, ServerTab from dzgui.const.enum import Preferences, ServerTab
from dzgui.strings import options from dzgui.strings import errors, options
from dzgui.util import strings, css, open_links from dzgui.util import strings, css, open_links
from dzgui.views.components.box import ShortHBox, VBox
from dzgui.views.components.eventbox import InfoEventBox
from dzgui.views.components.labels import LeftLabel from dzgui.views.components.labels import LeftLabel
from dzgui.views.components.buttons import SpinnerButton, WebButton from dzgui.views.components.buttons import WebButton
from dzgui.views.components.frame import HeadingFrame from dzgui.views.components.frame import HeadingFrame
from dzgui.views.components.misc import ClientCombo, ErrorPopover from dzgui.views.components.misc import ClientCombo
from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.generic import ExceptionDialog
from dzgui.views.mixins.scrollable_mixin import ScrollableMixin
import gi import gi
@@ -40,246 +37,54 @@ if TYPE_CHECKING:
from dzgui.controllers.emitter import Emitter from dzgui.controllers.emitter import Emitter
class SubmitField(Gtk.Box): class ShortHBox(Gtk.Box):
def __init__( def __init__(self, widget: Gtk.Widget) -> None:
self,
controller: "Controller",
placeholder: str,
context: Preferences,
slow: bool = False,
private: bool = True,
) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self.callback: Callable
self.controller = controller
self.context = context
self.old_text: str
self.placeholder = placeholder
self.entry = Gtk.Entry(placeholder_text=placeholder, hexpand=True)
# TODO: audit for all applicable callbacks
if private:
self.entry.set_icon_from_icon_name(
Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL
)
self.entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True)
self.entry.connect("icon-release", self._on_icon_release)
self.entry.set_visibility(False)
self.button: SpinnerButton | Gtk.Button
if slow:
self.button = SpinnerButton(label=options.save_button)
else:
self.button = Gtk.Button(label=options.save_button)
self.button.connect("clicked", self._on_save_clicked)
self.entry.connect("insert-text", self._on_text_typed)
self.entry.connect("activate", self._on_field_activated)
self.entry.get_property("buffer").connect("deleted-text", self._on_text_deleted)
self.add(self.entry)
self.add(self.button)
def set_text(self, text: str) -> None:
self.old_text = text
self.entry.set_text(text)
if len(text) == 0:
self.button.set_sensitive(False)
def _is_valid_text(self, text: str) -> bool:
if text.isspace():
return False
if len(text) == 0:
return False
if text == self.old_text:
return False
return True
def _on_text_deleted(
self,
buffer: Gtk.EntryBuffer,
position: int,
chars: int,
) -> None:
text = buffer.get_text()
state = self._is_valid_text(text)
self.button.set_sensitive(state)
def _on_text_typed(
self,
entry: Gtk.Entry,
text: str,
length: int,
pos: int,
) -> None:
buffer = entry.get_property("buffer")
text = buffer.get_text() + text
state = self._is_valid_text(text)
self.button.set_sensitive(state)
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:
text = entry.get_text()
if not self._is_valid_text(text):
return
self.save_option()
def set_callback(self, callback: Callable) -> None:
self.callback = callback
def save_option(self) -> None:
self.callback()
self.button.set_sensitive(False)
def _on_save_clicked(self, button: Gtk.Button) -> None:
self.save_option()
class SteamSubmitField(SubmitField):
def __init__(self, controller: "Controller") -> None:
super().__init__(
controller,
options.steam_placeholder,
Preferences.STEAM,
slow=True,
private=True,
)
self.pop = ErrorPopover(
position=Gtk.PositionType.BOTTOM, relative_to=self.entry
)
self.pop.set_label(options.api_failed)
self.pop.show_all()
self.pop.popdown()
emitter = controller.get_emitter()
emitter.connect("api_change_failed", self._on_api_failure)
emitter.connect("api_change_successful", self._on_api_success)
self.set_callback(self.save_setting)
def save_setting(self) -> None:
self.entry.set_sensitive(False)
text = "".join(self.entry.get_text().split())
self.controller.update_steam_api_key(text)
def _on_api_success(self, emitter: "Emitter") -> None:
self.old_text = self.entry.get_text()
if isinstance(self.button, SpinnerButton):
self.button.stop_spinner()
self.entry.set_sensitive(True)
def _on_api_failure(self, emitter: "Emitter") -> None:
self.pop.popup()
if isinstance(self.button, SpinnerButton):
self.button.stop_spinner()
self.entry.set_sensitive(True)
def block_text_entry(self) -> None:
self.entry.set_position(-1)
self.entry.set_can_focus(False)
def unblock_text_entry(self) -> None:
self.entry.set_can_focus(True)
class NameSubmitField(SubmitField):
def __init__(self, controller: "Controller") -> None:
super().__init__(
controller,
options.name_placeholder,
Preferences.NAME,
private=False,
)
self.entry.set_width_chars(30) # type: ignore
self.set_callback(self.save_player_name)
def save_player_name(self) -> None:
value = self.entry.get_text().strip()
self.old_text = value
self.controller.update_config(self.context, value)
class ToggleField(Gtk.Box):
def __init__(
self,
controller: "Controller",
first_option: str,
second_option: str,
context: Preferences,
) -> None:
super().__init__(spacing=5, halign=Gtk.Align.START) super().__init__(spacing=5, halign=Gtk.Align.START)
self.controller = controller self.pack_start(widget, NO_EXPAND, NO_FILL, NO_PADDING)
self.context = context
self.radio1 = Gtk.RadioButton.new_with_label(None, first_option)
self.radio2 = Gtk.RadioButton.new_from_widget(self.radio1)
self.radio2.set_label(second_option)
self.pack_start(self.radio1, NO_EXPAND, NO_FILL, NO_PADDING)
self.pack_start(self.radio2, NO_EXPAND, NO_FILL, NO_PADDING)
def set_suboption_active(self, state: bool) -> None:
# NOTE: defer connection of signal until after state is set
self.radio2.set_active(state)
self.radio1.connect("toggled", self._on_radio_toggled, self.context)
def _on_radio_toggled(self, button: Gtk.RadioButton, context: Preferences) -> None:
self.controller.toggle_config(context)
def set_sensitive(self, state: bool) -> None:
for el in self.radio1, self.radio2:
el.set_sensitive(state)
class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore class Options(Gtk.Box):
def __init__(self, controller: "Controller"): def __init__(self, controller: "Controller"):
super().__init__( super().__init__(
orientation=Gtk.Orientation.VERTICAL,
margin_start=10, margin_start=10,
margin_end=10, margin_end=10,
) )
self.controller = controller self.controller = controller
self.controller.register_widget("options", self) self.controller.register_widget("options", self)
emitter = controller.get_emitter()
emitter.connect("api_change_failed", self._on_api_change_failed)
self.DEFAULT_WIDTH = 1 self.DEFAULT_WIDTH = 1
self.DEFAULT_HEIGHT = 1 self.DEFAULT_HEIGHT = 1
label = Gtk.Label(label=strings.options.header)
label.set_halign(Gtk.Align.CENTER)
css.add_class(label, "page-heading")
self.add(label)
self.steam_entry: Gtk.Entry self.steam_entry: Gtk.Entry
self.pop: Gtk.Popover
self.steam = WebButton(label=strings.options.steam_web) self.steam = WebButton(label=strings.options.steam_web)
self.steam.connect("clicked", self._on_link_button_clicked, STEAM_API_SETUP) self.steam.connect("clicked", self._on_link_button_clicked, STEAM_API_SETUP)
self.steam_box = SteamSubmitField(controller) self.steam_box = self._make_submit_field(
strings.options.enter_steam, Preferences.STEAM, True
)
api_rows = [ api_rows = [
[LeftLabel(strings.options.steam_placeholder), self.steam_box, self.steam], [LeftLabel(strings.options.steam_placeholder), self.steam_box, self.steam],
] ]
self.player_box = NameSubmitField(controller) self.player_box = self._make_submit_field(
strings.options.name_placeholder, Preferences.NAME
)
self.player_box.set_halign(Gtk.Align.START) self.player_box.set_halign(Gtk.Align.START)
# TODO: make submit field a standalone class
self.player_box.get_children()[0].set_width_chars(30) # type: ignore self.player_box.get_children()[0].set_width_chars(30) # type: ignore
self.fullscreen_toggle = ToggleField( self.fullscreen_toggle = self.make_binary_radio(
self.controller,
strings.options.last_used, strings.options.last_used,
strings.options.always_fs, strings.options.always_fs,
Preferences.WINDOW, Preferences.WINDOW,
@@ -290,8 +95,8 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
client_hbox = ShortHBox(self.client_combo) client_hbox = ShortHBox(self.client_combo)
self.distance_toggle = ToggleField( self.distance_toggle = self.make_binary_radio(
self.controller, strings.options.km, strings.options.mi, Preferences.DIST strings.options.km, strings.options.mi, Preferences.DIST
) )
combo_store = Gtk.ListStore(str, object) combo_store = Gtk.ListStore(str, object)
@@ -360,28 +165,21 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
grid.attach(frame, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT) grid.attach(frame, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT)
row += 1 row += 1
label = Gtk.Label(label=strings.options.header) self.scrollable = Gtk.ScrolledWindow(vexpand=True)
label.set_halign(Gtk.Align.CENTER) self.scrollable.add(grid)
css.add_class(label, "page-heading") self.add(self.scrollable)
box = VBox() def get_client_name(self) -> str:
box.add(label) model = self.client_combo.get_model()
box.add(grid) ind = self.client_combo.get_active()
self.add(box) return str(model[ind][0])
self.connect("key-press-event", self._on_keypress)
def get_client_name(self, cmd: str) -> str:
return self.client_combo.get_client_human_readable(cmd)
def block_text_entry(self) -> None: def block_text_entry(self) -> None:
self.steam_box.block_text_entry() self.steam_entry.set_position(-1)
# self.steam_entry.set_position(-1) self.steam_entry.set_can_focus(False)
# self.steam_entry.set_can_focus(False)
def unblock_text_entry(self) -> None: def unblock_text_entry(self) -> None:
self.steam_box.unblock_text_entry() self.steam_entry.set_can_focus(True)
# self.steam_entry.set_can_focus(True)
def _on_developers_clicked(self, button: Gtk.Button) -> None: def _on_developers_clicked(self, button: Gtk.Button) -> None:
self.controller.show_developers_page() self.controller.show_developers_page()
@@ -389,6 +187,46 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
def _on_link_button_clicked(self, button: Gtk.Button, url: str) -> None: def _on_link_button_clicked(self, button: Gtk.Button, url: str) -> None:
open_links.open_link_by_url(url) open_links.open_link_by_url(url)
def _make_submit_field(
self,
placeholder: str,
context: Preferences,
private: bool = False,
) -> Gtk.Box:
entry = Gtk.Entry(placeholder_text=placeholder, hexpand=True)
button = Gtk.Button(label="Save")
button.connect("clicked", self._on_save_clicked, entry, context)
entry.connect("insert-text", self._on_text_typed, context, button)
entry.connect("activate", self._on_field_activated, context, button)
entry.get_property("buffer").connect(
"deleted-text", self._on_text_deleted, context, button
)
if private:
entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL)
entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True)
entry.connect("icon-release", self._on_icon_release)
entry.set_visibility(False)
if context == Preferences.STEAM:
self.steam_entry = entry
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
box.add(entry)
box.add(button)
return box
def _on_field_activated(
self, entry: Gtk.Entry, context: Preferences, button: Gtk.Button
) -> None:
text = entry.get_text()
if not self._is_valid_text(text, context):
return
self._on_save_clicked(button, entry, context)
def _make_grid(self, rows: list) -> Gtk.Grid: def _make_grid(self, rows: list) -> Gtk.Grid:
grid = Gtk.Grid( grid = Gtk.Grid(
orientation=Gtk.Orientation.VERTICAL, orientation=Gtk.Orientation.VERTICAL,
@@ -408,6 +246,35 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
row += 1 row += 1
return grid return grid
def _on_save_clicked(
self, button: Gtk.Button, entry: Gtk.Entry, enum: Preferences
) -> None:
old_text = self.controller.query_config(enum)
self.old_text = old_text
self.old_entry = entry
button.set_sensitive(False)
match enum:
case Preferences.NAME:
value = entry.get_text().strip()
self.controller.update_config(enum, value)
case Preferences.STEAM:
text = "".join(entry.get_text().split())
self.controller.update_api_key(enum, text)
def _on_api_change_failed(self, emitter: "Emitter") -> None:
self.old_entry.set_text(self.old_text)
# TODO: use popover
dialog = ExceptionDialog(self.controller, errors.api_validation_error)
dialog.run()
def restore_api_text(self, text: str, entry: Gtk.Entry) -> None:
entry.set_text(text)
def revert(self, mode: Preferences) -> None:
if mode == Preferences.STEAM:
self.steam_entry.set_text(self.old_steam)
def _on_start_tab_changed(self, combo: Gtk.ComboBoxText) -> None: def _on_start_tab_changed(self, combo: Gtk.ComboBoxText) -> None:
_iter = combo.get_active_iter() _iter = combo.get_active_iter()
if _iter is None: if _iter is None:
@@ -423,12 +290,79 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
real_cmd = combo.get_model()[_iter][1] real_cmd = combo.get_model()[_iter][1]
self.controller.update_config(Preferences.CLIENT, real_cmd) self.controller.update_config(Preferences.CLIENT, real_cmd)
def _on_radio_toggled(self, button: Gtk.RadioButton, context: Preferences) -> None:
try:
self.controller.toggle_config(context)
except Exception:
button.handler_block_by_func(self._on_radio_toggled)
self.populate_settings()
button.handler_unblock_by_func(self._on_radio_toggled)
def _is_valid_text(self, text: str, context: Preferences) -> bool:
if text.isspace():
return False
if len(text) == 0:
return False
match context:
case Preferences.NAME:
old = self.old_name
case Preferences.STEAM:
old = self.old_steam
if text == old:
return False
return True
def _on_text_deleted(
self,
buffer: Gtk.EntryBuffer,
position: int,
chars: int,
context: Preferences,
button: Gtk.Button,
) -> None:
text = buffer.get_text()
state = self._is_valid_text(text, context)
button.set_sensitive(state)
def _on_text_typed(
self,
entry: Gtk.Entry,
text: str,
length: int,
pos: int,
context: Preferences,
button: Gtk.Button,
) -> None:
buffer = entry.get_property("buffer")
text = buffer.get_text() + text
state = self._is_valid_text(text, context)
button.set_sensitive(state)
def make_binary_radio(
self,
first_option: str,
second_option: str,
context: Preferences,
) -> Gtk.Box:
hbox = Gtk.Box(spacing=5, halign=Gtk.Align.START)
radio1 = Gtk.RadioButton.new_with_label(None, first_option)
radio2 = Gtk.RadioButton.new_from_widget(radio1)
radio2.set_label(second_option)
radio1.connect("toggled", self._on_radio_toggled, context)
hbox.pack_start(radio1, NO_EXPAND, NO_FILL, NO_PADDING)
hbox.pack_start(radio2, NO_EXPAND, NO_FILL, NO_PADDING)
return hbox
def populate_settings(self) -> None: def populate_settings(self) -> None:
prefs = self.controller.get_prefs() prefs = self.controller.get_prefs()
# NOTE: re-check in case file was removed by user between runs # NOTE: re-check in case file was removed by user between runs
if prefs.paths.config.is_file() is False: if prefs.paths.config.is_file() is False:
dialog = ExceptionDialog(self.controller, strings.config_not_found) dialog = ExceptionDialog(self.controller, strings.config_not_found)
dialog.show_all()
dialog.run() dialog.run()
raise OSError(f"Config file '{prefs.paths.config}' not found") raise OSError(f"Config file '{prefs.paths.config}' not found")
@@ -441,21 +375,30 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
steam_path = Path(default_steam_path) steam_path = Path(default_steam_path)
self.steam_box.set_text(steam) self.old_steam = steam
self.player_box.set_text(name) self.old_name = name
fs = config["fullscreen"] self.steam_entry.set_text(steam)
miles = config["use_miles"] p = self.player_box.get_children()[0]
if hasattr(p, "set_text"):
p.set_text(name)
if prefs.is_steam_deck: # NOTE: suppress toggle signal until radios are built
self.fullscreen_toggle.set_sensitive(False) self._suppress_toggles(True)
eb = InfoEventBox(options.fullscreen_eventbox, self.controller) for el, conf_state in [
self.fullscreen_toggle.add(eb) (self.fullscreen_toggle, config["fullscreen"]),
(self.distance_toggle, config["use_miles"]),
]:
el.get_children()[conf_state].set_active(True)
self._suppress_toggles(False)
fs = True # NOTE: disable buttons if no text is set
for field in (
self.fullscreen_toggle.set_suboption_active(fs) [name, self.player_box],
self.distance_toggle.set_suboption_active(miles) [steam, self.steam_box],
):
if field[0] == "":
field[1].get_children()[1].set_sensitive(False)
dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ) dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ)
if dayz_version is None: if dayz_version is None:
@@ -484,5 +427,19 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
self, toggle.get_children()[0], "_on_radio_toggled", state self, toggle.get_children()[0], "_on_radio_toggled", state
) )
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 grab_content_area(self) -> None: def grab_content_area(self) -> None:
self.grab_focus() return
+39 -34
View File
@@ -11,7 +11,6 @@ from dzgui.util.keys import is_ctrl_mask
from dzgui.util.localize import number from dzgui.util.localize import number
from dzgui.strings.server_mods import checkmark from dzgui.strings.server_mods import checkmark
from dzgui.strings import preconnect from dzgui.strings import preconnect
from dzgui.views.dialogs.generic import DebugDialog
from dzgui.views.components.frame import HeadingFrame from dzgui.views.components.frame import HeadingFrame
from dzgui.views.trees.tree_server_mods import ServerModTreeView from dzgui.views.trees.tree_server_mods import ServerModTreeView
@@ -121,12 +120,6 @@ class PreConnectionAssistant(Gtk.Box):
spacing=5, spacing=5,
) )
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
# NOTE: -d flag
if self.controller.get_prefs().is_debug:
debug = Gtk.Button(label=preconnect.debug, halign=Gtk.Align.START)
debug.connect("clicked", self._on_debug_clicked)
box.add(debug)
for button in self.back, self.ok, self.connect_last: for button in self.back, self.ok, self.connect_last:
box.add(button) box.add(button)
self.button_box.add(box) self.button_box.add(box)
@@ -157,7 +150,8 @@ class PreConnectionAssistant(Gtk.Box):
self.tree_box.add(self.scrolled) self.tree_box.add(self.scrolled)
self.tree_box.add(self.progress_box) self.tree_box.add(self.progress_box)
self.mods_placeholder = Placeholder(preconnect.placeholder_no_mods) # TODO: strings
self.mods_placeholder = Placeholder("This server has no mods.")
self.tree_box.add(self.mods_placeholder) self.tree_box.add(self.mods_placeholder)
self.tree_frame = HeadingFrame.new_with_widget_and_label( self.tree_frame = HeadingFrame.new_with_widget_and_label(
@@ -167,7 +161,8 @@ class PreConnectionAssistant(Gtk.Box):
# TODO: abstract into components # TODO: abstract into components
self.warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.warning_tree = MaskedTree(WARNING) self.warning_tree = MaskedTree(WARNING)
self.warning_placeholder = Placeholder(preconnect.placeholder_no_warnings) # TODO: strings
self.warning_placeholder = Placeholder("No warnings.")
self.warning_box.add(self.warning_tree) self.warning_box.add(self.warning_tree)
self.warning_box.add(self.warning_placeholder) self.warning_box.add(self.warning_placeholder)
self.warning_frame = HeadingFrame.new_with_widget_and_label( self.warning_frame = HeadingFrame.new_with_widget_and_label(
@@ -176,7 +171,8 @@ class PreConnectionAssistant(Gtk.Box):
self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.error_tree = MaskedTree(ERROR) self.error_tree = MaskedTree(ERROR)
self.error_placeholder = Placeholder(preconnect.no_errors) # TODO: strings
self.error_placeholder = Placeholder("No errors.")
self.error_box.add(self.error_tree) self.error_box.add(self.error_tree)
self.error_box.add(self.error_placeholder) self.error_box.add(self.error_placeholder)
self.error_frame = HeadingFrame.new_with_widget_and_label( self.error_frame = HeadingFrame.new_with_widget_and_label(
@@ -227,12 +223,6 @@ class PreConnectionAssistant(Gtk.Box):
def _on_connect_last_clicked(self, button: Gtk.Button) -> None: def _on_connect_last_clicked(self, button: Gtk.Button) -> None:
self.controller.update_and_load_to_menu() self.controller.update_and_load_to_menu()
def _on_debug_clicked(self, button: Gtk.Button) -> None:
args = self.controller.get_debug_args()
d = DebugDialog(self.controller, args)
d.show_all()
d.run()
def _on_ok_clicked(self, button: Gtk.Button) -> None: def _on_ok_clicked(self, button: Gtk.Button) -> None:
self.controller.update_and_connect() self.controller.update_and_connect()
@@ -243,46 +233,61 @@ class PreConnectionAssistant(Gtk.Box):
warnings: list[str] = [] warnings: list[str] = []
errors: list[str] = [] errors: list[str] = []
resync_msg = preconnect.resync.format(prereqs.build) resync_msg = (
f"If you recently installed {prereqs.build} or moved it to a different drive,\n"
"restart Steam to allow these changes to synchronize, then try again."
)
"""Errors""" """Errors"""
if len(prereqs.invalid_mods) > 0: if len(prereqs.invalid_mods) > 0:
pairs = [": ".join(sub) for sub in prereqs.invalid_mods] pairs = [": ".join(sub) for sub in prereqs.invalid_mods]
lines = "\n".join(pairs) lines = "\n".join(pairs)
msg = preconnect.invalid_mods.format(lines) msg = (
"Server has invalid mods that are not recognized by Steam.\n"
"Contact the server owner and include these mod IDs in your report:\n"
f"{lines}"
)
errors.append(msg) errors.append(msg)
if prereqs.binary_missing: if prereqs.binary_missing:
msg = preconnect.version_missing.format(prereqs.build) errors.append(
msg += f"\n{resync_msg}" f"Remote server is running the build '{prereqs.build}', but it is not installed.\n{resync_msg}"
errors.append(msg) )
elif prereqs.local_version != prereqs.remote_version: elif prereqs.local_version != prereqs.remote_version:
msg = preconnect.version_mismatch.format( errors.append(
prereqs.local_version, prereqs.remote_version f"Local client version '{prereqs.local_version}' does not match remote version '{prereqs.remote_version}'.\n{resync_msg}"
) )
msg += f"\n{resync_msg}"
errors.append(msg)
if prereqs.required_space > prereqs.available_space: if prereqs.required_space > prereqs.available_space:
required_pretty = number(prereqs.required_space) required_pretty = number(prereqs.required_space)
available_pretty = number(prereqs.available_space) available_pretty = number(prereqs.available_space)
msg = preconnect.not_enough_space.format(required_pretty, available_pretty) errors.append(
errors.append(msg) f"Need to update {required_pretty} MiB of mods, but installation path only has {available_pretty} MiB."
)
if len(prereqs.mods) > 0 and prereqs.game_mode: if len(prereqs.mods) > 0 and prereqs.game_mode:
errors.append(preconnect.use_desktop_mode) errors.append("Use Desktop Mode to download mods on Steam Deck")
if prereqs.steam_proc.is_running is False: if prereqs.steam_proc.is_running is False:
client = prereqs.steam_proc.name client = prereqs.steam_proc.name
msg = preconnect.steam_not_running.format(client) errors.append(
errors.append(msg) f"'{client}' is set as the default Steam client, but it is either not installed or not running."
)
"""Warnings""" """Warnings"""
if prereqs.passworded: if prereqs.passworded:
warnings.append(preconnect.protected_server) warnings.append(
"Protected: you will be prompted for a password when connecting to this server."
)
if prereqs.dayz_running is True: if prereqs.dayz_running is True:
warnings.append(preconnect.dayz_running) warnings.append(
"It looks like DayZ is already running in the background. Exit DayZ before connecting."
)
allows_dl, running_app = prereqs.allows_downloads allows_dl, running_app = prereqs.allows_downloads
if len(prereqs.mods) > 0 and allows_dl is False: if len(prereqs.mods) > 0 and allows_dl is False:
msg = preconnect.running_app.format(running_app) msg = (
f"The game '{running_app}' is currently running in Steam, but background downloads are not enabled.\n"
"Either stop the game first, or update your global Steam settings or the game's local settings.\n"
"Otherwise, mods may be queued for download but never update."
)
warnings.append(msg) warnings.append(msg)
self.add_warnings(warnings) self.add_warnings(warnings)
@@ -321,7 +326,7 @@ class PreConnectionAssistant(Gtk.Box):
if prereqs.required_space != 0: if prereqs.required_space != 0:
pretty = number(prereqs.required_space) pretty = number(prereqs.required_space)
suffix = preconnect.required_space.format(pretty) suffix = f" Need to download {pretty} MiB of mod updates."
prefix = preconnect.total_mods prefix = preconnect.total_mods
self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}") self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}")
else: else:
+12 -24
View File
@@ -66,7 +66,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
self.handler_id: int self.handler_id: int
self.queue: Queue = Queue() self.queue: Queue = Queue()
self.seen_cache: list[Record] = [] self.seen_cache: list[str] = []
prefs = self.controller.get_prefs() prefs = self.controller.get_prefs()
columns = prefs.paths.columns columns = prefs.paths.columns
@@ -252,6 +252,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
case Gdk.KEY_c: case Gdk.KEY_c:
self.controller.menu_action(ContextMenu.COPY_SERVER_IP, self) self.controller.menu_action(ContextMenu.COPY_SERVER_IP, self)
case Gdk.KEY_r: case Gdk.KEY_r:
# TODO: unimplemented, needs threading
self.controller.menu_action(ContextMenu.REFRESH_PLAYERS, self) self.controller.menu_action(ContextMenu.REFRESH_PLAYERS, self)
else: else:
match event.keyval: match event.keyval:
@@ -294,12 +295,6 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
) -> None: ) -> None:
if self.loaded is False: if self.loaded is False:
return return
# NOTE: signal triggers twice on right-click events
# due to deselect-then-select behavior in GTK
sel = self.get_selection()
model, row = sel.get_selected()
if row is None:
return
self.start_distcalc() self.start_distcalc()
def get_name(self) -> str: def get_name(self) -> str:
@@ -340,24 +335,16 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
control = self.proxy_man.get_control() control = self.proxy_man.get_control()
return model, control return model, control
@staticmethod
def ping_server( def ping_server(
self,
model: "FastInsertListStore", model: "FastInsertListStore",
_iter: Gtk.TreeIter, _iter: Gtk.TreeIter,
record: Record, ip: str,
qport: int,
ping_column: int,
) -> None: ) -> None:
_ping = ping(record.ip, record.qport) _ping = ping(ip, qport)
GLib.idle_add(self._redraw, model, _iter, _ping) GLib.idle_add(lambda: model.set(_iter, ping_column, _ping))
def _redraw(
self, model: "FastInsertListStore", _iter: Gtk.TreeIter, _ping: int
) -> None:
ping_column = 9
model.set(_iter, ping_column, _ping)
self.queue_draw()
def wipe_cache(self) -> None:
self.seen_cache = []
def _get_ping( def _get_ping(
self, self,
@@ -370,12 +357,13 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
addr_column = 7 addr_column = 7
qport_column = 8 qport_column = 8
ping_column = 9
addr = model.get_value(_iter, addr_column).split(":") addr = model.get_value(_iter, addr_column).split(":")
ip = addr[0] ip = addr[0]
gameport = int(addr[1]) gameport = addr[1]
qport = model.get_value(_iter, qport_column) qport = model.get_value(_iter, qport_column)
record = Record(ip, gameport, qport) record = f"{addr}:{gameport}:{qport}"
if record in self.seen_cache: if record in self.seen_cache:
return return
@@ -384,7 +372,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
thread = threading.Thread( thread = threading.Thread(
daemon=True, daemon=True,
target=self.ping_server, target=self.ping_server,
args=(model, _iter, record), args=(model, _iter, ip, qport, ping_column),
) )
thread.start() thread.start()
+1 -2
View File
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
authors = [ authors = [
{name = "aclist"} {name = "aclist"}
] ]
version = "7.0.0b26" version = "7.0.0b20"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
license-files = ["LICENSE"] license-files = ["LICENSE"]
readme = "README.md" readme = "README.md"
@@ -47,7 +47,6 @@ docs = [
] ]
test = [ test = [
"black==25.1.0", "black==25.1.0",
"hypothesis==6.167.1",
"pre-commit==4.4.0", "pre-commit==4.4.0",
"pytest==8.4.2", "pytest==8.4.2",
"pytest-cov==7.0.0", "pytest-cov==7.0.0",
+1 -1
View File
@@ -27,7 +27,7 @@ def fetch(url: str, destination: str) -> None:
def get_cpython() -> None: def get_cpython() -> None:
source = f"https://github.com/astral-sh/python-build-standalone/releases/download/{DATE}/" source = f"https://github.com/astral-sh/python-build-standalone/releases/download/{DATE}/"
build = f"{CPYTHON_BUILD}%2B{DATE}-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" build = f"{CPYTHON_BUILD}%2B{DATE}-x86_64_v3-unknown-linux-gnu-install_only_stripped.tar.gz"
url = f"{source}{build}" url = f"{source}{build}"
outfile = unquote(build) outfile = unquote(build)
fetch(url, outfile) fetch(url, outfile)
-38
View File
@@ -1,38 +0,0 @@
from pathlib import Path
def iterate(search_str: str) -> None:
# TODO: exclude files from .gitignore
print()
print(f"Searching for '{search_str}'")
print()
files = Path(".").rglob("*")
ignore = [
"scripts/",
".git",
"egg",
"CHANGELOG",
".mypy",
"build/",
"test.py",
"bugs",
"pyproject.toml",
]
for file in files:
if file.is_dir():
continue
if any(s in str(file) for s in ignore):
continue
try:
text = file.read_text()
if search_str in text:
c = text.count(search_str)
if 0 < c < 2:
print(file, c)
except Exception:
continue
imports = ["Pango", "GObject", "GLib", "Gdk", "Gtk"]
for i in imports:
iterate(i)
-23
View File
@@ -1,23 +0,0 @@
"users"
{
"0"
{
"AccountName" "STEAMUSER"
"PersonaName" "STEAMUSER"
"RememberPassword" "0"
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"Timestamp" "1787904411"
}
"1"
{
"AccountName" "STEAMUSER2"
"PersonaName" "STEAMUSER2"
"RememberPassword" "0"
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"Timestamp" "1787904647"
}
}
@@ -1,23 +0,0 @@
"users"
{
"0"
{
"AccountName" "STEAMUSER"
"PersonaName" "STEAMUSER"
"RememberPassword" "0"
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"Timestamp" "1797904411"
}
"1"
{
"AccountName" "STEAMUSER2"
"PersonaName" "STEAMUSER2"
"RememberPassword" "0"
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"Timestamp" "1787904647"
}
}
+1 -1
View File
@@ -20,6 +20,6 @@
"SkipOfflineModeWarning" "0" "SkipOfflineModeWarning" "0"
"AllowAutoLogin" "1" "AllowAutoLogin" "1"
"MostRecent" "1" "MostRecent" "1"
"Timestamp" "10" "Timestamp" "0"
} }
} }
+13 -26
View File
@@ -1,33 +1,20 @@
import re
import pytest import pytest
from pathlib import Path from importlib import resources
from dzgui.const.constants import APP_NAME_LOWER, CHANGELOG_PATH
@pytest.fixture @pytest.fixture
def changelog(request) -> None: def changelog():
root = request.config.rootpath path = resources.files(APP_NAME_LOWER).joinpath(CHANGELOG_PATH)
changelog = Path(root).joinpath("CHANGELOG.md").read_text() return path
return changelog
def count_hash(line: str) -> int: def test_headings(changelog):
cnt = 0 with open(changelog, "r") as f:
for c in line: lines = f.readlines()
if c == "#": for line in lines:
cnt += 1 if line.startswith("#"):
return cnt pass
# TODO: use regex
pass
def test_changelog_prefix(changelog) -> None:
r = r".*(\[.*\]).*"
lines = changelog.splitlines()
sort = sorted(lines)
match = [line for line in sort if line.startswith("#")]
for m in match:
if "Changelog" in m:
assert count_hash(m) == 1
elif re.match(r, m) is not None:
assert count_hash(m) == 2
else:
assert count_hash(m) == 3
-59
View File
@@ -1,59 +0,0 @@
import pytest
import subprocess
from dzgui.const.constants import GIO_SETTINGS_KEY
from dzgui.views.mixins.colorscheme import ColorAwareApp
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gio, GLib # noqa
class TestWidget(ColorAwareApp, Gtk.Button):
def __init__(self) -> None:
super().__init__()
self.state: bool
def check_theme(self) -> None:
self.state = self.get_settings().get_property(GIO_SETTINGS_KEY)
Gtk.main_quit()
@pytest.fixture
def widget() -> TestWidget:
return TestWidget()
@pytest.mark.integration
@pytest.mark.parametrize("key, expect", [("default", False), ("prefer-dark", True)])
def test_light_mode(widget, key: str, expect: bool) -> None:
local_pref = subprocess.run(
["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"],
capture_output=True,
text=True,
).stdout
subprocess.run(
[
"gsettings",
"set",
"org.gnome.desktop.interface",
"color-scheme",
key,
],
)
# NOTE: allow changes to propagate
GLib.idle_add(widget.check_theme)
Gtk.main()
subprocess.run(
[
"gsettings",
"set",
"org.gnome.desktop.interface",
"color-scheme",
local_pref,
]
)
assert widget.state is expect
-35
View File
@@ -1,35 +0,0 @@
from dzgui.api.servers import Details
from dzgui.views.dialogs.servers import ServerDetailsDialog
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
class MockController:
def __init__(self) -> None:
self.window = Gtk.Window()
def get_window(self) -> Gtk.Window:
return self.window
def get_server_name(self) -> str:
return "My server"
def get_emitter(self) -> None:
return None
def test_html_escaping() -> None:
name = "Test server"
desc = "<Server description> Game & mods"
gametime = "05:30"
day_accel = 5.0
night_accel = 1.0
details = Details([["0", "1"]], name, desc, gametime, day_accel, night_accel)
controller = MockController()
dialog = ServerDetailsDialog(controller, details)
dialog.destroy()
assert dialog.description.get_text() == desc
-2
View File
@@ -14,8 +14,6 @@ pytestmark = pytest.mark.apitest
("api/loginusers_legacy_client.vdf", 0), ("api/loginusers_legacy_client.vdf", 0),
("api/loginusers_legacy_client_multiple.vdf", 1), ("api/loginusers_legacy_client_multiple.vdf", 1),
("api/loginusers_beta_client.vdf", 0), ("api/loginusers_beta_client.vdf", 0),
("api/loginusers_beta_client_multiple.vdf", 1),
("api/loginusers_beta_client_multiple_2.vdf", 0),
], ],
) )
def test_loginusers(monkeypatch, fixture: str, expect: int) -> None: def test_loginusers(monkeypatch, fixture: str, expect: int) -> None:
-13
View File
@@ -1,13 +0,0 @@
from pathlib import Path
from dzgui.config.xdg import get_xdg_paths
def test_path_fallback(monkeypatch) -> None:
envs = ["XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"]
for env in envs:
monkeypatch.setenv(env, "/etc/dzgui")
paths = get_xdg_paths()
assert paths["XDG_CONFIG_HOME"] == Path.home().joinpath(".config/dzgui")
assert paths["XDG_STATE_HOME"] == Path.home().joinpath(".local/state/dzgui")
assert paths["XDG_DATA_HOME"] == Path.home().joinpath(".local/share/dzgui")
assert paths["XDG_CACHE_HOME"] == Path.home().joinpath(".cache/dzgui")
-61
View File
@@ -1,61 +0,0 @@
import unittest
from unittest.mock import patch
from dzgui.const.constants import (
FLATPAK_APPID,
FLATPAK_RUN_CMD,
FLATPAK_SANDBOX,
STEAM_CMD,
)
from dzgui.init import proc
class TestSteamDetection(unittest.TestCase):
def test_flatpak_detection_with_multiple_running_apps(self):
def flatpak_output(args, *, text):
# Default `flatpak ps` includes instance, PID, app and runtime columns.
if args == ["flatpak", "ps", "--columns=application"]:
return f"com.spotify.Client\n{FLATPAK_APPID}\n{FLATPAK_APPID}\n"
return f"3904921611\t1257689\t{FLATPAK_APPID}\torg.freedesktop.Platform\n"
with (
patch.object(proc, "has_cmd", return_value=True),
patch.object(proc.subprocess, "check_output", side_effect=flatpak_output),
):
for client in (FLATPAK_RUN_CMD, FLATPAK_SANDBOX):
with self.subTest(client=client):
self.assertTrue(proc.is_steam_running(client))
def test_flatpak_not_running(self):
with patch.object(proc, "has_cmd", return_value=True):
for output in ("", "com.spotify.Client\n", f"{FLATPAK_APPID}Helper\n"):
with (
self.subTest(output=output),
patch.object(proc.subprocess, "check_output", return_value=output),
):
self.assertFalse(proc.is_flatpak_steam_running())
def test_flatpak_not_installed(self):
with (
patch.object(proc, "has_cmd", return_value=False),
patch.object(proc.subprocess, "check_output") as check_output,
):
self.assertFalse(proc.is_flatpak_steam_running())
check_output.assert_not_called()
def test_native_steam(self):
with patch.object(proc, "has_cmd", return_value=True):
for running in (True, False):
with (
self.subTest(running=running),
patch.object(proc, "is_running", return_value=running),
):
self.assertEqual(proc.is_steam_running(STEAM_CMD), running)
def test_native_steam_while_flatpak_is_running(self):
with patch.object(proc, "is_flatpak_steam_running", return_value=True):
self.assertEqual(proc.is_steam_running(STEAM_CMD), False)
def test_invalid_client(self):
with self.assertRaises(TypeError):
proc.is_steam_running("invalid-client")
-45
View File
@@ -1,45 +0,0 @@
import pytest
import re
from dzgui.api.servers import Details, Record, get_details
@pytest.fixture
def details() -> Details:
"""
Static official server, metadata is expected to be invariate
"""
r = Record("85.190.157.111", 10400, 10401)
return get_details(r)
def test_invalid_server() -> None:
r = Record("127.0.0.1", 10400, 10401)
assert get_details(r) is None
@pytest.mark.parametrize("key, expect", [("day_accel", 2.3), ("night_accel", 6.8)])
def test_time_accel(details, key, expect) -> None:
assert getattr(details, key) == expect
def test_gametime(details) -> None:
time_reg = r"^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]$)"
match = re.match(time_reg, details.gametime)
assert match is not None
def test_metadata(details) -> None:
for row in details.data:
label, value = row
match label:
case "Password":
assert value == "Disabled"
case "Platform":
assert value == "Windows"
case "Battleye":
assert value == "Enabled"
case "Valve Anti-Cheat":
assert value == "Enabled"
case _:
pass
-112
View File
@@ -1,112 +0,0 @@
import pytest
from hypothesis import given, strategies as st
from datetime import datetime, timedelta
from dzgui.views.dialogs.calc import (
LocalClock,
ServerClock,
Time,
TimePicker,
DAY_START_TIME,
DAY_END_TIME,
)
class DummyEmitter:
def __init__(self) -> None:
pass
def connect(self, _, __) -> None:
pass
@pytest.fixture(params=[("05:00", 10.0), ("15:30", 5.0)])
def accel_server_clock(request) -> None:
clock_time, expect = request.param
cur_time = Time(clock_time, 5.0, 2.0)
emitter = DummyEmitter()
local_clock = LocalClock(emitter)
server_clock = ServerClock(cur_time, emitter, local_clock)
return expect, server_clock
def test_accel_factor(accel_server_clock) -> None:
"""Static test checking times at known boundaries"""
expect, clock = accel_server_clock
assert clock.get_accel_factor(clock.cur_time) == expect
def reference_calc(start, end, day_factor, night_factor):
"""
Reference implementation to test validity of UI-side calculator
Aggregates total seconds and applies accel factors after
"""
day_start = datetime.combine(start.date(), DAY_START_TIME)
day_end = datetime.combine(start.date(), DAY_END_TIME)
night_factor = day_factor * night_factor
if end < start:
end += timedelta(days=1)
day_max = max(start, day_start)
day_min = min(end, day_end)
next_day_start = datetime.combine(end.date(), DAY_START_TIME)
next_day_end = datetime.combine(end.date(), DAY_END_TIME)
range1 = (day_end - day_max).total_seconds()
range2 = (min(end, next_day_end) - next_day_start).total_seconds()
if end.date() > start.date():
day_seconds = max(0, range1) + max(0, range2)
else:
day_seconds = max(0, (day_min - day_max).total_seconds())
total_seconds = (end - start).total_seconds()
night_seconds = (total_seconds - day_seconds) / night_factor
day_seconds = day_seconds / day_factor
total = night_seconds + day_seconds
return timedelta(seconds=total)
day_accel = st.floats(
min_value=1.0, max_value=24.0, allow_nan=False, allow_infinity=False
)
night_accel = st.floats(
min_value=1.0, max_value=64.0, allow_nan=False, allow_infinity=False
)
@st.composite
def time_ranges(draw):
start_time = draw(st.times())
deltas = st.timedeltas(min_value=timedelta(0), max_value=timedelta(days=1))
duration = draw(deltas)
start = datetime.combine(datetime.now(), start_time)
end = start + duration
return start, end
@given(span=time_ranges(), day_factor=day_accel, night_factor=night_accel)
def test_time_aggregation(span, day_factor, night_factor) -> None:
start, end = span
strtime = start.strftime("%H:%M")
t = Time(strtime, day_factor, night_factor)
emitter = DummyEmitter()
local_clock = LocalClock(emitter)
server_clock = ServerClock(t, emitter, local_clock)
h = end.time().hour
m = end.time().minute
picker = TimePicker(h, m)
# NOTE: truncates microseconds to mimic widget HH:MM selection behavior
start = server_clock.get_datetime()
end = picker.get_time()
actual = server_clock.calc_delta(end)
expect = reference_calc(start, end, day_factor, night_factor)
assert actual == expect
-22
View File
@@ -1,22 +0,0 @@
import re
from dzgui.model.proxy_model import DAY_REG, NIGHT_REG
def iterate(h: str, r: str) -> None:
for m in range(60):
time = f"{h:02}:{m:02}"
assert re.match(r, time) is not None
def test_day() -> None:
for h in range(17):
if h < 7:
continue
iterate(h, DAY_REG)
def test_night() -> None:
for h in range(24):
if 5 < h < 18:
continue
iterate(h, NIGHT_REG)