Compare commits

...
Author SHA1 Message Date
aclist 1752018331 fix: handle HTTPErrors from workshop (#477) 2026-10-14 22:53:02 +09: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
aclist 3c92108ac3 Merge pull request #468 from aclist/fix/wizard-config-exit
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
fix: write config later in install wizard
2026-08-29 16:21:57 +09:00
aclist 0f6f1c03ec chore: update changelog
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-08-29 15:55:36 +09:00
aclist 39d8607f57 chore: drop unused code 2026-08-29 15:55:24 +09:00
aclist 64d784f9a0 fix: apply keyword filter when refreshing (#469) 2026-08-29 15:55:02 +09:00
aclist 667203b4f9 chore: clear typehinting errors
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-08-28 17:36:23 +09:00
aclist 00369837cb fix: support multiple user ids 2026-08-28 17:35:29 +09:00
aclist 9bc5964df2 chore: add XDG path conversion test 2026-08-28 16:58:44 +09:00
aclist b7b83b3ca5 chore: drop unused imports 2026-08-28 16:35:34 +09:00
aclist d04c20498a change: write config only on last page 2026-08-28 16:35:08 +09:00
aclist 572ac5d7ce chore: drop unused code 2026-08-28 16:07:06 +09:00
aclist b4125c5a51 change: change log level 2026-08-28 16:01:09 +09:00
aclist 8bae2d5fb5 chore: add log output when setting start tab 2026-08-28 16:00:14 +09:00
aclist a85c632902 change: namespace dlc strings 2026-08-28 15:52:15 +09:00
aclist 74ecc30692 feat: add badlands string support 2026-08-28 15:49:50 +09:00
aclist 33843becae chore: bump version 2026-08-28 15:49:19 +09:00
aclist 07cbf7a36a change: always show shortcuts page
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
The expected shortcut path is already parsed by dzgui.config.xdg, which
invokes is_writeable(); it will use a writeable fallback path for
XDG_DATA_HOME.  There is no need for a conditional check here. Even if
the launcher itself is installed to a non-writeable system location, the
shortcut can still copy from that.
2026-08-26 16:55:25 +09:00
aclist 687c5ac168 chore: add comments 2026-08-25 20:09:56 +09:00
aclist f3fd7ef6ea change: revert prior changes 2026-08-25 20:09:02 +09:00
aclist 2383f7d2aa change: simplify method 2026-08-25 20:03:42 +09:00
aclist c839672f85 chore: clear typehinting errors 2026-08-25 20:03:32 +09:00
aclist ca234b8dea drop: unused imports 2026-08-25 19:58:37 +09:00
aclist 8e3ce72295 change: do not store pages linearly 2026-08-25 19:57:52 +09:00
aclist f02d959ebc change: write config later 2026-08-25 19:57:07 +09:00
aclist 04e9e5dcf0 Merge pull request #467 from aclist/change/baseline-cpu-arch
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
change: target baseline cpu architecture
2026-08-25 16:21:36 +09:00
aclist 1b06fcae42 change: target baseline cpu architecture 2026-08-25 16:20:48 +09:00
aclist 9759c39fd7 Merge pull request #466 from aclist/feat/color-aware-app
feat: color scheme aware app
2026-08-23 20:22:58 +09:00
aclist 4451eee849 docs: update changelog 2026-08-23 20:22:10 +09:00
aclist 3f587f64a0 chore: drop extraneous newline 2026-08-23 19:33:33 +09:00
aclist b00523661f chore: update exclusions 2026-08-23 19:30:10 +09:00
aclist f24b3574b8 chore: clear typehinting errors 2026-08-23 19:29:51 +09:00
aclist fad8d2f43b feat: add ColorAwareApp mixin (resolves #465) 2026-08-23 19:28:42 +09:00
aclist e1e034fffd chore: initialize and run app 2026-08-23 19:26:41 +09:00
aclist ea2ca684d6 chore: restructure as Gtk.Application and drop Gtk.main loop 2026-08-23 19:26:16 +09:00
aclist 2e2d3484c4 chore: drop unused imports 2026-08-23 19:24:06 +09:00
aclist 1254044ef8 chore: drop Gtk.main_quit call 2026-08-23 19:23:39 +09:00
aclist 7d651e9653 feat: add GIO property constants 2026-08-23 19:22:28 +09:00
55 changed files with 1147 additions and 224 deletions

No files matched your search

+3
View File
@@ -51,6 +51,8 @@
- Update map count (-u flag) - Update map count (-u flag)
- Filter button tooltips - Filter button tooltips
- Expand/collapse changelog versions - 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
@@ -89,6 +91,7 @@
- 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
+13 -5
View File
@@ -1,15 +1,23 @@
## What this is ## 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: 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 local mods and symlinks - Manage and delete local mods
- Prepare launch options to pass to Steam - 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 ## 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) - [GitHub](https://aclist.github.io/dzgui/index.html)
- [Codeberg (Mirror)](https://aclist.codeberg.page) - [Codeberg (Mirror)](https://aclist.codeberg.page)
![A screenshot of DZGUI](/images/example.png) ![A screenshot of DZGUI](/dzgui/data/images/example.png)
## Attribution ## Attribution
+29 -29
View File
@@ -54,19 +54,16 @@ class Res:
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
class Details: class Details:
data: Union[list, None] data: list[list[str]]
name: str
description: str description: str
success: bool gametime: str
day_accel: float
night_accel: float
@dataclass(slots=True) @dataclass(slots=True, frozen=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
@@ -283,43 +280,41 @@ 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.warning(e) logger.debug(e)
return None return None
def get_details(record: Record) -> Details: def get_details(record: Record) -> Details | None:
# 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 AttributeError: except Exception as e:
return Details(None, default_str, False) logger.critical(e)
return None
battleye = strings.disabled battleye = strings.disabled
if "battleye" in keywords: if "battleye" in keywords:
battleye = strings.enabled battleye = strings.enabled
day_accel = 0.0 name = info.server_name
night_accel = 0.0 day_accel = 1.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 keywords: if "entm" in keyword:
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
@@ -346,10 +341,15 @@ def get_details(record: Record) -> Details:
try: try:
dlc = rules.dlc_flags dlc = rules.dlc_flags
if dlc == 0: match dlc:
dlc = strings.none case 0:
if dlc == 2: dlc = strings.dlc_none
dlc = strings.dlc_frostline case 2:
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:
["Version", version], ["Version", version],
] ]
return Details(rows, description, True) return Details(rows, name, description, gametime, day_accel, night_accel)
def ping(ip: str, qport: int) -> int: def ping(ip: str, qport: int) -> int:
+39 -9
View File
@@ -6,6 +6,7 @@ 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
@@ -32,6 +33,7 @@ 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)
@@ -151,7 +153,9 @@ 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: def connect_debug(
client: str, addr: str, appid: int, name: str, mods: list[str]
) -> str:
concat = concat_mods(mods) concat = concat_mods(mods)
client_args = concat_bash_args(client) client_args = concat_bash_args(client)
params = [ params = [
@@ -167,6 +171,7 @@ def connect_debug(client: str, addr: str, appid: int, name: str, mods: list[str]
client_args.extend(params) client_args.extend(params)
return " ".join(client_args) 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:
@@ -235,14 +240,18 @@ 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)
# NOTE: beta client users = v["users"]
# /package/beta # NOTE: beta client: /package/beta
if len(v["users"]) == 1: last_user = str(next(iter(users)))
return str(list(v["users"].keys())[0]) if len(users) == 1:
for user in v["users"]: return last_user
if v["users"][user]["MostRecent"] == "1": last_stamp = int(users[last_user]["Timestamp"])
return str(user) for user in users:
return None stamp = int(users[user]["Timestamp"])
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
@@ -255,6 +264,16 @@ 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,
@@ -266,8 +285,19 @@ 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:
+2 -1
View File
@@ -154,4 +154,5 @@ 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(prefs) app = App(prefs)
app.run(None)
+10 -1
View File
@@ -30,12 +30,14 @@ 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"
# FIXME: normalize icon names # TODO: 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"
@@ -53,6 +55,8 @@ 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"
@@ -91,3 +95,8 @@ 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"
+8 -4
View File
@@ -7,11 +7,12 @@ 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
@@ -37,7 +38,6 @@ 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
@@ -498,6 +498,9 @@ 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
@@ -523,6 +526,7 @@ 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: def get_debug_args(self) -> str:
+3
View File
@@ -51,6 +51,8 @@
- Update map count (-u flag) - Update map count (-u flag)
- Filter button tooltips - Filter button tooltips
- Expand/collapse changelog versions - 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
@@ -89,6 +91,7 @@
- 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
@@ -24,6 +24,10 @@
.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
} }
+9 -2
View File
@@ -4,17 +4,24 @@ 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 from dzgui.const.constants import APPID_DAYZ, APP_NAME, APPNAME_DAYZ
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)
raise e msg = resync.format(APPNAME_DAYZ)
raise VDFSyncError(msg)
+3 -3
View File
@@ -1,3 +1,4 @@
import json
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -7,11 +8,10 @@ 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) -> None: def migrate_legacy_conf(config: Path) -> Any:
old_conf = Path.home() / LEGACY_CONFIG_PATH old_conf = Path.home() / LEGACY_CONFIG_PATH
j = rc2json(old_conf) j = rc2json(old_conf)
config.parent.mkdir(parents=True, exist_ok=True) return json.loads(j)
config.write_text(j)
def has_new_config(config: Path) -> bool: def has_new_config(config: Path) -> bool:
+8 -7
View File
@@ -19,11 +19,6 @@ 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
@@ -206,7 +201,10 @@ class ConfigManager:
logger.info("Normal user exit") logger.info("Normal user exit")
if window.props.is_maximized: if window.props.is_maximized:
main_quit() app = window.get_application()
if app is None:
return
app.quit()
return return
w, h = window.get_size() w, h = window.get_size()
@@ -218,7 +216,10 @@ class ConfigManager:
except Exception as e: except Exception as e:
logger.critical(e) logger.critical(e)
main_quit() app = window.get_application()
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:
+15 -4
View File
@@ -48,7 +48,7 @@ from dzgui.strings.dialogs import (
) )
from dzgui.strings import kb 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_mib from dzgui.util.format import format_exception, 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
@@ -235,7 +235,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.success is False: if details is None:
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,6 +300,11 @@ 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.set_details_buffer(kb.DZG_006)
@@ -351,8 +356,14 @@ 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
subscribe(key, int(mod)) try:
time.sleep(API_RATE_LIMIT) 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) 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,4 +1,3 @@
import gi
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -14,10 +13,6 @@ 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:
+2 -2
View File
@@ -32,10 +32,10 @@ class FilterManager:
# TODO: strings # TODO: strings
self.tooltips = { self.tooltips = {
strings.filter_1pp: "First-person perspective", 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_empty: "Servers with no players",
strings.filter_3pp: "Third-person perspective", 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_full: "Servers with no open slots",
strings.filter_official: "Bohemia official servers", strings.filter_official: "Bohemia official servers",
strings.filter_nonascii: "Server names using non-standard, complex glyphs", strings.filter_nonascii: "Server names using non-standard, complex glyphs",
+5 -1
View File
@@ -122,7 +122,11 @@ 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)
unsubscribe(key, int(mod)) try:
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)
+5 -7
View File
@@ -12,8 +12,8 @@ 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][7-9]|[1][0-6])" DAY_REG = r"([0][6-9]|[1][0-7])"
NIGHT_REG = r"([0][0-6]|[1][7-9]|[2][0-3])" NIGHT_REG = r"([0][0-5]|[1][8-9]|[2][0-3])"
class ProxyModelManager: class ProxyModelManager:
@@ -259,10 +259,6 @@ 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 = []
@@ -298,4 +294,6 @@ 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)
self.filter(FilterMode.INITIAL) cur_keyword = self.filter_man.get_active_keyword()
mode = FilterMode.TOGGLE_ON if len(cur_keyword) > 0 else FilterMode.INITIAL
self.filter(mode)
+3 -1
View File
@@ -21,7 +21,7 @@ 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 # noqa E402 from gi.repository import Gtk, GLib # noqa E402
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo, Record from dzgui.api.servers import A2SInfo, Record
@@ -390,6 +390,8 @@ 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()
+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" from dzgui.const.constants import UDP_PORT
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="Enter IP:Query port (e.g., 192.168.1.1:27016)" connect_entry_placeholder = f"Enter IP:Query port (e.g., 192.168.1.1:{UDP_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="Default port (27016)" lan_default_button = f"Default port ({UDP_PORT})"
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_entry_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"
+5
View File
@@ -23,3 +23,8 @@ custom_mod_dialog = "Set location to a custom mods folder"
debug_heading = "Debug" debug_heading = "Debug"
debug_secondary = "Launch arguments" 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." 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."
+1 -2
View File
@@ -11,8 +11,6 @@ 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."
"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." required_space = " Need to download {0} MiB of mod updates."
placeholder_no_mods = "This server has no mods." 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." 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_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." version_missing = "Remote server is running the build '{0}', but it is not installed."
no_errors = "No errors."
# Warnings # Warnings
+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." not_installed = "It looks like DZGUI was not previously set up; nothing to uninstall."
standalone_uninstall = ( standalone_uninstall = (
"It looks like you are running the standalone version of DZGUI.\n" "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 = ( system_uninstall = (
"It looks like DZGUI was installed via your system package manager or directly from source code.\n" "It looks like DZGUI was installed via your system package manager or directly from source code.\n"
-1
View File
@@ -84,6 +84,5 @@ 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
+1 -9
View File
@@ -2,14 +2,13 @@ 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 COORDS_API, IP_ECHO from dzgui.const.endpoints import IP_ECHO
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -127,10 +126,3 @@ 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
+2 -3
View File
@@ -11,7 +11,6 @@ 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"
@@ -59,7 +58,6 @@ 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
@@ -168,11 +166,12 @@ 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"
+10 -5
View File
@@ -2,7 +2,12 @@ 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 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.const.enum import Preferences
from dzgui.config.query import lookup from dzgui.config.query import lookup
@@ -20,6 +25,7 @@ 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)
@@ -63,10 +69,9 @@ 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)
try: if source.exists():
source.symlink_to(parent) source.unlink()
except Exception: source.symlink_to(parent)
return ""
return f"{DAYZ_COMMUNITY_ROOT}/{stem}" return f"{DAYZ_COMMUNITY_ROOT}/{stem}"
+20 -10
View File
@@ -2,9 +2,14 @@ import logging
import signal import signal
import warnings import warnings
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal, Self
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, STEAM_ICON from dzgui.const.constants import (
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
@@ -14,6 +19,7 @@ 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,9 +52,11 @@ logger = logging.getLogger(APP_NAME)
warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) warnings.filterwarnings("ignore", ".*g_value_get_int", Warning)
class OuterWindow(Gtk.Window): class OuterWindow(Gtk.ApplicationWindow):
def __init__(self) -> None: def __init__(self, app: Gtk.Application) -> None:
super().__init__(title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER) super().__init__(
application=app, 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)
@@ -279,13 +287,18 @@ class Grid(Gtk.Grid):
self.right_panel.refresh_button.set_visible(state) self.right_panel.refresh_button.set_visible(state)
class App(Gtk.Application): class App(ColorAwareApp, Gtk.Application): # type: ignore
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.win = OuterWindow() self._setup_signals()
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(
@@ -296,9 +309,6 @@ class App(Gtk.Application):
) )
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"
+1
View File
@@ -24,6 +24,7 @@ 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): class ShortHBox(HBox):
def __init__(self, widget: Gtk.Widget) -> None: def __init__(self, widget: Gtk.Widget) -> None:
super().__init__(spacing=5) super().__init__(spacing=5)
-6
View File
@@ -9,8 +9,6 @@ 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
@@ -148,10 +146,6 @@ 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:
+3
View File
@@ -9,3 +9,6 @@ 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)
+5 -2
View File
@@ -4,6 +4,9 @@ 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
@@ -37,7 +40,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(Gtk.Dialog): class BootDialog(ColorAwareApp, Gtk.Dialog): # type: ignore
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,
@@ -99,7 +102,7 @@ class BootDialog(Gtk.Dialog):
margin_top=30, margin_top=30,
margin_bottom=30, margin_bottom=30,
) )
self.error_label = Gtk.Label() self.error_label = Gtk.Label(margin_start=15, margin_end=15)
# 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
@@ -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)
+2 -1
View File
@@ -3,6 +3,7 @@ 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
@@ -10,7 +11,7 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk # noqa E402
class AbortDialog(Gtk.MessageDialog): class AbortDialog(ColorAwareApp, Gtk.MessageDialog): # type: ignore
def __init__(self, string: str, buttons: Gtk.ButtonsType) -> None: def __init__(self, string: str, buttons: Gtk.ButtonsType) -> None:
super().__init__( super().__init__(
title=dialog_header, title=dialog_header,
+8
View File
@@ -309,6 +309,14 @@ class ExceptionDialog(TextBufferDialog):
content.set_spacing(0) content.set_spacing(0)
content.add(scrollable) 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( def _on_page_changed(
self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int
) -> None: ) -> None:
+46 -25
View File
@@ -1,20 +1,21 @@
import gi
import html import html
from typing import Self, TYPE_CHECKING 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.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 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.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, GLib, Gdk, GObject, Pango # noqa from gi.repository import Gtk, Gdk, Pango # noqa
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import Details from dzgui.api.servers import Details
@@ -46,12 +47,19 @@ 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() self.scrollable_tree = Gtk.ScrolledWindow(vexpand=True)
self.scrollable_tree.add(self.view) 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 = 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: 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
@@ -66,9 +74,7 @@ class ServerDialog(GenericDialog):
class ServerDetailsDialog(ServerDialog): class ServerDetailsDialog(ServerDialog):
def __init__(self, controller: "Controller", details: "Details"): def __init__(self, controller: "Controller", details: "Details"):
# TODO: server name should also be packed in details struct 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)
@@ -85,14 +91,9 @@ class ServerDetailsDialog(ServerDialog):
column.set_sort_column_id(i) column.set_sort_column_id(i)
column.set_expand(True) column.set_expand(True)
# NOTE: constrain tree area to logical height of metadata scrollable_message = Gtk.ScrolledWindow(vexpand=True)
# leaving room for server message desc = Gtk.Label(label=dialogs.server_message, valign=Gtk.Align.START)
self.scrollable_tree.set_size_request(700, 150) css.add_class(desc, "server-subheading")
# 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)
@@ -100,11 +101,7 @@ 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])
@@ -113,8 +110,32 @@ class ServerDetailsDialog(ServerDialog):
text = format_hyperlinks(text) text = format_hyperlinks(text)
self.description.set_markup(html.escape(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() 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,
+2 -1
View File
@@ -13,6 +13,7 @@ from dzgui.util.css import load_css
from dzgui.util.format import format_exception from dzgui.util.format import format_exception
from dzgui.views.components.box import HBox from dzgui.views.components.box import HBox
from dzgui.views.dialogs.wizard import ScrolledWizardPage, CheckboxWithLabel from dzgui.views.dialogs.wizard import ScrolledWizardPage, CheckboxWithLabel
from dzgui.views.mixins.colorscheme import ColorAwareApp
import gi import gi
@@ -217,7 +218,7 @@ class UninstallPage(ScrolledWizardPage):
pass pass
class UninstallWizard(Gtk.Application): class UninstallWizard(ColorAwareApp, Gtk.Application): # type: ignore
def __init__(self, is_deck: bool, paths: dict[str, str]) -> None: def __init__(self, is_deck: bool, paths: dict[str, str]) -> None:
super().__init__() super().__init__()
config = Path(paths["XDG_CONFIG_HOME"]) config = Path(paths["XDG_CONFIG_HOME"])
+42 -39
View File
@@ -1,4 +1,3 @@
import os
import textwrap import textwrap
from enum import Enum from enum import Enum
@@ -17,9 +16,7 @@ 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
@@ -29,6 +26,7 @@ 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
@@ -304,6 +302,7 @@ 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
@@ -343,8 +342,7 @@ 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:
# TODO: this could be deferred to the final page (prevents accidental destruction of dialog via ESC) self.migrated_conf = migrate_legacy_conf(self.config)
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:
@@ -436,20 +434,18 @@ 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.page1 = IntroductionPage() self.page_intro = IntroductionPage()
self.page2 = ConfigMigrationPage(XDG.config) self.page_migration = ConfigMigrationPage(XDG.config)
self.page3 = SteamPathPage() self.page_paths = SteamPathPage()
self.page4 = SteamValidationPage() self.page_api = SteamValidationPage()
self.page5 = PreferencesPage() self.page_prefs = PreferencesPage()
self.page6 = ShortcutCreationPage(XDG.shortcut) self.page_shortcuts = ShortcutCreationPage(XDG.shortcut)
self.page7 = CompletionPage() self.page_completion = CompletionPage()
self.set_forward_page_func(self._advance_page) self.set_forward_page_func(self._advance_page)
@@ -457,16 +453,18 @@ 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.page1, self.page_intro,
self.page2, self.page_migration,
self.page3, self.page_paths,
self.page4, self.page_api,
self.page5, self.page_prefs,
self.page6, self.page_shortcuts,
self.page7, self.page_completion,
): ):
# NOTE: skip config migration page if no legacy config file # NOTE: skip config migration page if no legacy config file
if ( if (
@@ -474,10 +472,8 @@ class Assistant(Gtk.Assistant):
and self.has_legacy_config is False and self.has_legacy_config is False
): ):
continue continue
# NOTE: disabled for now on system-provided packages ind = self._add_page(page, page.get_page_type())
if isinstance(page, ShortcutCreationPage) and not self.is_binary: self.pages[page] = ind
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)
@@ -485,8 +481,11 @@ class Assistant(Gtk.Assistant):
self.show_all() self.show_all()
load_css() load_css()
def write_config(self) -> None: def write_config(self, config: dict[str, Any]) -> None:
write_json(self.config_values, self.config_path) write_json(config, 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)
@@ -495,24 +494,23 @@ class Assistant(Gtk.Assistant):
pass pass
case ConfigMigrationPage(): case ConfigMigrationPage():
if page.is_migrated(): if page.is_migrated():
steam_path = lookup(self.config_path, Preferences.DEFAULT) sp = page.migrated_conf["default_steam_path"]
self.page6.set_steam_path(steam_path) self.page_shortcuts.set_steam_path(sp)
offset = 1 if not self.is_binary else 2 self.config_values = self.page_migration.migrated_conf
self.setup_complete = True return self.get_final_page()
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.page5.get_prefs() name, use_miles, client = self.page_prefs.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.write_config() self.page_shortcuts.set_steam_path(
self.page6.set_steam_path(self.config_values["default_steam_path"]) self.config_values["default_steam_path"]
self.setup_complete = True )
case ShortcutCreationPage(): case ShortcutCreationPage():
page.create_shortcuts() page.create_shortcuts()
case _: case _:
@@ -542,11 +540,12 @@ class Assistant(Gtk.Assistant):
def _add_page( def _add_page(
self, page: EnumeratedWizardPage, ptype: Gtk.AssistantPageType self, page: EnumeratedWizardPage, ptype: Gtk.AssistantPageType
) -> None: ) -> int:
self.append_page(page) 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
@@ -563,6 +562,10 @@ 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:
@@ -706,7 +709,7 @@ class SteamPathPage(EnumeratedWizardPage):
self.err_box.set_visible(False) self.err_box.set_visible(False)
class SetupWizard(Gtk.Application): class SetupWizard(ColorAwareApp, Gtk.Application): # type: ignore
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
@@ -0,0 +1,37 @@
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)
+1 -2
View File
@@ -176,8 +176,7 @@ 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)
# TODO: strings self.error_placeholder = Placeholder(preconnect.no_errors)
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(
+18 -12
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[str] = [] self.seen_cache: list[Record] = []
prefs = self.controller.get_prefs() prefs = self.controller.get_prefs()
columns = prefs.paths.columns columns = prefs.paths.columns
@@ -252,7 +252,6 @@ 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:
@@ -341,16 +340,24 @@ 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,
ip: str, record: Record,
qport: int,
ping_column: int,
) -> None: ) -> None:
_ping = ping(ip, qport) _ping = ping(record.ip, record.qport)
GLib.idle_add(lambda: model.set(_iter, ping_column, _ping)) 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( def _get_ping(
self, self,
@@ -363,13 +370,12 @@ 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 = addr[1] gameport = int(addr[1])
qport = model.get_value(_iter, qport_column) qport = model.get_value(_iter, qport_column)
record = f"{addr}:{gameport}:{qport}" record = Record(ip, gameport, qport)
if record in self.seen_cache: if record in self.seen_cache:
return return
@@ -378,7 +384,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, ip, qport, ping_column), args=(model, _iter, record),
) )
thread.start() thread.start()
+2 -1
View File
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
authors = [ authors = [
{name = "aclist"} {name = "aclist"}
] ]
version = "7.0.0b22" version = "7.0.0b25"
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,6 +47,7 @@ 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_v3-unknown-linux-gnu-install_only_stripped.tar.gz" build = f"{CPYTHON_BUILD}%2B{DATE}-x86_64-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)
+12 -1
View File
@@ -1,12 +1,23 @@
from pathlib import Path from pathlib import Path
def iterate(search_str: str) -> None: def iterate(search_str: str) -> None:
# TODO: exclude files from .gitignore # TODO: exclude files from .gitignore
print() print()
print(f"Searching for '{search_str}'") print(f"Searching for '{search_str}'")
print() print()
files = Path(".").rglob("*") files = Path(".").rglob("*")
ignore = [".git", ".mypy", "build/", "test.py"] ignore = [
"scripts/",
".git",
"egg",
"CHANGELOG",
".mypy",
"build/",
"test.py",
"bugs",
"pyproject.toml",
]
for file in files: for file in files:
if file.is_dir(): if file.is_dir():
continue continue
+23
View File
@@ -0,0 +1,23 @@
"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"
}
}
@@ -0,0 +1,23 @@
"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" "0" "Timestamp" "10"
} }
} }
+59
View File
@@ -0,0 +1,59 @@
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
+8 -3
View File
@@ -22,9 +22,14 @@ class MockController:
def test_html_escaping() -> None: def test_html_escaping() -> None:
text = "<Server description> Game & mods" name = "Test server"
details = Details([["0", "1"]], text, True) 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() controller = MockController()
dialog = ServerDetailsDialog(controller, details) dialog = ServerDetailsDialog(controller, details)
dialog.destroy()
assert dialog.description.get_text() == text assert dialog.description.get_text() == desc
+2
View File
@@ -14,6 +14,8 @@ 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
@@ -0,0 +1,13 @@
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")
+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
+108
View File
@@ -0,0 +1,108 @@
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:
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 from dzgui.model.proxy_model import DAY_REG, NIGHT_REG
def iterate(h: str, r: str) -> None: def iterate(h: str, r: str) -> None:
for m in range(60): for m in range(60):
time = f"{h:02}:{m:02}" time = f"{h:02}:{m:02}"
@@ -18,6 +17,6 @@ def test_day() -> None:
def test_night() -> None: def test_night() -> None:
for h in range(24): for h in range(24):
if 6 < h < 17: if 5 < h < 18:
continue continue
iterate(h, NIGHT_REG) iterate(h, NIGHT_REG)