Compare commits

..
Author SHA1 Message Date
aclist 1dcfdda657 change: allow D-Bus registration when sandboxed 2026-10-23 11:36:13 +09:00
aclist 019847dcf9 fix: handle missing grid directory 2026-10-23 11:35:13 +09:00
aclist c2cfe2b4d9 fix: handle missing shortcut file 2026-10-23 11:34:52 +09:00
aclist 5cb8c68f5c Merge pull request #496 from aclist/fix/always-show-icon
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
fix: always show icon
2026-10-22 10:16:44 +09:00
aclist 2f92c2a44f chore: clear typehinting errors 2026-10-22 10:12:38 +09:00
aclist da48834fa7 feat: load Steam icon into search paths 2026-10-22 10:11:55 +09:00
aclist 197c2eba06 fix: always show icon 2026-10-22 10:11:13 +09:00
aclist 0131ee6f0e Merge pull request #486 from aclist/change/flatpak-sens
change: set flatpak option sensitivity dynamically
2026-10-22 08:20:09 +09:00
aclist b70edebd05 Merge branch 'dzgui7' into change/flatpak-sens
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
2026-10-21 16:44:56 +09:00
aclist ae1c3a19d4 chore: update tests 2026-10-21 16:35:01 +09:00
aclist 7822672cda chore: clear typehinting errors 2026-10-21 16:33:17 +09:00
aclist f2a7bde432 fix: collision with steam-runtime-launcher-service 2026-10-21 16:32:38 +09:00
aclist 00b5476aaf change: treat steam clients as separate processes 2026-10-21 14:51:56 +09:00
aclist 2a08b9ba82 chore: clear typehinting errors 2026-10-21 13:04:12 +09:00
aclist 0f8b6fcaad change: drop client combo from wizard 2026-10-21 13:03:30 +09:00
aclist 95988459d5 chore: drop unused import 2026-10-21 13:00:06 +09:00
aclist eed40e6f1a change: return client name from controller 2026-10-21 12:59:54 +09:00
aclist 07460a1f8f chore: clear typehinting errors 2026-10-21 12:44:41 +09:00
aclist a74d576387 change: interpolate client name from full command 2026-10-21 12:43:20 +09:00
aclist 052f7f0708 chore: remove client combo from options page 2026-10-21 12:42:50 +09:00
aclist 6feae44887 chore: add import 2026-10-21 12:41:10 +09:00
aclist 8cdb615796 chore: drop unused imports 2026-10-21 12:40:56 +09:00
aclist 31b6705017 change: parse client from path 2026-10-21 12:40:03 +09:00
aclist ef1e7d17f5 chore: drop unused functions 2026-10-21 12:39:23 +09:00
aclist 3c52072eb5 Merge pull request #493 from aclist/feat/paused-downloads
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
feat: paused downloads
2026-10-21 10:26:51 +09:00
aclist bf69936891 Merge pull request #495 from aclist/fix/vdf-timestamp
fix: support changes to VDF timestamp
2026-10-21 10:22:35 +09:00
aclist fa180b750a Merge pull request #494 from aclist/change/unmodded-filter
change: separate modded into unmodded
2026-10-21 10:20:46 +09:00
aclist 96d50ffafa chore: resolve pytest errors 2026-10-18 07:44:31 +09:00
aclist 6c801ffec5 chore: update tests 2026-10-18 07:44:14 +09:00
aclist 5eb70a1eb8 fix: support changes to VDF format 2026-10-18 07:35:17 +09:00
aclist a3fff40f8f change: separate modded into unmodded 2026-10-18 01:54:59 +09:00
aclist 343630d2da chore: update placeholder appid 2026-10-18 01:52:43 +09:00
aclist d63c7ce3ec chore: add test 2026-10-18 01:50:10 +09:00
aclist f7ad357e6f feat: print paused download warning on preconnect 2026-10-18 01:49:14 +09:00
aclist 035a01fe34 feat: check for pending patches 2026-10-18 01:48:42 +09:00
aclist 7dd14804aa Merge pull request #491 from aclist/fix/dword-parse
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
fix: dword parse
2026-10-16 00:09:26 +09:00
aclist 1cc1ba2864 chore: bump version 2026-10-16 00:06:54 +09:00
aclist 8c4d0aecdc fix: parse alternate VS_FIXEDFILEINFO structure 2026-10-16 00:06:37 +09:00
aclist 616f18452a Merge pull request #489 from aclist/fix/badlands
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
fix: badlands
2026-10-15 19:26:43 +09:00
aclist 1064f7a9d6 chore: clear typehinting errors 2026-10-15 19:23:42 +09:00
aclist b0e85f0e35 fix: update Badlands flag 2026-10-15 19:22:44 +09:00
aclist 4eec00e8e7 chore: trigger checks 2026-10-15 00:36:09 +09:00
aclist 09ef3e446a change: set flatpak option sensitivity dynamically 2026-10-15 00:35:02 +09:00
aclist 4d02604bcf Merge pull request #485 from aclist/fix/gint-warning
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
fix: use more stable GObject signal
2026-10-15 00:32:28 +09:00
aclist ebffbe39f2 fix: use more stable GObject signal 2026-10-15 00:23:11 +09:00
aclist 61860f6aee Merge pull request #482 from aclist/fix/duplicate-warnings
Fix/duplicate warnings
2026-10-14 23:12:18 +09:00
aclist 191776ce74 fix: use correct logger method 2026-10-14 23:11:29 +09:00
aclist 7ae34747e9 fix: add missing dummy method to test 2026-10-14 23:07:22 +09:00
aclist 1555ba2d64 chore: drop no longer relevant filter 2026-10-14 23:07:00 +09:00
aclist 4bac804fd7 fix: duplicate warnings (#480) 2026-10-14 23:06:41 +09:00
aclist 748e1870da Merge pull request #479 from sylvaingirardbe/fix/478-flatpak-steam-detection
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
fix: detect running Flatpak Steam correctly
2026-10-14 23:03:43 +09:00
aclist cd4c6fdfb2 Merge pull request #481 from aclist/fix/workshop-403
fix: handle HTTPErrors from workshop (#477)
2026-10-14 23:01:48 +09:00
aclist 1752018331 fix: handle HTTPErrors from workshop (#477) 2026-10-14 22:53:02 +09:00
aclist b3b05918b7 chore: add test case 2026-10-09 06:45:59 +09:00
aclist e6e0a3036d fix: drop unused assignment 2026-10-09 04:03:36 +09:00
aclist 8c44a6e255 fix: user-facing Steam client name not available until Options populated
The old method was checking the active ComboBox string after the Options
page was initialized, but before it was actually populated with the real
setting. This caused it to always return the string 'Steam' instead of
the actual client name.
2026-10-09 04:03:17 +09:00
aclist 7fc1264b65 fix: false positive when Flatpak client is running
The process ultimately spawned from the Flatpak run command has the same
process name as the one expected for STEAM_CMD (essentially just the
Steam client wrapped in a Flatpak), so the old logic would return True
for is_steam_running() if the Flatpak version had already been launched.
To prevent unintended collisions and isolate launch parameters,
STEAM_CMD and FLATPAK_RUN_CMD/FLATPAK_SANDBOX should be
mutually exclusive.
2026-10-09 04:02:58 +09:00
Sylvain Girard f748b09ba7 fix: detect running Flatpak Steam correctly
Request only application IDs from flatpak ps so running Steam instances match correctly. Fix client selection validation and cover detection with regression tests.

Refs aclist/dztui#478.
2026-10-08 15:58:51 +02:00
aclist 01be1247b4 Merge pull request #476 from aclist/fix/missing-mission
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
fix: missing mission
2026-09-24 13:48:24 +09:00
aclist 27d5c4b4b2 Merge branch 'dzgui7' into fix/missing-mission 2026-09-24 13:39:56 +09:00
aclist 045e47037e chore: bump version 2026-09-24 13:39:12 +09:00
aclist 3bc2a54ee4 fix: mission param returning empty 2026-09-24 13:38:16 +09:00
aclist 4e22b82703 change: more explicitchange: more explicit status message 2026-09-24 13:37:56 +09:00
aclist 492ed221c4 change: add padding to error area 2026-09-24 13:37:31 +09:00
aclist 71a61a9201 drop: old comment 2026-09-24 13:37:16 +09:00
aclist 516735defa chore: add string 2026-09-24 13:36:58 +09:00
aclist 9a3df70914 chore: format strings 2026-09-24 13:36:25 +09:00
aclist c41e47859f change: more explicit error message 2026-09-24 13:35:59 +09:00
aclist 11bbe15ecb chore: change comment flag 2026-09-24 13:35:35 +09:00
aclist d40512dfa0 Merge pull request #474 from aclist/prerelease/7.0.0b24
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
chore: bump version
2026-09-09 17:13:41 +09:00
aclist f07e503b74 chore: bump version 2026-09-09 17:13:07 +09:00
aclist 3caeb823a2 Merge pull request #473 from aclist/feat/server-time-calc
feat: server time calc
2026-09-09 17:12:01 +09:00
aclist 6fcd887d56 Merge pull request #472 from aclist/fix/refresh-ping
fix: refresh ping column on filter action
2026-09-09 17:07:34 +09:00
aclist c15b77d470 chore: add comments regarding time interpolation
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-09-08 05:07:41 +09:00
aclist cee1e3b8af chore: move comment 2026-09-08 02:58:39 +09:00
aclist e8ccdd7cbb fix: local clock drives virtual clock 2026-09-08 02:56:24 +09:00
aclist 2629fe254e fix: superclass type collision 2026-09-08 02:29:04 +09:00
aclist 3b15bf58e7 feat: add time of day icon 2026-09-08 02:28:04 +09:00
aclist e773a4909b fix: restore adjusted time to current time on reset 2026-09-08 01:02:23 +09:00
aclist 95380681f9 chore: update test to new Details dataclass format 2026-09-08 00:51:26 +09:00
aclist 18192a4667 chore: remove debug mark 2026-09-08 00:46:22 +09:00
aclist f07a2dea5f chore: add calculator reference test 2026-09-08 00:45:30 +09:00
aclist 0e58cf514e fix: date traversal logic 2026-09-08 00:45:07 +09:00
aclist 84c6a3868b change: normalize time boundaries 2026-09-08 00:44:32 +09:00
aclist 7c0981981f chore: drop unused import 2026-09-08 00:43:48 +09:00
aclist e653c61955 chore: remove debug mark 2026-09-08 00:43:27 +09:00
aclist d4bf9f71f6 chore: clear typehinting errors 2026-09-04 19:28:12 +09:00
aclist cb5c233f45 change: simplify calculation logic 2026-09-04 19:25:37 +09:00
aclist ac286f4a72 chore: add metadata test
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-09-04 04:00:34 +09:00
aclist e254821105 change: update time range assertions 2026-09-04 01:03:22 +09:00
aclist 32b8c5cbc5 chore: remove resolved comments 2026-09-04 00:40:25 +09:00
aclist 76ed7f2451 change: use greater precision only when setting target time 2026-09-04 00:38:37 +09:00
aclist f135f6a35d drop: GenericAdjustedClock 2026-09-03 23:26:45 +09:00
aclist eb5d5d5181 fix: reset time on subsequent invocations 2026-09-03 23:26:01 +09:00
aclist fef8c69f38 change: synchronize local and remainder clocks 2026-09-03 23:21:11 +09:00
aclist 32395812a0 change: support new dataclass format 2026-09-03 20:11:02 +09:00
aclist 6d046ee719 change: restructure Details dataclass 2026-09-03 20:10:50 +09:00
aclist 1ebf253e99 chore: apply linter changes 2026-09-03 20:10:20 +09:00
aclist ee31dce6e6 chore: add icon constant 2026-09-03 20:09:51 +09:00
aclist 4aaf7fe195 chore: update strings files 2026-09-03 20:08:32 +09:00
aclist b7e8982f40 feat: pack calculator in Gtk.Stack 2026-09-03 20:08:03 +09:00
aclist c3b41ed9b4 feat: add server time calculator 2026-09-03 20:07:30 +09:00
aclist e42fd26855 fix: enquote typehint 2026-09-02 01:36:48 +09:00
aclist 7a8ccca317 chore: clear typehinting errors 2026-09-02 00:48:25 +09:00
aclist 72e3054afb fix: wipe cache and redraw when filtering ServerTab.BROWSER 2026-09-02 00:45:44 +09:00
aclist 2f4606ede0 fix: focus OK button when dialog opens 2026-09-02 00:43:37 +09:00
aclist c6a75c1e78 chore: move refilter() call to controller 2026-09-02 00:43:13 +09:00
aclist bad7878c0a drop: remove old comments 2026-09-02 00:42:15 +09:00
aclist 5014402e39 Merge pull request #470 from aclist/docs/update-readme
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
docs: update readme with missing details from dzgui6
2026-09-01 15:37:29 +09:00
aclist baade5b721 docs: update readme with missing details from dzgui6 2026-09-01 15:36:53 +09:00
58 changed files with 1206 additions and 285 deletions

No files matched your search

+1
View File
@@ -52,6 +52,7 @@
- Filter button tooltips
- Expand/collapse changelog versions
- Support local light/dark mode changes on system theme
- Server time calculator
### Changed
- Conform to PEP 440 versioning for beta versions
+13 -5
View File
@@ -1,15 +1,23 @@
## What this is
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).
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).
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
- Supports official, third-party, and modded DayZ 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
- Manage local mods and symlinks
- Prepare launch options to pass to Steam
- Manage and delete local mods
- And more
## 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
@@ -18,7 +26,7 @@ Please refer to the documentation for installation and setup instructions:
- [GitHub](https://aclist.github.io/dzgui/index.html)
- [Codeberg (Mirror)](https://aclist.codeberg.page)
![A screenshot of DZGUI](/images/example.png)
![A screenshot of DZGUI](/dzgui/data/images/example.png)
## Attribution
+20 -8
View File
@@ -255,16 +255,28 @@ class PeFileError(Exception):
pass
def parse_version_number(data: BinaryIO) -> FileVersion:
# https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo
minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
major = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
build = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
# NOTE: 0-pad revision number
revision = str(struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF)
offset = 3 - len(revision)
padded = "0" * offset + revision
return FileVersion(major, minor, build, padded)
"""
Note: structure differs from standard VS_FIXEDFILEINFO
Major: lower 16 bits of first DWORD
Minor: upper 16 bits of first DWORD
Build: lower 16 bits of third DWORD
Revision: upper 16 bits of second DWORD, zero-padded
Lower 16 bits of revision DWORD appear unused.
"""
major_minor = struct.unpack("<I", data.read(4))[0]
revision_pair = struct.unpack("<I", data.read(4))[0]
build_dword = struct.unpack("<I", data.read(4))[0]
major = major_minor & 0xFFFF
minor = (major_minor >> 16) & 0xFFFF
build = build_dword & 0xFFFF
revision = (revision_pair >> 16) & 0xFFFF
revision = f"{revision:03d}"
return FileVersion(major, minor, build, revision)
def seek_to_hex(address: str, data: BinaryIO) -> None:
+21 -26
View File
@@ -54,19 +54,16 @@ class Res:
@dataclass(slots=True, frozen=True)
class Details:
data: Union[list, None]
data: list[list[str]]
name: str
description: str
success: bool
gametime: str
day_accel: float
night_accel: float
@dataclass(slots=True)
@dataclass(slots=True, frozen=True)
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
gameport: int
qport: int
@@ -283,43 +280,41 @@ def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] |
info = a2s.info((ip, qport), timeout)
return source_info_to_dict(ip, qport, info)
except Exception as e:
logger.warning(e)
logger.debug(e)
return None
def get_details(record: Record) -> Details:
# TODO: consolidate try/except statements
def get_details(record: Record) -> Details | None:
ip = record.ip
qport = record.qport
default_str = strings.none_provided
try:
info = a2s.info((ip, qport))
except (TimeoutError, a2s.exceptions.BrokenMessageError):
return Details(None, default_str, False)
try:
rules = dayzquery.dayz_rules((ip, qport))
except Exception:
return Details(None, default_str, False)
try:
keywords = info.keywords.split(",")
except AttributeError:
return Details(None, default_str, False)
except Exception as e:
logger.critical(e)
return None
battleye = strings.disabled
if "battleye" in keywords:
battleye = strings.enabled
day_accel = 0.0
night_accel = 0.0
name = info.server_name
day_accel = 1.0
night_accel = 1.0
gametime = "00:00"
for keyword in keywords:
if "etm" in keyword:
day_accel = float(keyword.lstrip("etm"))
day_accel = float(f"{day_accel:g}")
if "entm" in keywords:
if "entm" in keyword:
night_accel = float(keyword.lstrip("entm"))
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:
password = info.password_protected
@@ -351,7 +346,7 @@ def get_details(record: Record) -> Details:
dlc = strings.dlc_none
case 2:
dlc = strings.dlc_frostline
case 3:
case 4:
dlc = strings.dlc_badlands
case _:
dlc = strings.unspecified
@@ -385,7 +380,7 @@ def get_details(record: Record) -> Details:
["Version", version],
]
return Details(rows, description, True)
return Details(rows, name, description, gametime, day_accel, night_accel)
def ping(ip: str, qport: int) -> int:
+10 -3
View File
@@ -10,8 +10,8 @@ from pathlib import Path
from typing import Any
from dzgui.api.steam import find_user_id_32
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, IMAGES_PATH
from dzgui.util.dirs import copy_dzgui_to_xdg_data
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, GTK_APP_ID, IMAGES_PATH
from dzgui.util.dirs import copy_dzgui_to_xdg_data, make_parents
from dzgui.util.strings import unknown
logger = logging.getLogger(APP_NAME)
@@ -75,6 +75,8 @@ class Shortcuts:
shortcuts = vdf.binary_load(f)
self.shortcuts = shortcuts
logger.debug("Loaded shortcuts file")
except FileNotFoundError:
self.shortcuts = {"shortcuts": {}}
except Exception as e:
logger.critical(e)
raise e
@@ -109,6 +111,7 @@ class Shortcuts:
for img in ("_hero", "p", "_logo"):
filename = str(self.bpid) + img
dest = grid_path.joinpath(filename).with_suffix(".png")
make_parents(dest)
b = images.joinpath(img).with_suffix(".png").read_bytes()
dest.write_bytes(b)
@@ -155,7 +158,8 @@ class Shortcuts:
NEW_ENTRY["DevkitGameID"] = ""
NEW_ENTRY["DevkitOverrideAppID"] = 0
NEW_ENTRY["LastPlayTime"] = 0
NEW_ENTRY["FlatpakAppID"] = ""
NEW_ENTRY["FlatpakAppID"] = GTK_APP_ID
NEW_ENTRY["sortas"] = ""
NEW_ENTRY["tags"] = {}
return NEW_ENTRY
@@ -172,6 +176,9 @@ class Shortcuts:
try:
backup = self.shortcuts_path.with_suffix(".vdf.bak")
shutil.copy(self.shortcuts_path, backup)
except Exception as e:
logger.critical(e)
try:
with open(self.shortcuts_path, "wb") as f:
vdf.binary_dump(self.shortcuts, f)
except Exception as e:
+46 -5
View File
@@ -1,16 +1,18 @@
import logging
import os
import psutil
import re
import requests
import subprocess
import vdf # type: ignore
from pathlib import Path
from typing import Any, Union
from requests.exceptions import HTTPError
from typing import Any, Generator, Union
from warnings import deprecated
from dzgui.api.acf import ACF
from dzgui.init.prereqs import has_steam_client
from dzgui.init.prereqs import has_any_client
from dzgui.const.constants import (
APPID_DAYZ,
APPID_DAYZ_EXP,
@@ -19,6 +21,7 @@ from dzgui.const.constants import (
DAYZ_LAUNCHER,
DEBIAN_STEAM_PATH,
DEFAULT_STEAM_PATH,
DOWNLOADS_PATH,
FLATPAK_STEAM_PATH,
LIBRARYFOLDERS_PATH,
UBUNTU_STEAM_PATH,
@@ -32,6 +35,7 @@ from dzgui.const.endpoints import (
UNSUB_ENDPOINT,
)
from dzgui.strings import wizard
from dzgui.strings.errors import api_validation_error, workshop_endpoint_error
from dzgui.util.bash import concat_bash_args
logger = logging.getLogger(APP_NAME)
@@ -57,7 +61,7 @@ class VDFLoadError(Exception):
def get_steam_paths() -> list[tuple[Path, str]]:
paths = []
if has_steam_client():
if has_any_client():
HOME = Path.home()
env = os.environ.get("XDG_DATA_HOME")
if env is not None:
@@ -243,9 +247,9 @@ def find_user_id(path: Path) -> str | None:
last_user = str(next(iter(users)))
if len(users) == 1:
return last_user
last_stamp = int(users[last_user]["Timestamp"])
last_stamp = int(users[last_user]["timestamp"])
for user in users:
stamp = int(users[user]["Timestamp"])
stamp = int(users[user]["timestamp"])
if stamp > last_stamp:
last_stamp = stamp
last_user = user
@@ -262,6 +266,16 @@ def find_user_id_32(path: Path) -> int:
return int(uid) & 0xFFFFFFFF
class SteamAPIKeyError(Exception):
def __init__(self, msg: str | None = None) -> None:
super().__init__(msg or api_validation_error)
class SteamWorkshopEndpointError(Exception):
def __init__(self, msg: str | None = None) -> None:
super().__init__(msg or workshop_endpoint_error)
def update_workshop(key: str, mod: int, endpoint: str) -> None:
payload: dict[str, Union[int, str]] = {
"publishedfileid": mod,
@@ -273,8 +287,19 @@ def update_workshop(key: str, mod: int, endpoint: str) -> None:
try:
res = requests.post(endpoint, params=payload, timeout=REQUEST_TIMEOUT)
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:
logger.critical(e)
raise e
def subscribe(key: str, mod: int) -> None:
@@ -416,6 +441,22 @@ def is_dayz_running() -> bool:
return any(s in item for sublist in procs for item in sublist for s in substring)
def get_pending_downloads(steam_path: Path, appid: int) -> Generator[Path, None, None]:
root_path = get_app_path(steam_path, appid)
downloads_dir = root_path / DOWNLOADS_PATH
files = downloads_dir.iterdir()
return files
def is_dayz_downloading(steam_path: Path, appid: int) -> bool:
files = get_pending_downloads(steam_path, appid)
reg = re.compile(rf"^state_(?:{APPID_DAYZ_EXP}|{APPID_DAYZ})_.*\.patch$")
pending = [f for f in files if reg.match(f.name)]
if len(pending) > 0:
return True
return False
def get_app_path(folders_path: Path, appid: int) -> Path:
app_path = None
+30 -4
View File
@@ -1,10 +1,13 @@
import logging
import os
import shutil
from pathlib import Path
from typing import TYPE_CHECKING
from warnings import deprecated
from dzgui.const.constants import APP_NAME
from dzgui.const.constants import APP_NAME, STEAM_ICON, STEAM_ICONS_PATH
from dzgui.const.enum import Preferences
from dzgui.config.query import lookup
from dzgui.config.userprefs import UserPrefs
@@ -15,7 +18,7 @@ from dzgui.init.migrate import (
migrate_cols_file,
)
from dzgui.init.flock import lock_acquire
from dzgui.init.prereqs import has_steam_client
from dzgui.init.prereqs import has_any_client
from dzgui.strings import boot
from dzgui.util.deck import is_steam_deck, is_game_mode
@@ -23,7 +26,7 @@ from dzgui.util.dirs import make_parents
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.strings import init
from dzgui.util.strings import init, steam_icon_missing
from dzgui.views.base import App
from dzgui.views.dialogs.boot import BootWindow
@@ -96,6 +99,28 @@ def copy_bare_configs(config: "Path", resolution: "Path") -> tuple[bool, bool]:
return (config_changed, state_changed)
@deprecated("Currently unused")
def load_steam_icon(config: Path) -> None:
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa
default = lookup(config, Preferences.DEFAULT)
icon_path = Path(default).joinpath(STEAM_ICONS_PATH)
try:
theme = Gtk.IconTheme.get_default()
paths = theme.get_search_path()
theme.set_search_path(paths + [str(icon_path)])
paths = theme.get_search_path()
icons = theme.list_icons(None)
if STEAM_ICON not in icons:
logger.warning(steam_icon_missing)
except Exception as e:
print(e)
logger.warning(e)
def load_gui(version: str, is_debug: bool) -> None:
lock = lock_acquire() # noqa
@@ -136,13 +161,14 @@ def load_gui(version: str, is_debug: bool) -> None:
# TODO: sudo escalation dialog
# count = get_map_count()
# TODO: move into module
if has_steam_client() is False:
if has_any_client() is False:
EarlyAlertDialog(init.requires_steam)
bootwin = BootWindow(XDG, version)
local_coords, latest_release = bootwin.get_results()
use_miles = lookup(XDG.config, Preferences.DIST)
prefs = UserPrefs(
is_steam_deck=_is_steam_deck,
is_game_mode=_is_game_mode,
-20
View File
@@ -4,8 +4,6 @@ from pathlib import Path
from dzgui.util._json import read_json
from dzgui.const.enum import Preferences
from dzgui.const.constants import STEAM_CMD, FLATPAK_RUN_CMD, FLATPAK_SANDBOX
class ConfigFileError(Exception):
pass
@@ -40,23 +38,5 @@ def get_favorites(path: Path) -> list[str]:
return list(conf["ip_list"])
# def is_in_favs(record: str, path: Path) -> bool:
# favs = get_favorites(path)
# if record in favs:
# return True
# return False
def enum_to_key(enum: Preferences) -> str:
return str(enum.dict["key"])
def get_client_index(client: str) -> int:
if client == STEAM_CMD:
return 0
elif client == FLATPAK_RUN_CMD:
return 1
elif client == FLATPAK_SANDBOX:
return 2
else:
return 0
+8 -2
View File
@@ -15,6 +15,7 @@ DAYZ_BINARY = "DayZ_x64.exe"
DAYZ_LAUNCHER = "DayZLauncher.exe"
LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf"
DOWNLOADS_PATH = "steamapps/downloading"
WORKSHOP_PATH = "steamapps/workshop/content/" + str(APPID_DAYZ)
STEAM_CMD = "steam"
@@ -36,7 +37,8 @@ HEX_GREEN = "#32CD32"
HEX_RED = "#FF0000"
HEX_ORANGE = "#FFAC1C"
# FIXME: normalize icon names
# TODO: normalize icon names
CALCULATOR = "accessories-calculator-symbolic"
CARET_DOWN = "go-down-symbolic"
CARET_UP = "go-up-symbolic"
CLIPBOARD = "edit-copy-symbolic"
@@ -54,6 +56,8 @@ VIEW_CONCEAL = "view-conceal-symbolic"
VIEW_REVEAL = "view-reveal-symbolic"
WARNING = "dialog-warning-symbolic"
WEB_BROWSER = "web-browser-symbolic"
DAY_ICON = "weather-clear-symbolic"
NIGHT_ICON = "weather-clear-night-symbolic"
SEPARATOR = "SEPARATOR"
@@ -81,9 +85,11 @@ HERO_PATH = "data/images/_hero.png"
CSS_PATH = "data/app.css"
VDF_PATH = "steamapps/libraryfolders.vdf"
DEFAULT_STEAM_PATH = ".local/share/Steam"
FLATPAK_STEAM_PATH = ".var/app/com.valvesoftware.Steam/data/Steam"
FLATPAK_ROOT_PATH = ".var/app/com.valvesoftware.Steam"
FLATPAK_STEAM_PATH = FLATPAK_ROOT_PATH + "/data/Steam"
UBUNTU_STEAM_PATH = ".steam/steam"
DEBIAN_STEAM_PATH = ".steam/debian-installation"
STEAM_ICONS_PATH = "public"
LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG")
+11 -6
View File
@@ -7,11 +7,12 @@ import dzgui.util._json as JSON # noqa
from dzgui.const.enum import (
FilterMode,
Preferences,
NotebookPage,
ButtonType,
ContextMenu,
FilterMode,
NotebookPage,
Preferences,
ServerTab,
)
from dzgui.config.userprefs import UserPrefs
@@ -37,7 +38,6 @@ logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING:
from dzgui.api.servers import Record
from dzgui.const.enum import ServerTab
from dzgui.managers.connection import Prerequisites
from dzgui.managers.filter import FilterManager
from dzgui.util.dist import Haversine
@@ -498,6 +498,9 @@ class Controller(GObject.GObject):
def set_active_keyword(self, keyword: str) -> None:
self.get_filter_man().set_active_keyword(keyword)
ServerModelManager(self, self.get_active_treeview()).refilter(
FilterMode.TOGGLE_ON
)
def get_modtreeview(self) -> "ModTreeView":
return self.mediator.modtreeview
@@ -538,8 +541,10 @@ class Controller(GObject.GObject):
def update_status(self) -> None:
self.mediator.preconnect.mark_finished()
def get_steam_client_name(self) -> str:
return self.mediator.options.get_client_name()
def get_client_name(self) -> str:
# NOTE: only possible values are "Steam" and "Flatpak"
cmd = self.query_config(Preferences.CLIENT)
return str(cmd.split()[0].capitalize())
def present_window(self) -> None:
self.mediator.window.present_with_time(Gdk.CURRENT_TIME)
+1
View File
@@ -52,6 +52,7 @@
- Filter button tooltips
- Expand/collapse changelog versions
- Support local light/dark mode changes on system theme
- Server time calculator
### Changed
- Conform to PEP 440 versioning for beta versions
+4
View File
@@ -24,6 +24,10 @@
.left-label {
font-size: 1.3rem;
}
.server-subheading {
font-size: 1.1rem;
font-weight: 700;
}
.details-heading {
font-size: 1.2rem
}
+9 -2
View File
@@ -4,17 +4,24 @@ from pathlib import Path
from dzgui.api.steam import get_app_path
from dzgui.config.query import lookup
from dzgui.const.constants import APPID_DAYZ, APP_NAME
from dzgui.const.constants import APPID_DAYZ, APP_NAME, APPNAME_DAYZ
from dzgui.const.enum import Preferences
from dzgui.strings.preconnect import resync
logger = logging.getLogger(APP_NAME)
class VDFSyncError(Exception):
pass
def is_dayz_installed(config: Path) -> None:
try:
path = lookup(config, Preferences.DEFAULT)
get_app_path(Path(path), APPID_DAYZ)
except Exception as e:
logger.critical(e)
raise e
msg = resync.format(APPNAME_DAYZ)
raise VDFSyncError(msg)
+20 -7
View File
@@ -7,19 +7,32 @@ from dzgui.const.constants import APP_NAME, FLATPAK_APPID, FLATPAK_CMD, STEAM_CM
logger = logging.getLogger(APP_NAME)
def has_steam_client() -> bool:
if shutil.which(STEAM_CMD):
return True
if not shutil.which(FLATPAK_CMD):
return False
def has_flatpak_client() -> bool:
try:
res = subprocess.run(
[FLATPAK_CMD, "list"], capture_output=True, text=True, check=True
[FLATPAK_CMD, "list", "--columns=application"],
capture_output=True,
text=True,
check=True,
)
if FLATPAK_APPID in res.stdout:
return True
else:
return False
except subprocess.CalledProcessError as e:
except Exception as e:
logger.critical(e)
return False
def has_steam_client() -> bool:
if shutil.which(STEAM_CMD):
return True
return False
def has_any_client() -> bool:
if shutil.which(STEAM_CMD):
return True
if not shutil.which(FLATPAK_CMD):
return False
return has_flatpak_client()
+32 -13
View File
@@ -10,42 +10,61 @@ from dzgui.const.constants import (
FLATPAK_CMD,
FLATPAK_RUN_CMD,
FLATPAK_SANDBOX,
FLATPAK_ROOT_PATH,
FLATPAK_STEAM_PATH,
)
logger = logging.getLogger(APP_NAME)
def is_steam_running(cmd: str) -> bool:
if cmd == STEAM_CMD:
if has_cmd(STEAM_CMD) is False:
return False
return is_running(STEAM_CMD)
elif cmd == FLATPAK_RUN_CMD or FLATPAK_SANDBOX:
if cmd in (FLATPAK_RUN_CMD, FLATPAK_SANDBOX):
return is_flatpak_steam_running()
elif cmd == STEAM_CMD:
return is_native_steam_running()
else:
raise TypeError("Not a valid Steam client selection")
# CHORE: test alternate clients
# TODO: has_flatpak_steam
def is_flatpak_steam_running() -> bool:
if has_cmd(FLATPAK_CMD) is False:
return False
proc = subprocess.check_output([FLATPAK_CMD, "ps"], text=True)
proc = subprocess.check_output(
[FLATPAK_CMD, "ps", "--columns=application"], text=True
)
lines = proc.splitlines()
if FLATPAK_APPID in lines:
return True
return False
def is_running(proc: str) -> bool:
for process in psutil.process_iter(["pid", "name"]):
if process.info["name"] == proc:
return True
return False
def get_steam_procs() -> list[list[str]]:
return [
p.cmdline()
for p in psutil.process_iter(["pid", "name"])
if STEAM_CMD in p.info["name"]
]
def is_native_steam_running() -> bool:
if has_cmd(STEAM_CMD) is False:
return False
substrings = [FLATPAK_ROOT_PATH, "steam-runtime-launcher-service"]
steam_procs = get_steam_procs()
native_steam_procs = [
proc
for proc in steam_procs
if not any(sub in item for item in proc for sub in substrings)
]
return bool(native_steam_procs)
def has_cmd(cmd: str) -> bool:
if shutil.which(cmd) is not None:
return True
return False
def path_to_steam_cmd(path: str) -> str:
cmd = FLATPAK_RUN_CMD if FLATPAK_STEAM_PATH in path else STEAM_CMD
return cmd
-12
View File
@@ -6,9 +6,6 @@ from typing import Any, TYPE_CHECKING
from dzgui.const.constants import (
APP_NAME,
FLATPAK_RUN_CMD,
FLATPAK_SANDBOX,
STEAM_CMD,
WINDOW_DEFAULT_X,
WINDOW_DEFAULT_Y,
)
@@ -129,15 +126,6 @@ class ConfigManager:
def enum_to_key(self, enum: Preferences) -> str:
return str(enum.dict["key"])
def get_client_index(self, client: str) -> int:
if client == STEAM_CMD:
return 0
if client == FLATPAK_RUN_CMD:
return 1
if client == FLATPAK_SANDBOX:
return 2
return 0
def toggle_config(self, key: Preferences) -> None:
try:
real_key = self.enum_to_key(key)
+20 -5
View File
@@ -14,6 +14,7 @@ from dzgui.api.shortcuts import Shortcuts
from dzgui.api.steam import (
connect,
connect_debug,
is_dayz_downloading,
get_app_allows_downloads,
get_app_name,
get_needs_update,
@@ -48,7 +49,7 @@ from dzgui.strings.dialogs import (
)
from dzgui.strings import kb
from dzgui.strings.server_mods import checkmark, resync
from dzgui.util.format import format_mib
from dzgui.util.format import format_exception, format_mib
from dzgui.util.strings import dialog, server_timeout
from dzgui.util.symlink import rebuild_symlinks
from dzgui.views.dialogs.generic import ExceptionDialog
@@ -84,6 +85,7 @@ class Prerequisites:
available_space: float
passworded: bool
dayz_running: bool
download_paused: bool
steam_proc: SteamProcess
mods: list[list[str]]
game_mode: bool
@@ -169,6 +171,7 @@ class ConnectionManager:
free_mib = format_mib(free)
dayz_running = is_dayz_running()
download_paused = is_dayz_downloading(steam_path, self.appid)
running_app = get_running_app()
if running_app is not None:
@@ -184,8 +187,8 @@ class ConnectionManager:
else:
allows_downloads = (True, "")
client_name = self.controller.get_steam_client_name()
client = self.controller.query_config(Preferences.CLIENT)
client_name = self.controller.get_client_name()
running = is_steam_running(client)
steam_proc = SteamProcess(client_name, running)
self.client = client
@@ -210,6 +213,7 @@ class ConnectionManager:
available_space=free_mib,
passworded=info.password_protected,
dayz_running=dayz_running,
download_paused=download_paused,
steam_proc=steam_proc,
mods=remote_mods,
game_mode=game_mode,
@@ -235,7 +239,7 @@ class ConnectionManager:
@call_on_thread(dialog.querying)
def query_details(self, record: Servers.Record) -> None:
details = Servers.get_details(record)
if details.success is False:
if details is None:
self.thread_man.set_cleanup_func(
StoredFunc(self._server_timeout), destroy_first=True
)
@@ -300,6 +304,11 @@ class ConnectionManager:
dialog = ServerDetailsDialog(self.controller, details)
dialog.run()
def _workshop_timeout(self, msg: str) -> None:
dialog = ExceptionDialog(self.controller, msg)
dialog.show_all()
dialog.run()
def _server_timeout(self) -> None:
dialog = ExceptionDialog(self.controller, server_timeout)
dialog.set_details_buffer(kb.DZG_006)
@@ -351,8 +360,14 @@ class ConnectionManager:
for title, mod, stamp, size in self.missing_mods:
if self.controller.is_cancel_pending():
return
subscribe(key, int(mod))
time.sleep(API_RATE_LIMIT)
try:
subscribe(key, int(mod))
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)
for title, mod, stamp, size in self.missing_mods:
+6 -4
View File
@@ -22,20 +22,21 @@ class FilterManager:
strings.filter_night: True,
strings.filter_full: show_full,
strings.filter_official: True,
strings.filter_nonascii: False,
strings.filter_modded: True,
strings.filter_lowpop: True,
strings.filter_unofficial: True,
strings.filter_unmodded: True,
strings.filter_duplicate: False,
strings.filter_modded: True,
strings.filter_nonascii: False,
}
# TODO: strings
self.tooltips = {
strings.filter_1pp: "First-person perspective",
strings.filter_day: "In-game time between 0700 and 1659",
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 1700 and 0659",
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",
@@ -43,6 +44,7 @@ class FilterManager:
strings.filter_unofficial: "Third-party servers",
strings.filter_duplicate: "Duplicates of existing servers (usually spoofed)",
strings.filter_modded: "Servers with one or more mods",
strings.filter_unmodded: "Servers with no mods",
}
self.active_keyword = ""
+5 -1
View File
@@ -122,7 +122,11 @@ class ModManager:
def unsub_atomic_mod(self, mod: str) -> None:
config_man = self.controller.get_config_man()
key = config_man.lookup(Preferences.STEAM)
unsubscribe(key, int(mod))
try:
unsubscribe(key, int(mod))
except Exception as e:
logger.critical(e)
return
steam_path = Path(self.path)
app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
+4 -2
View File
@@ -12,8 +12,8 @@ if TYPE_CHECKING:
from dzgui.api.servers import Record
from dzgui.model.servers import NewPlayerCount
DAY_REG = r"([0][7-9]|[1][0-6])"
NIGHT_REG = r"([0][0-6]|[1][7-9]|[2][0-3])"
DAY_REG = r"([0][6-9]|[1][0-7])"
NIGHT_REG = r"([0][0-5]|[1][8-9]|[2][0-3])"
class ProxyModelManager:
@@ -243,6 +243,8 @@ class ProxyModelManager:
rows = [row for row in rows if (row[4] / row[5] * 100) > 30]
case strings.filter_modded:
rows = [row for row in rows if not row[11]]
case strings.filter_unmodded:
rows = [row for row in rows if row[11]]
return rows
def filter_toggle_on(self, filters: tuple[str]) -> list:
+3 -1
View File
@@ -21,7 +21,7 @@ from dzgui.views.dialogs.generic import ExceptionDialog
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
from gi.repository import Gtk, GLib # noqa E402
if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo, Record
@@ -390,6 +390,8 @@ class ServerModelManager:
def _cleanup_on_success(self) -> None:
proxy = self.proxy_man.get_proxy_model()
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:
self.proxy_man.wipe_cache()
+7
View File
@@ -0,0 +1,7 @@
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."
+17 -15
View File
@@ -1,20 +1,22 @@
add_button="Add"
add_con_heading="Add/connect"
from dzgui.const.constants import UDP_PORT
add_button = "Add"
add_con_heading = "Add/connect"
add_popover = "This address is already in your Saved Servers"
add_tooltip="Add to Saved Servers"
connect_button="Connect"
connect_tooltip="Connect to this server"
connect_entry_placeholder="Enter IP:Query port (e.g., 192.168.1.1:27016)"
add_tooltip = "Add to Saved Servers"
connect_button = "Connect"
connect_tooltip = "Connect to this server"
connect_entry_placeholder = f"Enter IP:Query port (e.g., 192.168.1.1:{UDP_PORT})"
fav_heading="Favorite server"
favs_empty="None set. Right click a server and select 'Set favorite' to set."
fav_heading = "Favorite server"
favs_empty = "None set. Right click a server and select 'Set favorite' to set."
lan_heading="LAN query port"
lan_default_button="Default port (27016)"
lan_custom_button="Custom port"
lan_scan_button="Scan"
lan_heading = "LAN query port"
lan_default_button = f"Default port ({UDP_PORT})"
lan_custom_button = "Custom port"
lan_scan_button = "Scan"
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_entry_placeholder="Enter the query port (1-65535)"
lan_entry_tooltip="Specify the port to search for DayZ servers on the local network"
lan_scan_tooltip="Scan for servers"
lan_entry_placeholder = "Enter the query port (1-65535)"
lan_entry_tooltip = "Specify the port to search for DayZ servers on the local network"
lan_scan_tooltip = "Scan for servers"
+5
View File
@@ -23,3 +23,8 @@ 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,3 +4,4 @@ api_validation_error = (
api_popover = "API key validation failed."
dayz_not_found = "Could not find a valid DayZ installation."
workshop_endpoint_error = "Steam Workshop service unavailable, try again later."
+5 -2
View File
@@ -11,8 +11,6 @@ mods = "Mods"
total_mods = "Total mods: "
up_to_date = "All mods are up to date."
all_updated = "All mods updated."
"If you recently installed {build} or moved it to a different drive, "
"restart Steam to allow these changes to synchronize, then try again."
required_space = " Need to download {0} MiB of mod updates."
placeholder_no_mods = "This server has no mods."
@@ -36,6 +34,7 @@ steam_not_running = "'{0}' is set as the default Steam client, but it is either
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
@@ -48,3 +47,7 @@ running_app = (
"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."
)
downloads_paused = (
"It looks like DayZ may have a pending update on Steam that was paused.\n"
"To enqueue mods for download, unpause any pending DayZ update and allow it to complete."
)
+2 -1
View File
@@ -30,7 +30,8 @@ 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, you can remove the 'dzgui' file."
"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"
+1 -2
View File
@@ -11,7 +11,6 @@ delimiter = "␞"
# GenericDialog
main_menu = "Return to main menu"
exit_app = "Exit"
server_details = "Server details"
modlist = "Modlist"
input_required = "User input required"
confirm = "Confirmation"
@@ -59,7 +58,6 @@ browser_cols = [
]
# DetailsDialog
server_message = "Server message"
workshop = "Enter/double click a row to open in Steam Workshop."
# LanDialog
@@ -158,6 +156,7 @@ filter_duplicate = "Duplicate"
filter_official = "Official"
filter_unofficial = "Unofficial"
filter_modded = "Modded"
filter_unmodded = "Unmodded"
# maps
all_maps = "All maps"
+10 -5
View File
@@ -2,7 +2,12 @@ import logging
from pathlib import Path
from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash
from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME, DAYZ_COMMUNITY_ROOT
from dzgui.const.constants import (
APPID_DAYZ,
APPID_DAYZ_EXP,
APP_NAME,
DAYZ_COMMUNITY_ROOT,
)
from dzgui.const.enum import Preferences
from dzgui.config.query import lookup
@@ -20,6 +25,7 @@ def expunge_link(file: Path) -> None:
if str(file.stem)[:2] == "@C":
file.unlink()
def rebuild_symlinks(config: Path) -> None:
# TODO: pass direct path as argument
path = lookup(config, Preferences.DEFAULT)
@@ -63,10 +69,9 @@ def symlink_mission(steam_path: Path, target: str) -> str:
stem = path.name
parent = path.parent
source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT)
try:
source.symlink_to(parent)
except Exception:
return ""
if source.exists():
source.unlink()
source.symlink_to(parent)
return f"{DAYZ_COMMUNITY_ROOT}/{stem}"
+1 -18
View File
@@ -1,6 +1,5 @@
import logging
import signal
import warnings
from typing import TYPE_CHECKING, Literal, Self
@@ -8,12 +7,11 @@ from dzgui.const.constants import (
APP_NAME,
APP_NAME_LOWER,
GTK_APP_ID,
STEAM_ICON,
)
from dzgui.const.enum import NotebookPage
from dzgui.controllers.emitter import Emitter
from dzgui.controllers.mc import Controller
from dzgui.util import css, strings
from dzgui.util import css
from dzgui.util.keys import is_ctrl_mask
from dzgui.views.components.connect_panel import ConnectPanel
from dzgui.views.components.crumbs import Breadcrumbs
@@ -47,10 +45,6 @@ if TYPE_CHECKING:
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:
@@ -337,15 +331,4 @@ class App(ColorAwareApp, Gtk.Application): # type: ignore
) -> None:
self.win.halt_proc_and_quit()
MainController = Controller()
try:
theme = Gtk.IconTheme.get_default()
icons = theme.list_icons(None)
if STEAM_ICON not in icons:
logger.warn(strings.steam_icon_missing)
warnings.warn(strings.steam_icon_missing, stacklevel=2)
except Exception as e:
logger.warning(e)
warnings.warn(strings.gtk_theme_missing, stacklevel=2)
+1
View File
@@ -24,6 +24,7 @@ class VBox(GenericBox):
def __init__(self, spacing: int = 0) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing)
class ShortHBox(HBox):
def __init__(self, widget: Gtk.Widget) -> None:
super().__init__(spacing=5)
+1
View File
@@ -65,6 +65,7 @@ class IconButton(Gtk.Button):
self.set_image(self.icon)
self.set_image_position(position)
self.set_focus_on_click(False)
self.set_always_show_image(True)
def swap_icon(self, icon: str) -> None:
start = self.icon.get_margin_start()
-6
View File
@@ -9,8 +9,6 @@ from dzgui.const.constants import (
NO_PADDING,
SEARCH_ICON,
)
from dzgui.const.enum import FilterMode
from dzgui.model.servers import ServerModelManager
from dzgui.util.format import format_exception
from dzgui.util.strings import all_maps
from dzgui.views.components.maps_combo import MapsCombo
@@ -148,10 +146,6 @@ class KeywordEntry(Gtk.Entry):
self.controller.set_active_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):
def __init__(self, controller: "Controller") -> None:
+3
View File
@@ -9,3 +9,6 @@ class Icon(Gtk.Image):
super().__init__(
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)
+34 -4
View File
@@ -1,42 +1,72 @@
from typing import Self
from warnings import deprecated
from dzgui.const.constants import ERROR, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD
from dzgui.init.prereqs import has_steam_client, has_flatpak_client
from dzgui.strings import options
from dzgui.views.components.box import HBox
from dzgui.views.components.buttons import Icon
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa
@deprecated("Client choice is determined at setup time")
class ClientCombo(Gtk.ComboBox):
def __init__(self) -> None:
super().__init__()
self.client_store = Gtk.ListStore(str, str)
clients = (
has_steam = has_steam_client()
has_flatpak = has_flatpak_client()
self.client_store = Gtk.ListStore(str, str, bool)
self.clients = (
(
options.steam_combo,
STEAM_CMD,
has_steam,
),
(
options.flatpak_combo,
FLATPAK_RUN_CMD,
has_flatpak,
),
(
options.flatpak_container_combo,
FLATPAK_SANDBOX,
has_flatpak,
),
)
for client in clients:
for client in self.clients:
self.client_store.append(client)
self.set_model(self.client_store)
renderer_text = Gtk.CellRendererText()
self.pack_start(renderer_text, True)
self.add_attribute(renderer_text, "text", 0)
self.set_active(0)
self.set_cell_data_func(self.get_cells()[0], self.data_func)
def data_func(
self,
combo: Self,
cell: Gtk.CellRendererText,
model: Gtk.ListStore,
_iter: Gtk.TreeIter,
) -> None:
sens = model[_iter][2]
cell.set_property("sensitive", sens)
def get_client_human_readable(self, cmd: str) -> str:
for client in self.clients:
human, command, sens = 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)
+1 -1
View File
@@ -102,7 +102,7 @@ class BootDialog(ColorAwareApp, Gtk.Dialog): # type: ignore
margin_top=30,
margin_bottom=30,
)
self.error_label = Gtk.Label()
self.error_label = Gtk.Label(margin_start=15, margin_end=15)
# TODO: abstract class
self.copy_button = ClipboardButton(None, lambda: self.error_label.get_text())
+457
View File
@@ -0,0 +1,457 @@
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)
+8
View File
@@ -309,6 +309,14 @@ class ExceptionDialog(TextBufferDialog):
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:
+46 -25
View File
@@ -1,20 +1,21 @@
import gi
import html
from typing import Self, TYPE_CHECKING
from dzgui.const.constants import EXPAND, FILL
from dzgui.const.constants import CALCULATOR
from dzgui.const.enum import ContextMenuGroup
from dzgui.model.model_factory import ModelFactory
from dzgui.util import css
from dzgui.util import strings
from dzgui.util.format import format_hyperlinks, format_server_mods
from dzgui.strings import server_mods
from dzgui.strings import server_mods, dialogs
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.trees.tree_base import TreeView
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
from gi.repository import Gtk, Gdk, Pango # noqa
if TYPE_CHECKING:
from dzgui.api.servers import Details
@@ -46,12 +47,19 @@ class ServerDialog(GenericDialog):
self.connect("response", self._on_response)
self.view.connect("key-press-event", self._on_keypress)
self.scrollable_tree = Gtk.ScrolledWindow()
self.scrollable_tree = Gtk.ScrolledWindow(vexpand=True)
self.scrollable_tree.add(self.view)
self.scrollable_tree.set_size_request(700, 400)
self.scrollable_tree.set_size_request(700, 500)
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.pack_start(self.scrollable_tree, EXPAND, FILL, 0)
self.content.pack_start(self.stack, expand=True, fill=True, padding=0)
def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> bool:
# NOTE: ESC normally unfocuses treeview instead of destroying dialog
@@ -66,9 +74,7 @@ class ServerDialog(GenericDialog):
class ServerDetailsDialog(ServerDialog):
def __init__(self, controller: "Controller", details: "Details"):
# TODO: server name should also be packed in details struct
name = controller.get_server_name()
super().__init__(controller, strings.server_details, name, menu=None)
super().__init__(controller, dialogs.server_details, details.name, menu=None)
self.store = Gtk.ListStore(str, str, Pango.Weight)
self.view.connect("row-activated", self._on_row_activated)
@@ -85,14 +91,9 @@ class ServerDetailsDialog(ServerDialog):
column.set_sort_column_id(i)
column.set_expand(True)
# NOTE: constrain tree area to logical height of metadata
# leaving room for server message
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")
scrollable_message = Gtk.ScrolledWindow(vexpand=True)
desc = Gtk.Label(label=dialogs.server_message, valign=Gtk.Align.START)
css.add_class(desc, "server-subheading")
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER)
self.description = Gtk.Label(justify=Gtk.Justification.CENTER, wrap=True)
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
@@ -100,11 +101,7 @@ class ServerDetailsDialog(ServerDialog):
for el in desc, sep, self.description:
box.add(el)
scrollable_message.add(box)
self.pack(scrollable_message)
if details.data is None:
return
self.content_box.add(scrollable_message)
for row in details.data:
self.store.append(row + [Pango.Weight.BOLD])
@@ -113,8 +110,32 @@ class ServerDetailsDialog(ServerDialog):
text = format_hyperlinks(text)
self.description.set_markup(html.escape(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()
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(
self,
treeview: Gtk.TreeView,
+8 -16
View File
@@ -17,6 +17,7 @@ from dzgui.const.constants import (
from dzgui.const.boilerplate import config_boilerplate
from dzgui.const.endpoints import STEAM_API_SETUP
from dzgui.config import freedesktop
from dzgui.init.proc import path_to_steam_cmd
from dzgui.init.migrate import migrate_legacy_conf
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import wizard
@@ -25,7 +26,6 @@ from dzgui.util.open_links import open_link_by_url
from dzgui.util.css import add_class, load_css
from dzgui.views.components.buttons import WebButton
from dzgui.views.components.entry import APIEntry
from dzgui.views.components.misc import ClientCombo
from dzgui.views.mixins.colorscheme import ColorAwareApp
import gi
@@ -384,26 +384,17 @@ class PreferencesPage(EnumeratedWizardPage):
self.dist_box.add(self.radio_km)
self.dist_box.add(self.radio_miles)
client_label = Gtk.Label(label=wizard.label_client, halign=Gtk.Align.START)
self.client_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self.client_combo = ClientCombo()
self.client_box.add(client_label)
self.client_box.add(self.client_combo)
outer_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER, spacing=10
)
for el in self.name_box, self.client_box, self.dist_box:
for el in self.name_box, self.dist_box:
outer_box.add(el)
self.add_start(outer_box)
def get_prefs(self) -> tuple[str, bool, str]:
def get_prefs(self) -> tuple[str, bool]:
name = self.name_entry.get_text().strip()
use_miles = self.radio_miles.get_active()
model = self.client_combo.get_model()
ind = self.client_combo.get_active()
client = model[ind][1]
return name, use_miles, client
return name, use_miles
def _on_entry_changed(self, entry: Gtk.Entry) -> None:
text = entry.get_text()
@@ -499,15 +490,16 @@ class Assistant(Gtk.Assistant):
self.config_values = self.page_migration.migrated_conf
return self.get_final_page()
case SteamPathPage():
self.config_values["default_steam_path"] = page.get_path_from_radio()
path = page.get_path_from_radio()
self.config_values["default_steam_path"] = path
self.config_values["client"] = path_to_steam_cmd(path)
case SteamValidationPage():
self.config_values["steam_api"] = page.get_api_key()
case PreferencesPage():
# NOTE: collects config values before advancing to last page
name, use_miles, client = self.page_prefs.get_prefs()
name, use_miles = self.page_prefs.get_prefs()
self.config_values["name"] = name
self.config_values["use_miles"] = use_miles
self.config_values["client"] = client
self.page_shortcuts.set_steam_path(
self.config_values["default_steam_path"]
)
+3 -32
View File
@@ -25,7 +25,7 @@ from dzgui.views.components.eventbox import InfoEventBox
from dzgui.views.components.labels import LeftLabel
from dzgui.views.components.buttons import SpinnerButton, WebButton
from dzgui.views.components.frame import HeadingFrame
from dzgui.views.components.misc import ClientCombo, ErrorPopover
from dzgui.views.components.misc import ErrorPopover
from dzgui.views.dialogs.generic import ExceptionDialog
from dzgui.views.mixins.scrollable_mixin import ScrollableMixin
@@ -76,7 +76,7 @@ class SubmitField(Gtk.Box):
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("changed", self._on_text_typed)
self.entry.connect("activate", self._on_field_activated)
self.entry.get_property("buffer").connect("deleted-text", self._on_text_deleted)
@@ -113,13 +113,9 @@ class SubmitField(Gtk.Box):
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
text = entry.get_text()
state = self._is_valid_text(text)
self.button.set_sensitive(state)
@@ -285,11 +281,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
Preferences.WINDOW,
)
self.client_combo = ClientCombo()
self.client_combo.connect("changed", self._on_client_changed)
client_hbox = ShortHBox(self.client_combo)
self.distance_toggle = ToggleField(
self.controller, strings.options.km, strings.options.mi, Preferences.DIST
)
@@ -313,7 +304,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
self.start_tab_combo.connect("changed", self._on_start_tab_changed)
pref_rows = [
[LeftLabel(strings.options.client), client_hbox],
[LeftLabel(strings.options.window_size), self.fullscreen_toggle],
[LeftLabel(strings.options.distance), self.distance_toggle],
[LeftLabel(options.start_tab), start_tab_hbox],
@@ -371,19 +361,11 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
self.connect("key-press-event", self._on_keypress)
def get_client_name(self) -> str:
model = self.client_combo.get_model()
ind = self.client_combo.get_active()
return str(model[ind][0])
def block_text_entry(self) -> None:
self.steam_box.block_text_entry()
# self.steam_entry.set_position(-1)
# self.steam_entry.set_can_focus(False)
def unblock_text_entry(self) -> None:
self.steam_box.unblock_text_entry()
# self.steam_entry.set_can_focus(True)
def _on_developers_clicked(self, button: Gtk.Button) -> None:
self.controller.show_developers_page()
@@ -418,13 +400,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
index = enum.value
self.controller.update_config(Preferences.START_TAB, index)
def _on_client_changed(self, combo: Gtk.ComboBoxText) -> None:
_iter = combo.get_active_iter()
if _iter is None:
raise ValueError(f"No active iterator set on {combo}")
real_cmd = combo.get_model()[_iter][1]
self.controller.update_config(Preferences.CLIENT, real_cmd)
def populate_settings(self) -> None:
prefs = self.controller.get_prefs()
# NOTE: re-check in case file was removed by user between runs
@@ -470,10 +445,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
self.dayz_version_label.set_text(dayz_version)
self.dayz_exp_version_label.set_text(dayz_exp_version)
# TODO: bicolumn list store with no cell renderer on index 1, use raw command names
active_combo = query.get_client_index(config["client"])
self.client_combo.set_active(active_combo)
start_tab = self.controller.query_config(Preferences.START_TAB)
self.start_tab_combo.set_active(start_tab)
+3 -2
View File
@@ -176,8 +176,7 @@ class PreConnectionAssistant(Gtk.Box):
self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.error_tree = MaskedTree(ERROR)
# TODO: strings
self.error_placeholder = Placeholder("No errors.")
self.error_placeholder = Placeholder(preconnect.no_errors)
self.error_box.add(self.error_tree)
self.error_box.add(self.error_placeholder)
self.error_frame = HeadingFrame.new_with_widget_and_label(
@@ -280,6 +279,8 @@ class PreConnectionAssistant(Gtk.Box):
warnings.append(preconnect.protected_server)
if prereqs.dayz_running is True:
warnings.append(preconnect.dayz_running)
if len(prereqs.mods) > 0 and prereqs.download_paused:
warnings.append(preconnect.downloads_paused)
allows_dl, running_app = prereqs.allows_downloads
if len(prereqs.mods) > 0 and allows_dl is False:
+18 -12
View File
@@ -66,7 +66,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
self.handler_id: int
self.queue: Queue = Queue()
self.seen_cache: list[str] = []
self.seen_cache: list[Record] = []
prefs = self.controller.get_prefs()
columns = prefs.paths.columns
@@ -252,7 +252,6 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
case Gdk.KEY_c:
self.controller.menu_action(ContextMenu.COPY_SERVER_IP, self)
case Gdk.KEY_r:
# TODO: unimplemented, needs threading
self.controller.menu_action(ContextMenu.REFRESH_PLAYERS, self)
else:
match event.keyval:
@@ -341,16 +340,24 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
control = self.proxy_man.get_control()
return model, control
@staticmethod
def ping_server(
self,
model: "FastInsertListStore",
_iter: Gtk.TreeIter,
ip: str,
qport: int,
ping_column: int,
record: Record,
) -> None:
_ping = ping(ip, qport)
GLib.idle_add(lambda: model.set(_iter, ping_column, _ping))
_ping = ping(record.ip, record.qport)
GLib.idle_add(self._redraw, model, _iter, _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(
self,
@@ -363,13 +370,12 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
addr_column = 7
qport_column = 8
ping_column = 9
addr = model.get_value(_iter, addr_column).split(":")
ip = addr[0]
gameport = addr[1]
gameport = int(addr[1])
qport = model.get_value(_iter, qport_column)
record = f"{addr}:{gameport}:{qport}"
record = Record(ip, gameport, qport)
if record in self.seen_cache:
return
@@ -378,7 +384,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
thread = threading.Thread(
daemon=True,
target=self.ping_server,
args=(model, _iter, ip, qport, ping_column),
args=(model, _iter, record),
)
thread.start()
+2 -1
View File
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
authors = [
{name = "aclist"}
]
version = "7.0.0b23"
version = "7.0.0b28"
license = "GPL-3.0-or-later"
license-files = ["LICENSE"]
readme = "README.md"
@@ -47,6 +47,7 @@ docs = [
]
test = [
"black==25.1.0",
"hypothesis==6.167.1",
"pre-commit==4.4.0",
"pytest==8.4.2",
"pytest-cov==7.0.0",
+1 -1
View File
@@ -8,6 +8,6 @@
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"Timestamp" "0"
"timestamp" "0"
}
}
+2 -2
View File
@@ -8,7 +8,7 @@
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"Timestamp" "1787904411"
"timestamp" "1787904411"
}
"1"
{
@@ -18,6 +18,6 @@
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"Timestamp" "1787904647"
"timestamp" "1787904647"
}
}
+2 -2
View File
@@ -8,7 +8,7 @@
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"Timestamp" "1797904411"
"timestamp" "1797904411"
}
"1"
{
@@ -18,6 +18,6 @@
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"Timestamp" "1787904647"
"timestamp" "1787904647"
}
}
+1 -1
View File
@@ -9,6 +9,6 @@
"SkipOfflineModeWarning" "0"
"AllowAutoLogin" "1"
"MostRecent" "1"
"Timestamp" "0"
"timestamp" "0"
}
}
+2 -2
View File
@@ -9,7 +9,7 @@
"SkipOfflineModeWarning" "0"
"AllowAutoLogin" "1"
"MostRecent" "0"
"Timestamp" "0"
"timestamp" "0"
}
"1"
{
@@ -20,6 +20,6 @@
"SkipOfflineModeWarning" "0"
"AllowAutoLogin" "1"
"MostRecent" "1"
"Timestamp" "10"
"timestamp" "10"
}
}
+19
View File
@@ -0,0 +1,19 @@
import pytest
from dzgui.const.constants import FLATPAK_RUN_CMD, STEAM_CMD
from dzgui.controllers.mc import Controller
@pytest.mark.parametrize(
"value, expected", [(FLATPAK_RUN_CMD, "Flatpak"), (STEAM_CMD, "Steam")]
)
def test_human_name(monkeypatch, value, expected) -> None:
def mock_query_config(*args) -> str:
return value
monkeypatch.setattr(
"dzgui.controllers.mc.Controller.query_config", mock_query_config
)
controller = Controller()
client = controller.get_client_name()
assert client == expected
+3 -4
View File
@@ -10,7 +10,7 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gio, GLib # noqa
class TestWidget(ColorAwareApp, Gtk.Button):
class DummyWidget(ColorAwareApp, Gtk.Button):
def __init__(self) -> None:
super().__init__()
@@ -22,11 +22,10 @@ class TestWidget(ColorAwareApp, Gtk.Button):
@pytest.fixture
def widget() -> TestWidget:
return TestWidget()
def widget() -> DummyWidget:
return DummyWidget()
@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(
+8 -3
View File
@@ -22,9 +22,14 @@ class MockController:
def test_html_escaping() -> None:
text = "<Server description> Game & mods"
details = Details([["0", "1"]], text, True)
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() == text
assert dialog.description.get_text() == desc
+9
View File
@@ -3,6 +3,7 @@ import pytest
from pathlib import Path
from dzgui.api.steam import find_user_id
from dzgui.const.constants import DEFAULT_STEAM_PATH
from tests.fixtures import fixture_path
pytestmark = pytest.mark.apitest
@@ -25,3 +26,11 @@ def test_loginusers(monkeypatch, fixture: str, expect: int) -> None:
monkeypatch.setattr("dzgui.api.steam.find_loginusers", mock_loginusers)
uid = find_user_id(Path(""))
assert int(uid) == expect
@pytest.mark.realconfig
def test_live_loginusers() -> None:
"""Added on 2026-10-17 following changes to VDF keys"""
steam = Path.home().joinpath(DEFAULT_STEAM_PATH)
uid = find_user_id(steam)
assert uid is not None
+27
View File
@@ -0,0 +1,27 @@
import pytest
from pathlib import Path
from dzgui.api.steam import is_dayz_downloading
@pytest.mark.parametrize(
"file_list, expect",
[
(["state_221100_1111.patch"], True),
(["state_730_1111.patch"], False),
(["state_1024020_1111.patch"], True),
(["221100"], False),
(["221100", "state_221100_1111.patch"], True),
(["state_221100_1111.patch", "state_1024020_1111.patch"], True),
(["state_221100_1111"], False),
],
)
def test_dayz_patch_pending(monkeypatch, file_list, expect) -> None:
def mock_get_pending_downloads(steam_path: Path, appid: int) -> None:
files = [Path(file) for file in file_list]
return files
monkeypatch.setattr(
"dzgui.api.steam.get_pending_downloads", mock_get_pending_downloads
)
assert is_dayz_downloading(Path("/tmp"), Path("/tmp")) == expect
+76
View File
@@ -0,0 +1,76 @@
import unittest
from unittest.mock import patch
from dzgui.const.constants import (
FLATPAK_APPID,
FLATPAK_ROOT_PATH,
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 output, expected in (
(
[
[FLATPAK_ROOT_PATH],
"/home/user/.local/share/Steam/ubuntu12_64/steamwebhelper",
],
True,
),
([[FLATPAK_ROOT_PATH]], False),
([[FLATPAK_ROOT_PATH], ["steam-runtime-launcher-service"]], False),
([["/home/user/.local/share/Steam/ubuntu12_64/steamwebhelper"]], True),
(
[
["/home/user/.local/share/Steam/ubuntu12_64/steamwebhelper"],
"steam-runtime-launcher-service",
],
True,
),
):
with (
self.subTest(running=output),
patch.object(proc, "get_steam_procs", return_value=output),
):
self.assertEqual(proc.is_native_steam_running(), expected)
def test_invalid_client(self):
with self.assertRaises(TypeError):
proc.is_steam_running("invalid-client")
+45
View File
@@ -0,0 +1,45 @@
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
@@ -0,0 +1,112 @@
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
+1 -2
View File
@@ -2,7 +2,6 @@ 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}"
@@ -18,6 +17,6 @@ def test_day() -> None:
def test_night() -> None:
for h in range(24):
if 6 < h < 17:
if 5 < h < 18:
continue
iterate(h, NIGHT_REG)