mirror of
https://github.com/aclist/dztui.git
synced 2026-10-25 12:12:31 +01:00
Compare commits
382
Commits
7.0.0b17
...
b70edebd05
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b70edebd05 | ||
|
|
ae1c3a19d4 | ||
|
|
7822672cda | ||
|
|
f2a7bde432 | ||
|
|
00b5476aaf | ||
|
|
2a08b9ba82 | ||
|
|
0f8b6fcaad | ||
|
|
95988459d5 | ||
|
|
eed40e6f1a | ||
|
|
07460a1f8f | ||
|
|
a74d576387 | ||
|
|
052f7f0708 | ||
|
|
6feae44887 | ||
|
|
8cdb615796 | ||
|
|
31b6705017 | ||
|
|
ef1e7d17f5 | ||
|
|
3c52072eb5 | ||
|
|
bf69936891 | ||
|
|
fa180b750a | ||
|
|
96d50ffafa | ||
|
|
6c801ffec5 | ||
|
|
5eb70a1eb8 | ||
|
|
a3fff40f8f | ||
|
|
343630d2da | ||
|
|
d63c7ce3ec | ||
|
|
f7ad357e6f | ||
|
|
035a01fe34 | ||
|
|
7dd14804aa | ||
|
|
1cc1ba2864 | ||
|
|
8c4d0aecdc | ||
|
|
616f18452a | ||
|
|
1064f7a9d6 | ||
|
|
b0e85f0e35 | ||
|
|
4eec00e8e7 | ||
|
|
09ef3e446a | ||
|
|
4d02604bcf | ||
|
|
ebffbe39f2 | ||
|
|
61860f6aee | ||
|
|
191776ce74 | ||
|
|
7ae34747e9 | ||
|
|
1555ba2d64 | ||
|
|
4bac804fd7 | ||
|
|
748e1870da | ||
|
|
cd4c6fdfb2 | ||
|
|
1752018331 | ||
|
|
b3b05918b7 | ||
|
|
e6e0a3036d | ||
|
|
8c44a6e255 | ||
|
|
7fc1264b65 | ||
|
|
f748b09ba7 | ||
|
|
01be1247b4 | ||
|
|
27d5c4b4b2 | ||
|
|
045e47037e | ||
|
|
3bc2a54ee4 | ||
|
|
4e22b82703 | ||
|
|
492ed221c4 | ||
|
|
71a61a9201 | ||
|
|
516735defa | ||
|
|
9a3df70914 | ||
|
|
c41e47859f | ||
|
|
11bbe15ecb | ||
|
|
d40512dfa0 | ||
|
|
f07e503b74 | ||
|
|
3caeb823a2 | ||
|
|
6fcd887d56 | ||
|
|
c15b77d470 | ||
|
|
cee1e3b8af | ||
|
|
e8ccdd7cbb | ||
|
|
2629fe254e | ||
|
|
3b15bf58e7 | ||
|
|
e773a4909b | ||
|
|
95380681f9 | ||
|
|
18192a4667 | ||
|
|
f07a2dea5f | ||
|
|
0e58cf514e | ||
|
|
84c6a3868b | ||
|
|
7c0981981f | ||
|
|
e653c61955 | ||
|
|
d4bf9f71f6 | ||
|
|
cb5c233f45 | ||
|
|
ac286f4a72 | ||
|
|
e254821105 | ||
|
|
32b8c5cbc5 | ||
|
|
76ed7f2451 | ||
|
|
f135f6a35d | ||
|
|
eb5d5d5181 | ||
|
|
fef8c69f38 | ||
|
|
32395812a0 | ||
|
|
6d046ee719 | ||
|
|
1ebf253e99 | ||
|
|
ee31dce6e6 | ||
|
|
4aaf7fe195 | ||
|
|
b7e8982f40 | ||
|
|
c3b41ed9b4 | ||
|
|
e42fd26855 | ||
|
|
7a8ccca317 | ||
|
|
72e3054afb | ||
|
|
2f4606ede0 | ||
|
|
c6a75c1e78 | ||
|
|
bad7878c0a | ||
|
|
5014402e39 | ||
|
|
baade5b721 | ||
|
|
3c92108ac3 | ||
|
|
0f6f1c03ec | ||
|
|
39d8607f57 | ||
|
|
64d784f9a0 | ||
|
|
667203b4f9 | ||
|
|
00369837cb | ||
|
|
9bc5964df2 | ||
|
|
b7b83b3ca5 | ||
|
|
d04c20498a | ||
|
|
572ac5d7ce | ||
|
|
b4125c5a51 | ||
|
|
8bae2d5fb5 | ||
|
|
a85c632902 | ||
|
|
74ecc30692 | ||
|
|
33843becae | ||
|
|
07cbf7a36a | ||
|
|
687c5ac168 | ||
|
|
f3fd7ef6ea | ||
|
|
2383f7d2aa | ||
|
|
c839672f85 | ||
|
|
ca234b8dea | ||
|
|
8e3ce72295 | ||
|
|
f02d959ebc | ||
|
|
04e9e5dcf0 | ||
|
|
1b06fcae42 | ||
|
|
9759c39fd7 | ||
|
|
4451eee849 | ||
|
|
3f587f64a0 | ||
|
|
b00523661f | ||
|
|
f24b3574b8 | ||
|
|
fad8d2f43b | ||
|
|
e1e034fffd | ||
|
|
ea2ca684d6 | ||
|
|
2e2d3484c4 | ||
|
|
1254044ef8 | ||
|
|
7d651e9653 | ||
|
|
d9f392f8c9 | ||
|
|
6281ed37b3 | ||
|
|
0bb2ae2266 | ||
|
|
89b4e2ec98 | ||
|
|
b6d3f8d385 | ||
|
|
1b6ebac3a3 | ||
|
|
629c974b9d | ||
|
|
371eaa34ac | ||
|
|
c29346e134 | ||
|
|
79fd96e834 | ||
|
|
cc1948d225 | ||
|
|
e32c7fcacb | ||
|
|
76b3186ed7 | ||
|
|
493818b770 | ||
|
|
e2a6fa0c7e | ||
|
|
0a310f0b70 | ||
|
|
3f1aff7cc6 | ||
|
|
c348a499b0 | ||
|
|
5ff936f266 | ||
|
|
dfeafc3d28 | ||
|
|
96b91fd87c | ||
|
|
71d760dc56 | ||
|
|
aeffce2579 | ||
|
|
93d870b5bf | ||
|
|
63c2b5acd4 | ||
|
|
0d5e190b75 | ||
|
|
e9d064820e | ||
|
|
17b1a75745 | ||
|
|
e7ff025605 | ||
|
|
ec004aad5d | ||
|
|
bd022ea1d8 | ||
|
|
52b18f710f | ||
|
|
9ec3fa09e3 | ||
|
|
cb9ffb5b99 | ||
|
|
37c3937aee | ||
|
|
cf850c8f34 | ||
|
|
1332026c8c | ||
|
|
4eaedd0cdd | ||
|
|
b1f189bff8 | ||
|
|
b153ab875e | ||
|
|
de4e74daa1 | ||
|
|
0dfcc5a387 | ||
|
|
c64631cdca | ||
|
|
97ddbcdf0a | ||
|
|
ddc18e5705 | ||
|
|
6fb83b5c31 | ||
|
|
b5105ca9f9 | ||
|
|
05fab5774e | ||
|
|
203cf0da7f | ||
|
|
7dfdb4209a | ||
|
|
c03aa8f166 | ||
|
|
c34fa2fe03 | ||
|
|
0f4d611345 | ||
|
|
ca1958b0a6 | ||
|
|
c7bd6f635c | ||
|
|
2b394553b3 | ||
|
|
5b83a26f6a | ||
|
|
6ded9a3eec | ||
|
|
1a3154712f | ||
|
|
9f8b760c79 | ||
|
|
0f59d92bc3 | ||
|
|
cca1006efb | ||
|
|
027e6c0781 | ||
|
|
3932a3aed4 | ||
|
|
22c9be36a6 | ||
|
|
a2938c17f5 | ||
|
|
48b0660ebd | ||
|
|
5eb89a172d | ||
|
|
c037cf9c40 | ||
|
|
7cc8076df1 | ||
|
|
b300b614a0 | ||
|
|
0bb8f3206f | ||
|
|
8087f3a707 | ||
|
|
0ebc25b3b4 | ||
|
|
1e073af27a | ||
|
|
250da7a635 | ||
|
|
8098c4b68c | ||
|
|
1e8a423ec3 | ||
|
|
067b77bf92 | ||
|
|
d0ccc3bf4c | ||
|
|
38cc55530f | ||
|
|
5536180fec | ||
|
|
79bbc25714 | ||
|
|
456c6e62e8 | ||
|
|
eeffdb57f3 | ||
|
|
88ad8cf7c2 | ||
|
|
9f9a88d2ef | ||
|
|
f7e0b44379 | ||
|
|
ee33ce6662 | ||
|
|
a421d4c70b | ||
|
|
080b99cda8 | ||
|
|
afca9e5e6a | ||
|
|
a97c17f66b | ||
|
|
fe8bf611e1 | ||
|
|
70ccbf29f3 | ||
|
|
f5d595eccd | ||
|
|
fd7a549dbc | ||
|
|
6c810b28a9 | ||
|
|
1a1145c79c | ||
|
|
510719e3ec | ||
|
|
e06775c3c8 | ||
|
|
f5c71200a8 | ||
|
|
557e221993 | ||
|
|
dd30fa33d1 | ||
|
|
1753a40b94 | ||
|
|
a72d692c68 | ||
|
|
ce1cad6dcf | ||
|
|
9b24928f74 | ||
|
|
87f167bd53 | ||
|
|
4917c0ce2e | ||
|
|
cc485289e3 | ||
|
|
69cba3460d | ||
|
|
dfd6850d6d | ||
|
|
8d1d5bde80 | ||
|
|
533def5061 | ||
|
|
c179e3a9e9 | ||
|
|
2b8e0944a4 | ||
|
|
cd4bc7ff4e | ||
|
|
b209c9912d | ||
|
|
bfb0ffc9ad | ||
|
|
0eb47a0427 | ||
|
|
ea785960d8 | ||
|
|
7102c21f5a | ||
|
|
22294d7125 | ||
|
|
e9ce292368 | ||
|
|
5532e4e267 | ||
|
|
120703e702 | ||
|
|
b31896387a | ||
|
|
e5bb25cd60 | ||
|
|
e59b7e01fd | ||
|
|
7994bf988c | ||
|
|
90b1c875df | ||
|
|
b61e806009 | ||
|
|
b46d0614c9 | ||
|
|
c73864d545 | ||
|
|
314fed559c | ||
|
|
1c346a6c3c | ||
|
|
65e11ddd03 | ||
|
|
a1c0592c7d | ||
|
|
22c82677cb | ||
|
|
c5b41346ca | ||
|
|
c2fc9b2cca | ||
|
|
ba9d6e1d3b | ||
|
|
be6ab2c9cb | ||
|
|
2413dd1f14 | ||
|
|
9d8d37e110 | ||
|
|
fc28d419bf | ||
|
|
095af395f9 | ||
|
|
4ada06d305 | ||
|
|
5a0ab92985 | ||
|
|
f2501871ee | ||
|
|
cf70182733 | ||
|
|
42feeae8d0 | ||
|
|
28cd6b8c45 | ||
|
|
81e327fb35 | ||
|
|
37e247d1d9 | ||
|
|
286e3f2d80 | ||
|
|
2ef27a55d8 | ||
|
|
d0ac09ffe5 | ||
|
|
c0015c05ec | ||
|
|
682b5bb4c0 | ||
|
|
3551877192 | ||
|
|
b22aba8593 | ||
|
|
f85f1ef2a5 | ||
|
|
010d7984b6 | ||
|
|
70e1e9e740 | ||
|
|
734af7cf22 | ||
|
|
d8f4977ffe | ||
|
|
c9fd00f938 | ||
|
|
f4db1484d8 | ||
|
|
99ef9a31b9 | ||
|
|
4c392dc571 | ||
|
|
155ffea15d | ||
|
|
57d231331d | ||
|
|
d5fe3c35dc | ||
|
|
3cf547e903 | ||
|
|
bd0bfcb03d | ||
|
|
4f6c583294 | ||
|
|
5e5a6b4eaf | ||
|
|
ff59d6dc35 | ||
|
|
1c154a709d | ||
|
|
fb47564618 | ||
|
|
30e309442b | ||
|
|
3e4ed95908 | ||
|
|
93edbac5b0 | ||
|
|
c947bc2c9b | ||
|
|
06315972b1 | ||
|
|
4e36824ed7 | ||
|
|
0bb618b703 | ||
|
|
b8e11d5b59 | ||
|
|
db8b1acbc6 | ||
|
|
1e5d66c9be | ||
|
|
202620c062 | ||
|
|
673038a1ab | ||
|
|
c97e34c336 | ||
|
|
799ea263a0 | ||
|
|
08cbd88262 | ||
|
|
5b5e91c2fa | ||
|
|
f8be7e33d1 | ||
|
|
54c950fc41 | ||
|
|
1e266c3cd2 | ||
|
|
16959a692d | ||
|
|
7c79b17fdc | ||
|
|
87e0af33cc | ||
|
|
3b4a736ce6 | ||
|
|
b70428fe3d | ||
|
|
3f037cdad6 | ||
|
|
9b63d38c5a | ||
|
|
4c26d85afd | ||
|
|
35b8d21181 | ||
|
|
e368e98a1a | ||
|
|
2ea6490a9b | ||
|
|
7b57f95f5f | ||
|
|
a811e5945d | ||
|
|
9b305d806b | ||
|
|
1955abd3e6 | ||
|
|
aac29c0a5c | ||
|
|
218ed43403 | ||
|
|
380f6f82a1 | ||
|
|
cef9e1984f | ||
|
|
0520ced2ea | ||
|
|
4bbe179940 | ||
|
|
1527239567 | ||
|
|
64c8b905c4 | ||
|
|
52ab81f4ec | ||
|
|
ec7828df85 | ||
|
|
525de56036 | ||
|
|
7edb66c311 | ||
|
|
dc2a635d2f | ||
|
|
8528300851 | ||
|
|
daa4aa0059 | ||
|
|
71a0481600 | ||
|
|
ba1b980a7f | ||
|
|
1dc20b838d | ||
|
|
0c6421fa63 | ||
|
|
b47cb482f5 | ||
|
|
152999d728 | ||
|
|
cdcb3b794f | ||
|
|
cd23f403c9 | ||
|
|
a40bef8f82 | ||
|
|
a4f9a67da1 | ||
|
|
5c07a77b63 | ||
|
|
91773c933c | ||
|
|
6a30ed44fc |
No files matched your search
+20
-1
@@ -3,6 +3,8 @@
|
||||
## [7.0.0] Unreleased
|
||||
### Added
|
||||
- Setup wizard
|
||||
- Uninstall wizard
|
||||
- Descriptive details panel to certain error dialogs
|
||||
- Changelog text wrapping and formatting
|
||||
- Changelog ships with source
|
||||
- Documentation ships with source
|
||||
@@ -24,6 +26,7 @@
|
||||
- Set favorite server from tables
|
||||
- Detailed/copyable trace in critical error dialogs
|
||||
- Visual icons
|
||||
- Unset favorite server button
|
||||
- Integrated server notebook
|
||||
- Propagate width changes to all tables
|
||||
- Remember tree position in menus
|
||||
@@ -44,11 +47,20 @@
|
||||
- Play offline (load mods directly)
|
||||
- Generate Steam shortcuts and cover art
|
||||
- Warn user if background downloads are disabled
|
||||
- Dynamic copy button
|
||||
- Update map count (-u flag)
|
||||
- Filter button tooltips
|
||||
- Expand/collapse changelog versions
|
||||
- Support local light/dark mode changes on system theme
|
||||
- Server time calculator
|
||||
|
||||
### Changed
|
||||
- Conform to PEP 440 versioning for beta versions
|
||||
- Enforce fullscreen mode on Steam Deck
|
||||
- Reduce padding on keys button
|
||||
- Boldface breadcrumbs
|
||||
- Changed some dialog error messages from popups to popovers
|
||||
- Changed how filter buttons are packed in sidebar
|
||||
- Bold labels inside frames
|
||||
- Sidebar buttons do not steal focus
|
||||
- Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method
|
||||
@@ -61,18 +73,25 @@
|
||||
- Reduce size of geolocation DB on disk (~100MB)
|
||||
- Enable LAN page Empty/Full filters on first run of app
|
||||
- Propagate subscribed mods to Steam client
|
||||
- Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow
|
||||
|
||||
### Dropped
|
||||
- Debug mode
|
||||
- Battlemetrics API support
|
||||
- Unused metadata from system log files
|
||||
- Branch switching
|
||||
- Manual mod install mode (describe rationale)
|
||||
- Force update mods
|
||||
|
||||
### Fixed
|
||||
- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input
|
||||
- In-game time field missing certain ranges of time at night
|
||||
- Wrap long entries in changelog
|
||||
- Certain server descriptions with special characters not rendering correctly
|
||||
- Center server title text on server dialogs
|
||||
- Rare segfaults when changing maps (threading)
|
||||
- Moved dialogs out of threads
|
||||
- History table not being in lock-step with state file
|
||||
- Current keyword filter not being applied when refreshing table
|
||||
|
||||
### Unreleased
|
||||
- Load offline mods
|
||||
|
||||
@@ -67,3 +67,7 @@ It is recommended to follow
|
||||
integrates well with tooling and helps the project review your code.
|
||||
|
||||
Please see DEVELOPERS.md for further details.
|
||||
|
||||
## License applicability to contributions
|
||||
|
||||
Code contributed to this repository becomes subject to the terms of the GPL license defined in the LICENSE file at the project root.
|
||||
@@ -1,15 +1,23 @@
|
||||
## What this is
|
||||
DZGUI is a turnkey browser and mod manager for DayZ on Linux. It allows you to connect to both official and modded/community DayZ servers on Linux via a graphical user interface (GUI).
|
||||
DZGUI is a turnkey server browser and mod manager for DayZ on Linux. It allows you to connect to both official and modded/community DayZ servers on Linux via a graphical user interface (GUI).
|
||||
|
||||
This overcomes certain limitations in the Linux client and helps prepare the game to launch by providing features like:
|
||||
|
||||
- Search for and display global and LAN servers via an interactive table
|
||||
- Supports official, third-party, and modded DayZ servers
|
||||
- Automatically find and prepare mods being requested by servers
|
||||
- Add/delete/manage favorite servers by IP or ID
|
||||
- Quick-connect to favorite/recent servers
|
||||
- Manage local mods and symlinks
|
||||
- Prepare launch options to pass to Steam
|
||||
- Manage and delete local mods
|
||||
- And more
|
||||
|
||||
## Design philosophy
|
||||
|
||||
DZGUI follows a specific set of guidelines, enumerated below:
|
||||
|
||||
- Provide a free and open-source tool under GPL
|
||||
- Do not use undocumented, third-party servers to aggregate data; no central "DZGUI" server
|
||||
- All connections go to Steam and DayZ servers directly; the tool should function in perpetuity without intermediary endpoints
|
||||
- Target Linux desktop and Steam deck specifically; not a cross-platform tool
|
||||
|
||||
## Setup and usage
|
||||
|
||||
@@ -18,7 +26,7 @@ Please refer to the documentation for installation and setup instructions:
|
||||
- [GitHub](https://aclist.github.io/dzgui/index.html)
|
||||
- [Codeberg (Mirror)](https://aclist.codeberg.page)
|
||||
|
||||

|
||||

|
||||
|
||||
## Attribution
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ runtime environment are built-in.
|
||||
Turnkey installer
|
||||
------------------------
|
||||
|
||||
- Visit the project's `Releases page <https://github.com/aclist/dztui/releases>`_.
|
||||
- Visit the project's `Releases page <https://github.com/aclist/dztui/releases/latest>`_.
|
||||
- Extract the DZGUI tarball from the top of the **Assets** list.
|
||||
- From a terminal, run the command ``./dzgui`` or double click on the extracted file in
|
||||
a file explorer.
|
||||
|
||||
+37
-7
@@ -3,17 +3,22 @@ Knowledge Base
|
||||
|
||||
.. _DZG-001:
|
||||
|
||||
DZG-001: Periodically getting dropped from servers, or servers time out in DZGUI
|
||||
DZG-001: Timeouts occur while trying to query a specific server
|
||||
------------------------------------------------------------------------------------
|
||||
DayZ opens a large number of connections while querying/connected to servers.
|
||||
The leading cause of specific servers periodically timing out is local network configuration.
|
||||
|
||||
In addition, packets sent from server responses are expected to be a standard size (see warning below).
|
||||
Many third-party DayZ servers use a server rental/hosting provider with DDoS protection.
|
||||
This can cause responses from servers to originate from a server other than the one originally queried.
|
||||
|
||||
If your network does not have enough headroom or has settings departing from defaults, this may lead to getting dropped from servers,
|
||||
unresponsiveness, or a timeout.
|
||||
Consumer-grade routers are likely to drop this traffic as invalid due to how they handle NAT (network address translation).
|
||||
By contrast, wireless routers and enterprise-grade routers may be less likely to have this issue.
|
||||
|
||||
If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for
|
||||
the server's query port.
|
||||
|
||||
In addition, a uniform packet size is expected. If your router's MTU setting differs from the default of 1,500,
|
||||
it could lead to incoming responses being discarded (see warning below).
|
||||
|
||||
If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. Consumer Wi-Fi routers
|
||||
tend to have less headroom than their wired counterparts.
|
||||
|
||||
.. important::
|
||||
Ensure that MTU (maximum tranmission unit) on your network is set to the standard size of 1,500 bytes.
|
||||
@@ -80,3 +85,28 @@ There is some misconception that a Steam Web API key could be used to gain infor
|
||||
A Steam Web API key is the most strict way of getting authentic, reliable, and consistent server information in a zero-trust model.
|
||||
|
||||
You are responsible for the creation, storage, management, and revocation of your Web API key.
|
||||
|
||||
DZG-008: Periodically getting dropped from servers while connected
|
||||
-------------------------------------------------------------------
|
||||
In some cases, DayZ opens a large number of connections while connected to servers.
|
||||
|
||||
If your network does not have enough headroom or has settings departing from defaults, this may lead to getting dropped from servers,
|
||||
unresponsiveness, or a timeout.
|
||||
|
||||
If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. Consumer Wi-Fi routers
|
||||
tend to have less headroom for simultaneous connections than their wired counterparts.
|
||||
|
||||
DZG-009: Floating dialogs appear maximized on tiling window managers
|
||||
-------------------------------------------------------------------
|
||||
|
||||
The main DZGUI window and its child dialogs are expected to be rendered as floating by your window manager.
|
||||
DZGUI sends window manager hints to this effect, but tiling window managers designed to bisect the screen into quadrants (e.g., i3 window manager)
|
||||
may try to always launch applications in fullscreen.
|
||||
|
||||
To resolve this, set specific exclusions or window hints in your WM's configuration file.
|
||||
For example, for i3, add the following to your `XDG_CONFIG_HOME/i3/config` file (defaults to `$HOME/.config/i3/config`):
|
||||
|
||||
.. code:: console
|
||||
|
||||
for_window [instance="DZGUI"] floating enable, move position center
|
||||
for_window [instance="DZGUI - Dialog"] floating enable, move position center
|
||||
+4
-14
@@ -4,7 +4,7 @@ Setup
|
||||
DayZ license
|
||||
-----------------
|
||||
|
||||
Prepare a Steam account with a DayZ license (i.e., own the game). From Steam's right-click menu for the game options, under ``Compatibility``, enable a Proton version ≥ ``6.8`` (or use Proton
|
||||
Prepare a Steam account with a DayZ license (i.e., own the game). From Steam's right-click menu for the game options, under ``Compatibility``, enable a Proton version ≥ ``6.8`` (or use Proton
|
||||
Experimental for the latest version).
|
||||
|
||||
As of this writing, any recent version of Proton should work, and it is encouraged to use the most recent one.
|
||||
@@ -33,20 +33,10 @@ Once configured, you can insert this key in the app when launching it for the fi
|
||||
|
||||
Battlemetrics API key
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
**This step is optional, but recommended**. Using this key lets you also connect to and query servers by their shorthand ID instead of by IP.
|
||||
.. warning::
|
||||
|
||||
Register for an API key at `BattleMetrics <https://www.battlemetrics.com/account/register?after=%2Fdevelopers>`_ (free).
|
||||
|
||||
From the ``Personal Access Tokens area``, select ``New Token``.
|
||||
|
||||
Give the token any name in the field at the top.
|
||||
|
||||
Leave **all options unchecked** and scroll to the bottom. Select ``Create Token``.
|
||||
|
||||
Once configured, you can insert this key in the app when launching it for the first time (optional), or later on when using the connect/query by ID methods in the app.
|
||||
|
||||
.. tip::
|
||||
Each server has a unique ID. This is the string of numbers at the end of the URL. For example, in the URL ``https://www.battlemetrics.com/servers/dayz/8039514``, the ID is ``8039514``.
|
||||
This feature is no longer supported in DZGUI. Battlemetrics has switched to a paid service.
|
||||
Battlemetrics queries are expected to fail in DZGUI 6. In DZGUI 7, this feature has been removed entirely.
|
||||
|
||||
Next steps
|
||||
--------------
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
Steam integration
|
||||
====================
|
||||
|
||||
.. note::
|
||||
|
||||
In :doc:`DZGUI 7 <dzgui7>`, Steam shortcut creation and cover art setup are handled automatically during setup. You can skip this step unless you are using DZGUI 6.
|
||||
|
||||
DZGUI can be added to Steam as a "non-Steam game" in order to facilitate integration with Steam Deck or desktop environments.
|
||||
|
||||
First, launch Steam in the **Large** (default) view.
|
||||
@@ -74,7 +78,7 @@ The final result will create box art looking like the above.
|
||||
:scale: 100%
|
||||
:alt: Adding a non-Steam game
|
||||
|
||||
Next, right-click the DZGUI entry in your Library and select **Properties** to open the properties dialog. Next to the **Shortcut** field, you will see a small square box which represents the game’s
|
||||
Next, right-click the DZGUI entry in your Library and select **Properties** to open the properties dialog. Next to the **Shortcut** field, you will see a small square box which represents the game’s
|
||||
icon. Click this to open a file explorer, navigate to ``$HOME/.local/share/dzgui``, and select ``icon.png``. This will add a small icon to the list view.
|
||||
|
||||
Finally, after you launch DZGUI for the first time, you should quit the application and return to the Library view. Select the **Recent Games** dropdown on the right-hand side.
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import logging
|
||||
import requests
|
||||
from typing import Any, Optional, TYPE_CHECKING
|
||||
|
||||
from dzgui.const.constants import APP_NAME
|
||||
from dzgui.const.endpoints import BM_SERVERS
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.api.servers import Record
|
||||
|
||||
|
||||
def get_attributes(key: str, uid: int) -> Any:
|
||||
# TODO: handle if key is not set
|
||||
# TODO: tests for malformed IDs/values
|
||||
|
||||
hdr = {"Authorization": "Bearer " + key}
|
||||
payload: dict[str, str] = {
|
||||
"filter[game]": "dayz",
|
||||
"sort": "-players",
|
||||
"filter[ids][whitelist]": str(uid),
|
||||
}
|
||||
res = requests.get(BM_SERVERS, params=payload, headers=hdr)
|
||||
res.raise_for_status()
|
||||
j = res.json()["data"][0]["attributes"]
|
||||
return j
|
||||
|
||||
|
||||
def map_id_to_record(key: str, uid: int) -> Optional["Record"]:
|
||||
from dzgui.api.servers import Record
|
||||
|
||||
try:
|
||||
record = get_attributes(key, uid)
|
||||
ip = record["ip"]
|
||||
port = int(record["port"])
|
||||
qport = int(record["portQuery"])
|
||||
return Record(ip, port, qport)
|
||||
except Exception:
|
||||
return None
|
||||
+20
-8
@@ -255,16 +255,28 @@ class PeFileError(Exception):
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def parse_version_number(data: BinaryIO) -> FileVersion:
|
||||
# https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo
|
||||
minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
|
||||
major = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
|
||||
build = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
|
||||
# NOTE: 0-pad revision number
|
||||
revision = str(struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF)
|
||||
offset = 3 - len(revision)
|
||||
padded = "0" * offset + revision
|
||||
return FileVersion(major, minor, build, padded)
|
||||
"""
|
||||
Note: structure differs from standard VS_FIXEDFILEINFO
|
||||
Major: lower 16 bits of first DWORD
|
||||
Minor: upper 16 bits of first DWORD
|
||||
Build: lower 16 bits of third DWORD
|
||||
Revision: upper 16 bits of second DWORD, zero-padded
|
||||
Lower 16 bits of revision DWORD appear unused.
|
||||
"""
|
||||
major_minor = struct.unpack("<I", data.read(4))[0]
|
||||
revision_pair = struct.unpack("<I", data.read(4))[0]
|
||||
build_dword = struct.unpack("<I", data.read(4))[0]
|
||||
|
||||
major = major_minor & 0xFFFF
|
||||
minor = (major_minor >> 16) & 0xFFFF
|
||||
build = build_dword & 0xFFFF
|
||||
revision = (revision_pair >> 16) & 0xFFFF
|
||||
revision = f"{revision:03d}"
|
||||
|
||||
return FileVersion(major, minor, build, revision)
|
||||
|
||||
|
||||
def seek_to_hex(address: str, data: BinaryIO) -> None:
|
||||
|
||||
@@ -41,21 +41,6 @@ def test_ipdb() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def test_bm_api(key: str) -> bool:
|
||||
payload: dict[str, str] = {
|
||||
"filter[game]": "dayz",
|
||||
}
|
||||
hdr = {"Authorization": "Bearer " + key}
|
||||
try:
|
||||
res = requests.get(
|
||||
endpoints.BM_SERVERS, params=payload, headers=hdr, timeout=REQUEST_TIMEOUT
|
||||
)
|
||||
return is_remote_up(res)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return False
|
||||
|
||||
|
||||
def is_remote_up(res: "Response") -> bool:
|
||||
if res.status_code == 200:
|
||||
return True
|
||||
|
||||
+29
-40
@@ -11,7 +11,6 @@ import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, TYPE_CHECKING, Union
|
||||
|
||||
from dzgui.api.bm import map_id_to_record
|
||||
from dzgui.const.constants import APP_NAME, REQUEST_TIMEOUT
|
||||
from dzgui.const.endpoints import STEAM_SERVERS
|
||||
from dzgui.util.format import format_exception
|
||||
@@ -55,19 +54,16 @@ class Res:
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Details:
|
||||
data: Union[list, None]
|
||||
data: list[list[str]]
|
||||
name: str
|
||||
description: str
|
||||
success: bool
|
||||
gametime: str
|
||||
day_accel: float
|
||||
night_accel: float
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Record:
|
||||
# TODO: investigate whether this enum is still being used;
|
||||
# can this be frozen?
|
||||
"""
|
||||
The gameport field is manipulated by the RowType.CONN_BY_IP method
|
||||
"""
|
||||
|
||||
ip: str
|
||||
gameport: int
|
||||
qport: int
|
||||
@@ -284,43 +280,41 @@ def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] |
|
||||
info = a2s.info((ip, qport), timeout)
|
||||
return source_info_to_dict(ip, qport, info)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
logger.debug(e)
|
||||
return None
|
||||
|
||||
|
||||
def get_details(record: Record) -> Details:
|
||||
# TODO: consolidate try/except statements
|
||||
def get_details(record: Record) -> Details | None:
|
||||
ip = record.ip
|
||||
qport = record.qport
|
||||
default_str = strings.none_provided
|
||||
|
||||
try:
|
||||
info = a2s.info((ip, qport))
|
||||
except (TimeoutError, a2s.exceptions.BrokenMessageError):
|
||||
return Details(None, default_str, False)
|
||||
try:
|
||||
rules = dayzquery.dayz_rules((ip, qport))
|
||||
except Exception:
|
||||
return Details(None, default_str, False)
|
||||
|
||||
try:
|
||||
keywords = info.keywords.split(",")
|
||||
except AttributeError:
|
||||
return Details(None, default_str, False)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return None
|
||||
|
||||
battleye = strings.disabled
|
||||
if "battleye" in keywords:
|
||||
battleye = strings.enabled
|
||||
|
||||
day_accel = 0.0
|
||||
night_accel = 0.0
|
||||
name = info.server_name
|
||||
day_accel = 1.0
|
||||
night_accel = 1.0
|
||||
gametime = "00:00"
|
||||
for keyword in keywords:
|
||||
if "etm" in keyword:
|
||||
day_accel = float(keyword.lstrip("etm"))
|
||||
day_accel = float(f"{day_accel:g}")
|
||||
if "entm" in keywords:
|
||||
if "entm" in keyword:
|
||||
night_accel = float(keyword.lstrip("entm"))
|
||||
night_accel = float(f"{night_accel:g}")
|
||||
time_reg = r"^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]$)"
|
||||
if re.match(time_reg, keyword):
|
||||
gametime = keyword
|
||||
|
||||
try:
|
||||
password = info.password_protected
|
||||
@@ -347,10 +341,15 @@ def get_details(record: Record) -> Details:
|
||||
|
||||
try:
|
||||
dlc = rules.dlc_flags
|
||||
if dlc == 0:
|
||||
dlc = strings.none
|
||||
if dlc == 2:
|
||||
dlc = strings.dlc_frostline
|
||||
match dlc:
|
||||
case 0:
|
||||
dlc = strings.dlc_none
|
||||
case 2:
|
||||
dlc = strings.dlc_frostline
|
||||
case 4:
|
||||
dlc = strings.dlc_badlands
|
||||
case _:
|
||||
dlc = strings.unspecified
|
||||
except AttributeError:
|
||||
dlc = strings.unspecified
|
||||
|
||||
@@ -381,7 +380,7 @@ def get_details(record: Record) -> Details:
|
||||
["Version", version],
|
||||
]
|
||||
|
||||
return Details(rows, description, True)
|
||||
return Details(rows, name, description, gametime, day_accel, night_accel)
|
||||
|
||||
|
||||
def ping(ip: str, qport: int) -> int:
|
||||
@@ -478,16 +477,6 @@ def query_by_ip(addr: str) -> A2SInfo:
|
||||
return query_by_record(record, update_gameport=True)
|
||||
|
||||
|
||||
def query_by_id(server_id: int, key: str) -> A2SInfo:
|
||||
"""
|
||||
Used with numeric Battlemetrics IDs
|
||||
"""
|
||||
record = map_id_to_record(key, server_id)
|
||||
if record is None:
|
||||
return A2SInfo(Record("0", 0, 0), None)
|
||||
return query_by_record(record)
|
||||
|
||||
|
||||
def query_by_record(record: Record, update_gameport: bool = False) -> A2SInfo:
|
||||
try:
|
||||
info = a2s.info((record.ip, record.qport), 3.0)
|
||||
|
||||
+29
-13
@@ -49,9 +49,21 @@ class Shortcuts:
|
||||
|
||||
def find_appname_by_unsigned_id(self, appid: int) -> str:
|
||||
# NOTE: bitmask signed int back to 32-bit CRC
|
||||
for key in self.shortcuts["shortcuts"].keys():
|
||||
if self.shortcuts["shortcuts"][key]["appid"] & 0xFFFFFFFF == appid:
|
||||
return str(self.shortcuts["shortcuts"][key]["appname"])
|
||||
# varying client versions treat case sensitivity differently
|
||||
try:
|
||||
for key in self.shortcuts["shortcuts"].keys():
|
||||
if self.shortcuts["shortcuts"][key]["appid"] & 0xFFFFFFFF == appid:
|
||||
try:
|
||||
return str(self.shortcuts["shortcuts"][key]["appname"])
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
try:
|
||||
return str(self.shortcuts["shortcuts"][key]["AppName"])
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
return unknown
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
return unknown
|
||||
|
||||
def get_shortcuts(self) -> Any:
|
||||
@@ -119,22 +131,18 @@ class Shortcuts:
|
||||
https://developer.valvesoftware.com/wiki/Add_Non-Steam_Game
|
||||
https://developer.valvesoftware.com/wiki/Steam_Library_Shortcuts
|
||||
|
||||
Wiki variously lists keys with title case and lowercase, but keys actually do
|
||||
not seem to be case-sensitive. Some entries generated by Steam do not match the wiki
|
||||
|
||||
Keys are entered into the dictionary in a linear insertion order
|
||||
|
||||
# TODO: try to replicate key case sensitivity as it is created by Steam with generic shortcut
|
||||
Key case is not internally consistent and varies between client versions
|
||||
Most keys use Pascal case, but some do not
|
||||
|
||||
appid: signed int CRC
|
||||
exe: absolute path to the executable, must be wrapped in literal quotes
|
||||
Exe: absolute path to the executable, must be wrapped in literal quotes
|
||||
StartDir: directory the executable starts in, generally the parent
|
||||
"""
|
||||
|
||||
NEW_ENTRY: dict[str, Any] = {}
|
||||
NEW_ENTRY["appid"] = meta.appid
|
||||
NEW_ENTRY["appname"] = meta.appname
|
||||
NEW_ENTRY["exe"] = f'"{meta.exe_path}"'
|
||||
NEW_ENTRY["AppName"] = meta.appname
|
||||
NEW_ENTRY["Exe"] = f'"{meta.exe_path}"'
|
||||
NEW_ENTRY["StartDir"] = f"{meta.start_dir}"
|
||||
NEW_ENTRY["icon"] = meta.icon
|
||||
NEW_ENTRY["ShortcutPath"] = ""
|
||||
@@ -142,7 +150,7 @@ class Shortcuts:
|
||||
NEW_ENTRY["IsHidden"] = 0
|
||||
NEW_ENTRY["AllowDesktopConfig"] = 1
|
||||
NEW_ENTRY["AllowOverlay"] = 1
|
||||
NEW_ENTRY["openvr"] = 0
|
||||
NEW_ENTRY["OpenVR"] = 0
|
||||
NEW_ENTRY["Devkit"] = 0
|
||||
NEW_ENTRY["DevkitGameID"] = ""
|
||||
NEW_ENTRY["DevkitOverrideAppID"] = 0
|
||||
@@ -152,6 +160,14 @@ class Shortcuts:
|
||||
NEW_ENTRY["tags"] = {}
|
||||
return NEW_ENTRY
|
||||
|
||||
def delete_shortcut(self, start_path: Path) -> None:
|
||||
shortcuts = self.shortcuts["shortcuts"]
|
||||
for s in shortcuts:
|
||||
if shortcuts[s]["StartDir"] == str(start_path):
|
||||
del shortcuts[s]
|
||||
break
|
||||
self.save_shortcuts()
|
||||
|
||||
def save_shortcuts(self) -> None:
|
||||
try:
|
||||
backup = self.shortcuts_path.with_suffix(".vdf.bak")
|
||||
|
||||
+87
-11
@@ -1,23 +1,27 @@
|
||||
import logging
|
||||
import os
|
||||
import psutil
|
||||
import re
|
||||
import requests
|
||||
import subprocess
|
||||
import vdf # type: ignore
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Union
|
||||
from requests.exceptions import HTTPError
|
||||
from typing import Any, Generator, Union
|
||||
from warnings import deprecated
|
||||
|
||||
from dzgui.api.acf import ACF
|
||||
from dzgui.init.prereqs import has_steam_client
|
||||
from dzgui.init.prereqs import has_any_client
|
||||
from dzgui.const.constants import (
|
||||
APPID_DAYZ,
|
||||
APPID_DAYZ_EXP,
|
||||
APP_NAME,
|
||||
DAYZ_BINARY,
|
||||
DAYZ_LAUNCHER,
|
||||
DEBIAN_STEAM_PATH,
|
||||
DEFAULT_STEAM_PATH,
|
||||
DOWNLOADS_PATH,
|
||||
FLATPAK_STEAM_PATH,
|
||||
LIBRARYFOLDERS_PATH,
|
||||
UBUNTU_STEAM_PATH,
|
||||
@@ -31,6 +35,7 @@ from dzgui.const.endpoints import (
|
||||
UNSUB_ENDPOINT,
|
||||
)
|
||||
from dzgui.strings import wizard
|
||||
from dzgui.strings.errors import api_validation_error, workshop_endpoint_error
|
||||
from dzgui.util.bash import concat_bash_args
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
@@ -56,7 +61,7 @@ class VDFLoadError(Exception):
|
||||
|
||||
def get_steam_paths() -> list[tuple[Path, str]]:
|
||||
paths = []
|
||||
if has_steam_client():
|
||||
if has_any_client():
|
||||
HOME = Path.home()
|
||||
env = os.environ.get("XDG_DATA_HOME")
|
||||
if env is not None:
|
||||
@@ -139,6 +144,9 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]:
|
||||
j = r.json()
|
||||
rows = j["response"]["publishedfiledetails"]
|
||||
for row in rows:
|
||||
# NOTE: not a valid published file
|
||||
if row["result"] == 9:
|
||||
continue
|
||||
title = str(row["title"])
|
||||
_id = str(row["publishedfileid"])
|
||||
time = int(row["time_updated"])
|
||||
@@ -147,6 +155,25 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]:
|
||||
return hashes
|
||||
|
||||
|
||||
def connect_debug(
|
||||
client: str, addr: str, appid: int, name: str, mods: list[str]
|
||||
) -> str:
|
||||
concat = concat_mods(mods)
|
||||
client_args = concat_bash_args(client)
|
||||
params = [
|
||||
"-applaunch",
|
||||
str(appid),
|
||||
f"-connect={addr}",
|
||||
"-nolauncher",
|
||||
"-nosplash",
|
||||
"-skipintro",
|
||||
f"-name={name}",
|
||||
f"-mod={concat}",
|
||||
]
|
||||
client_args.extend(params)
|
||||
return " ".join(client_args)
|
||||
|
||||
|
||||
# TODO: set config to name=user, use official server and no mods,
|
||||
# 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:
|
||||
@@ -206,17 +233,29 @@ def launch_offline(
|
||||
return proc.returncode
|
||||
|
||||
|
||||
def find_loginusers(path: Path) -> Path:
|
||||
return path / "config" / "loginusers.vdf"
|
||||
|
||||
|
||||
def find_user_id(path: Path) -> str | None:
|
||||
resolved_path = path / "config" / "loginusers.vdf"
|
||||
resolved_path = find_loginusers(path)
|
||||
try:
|
||||
with open(resolved_path, "r") as f:
|
||||
v = vdf.load(f)
|
||||
for user in v["users"]:
|
||||
if v["users"][user]["MostRecent"] == "1":
|
||||
return str(user)
|
||||
return None
|
||||
users = v["users"]
|
||||
# NOTE: beta client: /package/beta
|
||||
last_user = str(next(iter(users)))
|
||||
if len(users) == 1:
|
||||
return last_user
|
||||
last_stamp = int(users[last_user]["timestamp"])
|
||||
for user in users:
|
||||
stamp = int(users[user]["timestamp"])
|
||||
if stamp > last_stamp:
|
||||
last_stamp = stamp
|
||||
last_user = user
|
||||
return last_user
|
||||
except Exception as e:
|
||||
logger.warn(e)
|
||||
logger.debug(e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -227,6 +266,16 @@ def find_user_id_32(path: Path) -> int:
|
||||
return int(uid) & 0xFFFFFFFF
|
||||
|
||||
|
||||
class SteamAPIKeyError(Exception):
|
||||
def __init__(self, msg: str | None = None) -> None:
|
||||
super().__init__(msg or api_validation_error)
|
||||
|
||||
|
||||
class SteamWorkshopEndpointError(Exception):
|
||||
def __init__(self, msg: str | None = None) -> None:
|
||||
super().__init__(msg or workshop_endpoint_error)
|
||||
|
||||
|
||||
def update_workshop(key: str, mod: int, endpoint: str) -> None:
|
||||
payload: dict[str, Union[int, str]] = {
|
||||
"publishedfileid": mod,
|
||||
@@ -238,8 +287,19 @@ def update_workshop(key: str, mod: int, endpoint: str) -> None:
|
||||
try:
|
||||
res = requests.post(endpoint, params=payload, timeout=REQUEST_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
except HTTPError as e:
|
||||
logger.critical(e)
|
||||
match res.status_code:
|
||||
case 403:
|
||||
raise SteamAPIKeyError()
|
||||
case 404:
|
||||
raise SteamWorkshopEndpointError()
|
||||
case _:
|
||||
# TODO: apply redaction filter
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
raise e
|
||||
|
||||
|
||||
def subscribe(key: str, mod: int) -> None:
|
||||
@@ -371,14 +431,30 @@ def get_client_allows_downloads(path: Path) -> bool:
|
||||
def is_dayz_running() -> bool:
|
||||
"""Subprocesses spawned from Steam will not show up in regular process tree"""
|
||||
procs = []
|
||||
substring = DAYZ_BINARY
|
||||
substring = [DAYZ_BINARY, DAYZ_LAUNCHER]
|
||||
for proc in psutil.process_iter():
|
||||
try:
|
||||
procs.append(proc.cmdline())
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
continue
|
||||
return any(substring in item for sublist in procs for item in sublist)
|
||||
return any(s in item for sublist in procs for item in sublist for s in substring)
|
||||
|
||||
|
||||
def get_pending_downloads(steam_path: Path, appid: int) -> Generator[Path, None, None]:
|
||||
root_path = get_app_path(steam_path, appid)
|
||||
downloads_dir = root_path / DOWNLOADS_PATH
|
||||
files = downloads_dir.iterdir()
|
||||
return files
|
||||
|
||||
|
||||
def is_dayz_downloading(steam_path: Path, appid: int) -> bool:
|
||||
files = get_pending_downloads(steam_path, appid)
|
||||
reg = re.compile(rf"^state_(?:{APPID_DAYZ_EXP}|{APPID_DAYZ})_.*\.patch$")
|
||||
pending = [f for f in files if reg.match(f.name)]
|
||||
if len(pending) > 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_app_path(folders_path: Path, appid: int) -> Path:
|
||||
|
||||
+6
-4
@@ -15,13 +15,13 @@ from dzgui.init.migrate import (
|
||||
migrate_cols_file,
|
||||
)
|
||||
from dzgui.init.flock import lock_acquire
|
||||
from dzgui.init.prereqs import has_steam_client
|
||||
from dzgui.init.prereqs import has_any_client
|
||||
from dzgui.strings import boot
|
||||
|
||||
# from dzgui.util.map_count import get_map_count
|
||||
from dzgui.util.deck import is_steam_deck, is_game_mode
|
||||
from dzgui.util.dirs import make_parents
|
||||
from dzgui.util.localize import set_locale
|
||||
from dzgui.util.map_count import test_map_count
|
||||
from dzgui.util.redact import RedactionFilter, REDACTION_PATTERNS
|
||||
from dzgui.util.strings import init
|
||||
|
||||
@@ -119,6 +119,7 @@ def load_gui(version: str, is_debug: bool) -> None:
|
||||
del os.environ["GTK_IM_MODULE"]
|
||||
|
||||
if has_new_config(XDG.config) is False:
|
||||
test_map_count()
|
||||
migrate_cols_file(XDG.columns)
|
||||
copy_state_files(xdg_paths["XDG_STATE_HOME"])
|
||||
# TODO: add logging inside wizard
|
||||
@@ -135,7 +136,7 @@ def load_gui(version: str, is_debug: bool) -> None:
|
||||
# TODO: sudo escalation dialog
|
||||
# count = get_map_count()
|
||||
# TODO: move into module
|
||||
if has_steam_client() is False:
|
||||
if has_any_client() is False:
|
||||
EarlyAlertDialog(init.requires_steam)
|
||||
|
||||
bootwin = BootWindow(XDG, version)
|
||||
@@ -153,4 +154,5 @@ def load_gui(version: str, is_debug: bool) -> None:
|
||||
use_miles=use_miles,
|
||||
)
|
||||
print(boot.all_ok)
|
||||
App(prefs)
|
||||
app = App(prefs)
|
||||
app.run(None)
|
||||
@@ -42,6 +42,7 @@ def rc2json(file: Path) -> str:
|
||||
|
||||
toggles = ["fullscreen"]
|
||||
deprecated = [
|
||||
"api_key",
|
||||
"staging_dir",
|
||||
"src_path",
|
||||
"steam_path",
|
||||
@@ -66,8 +67,6 @@ def rc2json(file: Path) -> str:
|
||||
value = str2bool(ntok)
|
||||
elif tok == "preferred_client":
|
||||
tok = "client"
|
||||
elif tok == "api_key":
|
||||
tok = "bm_api"
|
||||
elif tok == "ip_list":
|
||||
while True:
|
||||
ntok = lex.get_token()
|
||||
|
||||
@@ -3,7 +3,6 @@ import textwrap
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.const.constants import APP_NAME
|
||||
from dzgui.util.dirs import copy_dzgui_to_xdg_data, find_icon_resource, make_parents
|
||||
|
||||
|
||||
@@ -21,7 +20,7 @@ def write_desktop_file(exe_path: Path) -> Path:
|
||||
Terminal=false
|
||||
Exec={exe_path}
|
||||
Name=DZGUI
|
||||
Comment=dzgui
|
||||
Comment=DayZ server browser and mod manager
|
||||
Icon={icon}
|
||||
Categories=Game"""
|
||||
|
||||
@@ -37,7 +36,8 @@ def write_desktop_file(exe_path: Path) -> Path:
|
||||
|
||||
def write_desktop_shortcut(desktop_file: Path) -> None:
|
||||
# NOTE: necessarily depends on the above (UI blocks creation without XDG entry first)
|
||||
link = Path.home().joinpath(f"Desktop/{APP_NAME}.desktop")
|
||||
link = Path.home().joinpath("Desktop/dzgui.desktop")
|
||||
make_parents(link)
|
||||
if link.exists():
|
||||
link.unlink()
|
||||
link.symlink_to(desktop_file)
|
||||
@@ -4,8 +4,6 @@ from pathlib import Path
|
||||
from dzgui.util._json import read_json
|
||||
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.const.constants import STEAM_CMD, FLATPAK_RUN_CMD, FLATPAK_SANDBOX
|
||||
|
||||
|
||||
class ConfigFileError(Exception):
|
||||
pass
|
||||
@@ -40,23 +38,5 @@ def get_favorites(path: Path) -> list[str]:
|
||||
return list(conf["ip_list"])
|
||||
|
||||
|
||||
# def is_in_favs(record: str, path: Path) -> bool:
|
||||
# favs = get_favorites(path)
|
||||
# if record in favs:
|
||||
# return True
|
||||
# return False
|
||||
|
||||
|
||||
def enum_to_key(enum: Preferences) -> str:
|
||||
return str(enum.dict["key"])
|
||||
|
||||
|
||||
def get_client_index(client: str) -> int:
|
||||
if client == STEAM_CMD:
|
||||
return 0
|
||||
elif client == FLATPAK_RUN_CMD:
|
||||
return 1
|
||||
elif client == FLATPAK_SANDBOX:
|
||||
return 2
|
||||
else:
|
||||
return 0
|
||||
@@ -2,7 +2,6 @@
|
||||
Generic defaults used when initializing a config file from scratch
|
||||
"""
|
||||
config_boilerplate = {
|
||||
"bm_api": "",
|
||||
"fav_server": "",
|
||||
"fav_label": "",
|
||||
"name": "",
|
||||
|
||||
@@ -12,8 +12,10 @@ APPNAME_DAYZ = "DayZ"
|
||||
APPNAME_DAYZ_EXP = "DayZ Exp"
|
||||
APPNAME_DAYZ_EXP_HUMAN = "DayZ Experimental"
|
||||
DAYZ_BINARY = "DayZ_x64.exe"
|
||||
DAYZ_LAUNCHER = "DayZLauncher.exe"
|
||||
|
||||
LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf"
|
||||
DOWNLOADS_PATH = "steamapps/downloading"
|
||||
WORKSHOP_PATH = "steamapps/workshop/content/" + str(APPID_DAYZ)
|
||||
|
||||
STEAM_CMD = "steam"
|
||||
@@ -29,15 +31,18 @@ BETA_REPO = "testing"
|
||||
APP_NAME = "DZGUI"
|
||||
APP_NAME_LOWER = "dzgui"
|
||||
APP_NAME_ABBR = "dzg"
|
||||
GTK_APP_ID = "io.github.aclist.dzgui"
|
||||
|
||||
HEX_GREEN = "#32CD32"
|
||||
HEX_RED = "#FF0000"
|
||||
HEX_ORANGE = "#FFAC1C"
|
||||
|
||||
# FIXME: normalize icon names
|
||||
# TODO: normalize icon names
|
||||
CALCULATOR = "accessories-calculator-symbolic"
|
||||
CARET_DOWN = "go-down-symbolic"
|
||||
CARET_UP = "go-up-symbolic"
|
||||
CLIPBOARD = "edit-copy-symbolic"
|
||||
CLOSE = "window-close-symbolic"
|
||||
FOLDER = "folder-symbolic"
|
||||
EDIT_DELETE = "edit-delete-symbolic"
|
||||
ERROR = "dialog-error-symbolic"
|
||||
@@ -51,6 +56,8 @@ VIEW_CONCEAL = "view-conceal-symbolic"
|
||||
VIEW_REVEAL = "view-reveal-symbolic"
|
||||
WARNING = "dialog-warning-symbolic"
|
||||
WEB_BROWSER = "web-browser-symbolic"
|
||||
DAY_ICON = "weather-clear-symbolic"
|
||||
NIGHT_ICON = "weather-clear-night-symbolic"
|
||||
|
||||
SEPARATOR = "SEPARATOR"
|
||||
|
||||
@@ -78,7 +85,8 @@ HERO_PATH = "data/images/_hero.png"
|
||||
CSS_PATH = "data/app.css"
|
||||
VDF_PATH = "steamapps/libraryfolders.vdf"
|
||||
DEFAULT_STEAM_PATH = ".local/share/Steam"
|
||||
FLATPAK_STEAM_PATH = ".var/app/com.valvesoftware.Steam/data/Steam"
|
||||
FLATPAK_ROOT_PATH = ".var/app/com.valvesoftware.Steam"
|
||||
FLATPAK_STEAM_PATH = FLATPAK_ROOT_PATH + "/data/Steam"
|
||||
UBUNTU_STEAM_PATH = ".steam/steam"
|
||||
DEBIAN_STEAM_PATH = ".steam/debian-installation"
|
||||
|
||||
@@ -89,3 +97,8 @@ TMP_TARBALL = "/tmp/dzgui.tar.gz"
|
||||
TMP_EXE = "/tmp/dzgui/dzgui"
|
||||
|
||||
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"
|
||||
@@ -7,7 +7,6 @@ SUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Subscribe/v1"
|
||||
UNSUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Unsubscribe/v1"
|
||||
APP_DETAILS = "https://store.steampowered.com/api/appdetails?"
|
||||
|
||||
BM_SERVERS = "https://api.battlemetrics.com/servers?"
|
||||
GITHUB = "https://github.com/aclist"
|
||||
GITHUB_RELEASES = "https://api.github.com/repos/aclist/dztui/releases/latest"
|
||||
CODEBERG_RELEASES = "https://codeberg.org/api/v1/repos/aclist/dzgui/releases/latest"
|
||||
@@ -27,5 +26,3 @@ GITHUB_ISSUES = "https://github.com/aclist/dzgui/issues"
|
||||
FORUM = "https://old.reddit.com/r/dzgui"
|
||||
SPONSORS = "https://github.com/sponsors/aclist"
|
||||
STEAM_API_SETUP = "https://steamcommunity.com/dev/apikey"
|
||||
BM_API_SETUP = "https://www.battlemetrics.com/developers"
|
||||
BM_BROWSE = "https://www.battlemetrics.com/servers/dayz"
|
||||
@@ -61,9 +61,6 @@ class Preferences(EnumWithAttrs):
|
||||
STEAM = {
|
||||
"key": "steam_api",
|
||||
}
|
||||
BM = {
|
||||
"key": "bm_api",
|
||||
}
|
||||
CLIENT = {
|
||||
"key": "client",
|
||||
}
|
||||
@@ -76,12 +73,6 @@ class Preferences(EnumWithAttrs):
|
||||
NAME = {
|
||||
"key": "name",
|
||||
}
|
||||
INSTALL = {
|
||||
"key": "auto_install",
|
||||
}
|
||||
DEBUG = {
|
||||
"key": "debug",
|
||||
}
|
||||
DEFAULT = {
|
||||
"key": "default_steam_path",
|
||||
}
|
||||
@@ -94,9 +85,6 @@ class Preferences(EnumWithAttrs):
|
||||
IP_LIST = {
|
||||
"key": "ip_list",
|
||||
}
|
||||
BRANCH = {
|
||||
"key": "branch",
|
||||
}
|
||||
START_TAB = {
|
||||
"key": "start_tab",
|
||||
}
|
||||
|
||||
@@ -145,6 +145,10 @@ class Emitter(GObject.GObject):
|
||||
def api_change_failed(self) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
|
||||
def api_change_successful(self) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
|
||||
def already_saved_server(self) -> None:
|
||||
pass
|
||||
|
||||
+30
-21
@@ -7,11 +7,12 @@ import dzgui.util._json as JSON # noqa
|
||||
|
||||
|
||||
from dzgui.const.enum import (
|
||||
FilterMode,
|
||||
Preferences,
|
||||
NotebookPage,
|
||||
ButtonType,
|
||||
ContextMenu,
|
||||
FilterMode,
|
||||
NotebookPage,
|
||||
Preferences,
|
||||
ServerTab,
|
||||
)
|
||||
|
||||
from dzgui.config.userprefs import UserPrefs
|
||||
@@ -37,7 +38,6 @@ logger = logging.getLogger(APP_NAME)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.api.servers import Record
|
||||
from dzgui.const.enum import ServerTab
|
||||
from dzgui.managers.connection import Prerequisites
|
||||
from dzgui.managers.filter import FilterManager
|
||||
from dzgui.util.dist import Haversine
|
||||
@@ -88,7 +88,6 @@ class Controller(GObject.GObject):
|
||||
|
||||
# NOTE: suppress requests until entire UI is loaded
|
||||
self.loaded = False
|
||||
self.pending_jobs = 1
|
||||
|
||||
self.exit_event = threading.Event()
|
||||
self.cancel_event = threading.Event()
|
||||
@@ -115,9 +114,6 @@ class Controller(GObject.GObject):
|
||||
def query_config(self, key: Preferences) -> Any:
|
||||
return self.config_man.lookup(key)
|
||||
|
||||
def is_auto_install(self) -> bool:
|
||||
return bool(self.query_config(Preferences.INSTALL))
|
||||
|
||||
def suppress_signal(
|
||||
self, owner: Any, child: Any, func_name: str, state: bool
|
||||
) -> None:
|
||||
@@ -267,6 +263,7 @@ class Controller(GObject.GObject):
|
||||
self.open_page(NotebookPage.LOG)
|
||||
except Exception as e:
|
||||
dialog = ExceptionDialog(self, str(e))
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
|
||||
def select_colorized(self) -> None:
|
||||
@@ -300,10 +297,11 @@ class Controller(GObject.GObject):
|
||||
write_diagnostic(self.prefs.paths.config, file)
|
||||
except Exception as e:
|
||||
dialog = ExceptionDialog(self, str(e))
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
|
||||
def update_api_key(self, key: Preferences, text: str) -> None:
|
||||
self.config_man.update_api_key(key, text)
|
||||
def update_steam_api_key(self, text: str) -> None:
|
||||
self.config_man.update_steam_api_key(text)
|
||||
|
||||
def set_resolution(self, window: "OuterWindow") -> None:
|
||||
self.config_man.set_resolution(window)
|
||||
@@ -402,6 +400,9 @@ class Controller(GObject.GObject):
|
||||
def get_menu(self) -> "MenuTreeView":
|
||||
return self.mediator.menu
|
||||
|
||||
def unset_fav(self) -> None:
|
||||
self.config_man.unset_fav()
|
||||
|
||||
def has_favorites(self) -> bool:
|
||||
favs = self.config_man.get_favorites()
|
||||
if len(favs) < 1:
|
||||
@@ -413,7 +414,7 @@ class Controller(GObject.GObject):
|
||||
|
||||
def add_by_str(self, addr: str) -> None:
|
||||
saved_tree = self.get_servers().get_saved()
|
||||
ServerModelManager(self, saved_tree).add_by_str(addr)
|
||||
ServerModelManager(self, saved_tree).add_by_ip(addr)
|
||||
|
||||
def add_by_record(self, record: "Record") -> None:
|
||||
saved_tree = self.get_servers().get_saved()
|
||||
@@ -437,14 +438,8 @@ class Controller(GObject.GObject):
|
||||
ServerModelManager(self, tv).remove_from_history(record)
|
||||
|
||||
def connect_by_str(self, addr: str) -> None:
|
||||
if addr.isdigit():
|
||||
config_man = self.get_config_man()
|
||||
key = config_man.lookup(Preferences.BM)
|
||||
self.connection_man = ConnectionManager(self)
|
||||
self.connection_man.connect_by_id(int(addr), key)
|
||||
else:
|
||||
self.connection_man = ConnectionManager(self)
|
||||
self.connection_man.connect_by_ip(addr)
|
||||
self.connection_man = ConnectionManager(self)
|
||||
self.connection_man.connect_by_ip(addr)
|
||||
|
||||
def connect_by_record(self, record: "Record") -> None:
|
||||
self.connection_man = ConnectionManager(self)
|
||||
@@ -465,6 +460,9 @@ class Controller(GObject.GObject):
|
||||
open_workshop_page(mod, cmd)
|
||||
|
||||
def has_note(self) -> bool:
|
||||
# TODO: get record string, store in memory
|
||||
# get note by record, get is in favs, etc. store as a block
|
||||
# this is only used by context mixin
|
||||
note = self.get_note()
|
||||
if len(note) > 0:
|
||||
return True
|
||||
@@ -479,11 +477,13 @@ class Controller(GObject.GObject):
|
||||
return self.notes_man.get_note(record)
|
||||
|
||||
def add_note(self, note: str) -> None:
|
||||
# TODO: record should be cached upon creation of dialog
|
||||
tv = self.get_active_treeview()
|
||||
record = tv.get_record_string()
|
||||
self.notes_man.add_note(record, note)
|
||||
|
||||
def delete_note(self) -> None:
|
||||
# TODO: record should be cached upon creation of dialog
|
||||
tv = self.get_active_treeview()
|
||||
record = tv.get_record_string()
|
||||
self.notes_man.delete_note(record)
|
||||
@@ -498,6 +498,9 @@ class Controller(GObject.GObject):
|
||||
|
||||
def set_active_keyword(self, keyword: str) -> None:
|
||||
self.get_filter_man().set_active_keyword(keyword)
|
||||
ServerModelManager(self, self.get_active_treeview()).refilter(
|
||||
FilterMode.TOGGLE_ON
|
||||
)
|
||||
|
||||
def get_modtreeview(self) -> "ModTreeView":
|
||||
return self.mediator.modtreeview
|
||||
@@ -523,8 +526,12 @@ class Controller(GObject.GObject):
|
||||
|
||||
def set_start_tab(self) -> None:
|
||||
ind = self.config_man.get_start_tab()
|
||||
logger.info(f"Setting start tab to index {ind}")
|
||||
self.get_servers().notebook.set_current_page(ind)
|
||||
|
||||
def get_debug_args(self) -> str:
|
||||
return self.connection_man.get_debug_args()
|
||||
|
||||
def update_and_load_to_menu(self) -> None:
|
||||
self.connection_man.update_and_connect(menu_only=True)
|
||||
|
||||
@@ -534,8 +541,10 @@ class Controller(GObject.GObject):
|
||||
def update_status(self) -> None:
|
||||
self.mediator.preconnect.mark_finished()
|
||||
|
||||
def get_steam_client_name(self) -> str:
|
||||
return self.mediator.options.get_client_name()
|
||||
def get_client_name(self) -> str:
|
||||
# NOTE: only possible values are "Steam" and "Flatpak"
|
||||
cmd = self.query_config(Preferences.CLIENT)
|
||||
return str(cmd.split()[0].capitalize())
|
||||
|
||||
def present_window(self) -> None:
|
||||
self.mediator.window.present_with_time(Gdk.CURRENT_TIME)
|
||||
|
||||
+20
-1
@@ -3,6 +3,8 @@
|
||||
## [7.0.0] Unreleased
|
||||
### Added
|
||||
- Setup wizard
|
||||
- Uninstall wizard
|
||||
- Descriptive details panel to certain error dialogs
|
||||
- Changelog text wrapping and formatting
|
||||
- Changelog ships with source
|
||||
- Documentation ships with source
|
||||
@@ -24,6 +26,7 @@
|
||||
- Set favorite server from tables
|
||||
- Detailed/copyable trace in critical error dialogs
|
||||
- Visual icons
|
||||
- Unset favorite server button
|
||||
- Integrated server notebook
|
||||
- Propagate width changes to all tables
|
||||
- Remember tree position in menus
|
||||
@@ -44,11 +47,20 @@
|
||||
- Play offline (load mods directly)
|
||||
- Generate Steam shortcuts and cover art
|
||||
- Warn user if background downloads are disabled
|
||||
- Dynamic copy button
|
||||
- Update map count (-u flag)
|
||||
- Filter button tooltips
|
||||
- Expand/collapse changelog versions
|
||||
- Support local light/dark mode changes on system theme
|
||||
- Server time calculator
|
||||
|
||||
### Changed
|
||||
- Conform to PEP 440 versioning for beta versions
|
||||
- Enforce fullscreen mode on Steam Deck
|
||||
- Reduce padding on keys button
|
||||
- Boldface breadcrumbs
|
||||
- Changed some dialog error messages from popups to popovers
|
||||
- Changed how filter buttons are packed in sidebar
|
||||
- Bold labels inside frames
|
||||
- Sidebar buttons do not steal focus
|
||||
- Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method
|
||||
@@ -61,18 +73,25 @@
|
||||
- Reduce size of geolocation DB on disk (~100MB)
|
||||
- Enable LAN page Empty/Full filters on first run of app
|
||||
- Propagate subscribed mods to Steam client
|
||||
- Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow
|
||||
|
||||
### Dropped
|
||||
- Debug mode
|
||||
- Battlemetrics API support
|
||||
- Unused metadata from system log files
|
||||
- Branch switching
|
||||
- Manual mod install mode (describe rationale)
|
||||
- Force update mods
|
||||
|
||||
### Fixed
|
||||
- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input
|
||||
- In-game time field missing certain ranges of time at night
|
||||
- Wrap long entries in changelog
|
||||
- Certain server descriptions with special characters not rendering correctly
|
||||
- Center server title text on server dialogs
|
||||
- Rare segfaults when changing maps (threading)
|
||||
- Moved dialogs out of threads
|
||||
- History table not being in lock-step with state file
|
||||
- Current keyword filter not being applied when refreshing table
|
||||
|
||||
### Unreleased
|
||||
- Load offline mods
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
.left-label {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.server-subheading {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.details-heading {
|
||||
font-size: 1.2rem
|
||||
}
|
||||
|
||||
+9
-2
@@ -4,17 +4,24 @@ from pathlib import Path
|
||||
|
||||
from dzgui.api.steam import get_app_path
|
||||
from dzgui.config.query import lookup
|
||||
from dzgui.const.constants import APPID_DAYZ, APP_NAME
|
||||
from dzgui.const.constants import APPID_DAYZ, APP_NAME, APPNAME_DAYZ
|
||||
from dzgui.const.enum import Preferences
|
||||
|
||||
from dzgui.strings.preconnect import resync
|
||||
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
class VDFSyncError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def is_dayz_installed(config: Path) -> None:
|
||||
try:
|
||||
path = lookup(config, Preferences.DEFAULT)
|
||||
get_app_path(Path(path), APPID_DAYZ)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
raise e
|
||||
msg = resync.format(APPNAME_DAYZ)
|
||||
raise VDFSyncError(msg)
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import shutil
|
||||
|
||||
from pathlib import Path
|
||||
@@ -7,11 +8,10 @@ from dzgui.config.convert import rc2json
|
||||
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
|
||||
j = rc2json(old_conf)
|
||||
config.parent.mkdir(parents=True, exist_ok=True)
|
||||
config.write_text(j)
|
||||
return json.loads(j)
|
||||
|
||||
|
||||
def has_new_config(config: Path) -> bool:
|
||||
|
||||
+20
-7
@@ -7,19 +7,32 @@ from dzgui.const.constants import APP_NAME, FLATPAK_APPID, FLATPAK_CMD, STEAM_CM
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
def has_steam_client() -> bool:
|
||||
if shutil.which(STEAM_CMD):
|
||||
return True
|
||||
if not shutil.which(FLATPAK_CMD):
|
||||
return False
|
||||
def has_flatpak_client() -> bool:
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[FLATPAK_CMD, "list"], capture_output=True, text=True, check=True
|
||||
[FLATPAK_CMD, "list", "--columns=application"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
if FLATPAK_APPID in res.stdout:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except subprocess.CalledProcessError as e:
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return False
|
||||
|
||||
|
||||
def has_steam_client() -> bool:
|
||||
if shutil.which(STEAM_CMD):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_any_client() -> bool:
|
||||
if shutil.which(STEAM_CMD):
|
||||
return True
|
||||
if not shutil.which(FLATPAK_CMD):
|
||||
return False
|
||||
return has_flatpak_client()
|
||||
+32
-13
@@ -10,42 +10,61 @@ from dzgui.const.constants import (
|
||||
FLATPAK_CMD,
|
||||
FLATPAK_RUN_CMD,
|
||||
FLATPAK_SANDBOX,
|
||||
FLATPAK_ROOT_PATH,
|
||||
FLATPAK_STEAM_PATH,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
def is_steam_running(cmd: str) -> bool:
|
||||
if cmd == STEAM_CMD:
|
||||
if has_cmd(STEAM_CMD) is False:
|
||||
return False
|
||||
return is_running(STEAM_CMD)
|
||||
elif cmd == FLATPAK_RUN_CMD or FLATPAK_SANDBOX:
|
||||
if cmd in (FLATPAK_RUN_CMD, FLATPAK_SANDBOX):
|
||||
return is_flatpak_steam_running()
|
||||
elif cmd == STEAM_CMD:
|
||||
return is_native_steam_running()
|
||||
else:
|
||||
raise TypeError("Not a valid Steam client selection")
|
||||
|
||||
|
||||
# CHORE: test alternate clients
|
||||
# TODO: has_flatpak_steam
|
||||
def is_flatpak_steam_running() -> bool:
|
||||
if has_cmd(FLATPAK_CMD) is False:
|
||||
return False
|
||||
proc = subprocess.check_output([FLATPAK_CMD, "ps"], text=True)
|
||||
proc = subprocess.check_output(
|
||||
[FLATPAK_CMD, "ps", "--columns=application"], text=True
|
||||
)
|
||||
lines = proc.splitlines()
|
||||
if FLATPAK_APPID in lines:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_running(proc: str) -> bool:
|
||||
for process in psutil.process_iter(["pid", "name"]):
|
||||
if process.info["name"] == proc:
|
||||
return True
|
||||
return False
|
||||
def get_steam_procs() -> list[list[str]]:
|
||||
return [
|
||||
p.cmdline()
|
||||
for p in psutil.process_iter(["pid", "name"])
|
||||
if STEAM_CMD in p.info["name"]
|
||||
]
|
||||
|
||||
|
||||
def is_native_steam_running() -> bool:
|
||||
if has_cmd(STEAM_CMD) is False:
|
||||
return False
|
||||
substrings = [FLATPAK_ROOT_PATH, "steam-runtime-launcher-service"]
|
||||
steam_procs = get_steam_procs()
|
||||
native_steam_procs = [
|
||||
proc
|
||||
for proc in steam_procs
|
||||
if not any(sub in item for item in proc for sub in substrings)
|
||||
]
|
||||
return bool(native_steam_procs)
|
||||
|
||||
|
||||
def has_cmd(cmd: str) -> bool:
|
||||
if shutil.which(cmd) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def path_to_steam_cmd(path: str) -> str:
|
||||
cmd = FLATPAK_RUN_CMD if FLATPAK_STEAM_PATH in path else STEAM_CMD
|
||||
return cmd
|
||||
+16
-13
@@ -5,33 +5,36 @@ import warnings
|
||||
from dzgui.const.constants import APP_NAME
|
||||
from dzgui.init.libgi import test_libgi_missing
|
||||
from dzgui.init.prefix import get_version
|
||||
from dzgui.util.deck import is_steam_deck
|
||||
from dzgui.util.map_count import set_map_count
|
||||
from dzgui.util.strings import flags
|
||||
|
||||
from dzgui.views.dialogs.uninstall import UninstallWizard
|
||||
from dzgui.config.xdg import get_xdg_paths
|
||||
|
||||
parser = argparse.ArgumentParser(description=flags.description)
|
||||
parser.add_argument("-v", "--version", action="store_true", help=flags.version)
|
||||
parser.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall)
|
||||
parser.add_argument("-d", "--debug", action="store_true", help=flags.debug)
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument("-d", "--debug", action="store_true", help=flags.debug)
|
||||
group.add_argument("-m", "--map", action="store_true", help=flags.map_count)
|
||||
group.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall)
|
||||
group.add_argument("-v", "--version", action="store_true", help=flags.version)
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def uninstall() -> None:
|
||||
# TODO: uninstall data files (-u)
|
||||
# -u removes state, log, freedesktop
|
||||
# XDG_SHARE_HOME/dzgui
|
||||
# XDG_STATE_HOME/dzgui
|
||||
# XDG_DATA_HOME/dzgui
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if args.version is True:
|
||||
print(get_version())
|
||||
sys.exit(0)
|
||||
if args.uninstall is True:
|
||||
uninstall()
|
||||
paths = get_xdg_paths()
|
||||
_is_steam_deck = is_steam_deck()
|
||||
UninstallWizard(_is_steam_deck, paths)
|
||||
sys.exit(0)
|
||||
if args.debug is True:
|
||||
warnings.filterwarnings("default", category=DeprecationWarning)
|
||||
if args.map is True:
|
||||
set_map_count()
|
||||
sys.exit(0)
|
||||
|
||||
version = get_version()
|
||||
|
||||
|
||||
+23
-28
@@ -6,24 +6,16 @@ from typing import Any, TYPE_CHECKING
|
||||
|
||||
from dzgui.const.constants import (
|
||||
APP_NAME,
|
||||
FLATPAK_RUN_CMD,
|
||||
FLATPAK_SANDBOX,
|
||||
STEAM_CMD,
|
||||
WINDOW_DEFAULT_X,
|
||||
WINDOW_DEFAULT_Y,
|
||||
)
|
||||
from dzgui.api.probe import test_steam_api, test_bm_api
|
||||
from dzgui.api.probe import test_steam_api
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
|
||||
from dzgui.views.dialogs.generic import ExceptionDialog
|
||||
from dzgui.strings import dialogs
|
||||
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:
|
||||
from dzgui.config.userprefs import UserPrefs
|
||||
from dzgui.controllers.mc import Controller
|
||||
@@ -98,14 +90,13 @@ class ConfigManager:
|
||||
logger.critical(e)
|
||||
raise e
|
||||
|
||||
@call_on_thread(dialogs.checking_api)
|
||||
def update_api_key(self, key: Preferences, text: str) -> None:
|
||||
if key is Preferences.STEAM:
|
||||
res = test_steam_api(text)
|
||||
else:
|
||||
res = test_bm_api(text)
|
||||
@call_on_thread(dialogs.checking_api, show_dialog=False)
|
||||
def update_steam_api_key(self, text: str) -> None:
|
||||
res = test_steam_api(text)
|
||||
if res is True:
|
||||
self.update_config(key, text)
|
||||
self.update_config(Preferences.STEAM, text)
|
||||
func = StoredFunc(lambda: self.emitter.emit("api_change_successful"))
|
||||
self.thread_man.set_cleanup_func(func)
|
||||
else:
|
||||
self.thread_man.set_cleanup_func(
|
||||
StoredFunc(lambda: self.emitter.emit("api_change_failed"))
|
||||
@@ -135,15 +126,6 @@ class ConfigManager:
|
||||
def enum_to_key(self, enum: Preferences) -> str:
|
||||
return str(enum.dict["key"])
|
||||
|
||||
def get_client_index(self, client: str) -> int:
|
||||
if client == STEAM_CMD:
|
||||
return 0
|
||||
if client == FLATPAK_RUN_CMD:
|
||||
return 1
|
||||
if client == FLATPAK_SANDBOX:
|
||||
return 2
|
||||
return 0
|
||||
|
||||
def toggle_config(self, key: Preferences) -> None:
|
||||
try:
|
||||
real_key = self.enum_to_key(key)
|
||||
@@ -162,6 +144,13 @@ class ConfigManager:
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def unset_fav(self) -> None:
|
||||
try:
|
||||
self.write_config(Preferences.FAV_LBL, "")
|
||||
self.write_config(Preferences.FAV_SRV, "")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def write_config(self, key: Preferences, value: str) -> None:
|
||||
try:
|
||||
real_key = self.enum_to_key(key)
|
||||
@@ -172,8 +161,8 @@ class ConfigManager:
|
||||
logger.critical(e)
|
||||
trace = traceback.format_exc()
|
||||
dialog = ExceptionDialog(self.controller, trace)
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
raise e
|
||||
|
||||
def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None:
|
||||
columns = tv.get_columns()
|
||||
@@ -200,7 +189,10 @@ class ConfigManager:
|
||||
|
||||
logger.info("Normal user exit")
|
||||
if window.props.is_maximized:
|
||||
main_quit()
|
||||
app = window.get_application()
|
||||
if app is None:
|
||||
return
|
||||
app.quit()
|
||||
return
|
||||
|
||||
w, h = window.get_size()
|
||||
@@ -212,7 +204,10 @@ class ConfigManager:
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
|
||||
main_quit()
|
||||
app = window.get_application()
|
||||
if app is None:
|
||||
return
|
||||
app.quit()
|
||||
|
||||
def set_resolution(self, window: "OuterWindow") -> None:
|
||||
if self.prefs.is_game_mode:
|
||||
|
||||
@@ -13,6 +13,8 @@ from dzgui.api.shortcuts import Shortcuts
|
||||
|
||||
from dzgui.api.steam import (
|
||||
connect,
|
||||
connect_debug,
|
||||
is_dayz_downloading,
|
||||
get_app_allows_downloads,
|
||||
get_app_name,
|
||||
get_needs_update,
|
||||
@@ -45,8 +47,9 @@ from dzgui.strings.dialogs import (
|
||||
waiting_for_mods,
|
||||
waiting_for_directories,
|
||||
)
|
||||
from dzgui.strings import kb
|
||||
from dzgui.strings.server_mods import checkmark, resync
|
||||
from dzgui.util.format import format_mib
|
||||
from dzgui.util.format import format_exception, format_mib
|
||||
from dzgui.util.strings import dialog, server_timeout
|
||||
from dzgui.util.symlink import rebuild_symlinks
|
||||
from dzgui.views.dialogs.generic import ExceptionDialog
|
||||
@@ -82,10 +85,12 @@ class Prerequisites:
|
||||
available_space: float
|
||||
passworded: bool
|
||||
dayz_running: bool
|
||||
download_paused: bool
|
||||
steam_proc: SteamProcess
|
||||
mods: list[list[str]]
|
||||
game_mode: bool
|
||||
is_last_server: bool
|
||||
invalid_mods: list[tuple[str, str]]
|
||||
allows_downloads: tuple[bool, str]
|
||||
|
||||
|
||||
@@ -103,11 +108,6 @@ class ConnectionManager:
|
||||
self.remote_mod_ids: list[str] = []
|
||||
self.missing_mods: list[tuple[str, str, int, int]] = []
|
||||
|
||||
@call_on_thread(dialog.querying)
|
||||
def connect_by_id(self, _id: int, key: str) -> None:
|
||||
res = Servers.query_by_id(_id, key)
|
||||
self._prepare_connection(res)
|
||||
|
||||
@call_on_thread(dialog.querying)
|
||||
def connect_by_ip(self, addr: str) -> None:
|
||||
res = Servers.query_by_ip(addr)
|
||||
@@ -171,6 +171,7 @@ class ConnectionManager:
|
||||
free_mib = format_mib(free)
|
||||
|
||||
dayz_running = is_dayz_running()
|
||||
download_paused = is_dayz_downloading(steam_path, self.appid)
|
||||
|
||||
running_app = get_running_app()
|
||||
if running_app is not None:
|
||||
@@ -186,9 +187,8 @@ class ConnectionManager:
|
||||
else:
|
||||
allows_downloads = (True, "")
|
||||
|
||||
|
||||
client_name = self.controller.get_steam_client_name()
|
||||
client = self.controller.query_config(Preferences.CLIENT)
|
||||
client_name = self.controller.get_client_name()
|
||||
running = is_steam_running(client)
|
||||
steam_proc = SteamProcess(client_name, running)
|
||||
self.client = client
|
||||
@@ -197,6 +197,11 @@ class ConnectionManager:
|
||||
|
||||
is_last = self.is_last_server()
|
||||
|
||||
# TODO: strings
|
||||
invalid_mods = [
|
||||
(mod[0], mod[1]) for mod in remote_mods if mod[2] == "Invalid mod"
|
||||
]
|
||||
|
||||
prereqs = Prerequisites(
|
||||
name=info.server_name,
|
||||
appid=info.game_id,
|
||||
@@ -208,10 +213,12 @@ class ConnectionManager:
|
||||
available_space=free_mib,
|
||||
passworded=info.password_protected,
|
||||
dayz_running=dayz_running,
|
||||
download_paused=download_paused,
|
||||
steam_proc=steam_proc,
|
||||
mods=remote_mods,
|
||||
game_mode=game_mode,
|
||||
is_last_server=is_last,
|
||||
invalid_mods=invalid_mods,
|
||||
allows_downloads=allows_downloads,
|
||||
)
|
||||
|
||||
@@ -232,7 +239,7 @@ class ConnectionManager:
|
||||
@call_on_thread(dialog.querying)
|
||||
def query_details(self, record: Servers.Record) -> None:
|
||||
details = Servers.get_details(record)
|
||||
if details.success is False:
|
||||
if details is None:
|
||||
self.thread_man.set_cleanup_func(
|
||||
StoredFunc(self._server_timeout), destroy_first=True
|
||||
)
|
||||
@@ -267,6 +274,11 @@ class ConnectionManager:
|
||||
for mod in alpha_mods:
|
||||
if any(mod[1] in tuple for tuple in missing_mods):
|
||||
mod[2] = resync
|
||||
for mod in alpha_mods:
|
||||
# NOTE: if the mod is neither synched or out of date, it is a malformed mod
|
||||
if mod[2] == "":
|
||||
# TODO: strings
|
||||
mod[2] = "Invalid mod"
|
||||
|
||||
return alpha_mods, missing_mods
|
||||
|
||||
@@ -292,10 +304,24 @@ class ConnectionManager:
|
||||
dialog = ServerDetailsDialog(self.controller, details)
|
||||
dialog.run()
|
||||
|
||||
def _workshop_timeout(self, msg: str) -> None:
|
||||
dialog = ExceptionDialog(self.controller, msg)
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
|
||||
def _server_timeout(self) -> None:
|
||||
dialog = ExceptionDialog(self.controller, server_timeout)
|
||||
dialog.set_details_buffer(kb.DZG_006)
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
|
||||
def get_debug_args(self) -> str:
|
||||
addr = f"{self.record.ip}:{self.record.gameport}"
|
||||
playername = self.controller.query_config(Preferences.NAME)
|
||||
return connect_debug(
|
||||
self.client, addr, self.appid, playername, self.remote_mod_ids
|
||||
)
|
||||
|
||||
def _connect_steam(self, menu_only: bool) -> None:
|
||||
addr = f"{self.record.ip}:{self.record.gameport}"
|
||||
playername = self.controller.query_config(Preferences.NAME)
|
||||
@@ -334,8 +360,14 @@ class ConnectionManager:
|
||||
for title, mod, stamp, size in self.missing_mods:
|
||||
if self.controller.is_cancel_pending():
|
||||
return
|
||||
subscribe(key, int(mod))
|
||||
time.sleep(API_RATE_LIMIT)
|
||||
try:
|
||||
subscribe(key, int(mod))
|
||||
time.sleep(API_RATE_LIMIT)
|
||||
except Exception as e:
|
||||
msg = format_exception(e)
|
||||
func = StoredFunc(self._workshop_timeout, msg)
|
||||
self.thread_man.set_cleanup_func(func, destroy_first=True)
|
||||
return
|
||||
|
||||
self.thread_man.update_dialog(waiting_for_directories)
|
||||
for title, mod, stamp, size in self.missing_mods:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import gi
|
||||
import logging
|
||||
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
|
||||
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402
|
||||
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -21,12 +21,30 @@ class FilterManager:
|
||||
strings.filter_3pp: True,
|
||||
strings.filter_night: True,
|
||||
strings.filter_full: show_full,
|
||||
strings.filter_lowpop: True,
|
||||
strings.filter_nonascii: False,
|
||||
strings.filter_duplicate: False,
|
||||
strings.filter_official: True,
|
||||
strings.filter_unofficial: True,
|
||||
strings.filter_modded: True,
|
||||
strings.filter_lowpop: True,
|
||||
strings.filter_unofficial: True,
|
||||
strings.filter_unmodded: True,
|
||||
strings.filter_duplicate: False,
|
||||
strings.filter_nonascii: False,
|
||||
}
|
||||
|
||||
# TODO: strings
|
||||
self.tooltips = {
|
||||
strings.filter_1pp: "First-person perspective",
|
||||
strings.filter_day: "In-game time between 0600 and 1759",
|
||||
strings.filter_empty: "Servers with no players",
|
||||
strings.filter_3pp: "Third-person perspective",
|
||||
strings.filter_night: "In-game time between 1800 and 0559",
|
||||
strings.filter_full: "Servers with no open slots",
|
||||
strings.filter_official: "Bohemia official servers",
|
||||
strings.filter_nonascii: "Server names using non-standard, complex glyphs",
|
||||
strings.filter_lowpop: "Current population is under 30% of total",
|
||||
strings.filter_unofficial: "Third-party servers",
|
||||
strings.filter_duplicate: "Duplicates of existing servers (usually spoofed)",
|
||||
strings.filter_modded: "Servers with one or more mods",
|
||||
strings.filter_unmodded: "Servers with no mods",
|
||||
}
|
||||
|
||||
self.active_keyword = ""
|
||||
@@ -36,6 +54,9 @@ class FilterManager:
|
||||
self.filters: list
|
||||
self.enabled_filters = dict(self.default_filters)
|
||||
|
||||
def get_tooltips(self) -> dict[str, str]:
|
||||
return self.tooltips
|
||||
|
||||
def set_prior_map(self, name: str) -> None:
|
||||
self.prior_map = name
|
||||
|
||||
@@ -86,6 +107,7 @@ class FilterManager:
|
||||
self.reinit_map_store()
|
||||
maps.sort()
|
||||
for m in maps:
|
||||
# TODO: strings
|
||||
if m == "All maps":
|
||||
continue
|
||||
self.append_map([m])
|
||||
|
||||
@@ -22,9 +22,7 @@ from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManage
|
||||
from dzgui.model.model_factory import FastInsertListStore, ModelFactory
|
||||
from dzgui.strings import dialogs
|
||||
from dzgui.util.format import format_mods
|
||||
from dzgui.util.strings import server_timeout
|
||||
from dzgui.util.symlink import rebuild_symlinks
|
||||
from dzgui.views.dialogs.generic import ExceptionDialog
|
||||
|
||||
|
||||
import dzgui.api.pefile as PeFile
|
||||
@@ -124,7 +122,11 @@ class ModManager:
|
||||
def unsub_atomic_mod(self, mod: str) -> None:
|
||||
config_man = self.controller.get_config_man()
|
||||
key = config_man.lookup(Preferences.STEAM)
|
||||
unsubscribe(key, int(mod))
|
||||
try:
|
||||
unsubscribe(key, int(mod))
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return
|
||||
|
||||
steam_path = Path(self.path)
|
||||
app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
|
||||
@@ -213,10 +215,6 @@ class ModManager:
|
||||
func = StoredFunc(self._on_stale_mods_found, stale)
|
||||
self.thread_man.set_cleanup_func(func)
|
||||
|
||||
def _server_timeout(self) -> None:
|
||||
dialog = ExceptionDialog(self.controller, server_timeout)
|
||||
dialog.run()
|
||||
|
||||
def select_colorized(self) -> None:
|
||||
model = self.treeview.get_model()
|
||||
if model is None:
|
||||
|
||||
@@ -64,4 +64,5 @@ class UpdateManager:
|
||||
|
||||
def _on_update_failure(self, msg: str) -> None:
|
||||
dialog = ExceptionDialog(self.controller, msg)
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
+12
-12
@@ -1,7 +1,6 @@
|
||||
import re
|
||||
|
||||
from typing import Any, TYPE_CHECKING, Union
|
||||
from warnings import deprecated
|
||||
|
||||
from dzgui.const.enum import FilterMode
|
||||
from dzgui.model.model_factory import ModelFactory
|
||||
@@ -13,6 +12,9 @@ if TYPE_CHECKING:
|
||||
from dzgui.api.servers import Record
|
||||
from dzgui.model.servers import NewPlayerCount
|
||||
|
||||
DAY_REG = r"([0][6-9]|[1][0-7])"
|
||||
NIGHT_REG = r"([0][0-5]|[1][8-9]|[2][0-3])"
|
||||
|
||||
|
||||
class ProxyModelManager:
|
||||
"""
|
||||
@@ -86,8 +88,8 @@ class ProxyModelManager:
|
||||
if found is False:
|
||||
self.control_model.append(history)
|
||||
|
||||
# if len(self.control_model) == 11:
|
||||
# del self.control_model[0]
|
||||
if len(self.control_model) == 11:
|
||||
del self.control_model[0]
|
||||
|
||||
self.filter(FilterMode.INITIAL, skip_cache=True)
|
||||
|
||||
@@ -232,17 +234,17 @@ class ProxyModelManager:
|
||||
final.append(row)
|
||||
rows = final
|
||||
case strings.filter_day:
|
||||
reg = r"([0][0-9]|[1][0-6])"
|
||||
rows = [row for row in rows if not re.match(reg, row[3])]
|
||||
rows = [row for row in rows if not re.match(DAY_REG, row[3])]
|
||||
case strings.filter_night:
|
||||
reg = r"([0][0-4]|[1][8]|[2][0-3])"
|
||||
rows = [row for row in rows if not re.match(reg, row[3])]
|
||||
rows = [row for row in rows if not re.match(NIGHT_REG, row[3])]
|
||||
case strings.filter_nonascii:
|
||||
rows = [row for row in rows if row[0].isascii()]
|
||||
case strings.filter_lowpop:
|
||||
rows = [row for row in rows if (row[4] / row[5] * 100) > 30]
|
||||
case strings.filter_modded:
|
||||
rows = [row for row in rows if not row[11]]
|
||||
case strings.filter_unmodded:
|
||||
rows = [row for row in rows if row[11]]
|
||||
return rows
|
||||
|
||||
def filter_toggle_on(self, filters: tuple[str]) -> list:
|
||||
@@ -259,10 +261,6 @@ class ProxyModelManager:
|
||||
def set_cache(self, filters: tuple[str], filtered_rows: list[tuple]) -> None:
|
||||
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:
|
||||
if rows is None:
|
||||
self.filtered = []
|
||||
@@ -298,4 +296,6 @@ class ProxyModelManager:
|
||||
def push(self, data: list[Any]) -> None:
|
||||
self.wipe_cache()
|
||||
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)
|
||||
+27
-29
@@ -14,14 +14,14 @@ from dzgui.const.constants import (
|
||||
)
|
||||
from dzgui.const.enum import FilterMode, Preferences, ServerTab
|
||||
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
|
||||
from dzgui.strings import dialogs
|
||||
from dzgui.util.strings import api_warn_msg, dialog
|
||||
from dzgui.strings import dialogs, kb
|
||||
from dzgui.util.strings import server_timeout, dialog
|
||||
from dzgui.views.dialogs.generic import ExceptionDialog
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa E402
|
||||
from gi.repository import Gtk, GLib # noqa E402
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.api.servers import A2SInfo, Record
|
||||
@@ -175,29 +175,29 @@ class ServerModelManager:
|
||||
# TODO: wrap except
|
||||
if self.controller.get_exit_event().is_set():
|
||||
return
|
||||
res = future.result(timeout=API_TIMEOUT)
|
||||
self.thread_man.increment_dialog()
|
||||
# NOTE: failing entries are culled
|
||||
if res is None:
|
||||
# TODO: log which servers failed
|
||||
continue
|
||||
servers.append(res)
|
||||
if len(servers) == 0:
|
||||
self.thread_man.set_cleanup_func(
|
||||
StoredFunc(self._cleanup_on_failure), destroy_first=True
|
||||
)
|
||||
return
|
||||
|
||||
"""
|
||||
Collects futures in linear order after completion
|
||||
for lock-step representation of the state file
|
||||
"""
|
||||
for future in futures:
|
||||
res = future.result(timeout=API_TIMEOUT)
|
||||
# NOTE: failing entries are culled
|
||||
if res is None:
|
||||
# TODO: log which servers failed
|
||||
# query_direct() currently does not return enough information
|
||||
continue
|
||||
servers.append(res)
|
||||
if len(servers) == 0:
|
||||
self.thread_man.set_cleanup_func(
|
||||
StoredFunc(self._cleanup_on_failure), destroy_first=True
|
||||
)
|
||||
return
|
||||
|
||||
parsed = Servers.parse_json(servers)
|
||||
self._push_data(parsed)
|
||||
|
||||
@call_on_thread(dialog.querying)
|
||||
def add_by_id(self, _id: str) -> None:
|
||||
config_man = self.controller.get_config_man()
|
||||
key = config_man.lookup(Preferences.BM)
|
||||
res = Servers.query_by_id(int(_id), key)
|
||||
self._parse_single_record(res)
|
||||
|
||||
@call_on_thread(dialog.querying)
|
||||
def add_by_ip(self, addr: str) -> None:
|
||||
res = Servers.query_by_ip(addr)
|
||||
@@ -241,7 +241,6 @@ class ServerModelManager:
|
||||
|
||||
self.first_iteration = False
|
||||
self.emitter.emit("load_maps", store)
|
||||
# self.emitter.emit("servers_loaded_init")
|
||||
|
||||
# TODO: dataclass for record rows; check for other dict annotations
|
||||
def add_to_history(self, data: tuple[dict[str, Any], "Record"]) -> None:
|
||||
@@ -263,12 +262,6 @@ class ServerModelManager:
|
||||
proxy_man.remove_row_from_control(record)
|
||||
self.update_history()
|
||||
|
||||
def add_by_str(self, addr: str) -> None:
|
||||
if addr.isdigit():
|
||||
self.add_by_id(addr)
|
||||
else:
|
||||
self.add_by_ip(addr)
|
||||
|
||||
@call_on_thread(dialog.querying)
|
||||
def update_playercount(
|
||||
self, treeiter: Gtk.TreeIter, record: Servers.Record
|
||||
@@ -397,6 +390,8 @@ class ServerModelManager:
|
||||
def _cleanup_on_success(self) -> None:
|
||||
proxy = self.proxy_man.get_proxy_model()
|
||||
self.tv.set_model(proxy)
|
||||
if self.tv.enum == ServerTab.BROWSER:
|
||||
GLib.idle_add(self.tv.wipe_cache)
|
||||
if self.preserve_on_fail is True:
|
||||
self.proxy_man.wipe_cache()
|
||||
|
||||
@@ -412,6 +407,7 @@ class ServerModelManager:
|
||||
|
||||
if show_dialog:
|
||||
dialog = ExceptionDialog(self.controller, dialogs.load_error_lan)
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
|
||||
def _cleanup_on_failure(self, show_dialog: bool = True) -> None:
|
||||
@@ -426,7 +422,9 @@ class ServerModelManager:
|
||||
# TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty"
|
||||
# customize statusbar and dialog accordingly
|
||||
if show_dialog:
|
||||
dialog = ExceptionDialog(self.controller, api_warn_msg)
|
||||
dialog = ExceptionDialog(self.controller, server_timeout)
|
||||
dialog.set_details_buffer(kb.DZG_006)
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
|
||||
def _push_data(self, data: list[Any]) -> None:
|
||||
|
||||
@@ -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."
|
||||
@@ -0,0 +1,2 @@
|
||||
collapse_all = "Collapse all"
|
||||
expand_all = "Expand all"
|
||||
@@ -1,24 +1,22 @@
|
||||
add_button="Add"
|
||||
add_con_heading="Add/connect"
|
||||
from dzgui.const.constants import UDP_PORT
|
||||
|
||||
add_button = "Add"
|
||||
add_con_heading = "Add/connect"
|
||||
add_popover = "This address is already in your Saved Servers"
|
||||
add_tooltip="Add to Saved Servers"
|
||||
connect_button="Connect"
|
||||
connect_tooltip="Connect to this server"
|
||||
connect_entry_tooltip=(
|
||||
"- IP: format as IP:Query port\ne.g. 192.168.1.1:27016\n"
|
||||
"- Battlemetrics: numeric server ID\ne.g. 123456"
|
||||
)
|
||||
connect_entry_placeholder="Enter IP (IP:Query port) or Battlemetrics ID (numerical id)"
|
||||
add_tooltip = "Add to Saved Servers"
|
||||
connect_button = "Connect"
|
||||
connect_tooltip = "Connect to this server"
|
||||
connect_entry_placeholder = f"Enter IP:Query port (e.g., 192.168.1.1:{UDP_PORT})"
|
||||
|
||||
fav_heading="Favorite server"
|
||||
favs_empty="None set. Right click a server and select 'Set favorite' to set."
|
||||
fav_heading = "Favorite server"
|
||||
favs_empty = "None set. Right click a server and select 'Set favorite' to set."
|
||||
|
||||
lan_heading="LAN query port"
|
||||
lan_default_button="Default port (27016)"
|
||||
lan_custom_button="Custom port"
|
||||
lan_scan_button="Scan"
|
||||
lan_heading = "LAN query port"
|
||||
lan_default_button = f"Default port ({UDP_PORT})"
|
||||
lan_custom_button = "Custom port"
|
||||
lan_scan_button = "Scan"
|
||||
lan_abort_tooltip = "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster"
|
||||
lan_checkbox = "Stop scanning on first hit"
|
||||
lan_placeholder="Enter the query port (1-65535)"
|
||||
lan_entry_tooltip="Specify the port to search for DayZ servers on the local network"
|
||||
lan_scan_tooltip="Scan for servers"
|
||||
lan_entry_placeholder = "Enter the query port (1-65535)"
|
||||
lan_entry_tooltip = "Specify the port to search for DayZ servers on the local network"
|
||||
lan_scan_tooltip = "Scan for servers"
|
||||
@@ -20,3 +20,11 @@ ok = "OK"
|
||||
|
||||
mission_dialog = "Set a mission folder location"
|
||||
custom_mod_dialog = "Set location to a custom mods folder"
|
||||
|
||||
debug_heading = "Debug"
|
||||
debug_secondary = "Launch arguments"
|
||||
|
||||
toggle_calc = "Toggle calculator"
|
||||
|
||||
server_details = "Server details"
|
||||
server_message = "Server message"
|
||||
@@ -4,3 +4,4 @@ api_validation_error = (
|
||||
|
||||
api_popover = "API key validation failed."
|
||||
dayz_not_found = "Could not find a valid DayZ installation."
|
||||
workshop_endpoint_error = "Steam Workshop service unavailable, try again later."
|
||||
@@ -0,0 +1,13 @@
|
||||
DZG_006 = """The leading cause of specific servers periodically timing out is local network configuration.
|
||||
|
||||
Many third-party DayZ servers use a server rental/hosting provider with DDoS protection.
|
||||
|
||||
This can cause responses from servers to originate from a server other than the one originally queried.
|
||||
|
||||
Consumer-grade routers are likely to drop this traffic as invalid due to how they handle NAT (network address translation).
|
||||
|
||||
By contrast, wireless routers and enterprise-grade routers may be less likely to have this issue.
|
||||
|
||||
If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for the server's query port.
|
||||
|
||||
In addition, a uniform packet size is expected. If your router's MTU (maximum transmission unit) setting differs from the default of 1500, it could lead to incoming responses being discarded."""
|
||||
@@ -0,0 +1,16 @@
|
||||
exit_msg = (
|
||||
"System map count is not high enough to run DayZ. "
|
||||
"Please exit and run 'dzgui -m' to update map count."
|
||||
)
|
||||
failed_to_parse = (
|
||||
"Failed to parse system map count.\n"
|
||||
"This usually indicates that systemd is not installed."
|
||||
)
|
||||
failed_to_update = (
|
||||
"Failed to update system map count.\n"
|
||||
"Please report the issue upstream and provide the following traceback.\n\n"
|
||||
"{0}"
|
||||
)
|
||||
meets_minimum = "System map count of {0} already meets the minimum."
|
||||
prompt = "Updated map count will be written to the file {0}."
|
||||
user_exit = "User exit"
|
||||
@@ -1,4 +1,7 @@
|
||||
heading = "Play offline"
|
||||
heading_disclaimer = (
|
||||
"Not all mod combinations will function offline. Use at your own discretion."
|
||||
)
|
||||
|
||||
mission_button = "Select mission folder"
|
||||
mission_frame = "Mission"
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
developers = "Developers"
|
||||
start_tab = "Start tab"
|
||||
workshop_label = "Subscribed mods"
|
||||
workshop_eventbox = "If you manually subscribed to mods on Steam prior to using DZGUI, you can unsubscribe via this link."
|
||||
steam_combo = "Steam"
|
||||
flatpak_combo = "Flatpak"
|
||||
flatpak_container_combo = "Flatpak (container)"
|
||||
|
||||
steam_placeholder = "Enter your Steam API key"
|
||||
name_placeholder = "Identifies you to other players"
|
||||
|
||||
server_combo = "Server browser"
|
||||
saved_combo = "Saved servers"
|
||||
recent_combo = "Recent"
|
||||
lan_combo = "LAN"
|
||||
|
||||
fullscreen_eventbox = "This option is not available on Steam Deck."
|
||||
save_button = "Save"
|
||||
|
||||
api_failed = "API validation failed"
|
||||
@@ -1,9 +1,8 @@
|
||||
update_mods = "Update mods and connect"
|
||||
connect = "Connect"
|
||||
connect_last = "Load to menu screen"
|
||||
connect_last_tooltip = (
|
||||
"Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting."
|
||||
)
|
||||
connect_last_tooltip = "Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting."
|
||||
debug = "Debug"
|
||||
back = "Back"
|
||||
cancel = "Cancel"
|
||||
warnings = "Warnings"
|
||||
@@ -12,5 +11,43 @@ mods = "Mods"
|
||||
total_mods = "Total mods: "
|
||||
up_to_date = "All mods are up to date."
|
||||
all_updated = "All mods updated."
|
||||
"If you recently installed {build} or moved it to a different drive, "
|
||||
"restart Steam to allow these changes to synchronize, then try again."
|
||||
required_space = " Need to download {0} MiB of mod updates."
|
||||
|
||||
placeholder_no_mods = "This server has no mods."
|
||||
placeholder_no_warnings = "No warnings."
|
||||
|
||||
|
||||
# Errors
|
||||
invalid_mods = (
|
||||
"Server has invalid mods that are not recognized by Steam.\n"
|
||||
"Contact the server owner and include these mod IDs in your report:\n"
|
||||
"{0}"
|
||||
)
|
||||
not_enough_space = (
|
||||
"Need to update {0} MiB of mods, but installation path only has {1} MiB."
|
||||
)
|
||||
resync = (
|
||||
"If you recently installed {0} or moved it to a different drive,\n"
|
||||
"restart Steam to allow these changes to synchronize, then try again."
|
||||
)
|
||||
steam_not_running = "'{0}' is set as the default Steam client, but it is either not installed or not running."
|
||||
use_desktop_mode = "Use Desktop Mode to download mods on Steam Deck."
|
||||
version_mismatch = "Local client version '{0}' does not match remote version '{1}'."
|
||||
version_missing = "Remote server is running the build '{0}', but it is not installed."
|
||||
no_errors = "No errors."
|
||||
|
||||
|
||||
# Warnings
|
||||
protected_server = (
|
||||
"Protected: you will be prompted for a password when connecting to this server."
|
||||
)
|
||||
dayz_running = "It looks like DayZ is already running in the background. Exit DayZ before connecting."
|
||||
running_app = (
|
||||
"The app '{0}' is currently running in Steam, but background downloads are not enabled.\n"
|
||||
"Either stop the game first, or update your global Steam settings or the game's local settings.\n"
|
||||
"Otherwise, mods may be queued for download but never update."
|
||||
)
|
||||
downloads_paused = (
|
||||
"It looks like DayZ may have a pending update on Steam that was paused.\n"
|
||||
"To enqueue mods for download, unpause any pending DayZ update and allow it to complete."
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
pg1_title = "Choose uninstall targets"
|
||||
pg1_heading = "Choose uninstall targets"
|
||||
pg1_blurb = "Here you can choose which DZGUI configuration/state files to remove. "
|
||||
|
||||
pg2_title = "Uninstall complete"
|
||||
pg2_heading = "Uninstall complete"
|
||||
pg2_blurb = "Finished removing the selected files. See the console for any errors."
|
||||
|
||||
config_label = "Configuration files"
|
||||
config_details = (
|
||||
"User preferences and API key. "
|
||||
"Keep this file if you want to import it again later."
|
||||
)
|
||||
desktop_label = "Desktop shortcut"
|
||||
desktop_details = "Shortcut set on the desktop, if applicable."
|
||||
|
||||
start_menu_label = "Start menu shortcut"
|
||||
start_menu_details = (
|
||||
"Shortcut set under the Games category of the start menu, if applicable."
|
||||
)
|
||||
state_label = "Long-term state files"
|
||||
state_details = (
|
||||
"UI size, filter preferences, column size, server notes, and mod signatures."
|
||||
)
|
||||
steam_shortcut = "Steam shortcut"
|
||||
steam_details = "Shortcut to DZGUI installed within Steam, if applicable. Requires Steam to be restarted to reflect the change."
|
||||
|
||||
|
||||
path_remove_prefix = "The following path will be removed:"
|
||||
not_installed = "It looks like DZGUI was not previously set up; nothing to uninstall."
|
||||
standalone_uninstall = (
|
||||
"It looks like you are running the standalone version of DZGUI.\n"
|
||||
"To finalize the uninstall process, invoke './dzgui self remove',\n"
|
||||
"then remove the 'dzgui' file."
|
||||
)
|
||||
system_uninstall = (
|
||||
"It looks like DZGUI was installed via your system package manager or directly from source code.\n"
|
||||
"To finalize the uninstall process, follow the standard removal procedure for that context."
|
||||
)
|
||||
@@ -46,11 +46,6 @@ You must set up a Steam Web API key in order to browse the global server list.
|
||||
\nIf you don't have one already, it can be set up via the page below.
|
||||
\nPlease refer to the DZGUI documentation for more instructions.
|
||||
"""
|
||||
heading_bm_api = "Battlemetrics Web API key"
|
||||
blurb_bm_api = """A Battlemetrics key is <b>optional</b>, but allows you to add/search for servers\n
|
||||
by numeric ID on the web. For example, in the URL https://www.battlemetrics.net/servers/dayz/24819107,\n
|
||||
the ID would be <b>24819107</b>.
|
||||
"""
|
||||
|
||||
### PreferencesPage
|
||||
heading_prefs = "User preferences"
|
||||
|
||||
@@ -54,8 +54,6 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
|
||||
cpu = get_cpu_model()
|
||||
version = get_version()
|
||||
|
||||
debug = lookup(config, Preferences.DEBUG)
|
||||
install = lookup(config, Preferences.INSTALL)
|
||||
default = lookup(config, Preferences.DEFAULT)
|
||||
|
||||
steam_path = Path(default)
|
||||
@@ -79,8 +77,6 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
|
||||
Kernel: {kernel}
|
||||
CPU: {cpu}
|
||||
|
||||
Debug: {debug}
|
||||
Auto-install: {install}
|
||||
Steam path: {steam_redacted}
|
||||
Workshop path: {workshop_redacted}
|
||||
|
||||
|
||||
@@ -84,6 +84,5 @@ class CalcDist:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# TODO: handle failed remote dist
|
||||
haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon)
|
||||
return haversine
|
||||
+1
-9
@@ -2,14 +2,13 @@ import a2s
|
||||
import requests
|
||||
import shutil
|
||||
import subprocess
|
||||
from warnings import deprecated
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dzgui.api.servers import Record
|
||||
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:
|
||||
@@ -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:
|
||||
return True
|
||||
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
|
||||
+59
-16
@@ -1,28 +1,71 @@
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
|
||||
from pathlib import Path
|
||||
from dzgui.const.constants import VM_FILE, MIN_COUNT
|
||||
from dzgui.const.constants import APP_NAME, VM_FILE, MIN_COUNT
|
||||
from dzgui.util.bash import concat_bash_args
|
||||
from dzgui.strings import map_count
|
||||
from dzgui.views.dialogs.early_alert import EarlyIgnoreDialog
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
def is_map_count_valid(count: int | None) -> bool:
|
||||
if count is None:
|
||||
# NOTE: permit if count was unreadable
|
||||
return True
|
||||
return count >= MIN_COUNT
|
||||
|
||||
|
||||
def get_map_count() -> int | None:
|
||||
path = Path(VM_FILE)
|
||||
if path.is_file() is False:
|
||||
try:
|
||||
count = int(path.read_text())
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
return None
|
||||
count = int(path.read_text())
|
||||
return count
|
||||
|
||||
# TODO: unfinished
|
||||
#if count < MIN_COUNT:
|
||||
# print("needs sudo escalation")
|
||||
# # pop prompt
|
||||
# # get response
|
||||
# set_map_count()
|
||||
#return count
|
||||
|
||||
# TODO: unfinished
|
||||
def test_map_count() -> None:
|
||||
count = get_map_count()
|
||||
if is_map_count_valid(count):
|
||||
return
|
||||
msg = map_count.exit_msg
|
||||
EarlyIgnoreDialog(msg)
|
||||
|
||||
|
||||
def set_map_count() -> None:
|
||||
count = get_map_count()
|
||||
if count is None:
|
||||
print(map_count.failed_to_parse)
|
||||
return
|
||||
if is_map_count_valid(count):
|
||||
msg = map_count.meets_minimum.format(count)
|
||||
print(msg)
|
||||
return
|
||||
|
||||
conf = "/etc/sysctl.d/dayz.conf"
|
||||
count = f"vm.max_map_count={MIN_COUNT}"
|
||||
with open(conf, "w") as f:
|
||||
f.write(count)
|
||||
# TODO: use concat_bash_args()
|
||||
subprocess.run(["/usr/bin/sudo", "sysctl", "-p", conf])
|
||||
value = f"vm.max_map_count={MIN_COUNT}"
|
||||
try:
|
||||
msg = map_count.prompt.format(conf)
|
||||
print(msg)
|
||||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||
tmp = f.name
|
||||
Path(tmp).write_text(value)
|
||||
|
||||
mv_cmd = f"sudo mv {tmp} {conf}"
|
||||
reload_cmd = f"sudo sysctl -p {conf}"
|
||||
for cmd in mv_cmd, reload_cmd:
|
||||
args = concat_bash_args(cmd)
|
||||
subprocess.run([*args])
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
trace = traceback.format_exc()
|
||||
print(map_count.failed_to_update.format(trace))
|
||||
except KeyboardInterrupt:
|
||||
print(map_count.user_exit)
|
||||
sys.exit(0)
|
||||
@@ -4,8 +4,9 @@ from typing import Literal
|
||||
|
||||
api_filter = r"(.*&key=)([^&]*)(.*)"
|
||||
home_filter = r"(/home/)([^\s'\/]*)(.*)"
|
||||
user_filter = r"(.*Steam/userdata/)([^/]*)(.*)"
|
||||
REDACTED = r"\1REDACTED\3"
|
||||
REDACTION_PATTERNS = [api_filter, home_filter]
|
||||
REDACTION_PATTERNS = [api_filter, home_filter, user_filter]
|
||||
|
||||
|
||||
def redact_home(text: str) -> str:
|
||||
|
||||
+13
-99
@@ -1,27 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
from dzgui.const.constants import SYSTEM_LOG
|
||||
|
||||
# TODO: move to util.format.py
|
||||
|
||||
|
||||
def build_missing(build: str) -> str:
|
||||
msg = (
|
||||
f"This server is running {build}. You can install "
|
||||
f"{build} by searching for it in your Steam library. "
|
||||
f"If you recently installed {build} or moved it to a different drive, "
|
||||
"restart Steam to allow these changes to synchronize, then try again."
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
def build_path_invalid(build: str) -> str:
|
||||
msg = (
|
||||
f"Steam is reporting that {build} is installed at a non-existent location. "
|
||||
f"If you recently installed {build} or moved it to a different drive, "
|
||||
"restart Steam to allow these changes to synchronize, then try again."
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
# General
|
||||
dialog_header = "DZGUI - Dialog"
|
||||
@@ -32,7 +11,6 @@ delimiter = "␞"
|
||||
# GenericDialog
|
||||
main_menu = "Return to main menu"
|
||||
exit_app = "Exit"
|
||||
server_details = "Server details"
|
||||
modlist = "Modlist"
|
||||
input_required = "User input required"
|
||||
confirm = "Confirmation"
|
||||
@@ -80,7 +58,6 @@ browser_cols = [
|
||||
]
|
||||
|
||||
# DetailsDialog
|
||||
server_message = "Server message"
|
||||
workshop = "Enter/double click a row to open in Steam Workshop."
|
||||
|
||||
# LanDialog
|
||||
@@ -100,10 +77,7 @@ steam_missing = "Local Steam installation is not set, possibly malformed config
|
||||
build_corrupted = (
|
||||
"Steam settings or DayZ installation may be corrupted. Try restarting Steam."
|
||||
)
|
||||
api_warn_msg = """No servers returned. Please wait and try again.
|
||||
This usually indicates a transitory network timeout. If this issue persists, your API key or network may be malfunctioning.
|
||||
"""
|
||||
server_timeout = "Timed out when querying server, check IP or try again later."
|
||||
server_timeout = "Timed out when querying server(s). Check IP or try again later."
|
||||
server_error = (
|
||||
"Error while contacting server, possibly timed out. Please wait and try again."
|
||||
)
|
||||
@@ -182,6 +156,7 @@ filter_duplicate = "Duplicate"
|
||||
filter_official = "Official"
|
||||
filter_unofficial = "Unofficial"
|
||||
filter_modded = "Modded"
|
||||
filter_unmodded = "Unmodded"
|
||||
|
||||
# maps
|
||||
all_maps = "All maps"
|
||||
@@ -192,11 +167,12 @@ none_provided = "None provided"
|
||||
disabled = "Disabled"
|
||||
enabled = "Enabled"
|
||||
null = "-"
|
||||
none = "None"
|
||||
unspecified = "not specified"
|
||||
|
||||
# DLC
|
||||
dlc_frostline = "Frostline"
|
||||
dlc_badlands = "Badlands"
|
||||
dlc_none = "None"
|
||||
|
||||
# platform
|
||||
windows = "Windows"
|
||||
@@ -286,11 +262,8 @@ class Thanks:
|
||||
class Options:
|
||||
header: str
|
||||
steam_web: str
|
||||
bm_web: str
|
||||
enter_steam: str
|
||||
enter_bm: str
|
||||
steam_placeholder: str
|
||||
bm_placeholder: str
|
||||
name_placeholder: str
|
||||
last_used: str
|
||||
always_fs: str
|
||||
@@ -311,7 +284,7 @@ class Options:
|
||||
force_eventbox: str
|
||||
self_update: str
|
||||
no_self_update: str
|
||||
api_keys: str
|
||||
api_key: str
|
||||
prefs: str
|
||||
mods: str
|
||||
version: str
|
||||
@@ -333,8 +306,8 @@ mod_panel = ModPanelStrings(
|
||||
unhighlight_stale_tooltip="Clears highlight from stale mods",
|
||||
highlight_stale="Highlight stale",
|
||||
highlight_stale_tooltip=(
|
||||
"Shows locally-installed mods which are not used by any server "
|
||||
"in your Saved Servers"
|
||||
"Shows locally-installed mods which are not \n"
|
||||
"used by any server in your Saved Servers"
|
||||
),
|
||||
unsub_selected="Unsubscribe selected",
|
||||
unsub_selected_tooltip="Unsubscribes from selected mods",
|
||||
@@ -378,11 +351,8 @@ thanks = Thanks(
|
||||
options = Options(
|
||||
header="Options",
|
||||
steam_web="Steam API page",
|
||||
bm_web="Battlemetrics API page",
|
||||
enter_steam="Enter your Steam API key",
|
||||
enter_bm="Enter your Battlemetrics API key",
|
||||
steam_placeholder="Steam API key",
|
||||
bm_placeholder="Battlemetrics API key",
|
||||
name_placeholder="Identifies you to other players in-game",
|
||||
last_used="Last used dimensions",
|
||||
always_fs="Always fullscreen",
|
||||
@@ -411,7 +381,7 @@ options = Options(
|
||||
"In-app updates are disabled when DZGUI is "
|
||||
"installed globally (e.g., via package manager)."
|
||||
),
|
||||
api_keys="API Keys",
|
||||
api_key="API Key",
|
||||
prefs="Preferences",
|
||||
mods="Mods",
|
||||
version="Version",
|
||||
@@ -476,13 +446,15 @@ class Flags:
|
||||
version: str
|
||||
uninstall: str
|
||||
debug: str
|
||||
map_count: str
|
||||
|
||||
|
||||
flags = Flags(
|
||||
description="DayZ server browser and mod manager",
|
||||
version="Print version information",
|
||||
uninstall="Uninstall data files (use prior to 'pip uninstall')",
|
||||
debug="Enables developer debugging features",
|
||||
version="print version information",
|
||||
uninstall="clean up state/config files",
|
||||
debug="enable developer debugging features",
|
||||
map_count="check and update system map count value",
|
||||
)
|
||||
|
||||
|
||||
@@ -534,64 +506,6 @@ server_labels = ServerLabels(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ConnectPanel:
|
||||
connect: str
|
||||
add: str
|
||||
add_con: str
|
||||
placeholder: str
|
||||
entry_tooltip: str
|
||||
add_tooltip: str
|
||||
connect_tooltip: str
|
||||
|
||||
|
||||
connect_panel = ConnectPanel(
|
||||
connect="Connect",
|
||||
add="Add",
|
||||
add_con="Add/connect",
|
||||
placeholder="Enter IP (IP:Query port) or Battlemetrics ID (numerical id)",
|
||||
entry_tooltip=(
|
||||
"- IP: format as IP:Query port\ne.g. 192.168.1.1:27016\n"
|
||||
"- Battlemetrics: numeric server ID\ne.g. 123456"
|
||||
),
|
||||
add_tooltip="Add to Saved Servers",
|
||||
connect_tooltip="Connect to this server",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class FavPanel:
|
||||
heading: str
|
||||
no_fav: str
|
||||
|
||||
|
||||
fav_panel = FavPanel(
|
||||
heading="Favorite server",
|
||||
no_fav="None set. Right click a server and select 'Set favorite' to set.",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class LanPanel:
|
||||
heading: str
|
||||
default_button: str
|
||||
custom_button: str
|
||||
scan_button: str
|
||||
placeholder: str
|
||||
entry_tooltip: str
|
||||
scan_tooltip: str
|
||||
|
||||
|
||||
lan_panel = LanPanel(
|
||||
heading="LAN query port",
|
||||
default_button="Default port (27016)",
|
||||
custom_button="Custom port",
|
||||
scan_button="Scan",
|
||||
placeholder="Enter the query port (1-65535)",
|
||||
entry_tooltip="Specify the port to search for DayZ servers on the local network",
|
||||
scan_tooltip="Scan for servers",
|
||||
)
|
||||
|
||||
distance_suffix = "Distance: calculating..."
|
||||
dialog_error = "ERROR"
|
||||
|
||||
|
||||
+10
-5
@@ -2,7 +2,12 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash
|
||||
from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME, DAYZ_COMMUNITY_ROOT
|
||||
from dzgui.const.constants import (
|
||||
APPID_DAYZ,
|
||||
APPID_DAYZ_EXP,
|
||||
APP_NAME,
|
||||
DAYZ_COMMUNITY_ROOT,
|
||||
)
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.config.query import lookup
|
||||
|
||||
@@ -20,6 +25,7 @@ def expunge_link(file: Path) -> None:
|
||||
if str(file.stem)[:2] == "@C":
|
||||
file.unlink()
|
||||
|
||||
|
||||
def rebuild_symlinks(config: Path) -> None:
|
||||
# TODO: pass direct path as argument
|
||||
path = lookup(config, Preferences.DEFAULT)
|
||||
@@ -63,10 +69,9 @@ def symlink_mission(steam_path: Path, target: str) -> str:
|
||||
stem = path.name
|
||||
parent = path.parent
|
||||
source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT)
|
||||
try:
|
||||
source.symlink_to(parent)
|
||||
except Exception:
|
||||
return ""
|
||||
if source.exists():
|
||||
source.unlink()
|
||||
source.symlink_to(parent)
|
||||
return f"{DAYZ_COMMUNITY_ROOT}/{stem}"
|
||||
|
||||
|
||||
|
||||
+26
-20
@@ -1,10 +1,14 @@
|
||||
import logging
|
||||
import signal
|
||||
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.controllers.emitter import Emitter
|
||||
from dzgui.controllers.mc import Controller
|
||||
@@ -14,6 +18,7 @@ from dzgui.views.components.connect_panel import ConnectPanel
|
||||
from dzgui.views.components.crumbs import Breadcrumbs
|
||||
from dzgui.views.components.right_panel import RightPanel
|
||||
from dzgui.views.components.statusbar import Statusbar
|
||||
from dzgui.views.mixins.colorscheme import ColorAwareApp
|
||||
from dzgui.views.mixins.scrollable_mixin import ScrollableMixin
|
||||
|
||||
# TODO: import notebook only and add components there?
|
||||
@@ -41,20 +46,21 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
# TODO: drop
|
||||
# https://bugzilla.gnome.org/show_bug.cgi?id=708676
|
||||
warnings.filterwarnings("ignore", ".*g_value_get_int", Warning)
|
||||
|
||||
|
||||
class OuterWindow(Gtk.Window):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER)
|
||||
class OuterWindow(Gtk.ApplicationWindow):
|
||||
def __init__(self, app: Gtk.Application) -> None:
|
||||
super().__init__(
|
||||
application=app, title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER
|
||||
)
|
||||
|
||||
self.hb = AppHeaderBar()
|
||||
MainController.register_widget("window", self)
|
||||
|
||||
# NOTE: steam deck taskbar may occlude elements
|
||||
if MainController.get_prefs().is_steam_deck is False:
|
||||
is_deck = MainController.get_prefs().is_steam_deck
|
||||
if is_deck:
|
||||
self.maximize()
|
||||
else:
|
||||
self.set_titlebar(self.hb)
|
||||
|
||||
self.connect("delete-event", self._on_delete_event)
|
||||
@@ -66,8 +72,8 @@ class OuterWindow(Gtk.Window):
|
||||
self.show_all()
|
||||
css.load_css()
|
||||
|
||||
MainController.open_page(NotebookPage.SERVERS)
|
||||
MainController.set_start_tab()
|
||||
MainController.open_page(NotebookPage.SERVERS)
|
||||
self.grid.hide_widgets_on_init()
|
||||
|
||||
MainController.loaded = True
|
||||
@@ -276,13 +282,18 @@ class Grid(Gtk.Grid):
|
||||
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:
|
||||
super().__init__(application_id=GTK_APP_ID)
|
||||
|
||||
GLib.set_prgname(APP_NAME)
|
||||
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.connect(
|
||||
@@ -293,9 +304,6 @@ class App(Gtk.Application):
|
||||
)
|
||||
self.win.add_accel_group(accel)
|
||||
|
||||
self._setup_signals()
|
||||
Gtk.main()
|
||||
|
||||
def _setup_signals(self) -> None:
|
||||
SIGNAL_ADD = "signal_add"
|
||||
SIGNAL_ADD_FULL = "signal_add_full"
|
||||
@@ -331,8 +339,6 @@ try:
|
||||
theme = Gtk.IconTheme.get_default()
|
||||
icons = theme.list_icons(None)
|
||||
if STEAM_ICON not in icons:
|
||||
logger.warn(strings.steam_icon_missing)
|
||||
warnings.warn(strings.steam_icon_missing, stacklevel=2)
|
||||
logger.warning(strings.steam_icon_missing)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
warnings.warn(strings.gtk_theme_missing, stacklevel=2)
|
||||
@@ -23,3 +23,11 @@ class HBox(GenericBox):
|
||||
class VBox(GenericBox):
|
||||
def __init__(self, spacing: int = 0) -> None:
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing)
|
||||
|
||||
|
||||
class ShortHBox(HBox):
|
||||
def __init__(self, widget: Gtk.Widget) -> None:
|
||||
super().__init__(spacing=5)
|
||||
|
||||
self.set_halign(Gtk.Align.START)
|
||||
self.pack_start(widget, expand=False, fill=False, padding=0)
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Callable, Self, TYPE_CHECKING, Union
|
||||
from typing import Callable, Literal, Self, TYPE_CHECKING, Union
|
||||
|
||||
from dzgui.util.clip import copy_clipboard
|
||||
from dzgui.util.format import pluralize
|
||||
@@ -6,10 +6,10 @@ from dzgui.strings import buttons
|
||||
from dzgui.util.strings import (
|
||||
alert_button_tooltip,
|
||||
atomic_buttons,
|
||||
connect_panel,
|
||||
)
|
||||
from dzgui.const.constants import (
|
||||
CLIPBOARD,
|
||||
CLOSE,
|
||||
INPUT_KEYBOARD,
|
||||
LIST_ADD,
|
||||
REFRESH_ICON,
|
||||
@@ -17,6 +17,7 @@ from dzgui.const.constants import (
|
||||
WARNING,
|
||||
WEB_BROWSER,
|
||||
)
|
||||
from dzgui.strings import connect_panel
|
||||
|
||||
import gi
|
||||
|
||||
@@ -63,9 +64,17 @@ class IconButton(Gtk.Button):
|
||||
self.icon = Icon(icon, margin_start=margin_start, margin_end=margin_end)
|
||||
self.set_image(self.icon)
|
||||
self.set_image_position(position)
|
||||
# self.set_image_position(Gtk.PositionType.RIGHT)
|
||||
self.set_focus_on_click(False)
|
||||
|
||||
def swap_icon(self, icon: str) -> None:
|
||||
start = self.icon.get_margin_start()
|
||||
end = self.icon.get_margin_end()
|
||||
alt_icon = Icon(icon, margin_start=start, margin_end=end)
|
||||
self.set_image(alt_icon)
|
||||
|
||||
def revert_icon(self) -> None:
|
||||
self.set_image(self.icon)
|
||||
|
||||
|
||||
class IconTextButton(IconButton):
|
||||
def __init__(
|
||||
@@ -96,11 +105,18 @@ class ClipboardButton(IconTextButton):
|
||||
self.controller = controller
|
||||
self.connect("clicked", self._on_button_clicked, func)
|
||||
|
||||
# TODO: strings
|
||||
self.set_tooltip_text("Copy to clipboard")
|
||||
|
||||
def _on_button_clicked(self, button: Self, func: Callable) -> None:
|
||||
def revert() -> Literal[False]:
|
||||
self.revert_icon()
|
||||
return False
|
||||
|
||||
self.swap_icon("object-select-symbolic")
|
||||
data = func()
|
||||
copy_clipboard(data)
|
||||
GLib.timeout_add(600, revert)
|
||||
|
||||
|
||||
# TODO: determine when controller would be passed to this button or drop
|
||||
@@ -108,9 +124,18 @@ class CopyIpButton(ClipboardButton):
|
||||
def __init__(self, controller: Union["Controller", None], func: Callable) -> None:
|
||||
super().__init__(controller, func)
|
||||
|
||||
# TODO: strings
|
||||
self.set_tooltip_text("Copy IP to clipboard")
|
||||
|
||||
|
||||
class CloseButton(IconTextButton):
|
||||
def __init__(self, label: str) -> None:
|
||||
super().__init__(icon=CLOSE, label=label)
|
||||
|
||||
# TODO: strings
|
||||
self.set_tooltip_text("Unset this server as favorite")
|
||||
|
||||
|
||||
class WebButton(IconTextButton):
|
||||
def __init__(self, label: str) -> None:
|
||||
super().__init__(icon=WEB_BROWSER, label=label)
|
||||
@@ -196,7 +221,7 @@ class KeysButton(IconTextButton):
|
||||
|
||||
class SteamConnectButton(LargeIconTextButton):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(icon=STEAM_ICON, label=connect_panel.connect)
|
||||
super().__init__(icon=STEAM_ICON, label=connect_panel.connect_button)
|
||||
self.set_tooltip_text(connect_panel.connect_tooltip)
|
||||
|
||||
|
||||
@@ -216,7 +241,7 @@ class SteamWorkshopButton(SteamTextButton):
|
||||
|
||||
class AddButton(IconTextButton):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(icon=LIST_ADD, label=connect_panel.add)
|
||||
super().__init__(icon=LIST_ADD, label=connect_panel.add_button)
|
||||
self.set_tooltip_text(connect_panel.add_tooltip)
|
||||
|
||||
|
||||
@@ -241,3 +266,38 @@ class LoggerAlertsButton(IconTextButton):
|
||||
self.set_halign(Gtk.Align.END)
|
||||
self.set_hexpand(True)
|
||||
self.set_tooltip_text(alert_button_tooltip)
|
||||
|
||||
|
||||
class SpinnerButton(Gtk.Button):
|
||||
def __init__(self, label: str) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.text = label
|
||||
self.label = Gtk.Label(label=label, halign=Gtk.Align.CENTER)
|
||||
|
||||
self.spinner = Gtk.Spinner()
|
||||
self.spinner.set_halign(Gtk.Align.END)
|
||||
self.spinner.set_sensitive(False)
|
||||
|
||||
grid = Gtk.Grid(column_spacing=10)
|
||||
for el in self.label, self.spinner:
|
||||
grid.add(el)
|
||||
|
||||
self.add(grid)
|
||||
|
||||
self.connect("clicked", self._on_button_clicked)
|
||||
self.connect("map", self._on_map)
|
||||
|
||||
def _on_map(self, button: Self) -> None:
|
||||
self.spinner.set_visible(False)
|
||||
|
||||
def _on_button_clicked(self, button: Self) -> None:
|
||||
self.start_spinner()
|
||||
|
||||
def stop_spinner(self) -> None:
|
||||
self.spinner.set_visible(False)
|
||||
self.spinner.stop()
|
||||
|
||||
def start_spinner(self) -> None:
|
||||
self.spinner.set_visible(True)
|
||||
self.spinner.start()
|
||||
@@ -1,68 +0,0 @@
|
||||
from dzgui.strings import preconnect
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, Gtk # noqa E402
|
||||
|
||||
|
||||
class ConnectBox(Gtk.Box):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
||||
|
||||
self.back = Gtk.Button(
|
||||
label=preconnect.back, halign=Gtk.Align.END, hexpand=True
|
||||
)
|
||||
|
||||
# TODO: dynamically update this
|
||||
self.ok = Gtk.Button(label="Launch", halign=Gtk.Align.END)
|
||||
|
||||
for button in self.back, self.ok:
|
||||
self.add(button)
|
||||
|
||||
def get_back_button(self) -> Gtk.Button:
|
||||
return self.back
|
||||
|
||||
def get_ok_button(self) -> Gtk.Button:
|
||||
return self.ok
|
||||
|
||||
|
||||
class PreconnectBox(Gtk.Box):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
valign=Gtk.Align.END,
|
||||
vexpand=True,
|
||||
spacing=5,
|
||||
)
|
||||
|
||||
self.button_hbox = ConnectBox()
|
||||
self.ok = self.button_hbox.get_ok_button()
|
||||
self.ok.set_label(preconnect.update_mods)
|
||||
|
||||
self.connect_last = Gtk.Button(
|
||||
label=preconnect.connect_last,
|
||||
halign=Gtk.Align.END,
|
||||
tooltip_text=preconnect.connect_last_tooltip,
|
||||
)
|
||||
self.button_hbox.add(self.connect_last)
|
||||
|
||||
self.raise_window = Gtk.CheckButton(
|
||||
# TODO: strings
|
||||
label="Foreground DZGUI while downloading",
|
||||
halign=Gtk.Align.END,
|
||||
hexpand=True,
|
||||
valign=Gtk.Align.END,
|
||||
visible=False,
|
||||
has_tooltip=True,
|
||||
sensitive=False,
|
||||
tooltip_text="Foreground the DZGUI window after mod downloads are queued",
|
||||
active=True,
|
||||
)
|
||||
self.add(self.button_hbox)
|
||||
self.add(self.raise_window)
|
||||
|
||||
def get_back_button(self) -> Gtk.Button:
|
||||
return self.button_hbox.get_back_button()
|
||||
|
||||
def get_ok_button(self) -> Gtk.Button:
|
||||
return self.ok
|
||||
@@ -6,6 +6,7 @@ from dzgui.strings import connect_panel
|
||||
from dzgui.util.keys import is_ctrl_mask
|
||||
from dzgui.views.components.buttons import (
|
||||
AddButton,
|
||||
CloseButton,
|
||||
CopyIpButton,
|
||||
Icon,
|
||||
IconButton,
|
||||
@@ -187,17 +188,27 @@ class FavPanel(Gtk.Frame):
|
||||
self.fav_button = SteamConnectButton()
|
||||
self.fav_button.connect("clicked", self._on_connect_clicked)
|
||||
self.copy_button = CopyIpButton(self.controller, self.get_fav_ip)
|
||||
|
||||
# TODO: strings
|
||||
self.unset_button = CloseButton("Unset")
|
||||
self.unset_button.connect("clicked", self._on_unset_clicked)
|
||||
|
||||
if favorite is None:
|
||||
self.toggle_buttons(False)
|
||||
|
||||
# NOTE: disable vscrollbar to prevent layout jumping behavior
|
||||
scrollable_label = Gtk.ScrolledWindow(vscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
scrollable_label.add(self.fav_label)
|
||||
self.scrollable_label = Gtk.ScrolledWindow(
|
||||
vscrollbar_policy=Gtk.PolicyType.NEVER, overlay_scrolling=False
|
||||
)
|
||||
self.scrollable_label.add(self.fav_label)
|
||||
|
||||
grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5)
|
||||
grid.attach(scrollable_label, 0, 0, 3, ROWS)
|
||||
grid.attach(self.scrollable_label, 0, 0, 3, ROWS)
|
||||
grid.attach_next_to(
|
||||
self.copy_button, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS
|
||||
self.unset_button, self.scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS
|
||||
)
|
||||
grid.attach_next_to(
|
||||
self.copy_button, self.unset_button, Gtk.PositionType.RIGHT, COLS, ROWS
|
||||
)
|
||||
grid.attach_next_to(
|
||||
self.fav_button, self.copy_button, Gtk.PositionType.RIGHT, COLS, ROWS
|
||||
@@ -205,8 +216,16 @@ class FavPanel(Gtk.Frame):
|
||||
|
||||
self.add(grid)
|
||||
|
||||
def _on_unset_clicked(self, button: Gtk.Button) -> None:
|
||||
try:
|
||||
self.controller.unset_fav()
|
||||
except Exception:
|
||||
return
|
||||
self.fav_label.set_text(connect_panel.favs_empty)
|
||||
self.toggle_buttons(False)
|
||||
|
||||
def toggle_buttons(self, state: bool) -> None:
|
||||
for button in self.fav_button, self.copy_button:
|
||||
for button in self.fav_button, self.copy_button, self.unset_button:
|
||||
button.set_sensitive(state)
|
||||
|
||||
def _on_connect_clicked(self, button: Gtk.Button) -> None:
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Any, Callable, TYPE_CHECKING
|
||||
from dzgui.api.servers import validate_ip
|
||||
from dzgui.const.constants import VIEW_CONCEAL, VIEW_REVEAL
|
||||
from dzgui.util.css import add_class, remove_class
|
||||
from dzgui.util.strings import connect_panel, lan_panel
|
||||
from dzgui.strings import connect_panel
|
||||
from dzgui.strings.errors import api_popover
|
||||
|
||||
import gi
|
||||
@@ -26,15 +26,12 @@ def validate_port(text: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def validate_ip_or_id(text: str) -> bool:
|
||||
def validate_ip_truthy(text: str) -> bool:
|
||||
try:
|
||||
validate_ip(text)
|
||||
return True
|
||||
except Exception:
|
||||
if text.isdigit():
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
class ValidatedEntry(Gtk.Entry):
|
||||
@@ -124,12 +121,9 @@ class IpEntry(ValidatedEntry):
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
super().__init__(
|
||||
controller,
|
||||
func=validate_ip_or_id,
|
||||
placeholder_text=connect_panel.placeholder,
|
||||
tooltip_text=connect_panel.entry_tooltip,
|
||||
func=validate_ip_truthy,
|
||||
placeholder_text=connect_panel.connect_entry_placeholder,
|
||||
)
|
||||
self.set_placeholder_text(connect_panel.placeholder)
|
||||
self.set_tooltip_text(connect_panel.entry_tooltip)
|
||||
|
||||
|
||||
class PortEntry(ValidatedEntry):
|
||||
@@ -137,11 +131,9 @@ class PortEntry(ValidatedEntry):
|
||||
super().__init__(
|
||||
controller,
|
||||
func=validate_port,
|
||||
placeholder_text=connect_panel.placeholder,
|
||||
tooltip_text=connect_panel.entry_tooltip,
|
||||
placeholder_text=connect_panel.lan_entry_placeholder,
|
||||
tooltip_text=connect_panel.lan_entry_tooltip,
|
||||
)
|
||||
self.set_placeholder_text(lan_panel.placeholder)
|
||||
self.set_tooltip_text(lan_panel.entry_tooltip)
|
||||
|
||||
|
||||
# TODO: backport to Options page
|
||||
@@ -162,7 +154,8 @@ class APIEntry(Gtk.Box):
|
||||
self.entry.connect("icon-release", self._on_icon_release)
|
||||
self.entry.connect("activate", self._on_field_activated)
|
||||
|
||||
self.submit = Gtk.Button(label="Submit")
|
||||
# TODO: strings
|
||||
self.submit = Gtk.Button(label="Validate")
|
||||
self.submit.set_sensitive(False)
|
||||
self.submit.connect("clicked", self._on_submit)
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ class InfoEventBox(Gtk.EventBox):
|
||||
self.connect("leave-notify-event", self._on_leave_tooltip)
|
||||
self.add(box)
|
||||
|
||||
def set_icon_yalign(self, offset: float) -> None:
|
||||
self.icon.set_property("yalign", offset)
|
||||
|
||||
def _on_enter_tooltip(
|
||||
self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing
|
||||
) -> None:
|
||||
|
||||
@@ -9,8 +9,6 @@ from dzgui.const.constants import (
|
||||
NO_PADDING,
|
||||
SEARCH_ICON,
|
||||
)
|
||||
from dzgui.const.enum import FilterMode
|
||||
from dzgui.model.servers import ServerModelManager
|
||||
from dzgui.util.format import format_exception
|
||||
from dzgui.util.strings import all_maps
|
||||
from dzgui.views.components.maps_combo import MapsCombo
|
||||
@@ -29,21 +27,33 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class ButtonGrid(Gtk.Grid):
|
||||
def __init__(self, controller: "Controller", defaults: dict) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
controller: "Controller",
|
||||
defaults: dict[str, str],
|
||||
tooltips: dict[str, str],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
halign=Gtk.Align.CENTER, column_spacing=5, column_homogeneous=True
|
||||
)
|
||||
row = 1
|
||||
col = 0
|
||||
|
||||
self.controller = controller
|
||||
self.emitter = controller.get_emitter()
|
||||
|
||||
self.checks: list[Gtk.CheckButton] = []
|
||||
|
||||
# TODO: use enumerated checks
|
||||
flowbox = Gtk.FlowBox(
|
||||
halign=Gtk.Align.CENTER, min_children_per_line=3, max_children_per_line=3, selection_mode=Gtk.SelectionMode.NONE
|
||||
)
|
||||
|
||||
for check in defaults.keys():
|
||||
checkbox = Gtk.CheckButton(label=check)
|
||||
try:
|
||||
tt = tooltips[check]
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
tt = ""
|
||||
|
||||
checkbox.set_tooltip_text(tt)
|
||||
label = checkbox.get_child()
|
||||
if label is not None:
|
||||
label.set_ellipsize(Pango.EllipsizeMode.END) # type: ignore
|
||||
@@ -51,14 +61,12 @@ class ButtonGrid(Gtk.Grid):
|
||||
if defaults[check]:
|
||||
checkbox.set_active(True)
|
||||
|
||||
col = col + 1
|
||||
if col > 3:
|
||||
row += 1
|
||||
col = 1
|
||||
self.attach(checkbox, col, row, 1, 1)
|
||||
checkbox.connect("toggled", self._on_check_toggled)
|
||||
flowbox.add(checkbox)
|
||||
self.checks.append(checkbox)
|
||||
|
||||
self.add(flowbox)
|
||||
|
||||
def block_toggles(self, state: bool) -> None:
|
||||
for check in self.checks:
|
||||
self.controller.suppress_signal(
|
||||
@@ -138,10 +146,6 @@ class KeywordEntry(Gtk.Entry):
|
||||
self.controller.set_active_keyword(keyword)
|
||||
logger.info(f"User filtered by keyword '{keyword}'")
|
||||
|
||||
ServerModelManager(
|
||||
self.controller, self.controller.get_active_treeview()
|
||||
).refilter(FilterMode.TOGGLE_ON)
|
||||
|
||||
|
||||
class FilterPanel(Gtk.Box):
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
@@ -163,11 +167,12 @@ class FilterPanel(Gtk.Box):
|
||||
|
||||
filter_man = self.controller.get_filter_man()
|
||||
defaults = filter_man.get_default_filters()
|
||||
tooltips = filter_man.get_tooltips()
|
||||
self.map_store = filter_man.get_map_store()
|
||||
self.enabled_filters = defaults
|
||||
|
||||
self.keyword_entry = KeywordEntry(self.controller)
|
||||
self.button_grid = ButtonGrid(self.controller, defaults)
|
||||
self.button_grid = ButtonGrid(self.controller, defaults, tooltips)
|
||||
|
||||
# TODO: strings
|
||||
self.filters_label = BoldLabel("Filters")
|
||||
|
||||
@@ -9,3 +9,6 @@ class Icon(Gtk.Image):
|
||||
super().__init__(
|
||||
icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin
|
||||
)
|
||||
|
||||
def set_icon_name(self, name: str) -> None:
|
||||
super().set_from_icon_name(name, Gtk.IconSize.BUTTON)
|
||||
@@ -1,5 +1,11 @@
|
||||
from typing import Self
|
||||
from warnings import deprecated
|
||||
|
||||
from dzgui.const.constants import ERROR, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD
|
||||
from dzgui.init.prereqs import has_steam_client, has_flatpak_client
|
||||
from dzgui.strings import options
|
||||
from dzgui.const.constants import FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD
|
||||
from dzgui.views.components.box import HBox
|
||||
from dzgui.views.components.buttons import Icon
|
||||
|
||||
import gi
|
||||
|
||||
@@ -7,29 +13,71 @@ gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa
|
||||
|
||||
|
||||
@deprecated("Client choice is determined at setup time")
|
||||
class ClientCombo(Gtk.ComboBox):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.client_store = Gtk.ListStore(str, str)
|
||||
clients = (
|
||||
has_steam = has_steam_client()
|
||||
has_flatpak = has_flatpak_client()
|
||||
self.client_store = Gtk.ListStore(str, str, bool)
|
||||
self.clients = (
|
||||
(
|
||||
options.steam_combo,
|
||||
STEAM_CMD,
|
||||
has_steam,
|
||||
),
|
||||
(
|
||||
options.flatpak_combo,
|
||||
FLATPAK_RUN_CMD,
|
||||
has_flatpak,
|
||||
),
|
||||
(
|
||||
options.flatpak_container_combo,
|
||||
FLATPAK_SANDBOX,
|
||||
has_flatpak,
|
||||
),
|
||||
)
|
||||
for client in clients:
|
||||
for client in self.clients:
|
||||
self.client_store.append(client)
|
||||
|
||||
self.set_model(self.client_store)
|
||||
renderer_text = Gtk.CellRendererText()
|
||||
self.pack_start(renderer_text, True)
|
||||
self.add_attribute(renderer_text, "text", 0)
|
||||
self.set_active(0)
|
||||
|
||||
self.set_cell_data_func(self.get_cells()[0], self.data_func)
|
||||
|
||||
def data_func(
|
||||
self,
|
||||
combo: Self,
|
||||
cell: Gtk.CellRendererText,
|
||||
model: Gtk.ListStore,
|
||||
_iter: Gtk.TreeIter,
|
||||
) -> None:
|
||||
sens = model[_iter][2]
|
||||
cell.set_property("sensitive", sens)
|
||||
|
||||
def get_client_human_readable(self, cmd: str) -> str:
|
||||
for client in self.clients:
|
||||
human, command, sens = client
|
||||
if command == cmd:
|
||||
return human
|
||||
raise TypeError("Not a valid Steam client selection")
|
||||
|
||||
|
||||
class ErrorPopover(Gtk.Popover):
|
||||
def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None:
|
||||
super().__init__(relative_to=relative_to, position=position)
|
||||
|
||||
self.hbox = HBox()
|
||||
self.label = Gtk.Label(label="", margin_start=10, margin_end=10)
|
||||
error_icon = Icon(ERROR, margin_start=10)
|
||||
self.hbox.extend([error_icon, self.label])
|
||||
self.add(self.hbox)
|
||||
self.show_all()
|
||||
self.popdown()
|
||||
|
||||
def set_label(self, label: str) -> None:
|
||||
self.label.set_label(label)
|
||||
@@ -131,6 +131,7 @@ class RightPanel(Gtk.Box):
|
||||
return
|
||||
self.copying = True
|
||||
version = self.version_label.get_text()
|
||||
# TODO: strings
|
||||
self.version_label.set_text("Copied!")
|
||||
copy_clipboard(version)
|
||||
GLib.timeout_add_seconds(1, revert)
|
||||
@@ -4,6 +4,9 @@ from typing import Any, Literal, Self, TYPE_CHECKING
|
||||
# import time
|
||||
from enum import Enum
|
||||
|
||||
|
||||
from dzgui.views.mixins.colorscheme import ColorAwareApp
|
||||
|
||||
# TODO: import dialog titles
|
||||
from dzgui.api.mods import remove_stale_signatures as remove_stale
|
||||
from dzgui.config.ipdb import get_ipdb
|
||||
@@ -37,11 +40,11 @@ class Success(Enum):
|
||||
|
||||
# TODO: strings for "Running", "Failed", etc.
|
||||
# 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:
|
||||
super().__init__(
|
||||
title=dialog_header,
|
||||
parent=parent,
|
||||
transient_for=parent,
|
||||
modal=True,
|
||||
)
|
||||
|
||||
@@ -99,7 +102,7 @@ class BootDialog(Gtk.Dialog):
|
||||
margin_top=30,
|
||||
margin_bottom=30,
|
||||
)
|
||||
self.error_label = Gtk.Label()
|
||||
self.error_label = Gtk.Label(margin_start=15, margin_end=15)
|
||||
|
||||
# TODO: abstract class
|
||||
self.copy_button = ClipboardButton(None, lambda: self.error_label.get_text())
|
||||
|
||||
@@ -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)
|
||||
@@ -3,6 +3,7 @@ import sys
|
||||
|
||||
from typing import Self
|
||||
from dzgui.util.strings import dialog_error, dialog_header
|
||||
from dzgui.views.mixins.colorscheme import ColorAwareApp
|
||||
|
||||
import gi
|
||||
|
||||
@@ -10,21 +11,26 @@ gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa E402
|
||||
|
||||
|
||||
class EarlyAlertDialog(Gtk.MessageDialog):
|
||||
def __init__(self, string: str) -> None:
|
||||
class AbortDialog(ColorAwareApp, Gtk.MessageDialog): # type: ignore
|
||||
def __init__(self, string: str, buttons: Gtk.ButtonsType) -> None:
|
||||
super().__init__(
|
||||
title=dialog_header,
|
||||
text=dialog_error,
|
||||
transient_for=None,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
buttons=buttons,
|
||||
)
|
||||
|
||||
msg = textwrap.fill(string, 50)
|
||||
self.format_secondary_text(msg)
|
||||
|
||||
ma = self.get_message_area()
|
||||
label = ma.get_children()[1] # type: ignore
|
||||
label.set_justify(Gtk.Justification.CENTER)
|
||||
|
||||
aa = self.get_action_area()
|
||||
aa.set_margin_bottom(20)
|
||||
# self.action_area.set_margin_bottom(20)
|
||||
aa.set_layout(Gtk.ButtonBoxStyle.CENTER) # type: ignore
|
||||
|
||||
self.outer = self.get_content_area()
|
||||
self.outer.set_margin_start(30)
|
||||
self.outer.set_margin_end(30)
|
||||
@@ -32,26 +38,30 @@ class EarlyAlertDialog(Gtk.MessageDialog):
|
||||
self.set_default_size(250, 100)
|
||||
|
||||
abort = self.get_widget_for_response(Gtk.ResponseType.OK)
|
||||
ignore = self.get_widget_for_response(Gtk.ResponseType.CANCEL)
|
||||
if abort is not None and hasattr(abort, "set_label"):
|
||||
abort.set_label("Exit")
|
||||
if ignore is not None and hasattr(ignore, "set_label"):
|
||||
ignore.set_label("Ignore")
|
||||
|
||||
self.connect("response", self._on_response)
|
||||
|
||||
self.run()
|
||||
self.destroy()
|
||||
|
||||
def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None:
|
||||
match response:
|
||||
case Gtk.ResponseType.OK:
|
||||
case Gtk.ResponseType.OK | Gtk.ResponseType.DELETE_EVENT:
|
||||
sys.exit(1)
|
||||
case Gtk.ResponseType.CANCEL:
|
||||
return
|
||||
|
||||
|
||||
class EarlyIgnoreDialog(EarlyAlertDialog):
|
||||
class EarlyAlertDialog(AbortDialog):
|
||||
def __init__(self, string: str) -> None:
|
||||
super().__init__(string=string)
|
||||
super().__init__(string=string, buttons=Gtk.ButtonsType.OK)
|
||||
|
||||
# TODO: reverse order
|
||||
self.add_button("Ignore", Gtk.ResponseType.CANCEL)
|
||||
|
||||
# TODO: if exit, sys.exit(1), else pass
|
||||
class EarlyIgnoreDialog(AbortDialog):
|
||||
def __init__(self, string: str) -> None:
|
||||
super().__init__(string=string, buttons=Gtk.ButtonsType.OK_CANCEL)
|
||||
@@ -13,7 +13,7 @@ class FilePicker(Gtk.FileChooserDialog):
|
||||
super().__init__(
|
||||
title=picker.title,
|
||||
action=Gtk.FileChooserAction.SAVE,
|
||||
parent=parent,
|
||||
transient_for=parent,
|
||||
resizable=True,
|
||||
)
|
||||
self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL)
|
||||
|
||||
+152
-51
@@ -1,6 +1,7 @@
|
||||
from typing import Literal, Self, TYPE_CHECKING
|
||||
|
||||
from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING, EXPAND, FILL
|
||||
from dzgui.strings import dialogs
|
||||
from dzgui.util import strings
|
||||
from dzgui.views.components.buttons import ClipboardButton
|
||||
|
||||
@@ -107,23 +108,26 @@ class WaitDialog(GenericDialog):
|
||||
self.cur_job = 1
|
||||
|
||||
self.cancel = Gtk.Button(label="Cancel", halign=Gtk.Align.CENTER)
|
||||
self.cancel.connect("clicked", lambda _: self.controller.set_cancel_event())
|
||||
self.cancel.connect("clicked", self._on_cancel_clicked)
|
||||
|
||||
self.connect("delete-event", lambda widget, event: True)
|
||||
content = self.get_content_area()
|
||||
spinner = Gtk.Spinner()
|
||||
self.prog = Gtk.ProgressBar()
|
||||
|
||||
content.pack_end(self.cancel, NO_EXPAND, NO_FILL, 0)
|
||||
content.pack_end(spinner, NO_EXPAND, NO_FILL, 0)
|
||||
spinner_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.CENTER
|
||||
)
|
||||
spinner = Gtk.Spinner()
|
||||
spinner_box.add(spinner)
|
||||
|
||||
if self.jobs > 1:
|
||||
content.pack_end(self.prog, NO_EXPAND, NO_FILL, 0)
|
||||
content.pack_start(self.prog, NO_EXPAND, NO_FILL, 0)
|
||||
else:
|
||||
content.pack_start(spinner_box, NO_EXPAND, NO_FILL, 0)
|
||||
spinner.start()
|
||||
|
||||
if show_cancel is False:
|
||||
self.connect("realize", lambda _: self.cancel.set_visible(False))
|
||||
if show_cancel is True:
|
||||
content.pack_start(self.cancel, NO_EXPAND, NO_FILL, 0)
|
||||
|
||||
def update_text(self, msg: str) -> None:
|
||||
self.format_secondary_text(msg)
|
||||
@@ -135,6 +139,11 @@ class WaitDialog(GenericDialog):
|
||||
self.prog.set_fraction(fraction)
|
||||
self.cur_job += 1
|
||||
|
||||
def _on_cancel_clicked(self, button: Gtk.Button) -> None:
|
||||
# TODO: strings
|
||||
GLib.idle_add(self.update_text, "Caught cancel signal, cleaning up")
|
||||
self.controller.set_cancel_event()
|
||||
|
||||
def show_cancel(self, state: bool) -> None:
|
||||
self.cancel.set_visible(state)
|
||||
|
||||
@@ -153,6 +162,7 @@ class QuitDialog(GenericDialog):
|
||||
self.add_button(strings.exit_app, Gtk.ResponseType.OK)
|
||||
|
||||
self.connect("response", self._on_response)
|
||||
# TODO: superfluous with the above
|
||||
self.connect("delete-event", self._on_response)
|
||||
|
||||
def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None:
|
||||
@@ -160,58 +170,30 @@ class QuitDialog(GenericDialog):
|
||||
self.controller.save_res_and_quit()
|
||||
|
||||
|
||||
class ExceptionDialog(GenericDialog):
|
||||
"""
|
||||
Error dialog with rich traceback.
|
||||
Usage:
|
||||
try:
|
||||
foo()
|
||||
except Exception:
|
||||
trace = traceback.format_exc()
|
||||
dialog = ExceptionDialog(Controller, trace)
|
||||
dialog.run()
|
||||
"""
|
||||
|
||||
def __init__(self, controller: "Controller", trace: str):
|
||||
class TextBufferDialog(GenericDialog):
|
||||
def __init__(
|
||||
self,
|
||||
controller: "Controller",
|
||||
mtype: Gtk.MessageType,
|
||||
heading: str,
|
||||
secondary: str,
|
||||
text: str,
|
||||
):
|
||||
super().__init__(
|
||||
controller=controller,
|
||||
text=strings.error_heading,
|
||||
mtype=Gtk.MessageType.ERROR,
|
||||
text=heading,
|
||||
mtype=mtype,
|
||||
buttons=Gtk.ButtonsType.NONE,
|
||||
secondary=strings.something_wrong,
|
||||
secondary=secondary,
|
||||
)
|
||||
|
||||
self.trace = trace
|
||||
# NOTE: box expands to end of content area
|
||||
scrollable = Gtk.ScrolledWindow(
|
||||
propagate_natural_height=True, max_content_height=500
|
||||
)
|
||||
box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL)
|
||||
# TODO: wrap/truncate long messages
|
||||
textview = Gtk.TextView(
|
||||
wrap_mode=Gtk.WrapMode.WORD, editable=False, left_margin=10, right_margin=10
|
||||
)
|
||||
textview.set_buffer(Gtk.TextBuffer(text=self.trace))
|
||||
box.pack_start(textview, EXPAND, FILL, 10)
|
||||
scrollable.add(box)
|
||||
|
||||
content = self.get_content_area()
|
||||
content.set_spacing(0)
|
||||
# TODO: padding around top of content area when traceback is long
|
||||
content.add(scrollable)
|
||||
|
||||
copy_button = ClipboardButton(controller, self.get_trace)
|
||||
self.text = text
|
||||
copy_button = ClipboardButton(controller, self.get_text)
|
||||
self.add_action_widget(copy_button, Gtk.ResponseType.NONE)
|
||||
self.add_button("OK", Gtk.ResponseType.OK)
|
||||
|
||||
self.show_all()
|
||||
self.ok = self.get_widget_for_response(Gtk.ResponseType.OK)
|
||||
if self.ok is not None:
|
||||
self.ok.grab_focus()
|
||||
self.connect("response", self._on_response)
|
||||
|
||||
def get_trace(self) -> str:
|
||||
return self.trace
|
||||
def get_text(self) -> str:
|
||||
return self.text
|
||||
|
||||
def _on_response(
|
||||
self, dialog: Self, response: Gtk.ResponseType
|
||||
@@ -227,3 +209,122 @@ class ExceptionDialog(GenericDialog):
|
||||
return None
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
class DebugDialog(TextBufferDialog):
|
||||
def __init__(self, controller: "Controller", debug: str):
|
||||
super().__init__(
|
||||
controller=controller,
|
||||
mtype=Gtk.MessageType.INFO,
|
||||
heading=dialogs.debug_heading,
|
||||
secondary=dialogs.debug_secondary,
|
||||
text=debug,
|
||||
)
|
||||
scrollable = Gtk.ScrolledWindow(
|
||||
propagate_natural_height=False,
|
||||
min_content_height=200,
|
||||
max_content_height=200,
|
||||
margin_bottom=10,
|
||||
)
|
||||
textview = Gtk.TextView(
|
||||
wrap_mode=Gtk.WrapMode.CHAR, editable=False, left_margin=10, right_margin=10
|
||||
)
|
||||
textview.set_buffer(Gtk.TextBuffer(text=debug))
|
||||
scrollable.add(textview)
|
||||
content = self.get_content_area()
|
||||
content.set_spacing(0)
|
||||
content.add(scrollable)
|
||||
|
||||
|
||||
class ExceptionDialog(TextBufferDialog):
|
||||
"""
|
||||
Error dialog with rich traceback.
|
||||
Usage:
|
||||
try:
|
||||
foo()
|
||||
except Exception:
|
||||
trace = traceback.format_exc()
|
||||
dialog = ExceptionDialog(Controller, trace)
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
"""
|
||||
|
||||
def __init__(self, controller: "Controller", trace: str):
|
||||
super().__init__(
|
||||
controller=controller,
|
||||
heading=strings.error_heading,
|
||||
secondary=strings.something_wrong,
|
||||
text=trace,
|
||||
mtype=Gtk.MessageType.ERROR,
|
||||
)
|
||||
|
||||
# NOTE: box expands to end of content area
|
||||
scrollable = Gtk.ScrolledWindow(
|
||||
propagate_natural_height=True, max_content_height=500
|
||||
)
|
||||
box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL)
|
||||
textview = Gtk.TextView(
|
||||
wrap_mode=Gtk.WrapMode.WORD,
|
||||
editable=False,
|
||||
left_margin=10,
|
||||
right_margin=10,
|
||||
top_margin=15,
|
||||
bottom_margin=10,
|
||||
)
|
||||
textview.set_buffer(Gtk.TextBuffer(text=trace))
|
||||
box.pack_start(textview, EXPAND, FILL, 0)
|
||||
|
||||
self.error_details = Gtk.ScrolledWindow(
|
||||
overlay_scrolling=False,
|
||||
max_content_height=150,
|
||||
propagate_natural_height=False,
|
||||
)
|
||||
details_box = Gtk.Box(
|
||||
hexpand=True,
|
||||
vexpand=True,
|
||||
margin_right=5,
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
)
|
||||
|
||||
self.details_buffer = Gtk.TextBuffer()
|
||||
details_textview = Gtk.TextView(
|
||||
wrap_mode=Gtk.WrapMode.WORD_CHAR,
|
||||
editable=False,
|
||||
left_margin=10,
|
||||
right_margin=10,
|
||||
top_margin=15,
|
||||
)
|
||||
|
||||
details_textview.set_buffer(self.details_buffer)
|
||||
details_box.pack_start(details_textview, EXPAND, FILL, 10)
|
||||
self.error_details.add(details_box)
|
||||
|
||||
self.error_notebook = Gtk.Notebook(show_tabs=False, margin_bottom=15)
|
||||
self.error_notebook.append_page(box, Gtk.Label(label="Error"))
|
||||
self.error_notebook.append_page(self.error_details, Gtk.Label(label="Details"))
|
||||
self.error_notebook.connect("switch-page", self._on_page_changed)
|
||||
scrollable.add(self.error_notebook)
|
||||
|
||||
content = self.get_content_area()
|
||||
content.set_spacing(0)
|
||||
content.add(scrollable)
|
||||
|
||||
self.connect("map", self._on_map)
|
||||
|
||||
def _on_map(self, dialog: Self) -> None:
|
||||
button = self.get_widget_for_response(Gtk.ResponseType.OK)
|
||||
if button is None:
|
||||
return
|
||||
button.grab_focus()
|
||||
|
||||
def _on_page_changed(
|
||||
self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int
|
||||
) -> None:
|
||||
if child == self.error_details:
|
||||
GLib.idle_add(self.error_details.set_propagate_natural_height, True)
|
||||
else:
|
||||
GLib.idle_add(self.error_details.set_propagate_natural_height, False)
|
||||
|
||||
def set_details_buffer(self, text: str) -> None:
|
||||
self.details_buffer.set_text(text)
|
||||
self.error_notebook.set_show_tabs(True)
|
||||
@@ -1,19 +1,21 @@
|
||||
import gi
|
||||
import html
|
||||
from typing import Self, TYPE_CHECKING
|
||||
|
||||
from dzgui.const.constants import EXPAND, FILL
|
||||
from dzgui.const.constants import CALCULATOR
|
||||
from dzgui.const.enum import ContextMenuGroup
|
||||
from dzgui.model.model_factory import ModelFactory
|
||||
from dzgui.util import css
|
||||
from dzgui.util import strings
|
||||
from dzgui.util.format import format_hyperlinks, format_server_mods
|
||||
from dzgui.strings import server_mods
|
||||
from dzgui.strings import server_mods, dialogs
|
||||
from dzgui.views.components.buttons import IconTextButton
|
||||
from dzgui.views.components.box import VBox
|
||||
from dzgui.views.dialogs.calc import Time, ServerTimeCalculator
|
||||
from dzgui.views.dialogs.generic import GenericDialog
|
||||
from dzgui.views.trees.tree_base import TreeView
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
|
||||
from gi.repository import Gtk, Gdk, Pango # noqa
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.api.servers import Details
|
||||
@@ -45,12 +47,19 @@ class ServerDialog(GenericDialog):
|
||||
self.connect("response", self._on_response)
|
||||
self.view.connect("key-press-event", self._on_keypress)
|
||||
|
||||
self.scrollable_tree = Gtk.ScrolledWindow()
|
||||
self.scrollable_tree = Gtk.ScrolledWindow(vexpand=True)
|
||||
self.scrollable_tree.add(self.view)
|
||||
self.scrollable_tree.set_size_request(700, 400)
|
||||
self.scrollable_tree.set_size_request(700, 500)
|
||||
|
||||
self.stack = Gtk.Stack()
|
||||
self.content_box = VBox(10)
|
||||
self.content_box.set_valign(Gtk.Align.FILL)
|
||||
self.content_box.add(self.scrollable_tree)
|
||||
|
||||
self.stack.add_named(self.content_box, "METADATA")
|
||||
|
||||
self.content = self.get_content_area()
|
||||
self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0)
|
||||
self.content.pack_start(self.stack, expand=True, fill=True, padding=0)
|
||||
|
||||
def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> bool:
|
||||
# NOTE: ESC normally unfocuses treeview instead of destroying dialog
|
||||
@@ -65,8 +74,7 @@ class ServerDialog(GenericDialog):
|
||||
|
||||
class ServerDetailsDialog(ServerDialog):
|
||||
def __init__(self, controller: "Controller", details: "Details"):
|
||||
name = controller.get_server_name()
|
||||
super().__init__(controller, strings.server_details, name, menu=None)
|
||||
super().__init__(controller, dialogs.server_details, details.name, menu=None)
|
||||
|
||||
self.store = Gtk.ListStore(str, str, Pango.Weight)
|
||||
self.view.connect("row-activated", self._on_row_activated)
|
||||
@@ -83,14 +91,9 @@ class ServerDetailsDialog(ServerDialog):
|
||||
column.set_sort_column_id(i)
|
||||
column.set_expand(True)
|
||||
|
||||
# NOTE: constrain tree area to logical height of metadata
|
||||
# leaving room for server message
|
||||
self.scrollable_tree.set_size_request(700, 150)
|
||||
|
||||
# TODO: make "Server message" text boldface
|
||||
scrollable_message = Gtk.ScrolledWindow()
|
||||
desc = Gtk.Label(label=strings.server_message, valign=Gtk.Align.START)
|
||||
css.add_class(desc, "details-heading")
|
||||
scrollable_message = Gtk.ScrolledWindow(vexpand=True)
|
||||
desc = Gtk.Label(label=dialogs.server_message, valign=Gtk.Align.START)
|
||||
css.add_class(desc, "server-subheading")
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER)
|
||||
self.description = Gtk.Label(justify=Gtk.Justification.CENTER, wrap=True)
|
||||
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
@@ -98,21 +101,41 @@ class ServerDetailsDialog(ServerDialog):
|
||||
for el in desc, sep, self.description:
|
||||
box.add(el)
|
||||
scrollable_message.add(box)
|
||||
|
||||
self.pack(scrollable_message)
|
||||
|
||||
if details.data is None:
|
||||
return
|
||||
self.content_box.add(scrollable_message)
|
||||
|
||||
for row in details.data:
|
||||
self.store.append(row + [Pango.Weight.BOLD])
|
||||
self.view.set_model(self.store)
|
||||
text = details.description
|
||||
text = format_hyperlinks(text)
|
||||
self.description.set_markup(text)
|
||||
self.description.set_markup(html.escape(text))
|
||||
|
||||
calc_button = IconTextButton(CALCULATOR, dialogs.toggle_calc)
|
||||
calc_button.connect("clicked", self._on_calc_pressed)
|
||||
calc_button.set_halign(Gtk.Align.END)
|
||||
calc_button.set_valign(Gtk.Align.START)
|
||||
self.content.pack_start(calc_button, False, False, 0)
|
||||
self.content.reorder_child(calc_button, 2)
|
||||
|
||||
self.scrollable_tree.set_size_request(700, 250)
|
||||
self.calc = ServerTimeCalculator(
|
||||
Time(details.gametime, details.day_accel, details.night_accel)
|
||||
)
|
||||
self.stack.add_named(self.calc, "CALCULATOR")
|
||||
|
||||
self.connect("map", self._on_map)
|
||||
self.show_all()
|
||||
|
||||
def _on_map(self, dialog: Self) -> None:
|
||||
if widget := self.get_widget_for_response(Gtk.ResponseType.OK):
|
||||
widget.grab_focus()
|
||||
|
||||
def _on_calc_pressed(self, button: Gtk.Button) -> None:
|
||||
if self.stack.get_visible_child_name() == "METADATA":
|
||||
self.stack.set_visible_child_name("CALCULATOR")
|
||||
else:
|
||||
self.stack.set_visible_child_name("METADATA")
|
||||
|
||||
def _on_row_activated(
|
||||
self,
|
||||
treeview: Gtk.TreeView,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Self
|
||||
|
||||
from dzgui.api.shortcuts import Shortcuts
|
||||
from dzgui.const.constants import APP_NAME
|
||||
from dzgui.strings import uninstall
|
||||
from dzgui.util._json import read_json
|
||||
from dzgui.util.css import load_css
|
||||
from dzgui.util.format import format_exception
|
||||
from dzgui.views.components.box import HBox
|
||||
from dzgui.views.dialogs.wizard import ScrolledWizardPage, CheckboxWithLabel
|
||||
from dzgui.views.mixins.colorscheme import ColorAwareApp
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Pango # noqa E402
|
||||
|
||||
|
||||
class Assistant(Gtk.Assistant):
|
||||
def __init__(self, is_deck: bool, paths: dict[str, str]):
|
||||
super().__init__()
|
||||
if is_deck:
|
||||
# NOTE: deemed to be "safe" dimensions that exclude taskbar size
|
||||
self.set_default_size(1085, 670)
|
||||
else:
|
||||
self.set_default_size(1500, 900)
|
||||
|
||||
self.set_forward_page_func(self._advance_page)
|
||||
|
||||
pyapp = os.getenv("PYAPP")
|
||||
|
||||
self.page1 = UninstallPage(paths, pyapp)
|
||||
self.page2 = CompletionPage(pyapp)
|
||||
|
||||
self.append_page(self.page1)
|
||||
self.set_page_type(self.page1, Gtk.AssistantPageType.INTRO)
|
||||
self.set_page_title(self.page1, uninstall.pg1_title)
|
||||
self.set_page_complete(self.page1, True)
|
||||
|
||||
self.append_page(self.page2)
|
||||
self.set_page_title(self.page2, uninstall.pg2_title)
|
||||
self.set_page_type(self.page2, Gtk.AssistantPageType.SUMMARY)
|
||||
|
||||
self.connect("cancel", self.destroy_and_quit)
|
||||
self.connect("close", self.destroy_and_quit)
|
||||
self.show_all()
|
||||
load_css()
|
||||
|
||||
def _advance_page(self, index: int) -> int:
|
||||
cur_page = self.get_nth_page(index)
|
||||
if cur_page == self.page1:
|
||||
self.page1.uninstall()
|
||||
return index + 1
|
||||
|
||||
def destroy_and_quit(self, widget: Self) -> None:
|
||||
self.destroy()
|
||||
Gtk.main_quit()
|
||||
|
||||
|
||||
class Window(Gtk.Window):
|
||||
def __init__(self, is_deck: bool, paths: dict[str, str]) -> None:
|
||||
super().__init__(title=APP_NAME, icon_name=APP_NAME)
|
||||
self.assistant = Assistant(is_deck, paths)
|
||||
|
||||
|
||||
class CompletionPage(ScrolledWizardPage):
|
||||
def __init__(self, pyapp: str | None):
|
||||
super().__init__(heading=uninstall.pg2_heading, description=uninstall.pg2_blurb)
|
||||
|
||||
text = (
|
||||
uninstall.standalone_uninstall
|
||||
if pyapp is not None
|
||||
else uninstall.system_uninstall
|
||||
)
|
||||
|
||||
label = Gtk.Label(
|
||||
label=text,
|
||||
wrap_mode=Pango.WrapMode.WORD,
|
||||
margin=10,
|
||||
max_width_chars=80,
|
||||
justify=Gtk.Justification.CENTER,
|
||||
)
|
||||
frame = Gtk.Frame()
|
||||
frame.add(label)
|
||||
self.add_start(frame)
|
||||
|
||||
|
||||
class FileLabel(HBox):
|
||||
def __init__(self, path: Path) -> None:
|
||||
super().__init__(spacing=10)
|
||||
|
||||
label = Gtk.Label(label=uninstall.path_remove_prefix, halign=Gtk.Align.START)
|
||||
textview = Gtk.TextView(
|
||||
editable=False,
|
||||
halign=Gtk.Align.START,
|
||||
left_margin=10,
|
||||
right_margin=10,
|
||||
)
|
||||
textview.set_buffer(Gtk.TextBuffer(text=str(path)))
|
||||
self.extend([label, textview])
|
||||
|
||||
|
||||
class CheckboxWithPath(CheckboxWithLabel):
|
||||
def __init__(
|
||||
self, label: str, details: str, path: Path, active: bool = True
|
||||
) -> None:
|
||||
super().__init__(text=label, blurb_text=details)
|
||||
|
||||
self.conf_path = path
|
||||
self.set_active(active)
|
||||
fl = FileLabel(path)
|
||||
self.indent_below(fl)
|
||||
|
||||
def get_conf_path(self) -> Path:
|
||||
return self.conf_path
|
||||
|
||||
|
||||
class UninstallPage(ScrolledWizardPage):
|
||||
def __init__(self, paths: dict[str, str], pyapp: str | None):
|
||||
super().__init__(heading=uninstall.pg1_heading, description=uninstall.pg1_blurb)
|
||||
|
||||
self.pyapp = pyapp
|
||||
|
||||
config = Path(paths["XDG_CONFIG_HOME"])
|
||||
state = Path(paths["XDG_STATE_HOME"])
|
||||
self.share = Path(paths["XDG_DATA_HOME"])
|
||||
shortcut = self.share.parent.joinpath("applications/dzgui.desktop")
|
||||
desktop = config.parent.parent.joinpath("Desktop/dzgui.desktop")
|
||||
self.steam_path = self.parse_steam_path(config, self.share)
|
||||
|
||||
self.boxes: list[CheckboxWithPath] = []
|
||||
self.checks_area = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
halign=Gtk.Align.CENTER,
|
||||
spacing=20,
|
||||
margin_top=20,
|
||||
margin_bottom=20,
|
||||
)
|
||||
self.config_box = CheckboxWithPath(
|
||||
uninstall.config_label, uninstall.config_details, config, active=False
|
||||
)
|
||||
self.desktop_box = CheckboxWithPath(
|
||||
uninstall.desktop_label, uninstall.desktop_details, desktop
|
||||
)
|
||||
self.start_menu_box = CheckboxWithPath(
|
||||
uninstall.start_menu_label, uninstall.start_menu_details, shortcut
|
||||
)
|
||||
self.state_box = CheckboxWithPath(
|
||||
uninstall.state_label, uninstall.state_details, state
|
||||
)
|
||||
self.steam_box = CheckboxWithLabel(
|
||||
uninstall.steam_shortcut, uninstall.steam_details
|
||||
)
|
||||
|
||||
for box in (
|
||||
self.config_box,
|
||||
self.state_box,
|
||||
self.desktop_box,
|
||||
self.start_menu_box,
|
||||
):
|
||||
self.checks_area.add(box)
|
||||
self.boxes.append(box)
|
||||
|
||||
self.checks_area.add(self.steam_box)
|
||||
|
||||
self.add_start(self.checks_area)
|
||||
self.show_all()
|
||||
|
||||
def parse_steam_path(self, config: Path, steam: Path) -> Path | None:
|
||||
try:
|
||||
file = config.joinpath("config.json")
|
||||
conf = read_json(file)
|
||||
steam = conf["default_steam_path"]
|
||||
self.steam_path = Path(steam)
|
||||
return Path(steam)
|
||||
except Exception as e:
|
||||
print(format_exception(e))
|
||||
return None
|
||||
|
||||
def wipe_conf_file(self, box: CheckboxWithPath) -> None:
|
||||
if box.get_active():
|
||||
path = box.get_conf_path()
|
||||
try:
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
path.unlink()
|
||||
print(f"Deleted '{path}'")
|
||||
except Exception as e:
|
||||
print(format_exception(e))
|
||||
|
||||
def wipe_steam_shortcut(self) -> None:
|
||||
if self.steam_path is None:
|
||||
return
|
||||
try:
|
||||
shortcuts = Shortcuts(self.steam_path)
|
||||
shortcuts.delete_shortcut(self.share)
|
||||
shutil.rmtree(self.share)
|
||||
except Exception as e:
|
||||
print(format_exception(e))
|
||||
|
||||
def wipe_pyapp(self) -> None:
|
||||
if self.pyapp is None:
|
||||
return
|
||||
subprocess.run([self.pyapp, "self", "remove"])
|
||||
|
||||
def uninstall(self) -> None:
|
||||
for box in self.boxes:
|
||||
self.wipe_conf_file(box)
|
||||
if self.steam_box.get_active():
|
||||
self.wipe_steam_shortcut()
|
||||
self.wipe_pyapp()
|
||||
pass
|
||||
|
||||
|
||||
class UninstallWizard(ColorAwareApp, Gtk.Application): # type: ignore
|
||||
def __init__(self, is_deck: bool, paths: dict[str, str]) -> None:
|
||||
super().__init__()
|
||||
config = Path(paths["XDG_CONFIG_HOME"])
|
||||
if not config.is_dir() or not config.joinpath("config.json").is_file():
|
||||
print(uninstall.not_installed)
|
||||
return
|
||||
GLib.set_prgname(APP_NAME)
|
||||
self.win = Window(is_deck, paths)
|
||||
Gtk.main()
|
||||
+108
-122
@@ -1,4 +1,3 @@
|
||||
import os
|
||||
import textwrap
|
||||
|
||||
from enum import Enum
|
||||
@@ -6,7 +5,7 @@ from importlib import resources
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Self, TYPE_CHECKING
|
||||
|
||||
from dzgui.api.probe import test_steam_api, test_bm_api
|
||||
from dzgui.api.probe import test_steam_api
|
||||
from dzgui.api.shortcuts import add_steam_shortcut
|
||||
from dzgui.api.steam import get_steam_paths
|
||||
from dzgui.const.constants import (
|
||||
@@ -16,10 +15,9 @@ from dzgui.const.constants import (
|
||||
LEGACY_CONFIG_PATH,
|
||||
)
|
||||
from dzgui.const.boilerplate import config_boilerplate
|
||||
from dzgui.const.endpoints import BM_API_SETUP, STEAM_API_SETUP
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.const.endpoints import STEAM_API_SETUP
|
||||
from dzgui.config import freedesktop
|
||||
from dzgui.config.query import lookup
|
||||
from dzgui.init.proc import path_to_steam_cmd
|
||||
from dzgui.init.migrate import migrate_legacy_conf
|
||||
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
|
||||
from dzgui.strings import wizard
|
||||
@@ -28,7 +26,7 @@ from dzgui.util.open_links import open_link_by_url
|
||||
from dzgui.util.css import add_class, load_css
|
||||
from dzgui.views.components.buttons import WebButton
|
||||
from dzgui.views.components.entry import APIEntry
|
||||
from dzgui.views.components.misc import ClientCombo
|
||||
from dzgui.views.mixins.colorscheme import ColorAwareApp
|
||||
|
||||
import gi
|
||||
|
||||
@@ -44,10 +42,16 @@ class PageNum(Enum):
|
||||
HAS_CONFIG = 2
|
||||
STEAM_PATH = 3
|
||||
STEAM_API = 4
|
||||
BM_API = 5
|
||||
USER_PREFS = 6
|
||||
SHORTCUTS = 7
|
||||
FINAL = 8
|
||||
USER_PREFS = 5
|
||||
SHORTCUTS = 6
|
||||
FINAL = 7
|
||||
|
||||
|
||||
class OptionalPageMixin:
|
||||
"""Marks optional pages as advanceable"""
|
||||
|
||||
def _on_map(self, page: "EnumeratedWizardPage") -> None:
|
||||
EMITTER.emit("step_complete")
|
||||
|
||||
|
||||
class DescriptionArea(Gtk.Box):
|
||||
@@ -66,10 +70,9 @@ class Progress(Gtk.ProgressBar):
|
||||
|
||||
|
||||
class ScrolledWizardPage(Gtk.ScrolledWindow):
|
||||
def __init__(self, enum: PageNum, heading: str, description: str):
|
||||
def __init__(self, heading: str, description: str):
|
||||
super().__init__(overlay_scrolling=False)
|
||||
|
||||
self.enum = enum
|
||||
self.page_type: Gtk.AssistantPageType
|
||||
self.title = heading
|
||||
self.heading = Heading(heading)
|
||||
@@ -82,7 +85,6 @@ class ScrolledWizardPage(Gtk.ScrolledWindow):
|
||||
height=600,
|
||||
preserve_aspect_ratio=True,
|
||||
)
|
||||
image = Gtk.Image.new_from_pixbuf(pixbuf)
|
||||
|
||||
self.box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
@@ -91,21 +93,13 @@ class ScrolledWizardPage(Gtk.ScrolledWindow):
|
||||
margin_top=50,
|
||||
spacing=20,
|
||||
)
|
||||
self.add(self.box)
|
||||
self.prog = Progress()
|
||||
self.box.pack_end(self.prog, expand=False, fill=False, padding=0)
|
||||
image = Gtk.Image.new_from_pixbuf(pixbuf)
|
||||
self.box.pack_start(image, expand=False, fill=True, padding=0)
|
||||
|
||||
self.add(self.box)
|
||||
self.box.pack_start(self.heading, expand=False, fill=True, padding=0)
|
||||
self.box.pack_start(self.description, expand=False, fill=True, padding=0)
|
||||
|
||||
self.connect("map", self._on_map)
|
||||
|
||||
def get_enum(self) -> PageNum:
|
||||
return self.enum
|
||||
|
||||
def get_progress_bar(self) -> Progress:
|
||||
return self.prog
|
||||
|
||||
def get_page_type(self) -> Gtk.AssistantPageType:
|
||||
return self.page_type
|
||||
|
||||
@@ -124,8 +118,20 @@ class ScrolledWizardPage(Gtk.ScrolledWindow):
|
||||
def get_box(self) -> Gtk.Box:
|
||||
return self.box
|
||||
|
||||
def _on_map(self, page: "ScrolledWizardPage") -> None:
|
||||
pass
|
||||
|
||||
class EnumeratedWizardPage(ScrolledWizardPage):
|
||||
def __init__(self, enum: PageNum, heading: str, description: str) -> None:
|
||||
super().__init__(heading=heading, description=description)
|
||||
|
||||
self.enum = enum
|
||||
self.prog = Progress()
|
||||
self.box.pack_end(self.prog, expand=False, fill=False, padding=0)
|
||||
|
||||
def get_enum(self) -> PageNum:
|
||||
return self.enum
|
||||
|
||||
def get_progress_bar(self) -> Progress:
|
||||
return self.prog
|
||||
|
||||
|
||||
class NotificationFrame(Gtk.Frame):
|
||||
@@ -155,7 +161,7 @@ class NotificationFrame(Gtk.Frame):
|
||||
self.label.set_markup(wrapped)
|
||||
|
||||
|
||||
class APIValidationPage(ScrolledWizardPage):
|
||||
class APIValidationPage(EnumeratedWizardPage):
|
||||
def __init__(
|
||||
self, enum: PageNum, heading: str, description: str, link: str, func: Callable
|
||||
) -> None:
|
||||
@@ -215,23 +221,6 @@ class APIValidationPage(ScrolledWizardPage):
|
||||
self.spinner.stop()
|
||||
|
||||
|
||||
class BMValidationPage(APIValidationPage):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
enum=PageNum.BM_API,
|
||||
heading=wizard.heading_bm_api,
|
||||
description=wizard.blurb_bm_api,
|
||||
link=BM_API_SETUP,
|
||||
func=self._validate,
|
||||
)
|
||||
|
||||
@call_on_thread("", show_dialog=False)
|
||||
def _validate(self, key: str) -> None:
|
||||
is_valid = test_bm_api(key.strip())
|
||||
cleanup = StoredFunc(self._cleanup, is_valid, key)
|
||||
self.thread_man.set_cleanup_func(cleanup)
|
||||
|
||||
|
||||
class SteamValidationPage(APIValidationPage):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
@@ -249,7 +238,7 @@ class SteamValidationPage(APIValidationPage):
|
||||
self.thread_man.set_cleanup_func(cleanup)
|
||||
|
||||
|
||||
class IntroductionPage(ScrolledWizardPage):
|
||||
class IntroductionPage(EnumeratedWizardPage):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
enum=PageNum.INTRO,
|
||||
@@ -304,7 +293,7 @@ class RadioFrame(Gtk.Frame):
|
||||
return self.button
|
||||
|
||||
|
||||
class ConfigMigrationPage(ScrolledWizardPage):
|
||||
class ConfigMigrationPage(EnumeratedWizardPage):
|
||||
def __init__(self, config: Path) -> None:
|
||||
super().__init__(
|
||||
enum=PageNum.HAS_CONFIG,
|
||||
@@ -313,6 +302,7 @@ class ConfigMigrationPage(ScrolledWizardPage):
|
||||
)
|
||||
|
||||
self.migrated = False
|
||||
self.migrated_conf: dict[str, Any] = {}
|
||||
self.config = config
|
||||
self.page_type = Gtk.AssistantPageType.INTRO
|
||||
|
||||
@@ -352,8 +342,7 @@ class ConfigMigrationPage(ScrolledWizardPage):
|
||||
def _on_import_clicked(self, button: Gtk.Button) -> None:
|
||||
self.grid.set_sensitive(False)
|
||||
try:
|
||||
# TODO: this could be deferred to the final page (prevents accidental destruction of dialog via ESC)
|
||||
migrate_legacy_conf(self.config)
|
||||
self.migrated_conf = migrate_legacy_conf(self.config)
|
||||
self.migrated = True
|
||||
self.success_box.set_visible(True)
|
||||
except Exception:
|
||||
@@ -364,7 +353,7 @@ class ConfigMigrationPage(ScrolledWizardPage):
|
||||
EMITTER.emit("config", True)
|
||||
|
||||
|
||||
class PreferencesPage(ScrolledWizardPage):
|
||||
class PreferencesPage(EnumeratedWizardPage):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
enum=PageNum.USER_PREFS,
|
||||
@@ -395,26 +384,17 @@ class PreferencesPage(ScrolledWizardPage):
|
||||
self.dist_box.add(self.radio_km)
|
||||
self.dist_box.add(self.radio_miles)
|
||||
|
||||
client_label = Gtk.Label(label=wizard.label_client, halign=Gtk.Align.START)
|
||||
self.client_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
self.client_combo = ClientCombo()
|
||||
self.client_box.add(client_label)
|
||||
self.client_box.add(self.client_combo)
|
||||
|
||||
outer_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER, spacing=10
|
||||
)
|
||||
for el in self.name_box, self.client_box, self.dist_box:
|
||||
for el in self.name_box, self.dist_box:
|
||||
outer_box.add(el)
|
||||
self.add_start(outer_box)
|
||||
|
||||
def get_prefs(self) -> tuple[str, bool, str]:
|
||||
def get_prefs(self) -> tuple[str, bool]:
|
||||
name = self.name_entry.get_text().strip()
|
||||
use_miles = self.radio_miles.get_active()
|
||||
model = self.client_combo.get_model()
|
||||
ind = self.client_combo.get_active()
|
||||
client = model[ind][1]
|
||||
return name, use_miles, client
|
||||
return name, use_miles
|
||||
|
||||
def _on_entry_changed(self, entry: Gtk.Entry) -> None:
|
||||
text = entry.get_text()
|
||||
@@ -424,7 +404,7 @@ class PreferencesPage(ScrolledWizardPage):
|
||||
EMITTER.emit("step_complete")
|
||||
|
||||
|
||||
class CompletionPage(ScrolledWizardPage):
|
||||
class CompletionPage(EnumeratedWizardPage):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
enum=PageNum.FINAL,
|
||||
@@ -440,24 +420,23 @@ class Assistant(Gtk.Assistant):
|
||||
def __init__(self, is_deck: bool, XDG: "Xdg"):
|
||||
super().__init__()
|
||||
if is_deck:
|
||||
self.fullscreen()
|
||||
# NOTE: deemed to be "safe" dimensions that exclude taskbar size
|
||||
self.set_default_size(1085, 670)
|
||||
else:
|
||||
self.set_default_size(1500, 900)
|
||||
|
||||
self.config_path = XDG.config
|
||||
|
||||
self.config_values: dict[str, Any] = config_boilerplate
|
||||
|
||||
self.setup_complete = False
|
||||
|
||||
self.page1 = IntroductionPage()
|
||||
self.page2 = ConfigMigrationPage(XDG.config)
|
||||
self.page3 = SteamPathPage()
|
||||
self.page4 = SteamValidationPage()
|
||||
self.page5 = BMValidationPage()
|
||||
self.page6 = PreferencesPage()
|
||||
self.page7 = ShortcutCreationPage(XDG.shortcut)
|
||||
self.page8 = CompletionPage()
|
||||
self.page_intro = IntroductionPage()
|
||||
self.page_migration = ConfigMigrationPage(XDG.config)
|
||||
self.page_paths = SteamPathPage()
|
||||
self.page_api = SteamValidationPage()
|
||||
self.page_prefs = PreferencesPage()
|
||||
self.page_shortcuts = ShortcutCreationPage(XDG.shortcut)
|
||||
self.page_completion = CompletionPage()
|
||||
|
||||
self.set_forward_page_func(self._advance_page)
|
||||
|
||||
@@ -465,17 +444,18 @@ class Assistant(Gtk.Assistant):
|
||||
EMITTER.connect("step_pending", self._mark_page_incomplete)
|
||||
EMITTER.connect("config", self._set_config_state)
|
||||
|
||||
self.pages: dict[EnumeratedWizardPage, int] = {}
|
||||
|
||||
legacy_path = Path.home().joinpath(LEGACY_CONFIG_PATH)
|
||||
self.has_legacy_config = legacy_path.is_file()
|
||||
for page in (
|
||||
self.page1,
|
||||
self.page2,
|
||||
self.page3,
|
||||
self.page4,
|
||||
self.page5,
|
||||
self.page6,
|
||||
self.page7,
|
||||
self.page8,
|
||||
self.page_intro,
|
||||
self.page_migration,
|
||||
self.page_paths,
|
||||
self.page_api,
|
||||
self.page_prefs,
|
||||
self.page_shortcuts,
|
||||
self.page_completion,
|
||||
):
|
||||
# NOTE: skip config migration page if no legacy config file
|
||||
if (
|
||||
@@ -483,10 +463,8 @@ class Assistant(Gtk.Assistant):
|
||||
and self.has_legacy_config is False
|
||||
):
|
||||
continue
|
||||
# NOTE: disabled for now on system-provided packages
|
||||
if isinstance(page, ShortcutCreationPage) and os.getenv("PYAPP") is None:
|
||||
continue
|
||||
self._add_page(page, page.get_page_type())
|
||||
ind = self._add_page(page, page.get_page_type())
|
||||
self.pages[page] = ind
|
||||
|
||||
self.connect("prepare", self._on_page_prepare)
|
||||
self.connect("cancel", self.destroy_and_quit)
|
||||
@@ -494,37 +472,39 @@ class Assistant(Gtk.Assistant):
|
||||
self.show_all()
|
||||
load_css()
|
||||
|
||||
def write_config(self) -> None:
|
||||
write_json(self.config_values, self.config_path)
|
||||
def write_config(self, config: dict[str, Any]) -> None:
|
||||
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:
|
||||
page = self.get_nth_page(index)
|
||||
# TODO: use enums/isinstance
|
||||
match page:
|
||||
case self.page1:
|
||||
case IntroductionPage():
|
||||
pass
|
||||
case self.page2:
|
||||
if self.page2.is_migrated():
|
||||
steam_path = lookup(self.config_path, Preferences.DEFAULT)
|
||||
self.page7.set_steam_path(steam_path)
|
||||
return self.get_n_pages() - 2
|
||||
case self.page3:
|
||||
self.config_values["default_steam_path"] = page.get_path_from_radio()
|
||||
case self.page4:
|
||||
case ConfigMigrationPage():
|
||||
if page.is_migrated():
|
||||
sp = page.migrated_conf["default_steam_path"]
|
||||
self.page_shortcuts.set_steam_path(sp)
|
||||
self.config_values = self.page_migration.migrated_conf
|
||||
return self.get_final_page()
|
||||
case SteamPathPage():
|
||||
path = page.get_path_from_radio()
|
||||
self.config_values["default_steam_path"] = path
|
||||
self.config_values["client"] = path_to_steam_cmd(path)
|
||||
case SteamValidationPage():
|
||||
self.config_values["steam_api"] = page.get_api_key()
|
||||
case self.page5:
|
||||
self.config_values["bm_api"] = page.get_api_key()
|
||||
# NOTE: collects config values before advancing to last page
|
||||
case self.page6:
|
||||
name, use_miles, client = self.page6.get_prefs()
|
||||
case PreferencesPage():
|
||||
# NOTE: collects config values before advancing to last page
|
||||
name, use_miles = self.page_prefs.get_prefs()
|
||||
self.config_values["name"] = name
|
||||
self.config_values["use_miles"] = use_miles
|
||||
self.config_values["client"] = client
|
||||
self.write_config()
|
||||
self.page7.set_steam_path(self.config_values["default_steam_path"])
|
||||
case self.page7:
|
||||
self.page7.create_shortcuts()
|
||||
self.setup_complete = True
|
||||
self.page_shortcuts.set_steam_path(
|
||||
self.config_values["default_steam_path"]
|
||||
)
|
||||
case ShortcutCreationPage():
|
||||
page.create_shortcuts()
|
||||
case _:
|
||||
raise AttributeError("Trying to advance a non-canonical page")
|
||||
return index + 1
|
||||
@@ -550,16 +530,19 @@ class Assistant(Gtk.Assistant):
|
||||
return
|
||||
self.set_page_complete(page, True)
|
||||
|
||||
def _add_page(self, page: ScrolledWizardPage, ptype: Gtk.AssistantPageType) -> None:
|
||||
self.append_page(page)
|
||||
def _add_page(
|
||||
self, page: EnumeratedWizardPage, ptype: Gtk.AssistantPageType
|
||||
) -> int:
|
||||
ind = self.append_page(page)
|
||||
self.set_page_type(page, ptype)
|
||||
self.set_page_title(page, page.get_title())
|
||||
self.set_page_complete(page, True)
|
||||
return ind
|
||||
|
||||
def _set_config_state(self, emitter: "Emitter", state: bool) -> None:
|
||||
self.config = state
|
||||
|
||||
def _on_page_prepare(self: Self, wizard: Self, page: ScrolledWizardPage) -> None:
|
||||
def _on_page_prepare(self: Self, wizard: Self, page: EnumeratedWizardPage) -> None:
|
||||
page_num = self.get_current_page() + 1
|
||||
total = self.get_n_pages()
|
||||
fraction = page_num / total
|
||||
@@ -568,27 +551,33 @@ class Assistant(Gtk.Assistant):
|
||||
bar.set_fraction(fraction)
|
||||
bar.set_text(f"{page_num}/{total}")
|
||||
|
||||
# NOTE: disable forward action
|
||||
# TODO: use page enums
|
||||
if page == self.page5:
|
||||
return
|
||||
if page != self.page1:
|
||||
if not isinstance(page, IntroductionPage):
|
||||
EMITTER.emit("step_pending")
|
||||
|
||||
if page == self.page_completion:
|
||||
self.write_config(self.config_values)
|
||||
self.setup_complete = True
|
||||
|
||||
|
||||
class CheckboxWithLabel(Gtk.Box):
|
||||
def __init__(self, text: str, blurb_text: str) -> None:
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=5)
|
||||
|
||||
self.indent = 20
|
||||
self.button = Gtk.CheckButton(label=text)
|
||||
self.button.set_active(True)
|
||||
label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=20)
|
||||
label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=self.indent)
|
||||
|
||||
wrapped = textwrap.fill(blurb_text, width=100)
|
||||
label.set_markup(f"- {wrapped}")
|
||||
|
||||
for el in self.button, label:
|
||||
self.add(el)
|
||||
|
||||
def indent_below(self, widget: Gtk.Widget) -> None:
|
||||
widget.set_margin_start(self.indent)
|
||||
self.add(widget)
|
||||
|
||||
def get_checkbox(self) -> Gtk.CheckButton:
|
||||
return self.button
|
||||
|
||||
@@ -599,7 +588,7 @@ class CheckboxWithLabel(Gtk.Box):
|
||||
self.button.set_active(state)
|
||||
|
||||
|
||||
class ShortcutCreationPage(ScrolledWizardPage):
|
||||
class ShortcutCreationPage(OptionalPageMixin, EnumeratedWizardPage): # type: ignore
|
||||
def __init__(self, shortcut: Path) -> None:
|
||||
super().__init__(
|
||||
enum=PageNum.SHORTCUTS,
|
||||
@@ -642,9 +631,6 @@ class ShortcutCreationPage(ScrolledWizardPage):
|
||||
self.desktop_checkbox.set_active(state)
|
||||
self.desktop_checkbox.set_sensitive(state)
|
||||
|
||||
def _on_map(self, page: "ScrolledWizardPage") -> None:
|
||||
EMITTER.emit("step_complete")
|
||||
|
||||
def set_steam_path(self, path: Path) -> None:
|
||||
self.steam_path = path
|
||||
|
||||
@@ -660,7 +646,7 @@ class ShortcutCreationPage(ScrolledWizardPage):
|
||||
freedesktop.write_desktop_shortcut(desktop_file)
|
||||
|
||||
|
||||
class SteamPathPage(ScrolledWizardPage):
|
||||
class SteamPathPage(EnumeratedWizardPage):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
enum=PageNum.USER_PREFS,
|
||||
@@ -715,7 +701,7 @@ class SteamPathPage(ScrolledWizardPage):
|
||||
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:
|
||||
super().__init__()
|
||||
GLib.set_prgname(APP_NAME)
|
||||
|
||||
@@ -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)
|
||||
@@ -104,20 +104,11 @@ class ContextMixin(TreeView):
|
||||
return False
|
||||
menu = self.context_menu
|
||||
children = menu.get_children()
|
||||
sel = menu.get_selected_item()
|
||||
for i, child in enumerate(children):
|
||||
if sel is child:
|
||||
ind = i
|
||||
break
|
||||
|
||||
match event.keyval:
|
||||
case Gdk.KEY_j:
|
||||
if ind + 1 > len(children) - 1:
|
||||
return False
|
||||
menu.emit("move-current", Gtk.MenuDirectionType.NEXT)
|
||||
case Gdk.KEY_k:
|
||||
if ind - 1 < 0:
|
||||
return False
|
||||
menu.emit("move-current", Gtk.MenuDirectionType.PREV)
|
||||
case Gdk.KEY_g:
|
||||
menu.select_item(children[0])
|
||||
|
||||
@@ -37,7 +37,11 @@ class CursorMixin:
|
||||
end = len(model) - 1
|
||||
else:
|
||||
return False
|
||||
cur_row = self.get_focused_row_index() # type: ignore
|
||||
|
||||
try:
|
||||
cur_row = self.get_focused_row_index() # type: ignore
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if position == CursorPosition.DOWN:
|
||||
if cur_row == end:
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import logging
|
||||
import re
|
||||
import textwrap
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from importlib import resources
|
||||
|
||||
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, CHANGELOG_PATH
|
||||
from dzgui.strings import changelog as clog
|
||||
from dzgui.util.strings import missing_changelog
|
||||
from dzgui.util.format import format_pango
|
||||
from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin
|
||||
@@ -31,18 +33,39 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
|
||||
logger.critical(e)
|
||||
changelog = missing_changelog
|
||||
|
||||
# TODO: should long text be wrapped?
|
||||
self.controller = controller
|
||||
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5, margin_top=10)
|
||||
self.box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=5, margin_top=10
|
||||
)
|
||||
self.add(self.box)
|
||||
|
||||
expand_all = Gtk.Button(
|
||||
label=clog.expand_all, halign=Gtk.Align.START, margin_start=20
|
||||
)
|
||||
expand_all.connect("clicked", self._on_expand_all_clicked)
|
||||
self.box.add(expand_all)
|
||||
|
||||
self.expanded = False
|
||||
self.expanders: list[Gtk.Expander] = []
|
||||
self.connect("key-press-event", self._on_keypress)
|
||||
self.connect("key-press-event", self._on_esc_keypress)
|
||||
|
||||
changes = self._parse(changelog)
|
||||
self._generate_nodes(changes)
|
||||
|
||||
self.show_all()
|
||||
|
||||
def _on_expand_all_clicked(self, button: Gtk.Button) -> None:
|
||||
self.expanded = not self.expanded
|
||||
for expander in self.expanders:
|
||||
# NOTE: simply setting set_expanded() does not trigger activate() signal,
|
||||
# so margins are not applied
|
||||
if expander.get_expanded() == self.expanded:
|
||||
continue
|
||||
expander.activate()
|
||||
label = clog.collapse_all if self.expanded else clog.expand_all
|
||||
button.set_label(label)
|
||||
|
||||
def grab_content_area(self) -> None:
|
||||
self.grab_focus()
|
||||
|
||||
@@ -55,7 +78,6 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
|
||||
return version, date
|
||||
raise ValueError("Changelog parse error")
|
||||
|
||||
|
||||
def _generate_nodes(self, releases: list[tuple[str, list[str]]]) -> None:
|
||||
for header, changes in releases:
|
||||
for ind in (0, -1):
|
||||
@@ -64,8 +86,10 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
|
||||
text = "\n".join(changes)
|
||||
formatted = format_pango(text)
|
||||
|
||||
label = Gtk.Label(valign=Gtk.Align.START, margin=15, halign=Gtk.Align.START)
|
||||
container = Gtk.Box(valign=Gtk.Align.START, halign=Gtk.Align.START)
|
||||
label = Gtk.Label()
|
||||
label.set_markup(formatted)
|
||||
container.add(label)
|
||||
|
||||
version, date = self._parse_version(header)
|
||||
button_text = f"{version} ({date})"
|
||||
@@ -73,11 +97,24 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
|
||||
|
||||
expander = Gtk.Expander()
|
||||
expander.set_label_widget(button)
|
||||
expander.add(label)
|
||||
expander.add(container)
|
||||
expander.connect("activate", self._on_expand, container)
|
||||
self.box.add(expander)
|
||||
self.expanders.append(expander)
|
||||
|
||||
def _on_expand(self, expander: Gtk.Box, container: Gtk.Box) -> None:
|
||||
"""
|
||||
Wayland/some themes do not expect widgets to have a size allocation until expanded.
|
||||
Trying to set margins a priori may cause negative allocation,
|
||||
as the margins are subtracted from zero. In effect, the widget is not realized
|
||||
until it is expanded for the first time.
|
||||
"""
|
||||
container.set_margin_start(15)
|
||||
container.set_margin_top(15)
|
||||
container.set_margin_bottom(15)
|
||||
|
||||
def _parse(self, changelog: str) -> list[tuple[str, list[str]]]:
|
||||
|
||||
release = ""
|
||||
releases: list[tuple[str, list[str]]] = []
|
||||
release_notes: list[str] = []
|
||||
@@ -91,5 +128,5 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
|
||||
release = ""
|
||||
release = line
|
||||
continue
|
||||
release_notes.append(line.rstrip())
|
||||
release_notes.append(textwrap.fill(line.rstrip(), width=120))
|
||||
return releases
|
||||
@@ -103,4 +103,4 @@ class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
|
||||
return grid
|
||||
|
||||
def grab_content_area(self) -> None:
|
||||
self.box.grab_focus()
|
||||
self.grab_focus()
|
||||
@@ -9,7 +9,6 @@ from dzgui.const.constants import (
|
||||
APPNAME_DAYZ,
|
||||
APPNAME_DAYZ_EXP_HUMAN,
|
||||
EDIT_DELETE,
|
||||
ERROR,
|
||||
FOLDER,
|
||||
WARNING,
|
||||
)
|
||||
@@ -21,6 +20,7 @@ from dzgui.views.components.buttons import Icon, IconTextButton
|
||||
from dzgui.views.components.eventbox import InfoEventBox
|
||||
from dzgui.views.components.frame import HeadingFrame
|
||||
from dzgui.views.components.scrollable import NoOverlayScrolledWindow
|
||||
from dzgui.views.components.misc import ErrorPopover
|
||||
from dzgui.views.trees.tree_mods import OfflineModTreeView
|
||||
|
||||
|
||||
@@ -41,26 +41,27 @@ class FolderError(Enum):
|
||||
FOLDER_CHANGED = 3
|
||||
|
||||
|
||||
class PageHeading(Gtk.Label):
|
||||
def __init__(self, label: str) -> None:
|
||||
super().__init__(label=label, halign=Gtk.Align.CENTER)
|
||||
class PageHeading(Gtk.Box):
|
||||
def __init__(self, label: str, controller: "Controller") -> None:
|
||||
super().__init__(
|
||||
halign=Gtk.Align.CENTER, orientation=Gtk.Orientation.HORIZONTAL
|
||||
)
|
||||
|
||||
self.label = Gtk.Label(label=label)
|
||||
self.eb = InfoEventBox(offline.heading_disclaimer, controller)
|
||||
self.eb.set_icon_yalign(0.7)
|
||||
|
||||
for el in self.label, self.eb:
|
||||
self.add(el)
|
||||
|
||||
css.add_class(self, "page-heading")
|
||||
|
||||
|
||||
class ErrorPopover(Gtk.Popover):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(position=Gtk.PositionType.RIGHT)
|
||||
class OfflineErrorPopover(ErrorPopover):
|
||||
def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None:
|
||||
super().__init__(relative_to=relative_to, position=position)
|
||||
|
||||
self.hbox = HBox()
|
||||
self.label = Gtk.Label(label="", margin_start=10, margin_end=10)
|
||||
error_icon = Icon(ERROR, margin_start=10)
|
||||
self.hbox.extend([error_icon, self.label])
|
||||
self.add(self.hbox)
|
||||
self.show_all()
|
||||
self.popdown()
|
||||
|
||||
def set_label(self, error: FolderError, msg: str) -> None:
|
||||
def set_error_label(self, error: FolderError, msg: str) -> None:
|
||||
match error:
|
||||
case FolderError.NO_VALID_MODS:
|
||||
prefix = offline.no_mods
|
||||
@@ -106,13 +107,10 @@ class FolderHBox(HBox):
|
||||
[self.eb, self.button, self.spinner, self.scrolled_label, self.unset_button]
|
||||
)
|
||||
|
||||
self.pop = ErrorPopover()
|
||||
self.pop.set_relative_to(self.button)
|
||||
self.pop = OfflineErrorPopover(self.button, Gtk.PositionType.RIGHT)
|
||||
self.pop.connect("unmap", lambda _: self.grab_focus())
|
||||
|
||||
self.sidepop = ErrorPopover()
|
||||
self.sidepop.set_position(Gtk.PositionType.BOTTOM)
|
||||
self.sidepop.set_relative_to(self.scrolled_label)
|
||||
self.sidepop = OfflineErrorPopover(self.scrolled_label, Gtk.PositionType.BOTTOM)
|
||||
self.sidepop.connect("unmap", lambda _: self.grab_focus())
|
||||
|
||||
self.connect("map", self._on_map)
|
||||
@@ -161,13 +159,13 @@ class FolderHBox(HBox):
|
||||
|
||||
def present_error(self, error: FolderError, msg: str) -> None:
|
||||
if error == FolderError.FOLDER_CHANGED:
|
||||
self.sidepop.set_label(error, msg)
|
||||
self.sidepop.set_error_label(error, msg)
|
||||
self.sidepop.popup()
|
||||
return
|
||||
self.folder = ""
|
||||
self.label.set_text("")
|
||||
self.unset_button.hide()
|
||||
self.pop.set_label(error, msg)
|
||||
self.pop.set_error_label(error, msg)
|
||||
self.pop.popup()
|
||||
|
||||
|
||||
@@ -434,7 +432,8 @@ class OfflineLoader(Gtk.Box):
|
||||
self.emitter = controller.get_emitter()
|
||||
self.offline_man = OfflineManager(controller)
|
||||
|
||||
self.add(PageHeading(offline.heading))
|
||||
heading = PageHeading(offline.heading, controller)
|
||||
self.add(heading)
|
||||
|
||||
self.local_frame = ModFrame(self, controller, offline.local_frame)
|
||||
self.custom_frame = CustomModFrame(self, controller, offline.custom_frame)
|
||||
@@ -457,7 +456,6 @@ class OfflineLoader(Gtk.Box):
|
||||
]
|
||||
)
|
||||
|
||||
# TODO: share ConnectBox class with preconnect dialog?
|
||||
self.button_box = HBox(spacing=5)
|
||||
self.button_box.set_halign(Gtk.Align.END)
|
||||
self.button_box.set_margin_top(15)
|
||||
@@ -511,3 +509,6 @@ class OfflineLoader(Gtk.Box):
|
||||
self.mission_frame.present_folder_changed(mission)
|
||||
return
|
||||
self.offline_man.launch(appid, mission, local_mods, custom_folder, custom_mods)
|
||||
|
||||
def grab_content_area(self) -> None:
|
||||
self.local_frame.tree.grab_focus()
|
||||
+245
-262
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Callable, TYPE_CHECKING
|
||||
|
||||
from dzgui.api import pefile as PeFile
|
||||
|
||||
@@ -15,16 +15,19 @@ from dzgui.const.constants import (
|
||||
VIEW_CONCEAL,
|
||||
VIEW_REVEAL,
|
||||
)
|
||||
from dzgui.const.endpoints import STEAM_API_SETUP, BM_API_SETUP
|
||||
from dzgui.const.endpoints import STEAM_API_SETUP
|
||||
from dzgui.const.enum import Preferences, ServerTab
|
||||
from dzgui.strings import errors, options
|
||||
from dzgui.strings import options
|
||||
from dzgui.util import strings, css, open_links
|
||||
|
||||
from dzgui.views.components.box import ShortHBox, VBox
|
||||
from dzgui.views.components.eventbox import InfoEventBox
|
||||
from dzgui.views.components.labels import LeftLabel
|
||||
from dzgui.views.components.buttons import WebButton
|
||||
from dzgui.views.components.buttons import SpinnerButton, WebButton
|
||||
from dzgui.views.components.frame import HeadingFrame
|
||||
from dzgui.views.components.misc import ClientCombo
|
||||
from dzgui.views.components.misc import ErrorPopover
|
||||
from dzgui.views.dialogs.generic import ExceptionDialog
|
||||
from dzgui.views.mixins.scrollable_mixin import ScrollableMixin
|
||||
|
||||
|
||||
import gi
|
||||
@@ -37,74 +40,249 @@ if TYPE_CHECKING:
|
||||
from dzgui.controllers.emitter import Emitter
|
||||
|
||||
|
||||
class ShortHBox(Gtk.Box):
|
||||
def __init__(self, widget: Gtk.Widget) -> None:
|
||||
class SubmitField(Gtk.Box):
|
||||
def __init__(
|
||||
self,
|
||||
controller: "Controller",
|
||||
placeholder: str,
|
||||
context: Preferences,
|
||||
slow: bool = False,
|
||||
private: bool = True,
|
||||
) -> None:
|
||||
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
|
||||
self.callback: Callable
|
||||
self.controller = controller
|
||||
self.context = context
|
||||
|
||||
self.old_text: str
|
||||
self.placeholder = placeholder
|
||||
|
||||
self.entry = Gtk.Entry(placeholder_text=placeholder, hexpand=True)
|
||||
|
||||
# TODO: audit for all applicable callbacks
|
||||
if private:
|
||||
self.entry.set_icon_from_icon_name(
|
||||
Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL
|
||||
)
|
||||
self.entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True)
|
||||
self.entry.connect("icon-release", self._on_icon_release)
|
||||
self.entry.set_visibility(False)
|
||||
|
||||
self.button: SpinnerButton | Gtk.Button
|
||||
if slow:
|
||||
self.button = SpinnerButton(label=options.save_button)
|
||||
else:
|
||||
self.button = Gtk.Button(label=options.save_button)
|
||||
|
||||
self.button.connect("clicked", self._on_save_clicked)
|
||||
self.entry.connect("changed", self._on_text_typed)
|
||||
self.entry.connect("activate", self._on_field_activated)
|
||||
self.entry.get_property("buffer").connect("deleted-text", self._on_text_deleted)
|
||||
|
||||
self.add(self.entry)
|
||||
self.add(self.button)
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
self.old_text = text
|
||||
self.entry.set_text(text)
|
||||
if len(text) == 0:
|
||||
self.button.set_sensitive(False)
|
||||
|
||||
def _is_valid_text(self, text: str) -> bool:
|
||||
if text.isspace():
|
||||
return False
|
||||
if len(text) == 0:
|
||||
return False
|
||||
|
||||
if text == self.old_text:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _on_text_deleted(
|
||||
self,
|
||||
buffer: Gtk.EntryBuffer,
|
||||
position: int,
|
||||
chars: int,
|
||||
) -> None:
|
||||
|
||||
text = buffer.get_text()
|
||||
state = self._is_valid_text(text)
|
||||
self.button.set_sensitive(state)
|
||||
|
||||
def _on_text_typed(
|
||||
self,
|
||||
entry: Gtk.Entry,
|
||||
) -> None:
|
||||
|
||||
text = entry.get_text()
|
||||
state = self._is_valid_text(text)
|
||||
self.button.set_sensitive(state)
|
||||
|
||||
def _on_icon_release(
|
||||
self,
|
||||
widget: Gtk.Entry,
|
||||
icon_pos: Gtk.EntryIconPosition,
|
||||
event: Gdk.Event,
|
||||
) -> None:
|
||||
visible = widget.get_visibility()
|
||||
if visible:
|
||||
icon, state = VIEW_REVEAL, False
|
||||
else:
|
||||
icon, state = VIEW_CONCEAL, True
|
||||
widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon)
|
||||
widget.set_visibility(state)
|
||||
|
||||
def _on_field_activated(self, entry: Gtk.Entry) -> None:
|
||||
text = entry.get_text()
|
||||
if not self._is_valid_text(text):
|
||||
return
|
||||
self.save_option()
|
||||
|
||||
def set_callback(self, callback: Callable) -> None:
|
||||
self.callback = callback
|
||||
|
||||
def save_option(self) -> None:
|
||||
self.callback()
|
||||
self.button.set_sensitive(False)
|
||||
|
||||
def _on_save_clicked(self, button: Gtk.Button) -> None:
|
||||
self.save_option()
|
||||
|
||||
|
||||
class SteamSubmitField(SubmitField):
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
super().__init__(
|
||||
controller,
|
||||
options.steam_placeholder,
|
||||
Preferences.STEAM,
|
||||
slow=True,
|
||||
private=True,
|
||||
)
|
||||
|
||||
self.pop = ErrorPopover(
|
||||
position=Gtk.PositionType.BOTTOM, relative_to=self.entry
|
||||
)
|
||||
self.pop.set_label(options.api_failed)
|
||||
self.pop.show_all()
|
||||
self.pop.popdown()
|
||||
|
||||
emitter = controller.get_emitter()
|
||||
emitter.connect("api_change_failed", self._on_api_failure)
|
||||
emitter.connect("api_change_successful", self._on_api_success)
|
||||
|
||||
self.set_callback(self.save_setting)
|
||||
|
||||
def save_setting(self) -> None:
|
||||
self.entry.set_sensitive(False)
|
||||
text = "".join(self.entry.get_text().split())
|
||||
self.controller.update_steam_api_key(text)
|
||||
|
||||
def _on_api_success(self, emitter: "Emitter") -> None:
|
||||
self.old_text = self.entry.get_text()
|
||||
if isinstance(self.button, SpinnerButton):
|
||||
self.button.stop_spinner()
|
||||
self.entry.set_sensitive(True)
|
||||
|
||||
def _on_api_failure(self, emitter: "Emitter") -> None:
|
||||
self.pop.popup()
|
||||
if isinstance(self.button, SpinnerButton):
|
||||
self.button.stop_spinner()
|
||||
self.entry.set_sensitive(True)
|
||||
|
||||
def block_text_entry(self) -> None:
|
||||
self.entry.set_position(-1)
|
||||
self.entry.set_can_focus(False)
|
||||
|
||||
def unblock_text_entry(self) -> None:
|
||||
self.entry.set_can_focus(True)
|
||||
|
||||
|
||||
class NameSubmitField(SubmitField):
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
super().__init__(
|
||||
controller,
|
||||
options.name_placeholder,
|
||||
Preferences.NAME,
|
||||
private=False,
|
||||
)
|
||||
self.entry.set_width_chars(30) # type: ignore
|
||||
self.set_callback(self.save_player_name)
|
||||
|
||||
def save_player_name(self) -> None:
|
||||
value = self.entry.get_text().strip()
|
||||
self.old_text = value
|
||||
self.controller.update_config(self.context, value)
|
||||
|
||||
|
||||
class ToggleField(Gtk.Box):
|
||||
def __init__(
|
||||
self,
|
||||
controller: "Controller",
|
||||
first_option: str,
|
||||
second_option: str,
|
||||
context: Preferences,
|
||||
) -> None:
|
||||
super().__init__(spacing=5, halign=Gtk.Align.START)
|
||||
|
||||
self.pack_start(widget, NO_EXPAND, NO_FILL, NO_PADDING)
|
||||
self.controller = controller
|
||||
self.context = context
|
||||
self.radio1 = Gtk.RadioButton.new_with_label(None, first_option)
|
||||
self.radio2 = Gtk.RadioButton.new_from_widget(self.radio1)
|
||||
self.radio2.set_label(second_option)
|
||||
self.pack_start(self.radio1, NO_EXPAND, NO_FILL, NO_PADDING)
|
||||
self.pack_start(self.radio2, NO_EXPAND, NO_FILL, NO_PADDING)
|
||||
|
||||
def set_suboption_active(self, state: bool) -> None:
|
||||
# NOTE: defer connection of signal until after state is set
|
||||
self.radio2.set_active(state)
|
||||
self.radio1.connect("toggled", self._on_radio_toggled, self.context)
|
||||
|
||||
def _on_radio_toggled(self, button: Gtk.RadioButton, context: Preferences) -> None:
|
||||
self.controller.toggle_config(context)
|
||||
|
||||
def set_sensitive(self, state: bool) -> None:
|
||||
for el in self.radio1, self.radio2:
|
||||
el.set_sensitive(state)
|
||||
|
||||
|
||||
class Options(Gtk.Box):
|
||||
class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
|
||||
def __init__(self, controller: "Controller"):
|
||||
super().__init__(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
margin_start=10,
|
||||
margin_end=10,
|
||||
)
|
||||
|
||||
self.controller = controller
|
||||
self.controller.register_widget("options", self)
|
||||
emitter = controller.get_emitter()
|
||||
emitter.connect("api_change_failed", self._on_api_change_failed)
|
||||
|
||||
self.DEFAULT_WIDTH = 1
|
||||
self.DEFAULT_HEIGHT = 1
|
||||
|
||||
label = Gtk.Label(label=strings.options.header)
|
||||
label.set_halign(Gtk.Align.CENTER)
|
||||
css.add_class(label, "page-heading")
|
||||
self.add(label)
|
||||
|
||||
self.steam_entry: Gtk.Entry
|
||||
self.bm_entry: Gtk.Entry
|
||||
self.pop: Gtk.Popover
|
||||
|
||||
self.steam = WebButton(label=strings.options.steam_web)
|
||||
self.steam.connect("clicked", self._on_link_button_clicked, STEAM_API_SETUP)
|
||||
|
||||
self.bm = WebButton(label=strings.options.bm_web)
|
||||
self.bm.connect("clicked", self._on_link_button_clicked, BM_API_SETUP)
|
||||
|
||||
self.steam_box = self._make_submit_field(
|
||||
strings.options.enter_steam, Preferences.STEAM, True
|
||||
)
|
||||
self.bm_box = self._make_submit_field(
|
||||
strings.options.enter_bm, Preferences.BM, True
|
||||
)
|
||||
self.steam_box = SteamSubmitField(controller)
|
||||
api_rows = [
|
||||
[LeftLabel(strings.options.steam_placeholder), self.steam_box],
|
||||
[LeftLabel(strings.options.bm_placeholder), self.bm_box],
|
||||
[LeftLabel(strings.options.steam_placeholder), self.steam_box, self.steam],
|
||||
]
|
||||
|
||||
self.player_box = self._make_submit_field(
|
||||
strings.options.name_placeholder, Preferences.NAME
|
||||
)
|
||||
self.player_box = NameSubmitField(controller)
|
||||
self.player_box.set_halign(Gtk.Align.START)
|
||||
# TODO: make submit field a standalone class
|
||||
self.player_box.get_children()[0].set_width_chars(30) # type: ignore
|
||||
|
||||
self.fullscreen_toggle = self.make_binary_radio(
|
||||
self.fullscreen_toggle = ToggleField(
|
||||
self.controller,
|
||||
strings.options.last_used,
|
||||
strings.options.always_fs,
|
||||
Preferences.WINDOW,
|
||||
)
|
||||
|
||||
self.client_combo = ClientCombo()
|
||||
self.client_combo.connect("changed", self._on_client_changed)
|
||||
|
||||
client_hbox = ShortHBox(self.client_combo)
|
||||
|
||||
self.distance_toggle = self.make_binary_radio(
|
||||
strings.options.km, strings.options.mi, Preferences.DIST
|
||||
self.distance_toggle = ToggleField(
|
||||
self.controller, strings.options.km, strings.options.mi, Preferences.DIST
|
||||
)
|
||||
|
||||
combo_store = Gtk.ListStore(str, object)
|
||||
@@ -126,7 +304,6 @@ class Options(Gtk.Box):
|
||||
self.start_tab_combo.connect("changed", self._on_start_tab_changed)
|
||||
|
||||
pref_rows = [
|
||||
[LeftLabel(strings.options.client), client_hbox],
|
||||
[LeftLabel(strings.options.window_size), self.fullscreen_toggle],
|
||||
[LeftLabel(strings.options.distance), self.distance_toggle],
|
||||
[LeftLabel(options.start_tab), start_tab_hbox],
|
||||
@@ -144,18 +321,6 @@ class Options(Gtk.Box):
|
||||
api_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
api_grid = self._make_grid(api_rows)
|
||||
api_box.add(api_grid)
|
||||
api_links_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
margin_start=5,
|
||||
margin_end=5,
|
||||
margin_top=5,
|
||||
margin_bottom=10,
|
||||
homogeneous=True,
|
||||
spacing=10,
|
||||
)
|
||||
api_links_box.add(self.steam)
|
||||
api_links_box.add(self.bm)
|
||||
api_box.add(api_links_box)
|
||||
|
||||
prefs_grid = self._make_grid(pref_rows)
|
||||
version_grid = self._make_grid(version_rows)
|
||||
@@ -176,7 +341,7 @@ class Options(Gtk.Box):
|
||||
grid.attach(developers, 1, 0, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT)
|
||||
|
||||
for pair in [
|
||||
(api_box, strings.options.api_keys),
|
||||
(api_box, strings.options.api_key),
|
||||
(prefs_grid, strings.options.prefs),
|
||||
(version_grid, strings.options.version),
|
||||
]:
|
||||
@@ -185,23 +350,22 @@ class Options(Gtk.Box):
|
||||
grid.attach(frame, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT)
|
||||
row += 1
|
||||
|
||||
self.scrollable = Gtk.ScrolledWindow(vexpand=True)
|
||||
self.scrollable.add(grid)
|
||||
self.add(self.scrollable)
|
||||
label = Gtk.Label(label=strings.options.header)
|
||||
label.set_halign(Gtk.Align.CENTER)
|
||||
css.add_class(label, "page-heading")
|
||||
|
||||
def get_client_name(self) -> str:
|
||||
model = self.client_combo.get_model()
|
||||
ind = self.client_combo.get_active()
|
||||
return str(model[ind][0])
|
||||
box = VBox()
|
||||
box.add(label)
|
||||
box.add(grid)
|
||||
self.add(box)
|
||||
|
||||
self.connect("key-press-event", self._on_keypress)
|
||||
|
||||
def block_text_entry(self) -> None:
|
||||
for entry in self.steam_entry, self.bm_entry:
|
||||
entry.set_position(-1)
|
||||
entry.set_can_focus(False)
|
||||
self.steam_box.block_text_entry()
|
||||
|
||||
def unblock_text_entry(self) -> None:
|
||||
for entry in self.steam_entry, self.bm_entry:
|
||||
entry.set_can_focus(True)
|
||||
self.steam_box.unblock_text_entry()
|
||||
|
||||
def _on_developers_clicked(self, button: Gtk.Button) -> None:
|
||||
self.controller.show_developers_page()
|
||||
@@ -209,48 +373,6 @@ class Options(Gtk.Box):
|
||||
def _on_link_button_clicked(self, button: Gtk.Button, url: str) -> None:
|
||||
open_links.open_link_by_url(url)
|
||||
|
||||
def _make_submit_field(
|
||||
self,
|
||||
placeholder: str,
|
||||
context: Preferences,
|
||||
private: bool = False,
|
||||
) -> Gtk.Box:
|
||||
|
||||
entry = Gtk.Entry(placeholder_text=placeholder, hexpand=True)
|
||||
button = Gtk.Button(label="Save")
|
||||
|
||||
button.connect("clicked", self._on_save_clicked, entry, context)
|
||||
entry.connect("insert-text", self._on_text_typed, context, button)
|
||||
entry.connect("activate", self._on_field_activated, context, button)
|
||||
entry.get_property("buffer").connect(
|
||||
"deleted-text", self._on_text_deleted, context, button
|
||||
)
|
||||
|
||||
if private:
|
||||
entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL)
|
||||
entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True)
|
||||
entry.connect("icon-release", self._on_icon_release)
|
||||
entry.set_visibility(False)
|
||||
|
||||
if context == Preferences.STEAM:
|
||||
self.steam_entry = entry
|
||||
else:
|
||||
self.bm_entry = entry
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
box.add(entry)
|
||||
box.add(button)
|
||||
|
||||
return box
|
||||
|
||||
def _on_field_activated(
|
||||
self, entry: Gtk.Entry, context: Preferences, button: Gtk.Button
|
||||
) -> None:
|
||||
text = entry.get_text()
|
||||
if not self._is_valid_text(text, context):
|
||||
return
|
||||
self._on_save_clicked(button, entry, context)
|
||||
|
||||
def _make_grid(self, rows: list) -> Gtk.Grid:
|
||||
grid = Gtk.Grid(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
@@ -270,38 +392,6 @@ class Options(Gtk.Box):
|
||||
row += 1
|
||||
return grid
|
||||
|
||||
def _on_save_clicked(
|
||||
self, button: Gtk.Button, entry: Gtk.Entry, enum: Preferences
|
||||
) -> None:
|
||||
old_text = self.controller.query_config(enum)
|
||||
self.old_text = old_text
|
||||
self.old_entry = entry
|
||||
|
||||
button.set_sensitive(False)
|
||||
match enum:
|
||||
case Preferences.NAME:
|
||||
value = entry.get_text().strip()
|
||||
self.controller.update_config(enum, value)
|
||||
case Preferences.BM | Preferences.STEAM:
|
||||
text = "".join(entry.get_text().split())
|
||||
self.controller.update_api_key(enum, text)
|
||||
|
||||
def _on_api_change_failed(self, emitter: "Emitter") -> None:
|
||||
self.old_entry.set_text(self.old_text)
|
||||
# TODO: use popover
|
||||
dialog = ExceptionDialog(self.controller, errors.api_validation_error)
|
||||
dialog.run()
|
||||
|
||||
def restore_api_text(self, text: str, entry: Gtk.Entry) -> None:
|
||||
entry.set_text(text)
|
||||
|
||||
def revert(self, mode: Preferences) -> None:
|
||||
if mode == Preferences.STEAM:
|
||||
self.steam_entry.set_text(self.old_steam)
|
||||
else:
|
||||
self.bm_entry.set_text(self.old_bm)
|
||||
pass
|
||||
|
||||
def _on_start_tab_changed(self, combo: Gtk.ComboBoxText) -> None:
|
||||
_iter = combo.get_active_iter()
|
||||
if _iter is None:
|
||||
@@ -310,88 +400,12 @@ class Options(Gtk.Box):
|
||||
index = enum.value
|
||||
self.controller.update_config(Preferences.START_TAB, index)
|
||||
|
||||
def _on_client_changed(self, combo: Gtk.ComboBoxText) -> None:
|
||||
_iter = combo.get_active_iter()
|
||||
if _iter is None:
|
||||
raise ValueError(f"No active iterator set on {combo}")
|
||||
real_cmd = combo.get_model()[_iter][1]
|
||||
self.controller.update_config(Preferences.CLIENT, real_cmd)
|
||||
|
||||
def _on_radio_toggled(self, button: Gtk.RadioButton, context: Preferences) -> None:
|
||||
try:
|
||||
self.controller.toggle_config(context)
|
||||
except Exception:
|
||||
button.handler_block_by_func(self._on_radio_toggled)
|
||||
self.populate_settings()
|
||||
button.handler_unblock_by_func(self._on_radio_toggled)
|
||||
|
||||
def _is_valid_text(self, text: str, context: Preferences) -> bool:
|
||||
if text.isspace():
|
||||
return False
|
||||
if len(text) == 0:
|
||||
return False
|
||||
|
||||
match context:
|
||||
case Preferences.NAME:
|
||||
old = self.old_name
|
||||
case Preferences.STEAM:
|
||||
old = self.old_steam
|
||||
case Preferences.BM:
|
||||
old = self.old_bm
|
||||
if text == old:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _on_text_deleted(
|
||||
self,
|
||||
buffer: Gtk.EntryBuffer,
|
||||
position: int,
|
||||
chars: int,
|
||||
context: Preferences,
|
||||
button: Gtk.Button,
|
||||
) -> None:
|
||||
|
||||
text = buffer.get_text()
|
||||
state = self._is_valid_text(text, context)
|
||||
button.set_sensitive(state)
|
||||
|
||||
def _on_text_typed(
|
||||
self,
|
||||
entry: Gtk.Entry,
|
||||
text: str,
|
||||
length: int,
|
||||
pos: int,
|
||||
context: Preferences,
|
||||
button: Gtk.Button,
|
||||
) -> None:
|
||||
|
||||
buffer = entry.get_property("buffer")
|
||||
text = buffer.get_text() + text
|
||||
state = self._is_valid_text(text, context)
|
||||
button.set_sensitive(state)
|
||||
|
||||
def make_binary_radio(
|
||||
self,
|
||||
first_option: str,
|
||||
second_option: str,
|
||||
context: Preferences,
|
||||
) -> Gtk.Box:
|
||||
|
||||
hbox = Gtk.Box(spacing=5, halign=Gtk.Align.START)
|
||||
radio1 = Gtk.RadioButton.new_with_label(None, first_option)
|
||||
radio2 = Gtk.RadioButton.new_from_widget(radio1)
|
||||
radio2.set_label(second_option)
|
||||
radio1.connect("toggled", self._on_radio_toggled, context)
|
||||
hbox.pack_start(radio1, NO_EXPAND, NO_FILL, NO_PADDING)
|
||||
hbox.pack_start(radio2, NO_EXPAND, NO_FILL, NO_PADDING)
|
||||
|
||||
return hbox
|
||||
|
||||
def populate_settings(self) -> None:
|
||||
prefs = self.controller.get_prefs()
|
||||
# NOTE: re-check in case file was removed by user between runs
|
||||
if prefs.paths.config.is_file() is False:
|
||||
dialog = ExceptionDialog(self.controller, strings.config_not_found)
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
raise OSError(f"Config file '{prefs.paths.config}' not found")
|
||||
|
||||
@@ -401,37 +415,24 @@ class Options(Gtk.Box):
|
||||
name = self.controller.query_config(Preferences.NAME)
|
||||
default_steam_path = self.controller.query_config(Preferences.DEFAULT)
|
||||
steam = self.controller.query_config(Preferences.STEAM)
|
||||
bm = self.controller.query_config(Preferences.BM)
|
||||
|
||||
steam_path = Path(default_steam_path)
|
||||
|
||||
self.old_steam = steam
|
||||
self.old_bm = bm
|
||||
self.old_name = name
|
||||
self.steam_box.set_text(steam)
|
||||
self.player_box.set_text(name)
|
||||
|
||||
self.steam_entry.set_text(steam)
|
||||
self.bm_entry.set_text(bm)
|
||||
p = self.player_box.get_children()[0]
|
||||
if hasattr(p, "set_text"):
|
||||
p.set_text(name)
|
||||
fs = config["fullscreen"]
|
||||
miles = config["use_miles"]
|
||||
|
||||
# NOTE: suppress toggle signal until radios are built
|
||||
self._suppress_toggles(True)
|
||||
for el, conf_state in [
|
||||
(self.fullscreen_toggle, config["fullscreen"]),
|
||||
(self.distance_toggle, config["use_miles"]),
|
||||
]:
|
||||
el.get_children()[conf_state].set_active(True)
|
||||
self._suppress_toggles(False)
|
||||
if prefs.is_steam_deck:
|
||||
self.fullscreen_toggle.set_sensitive(False)
|
||||
eb = InfoEventBox(options.fullscreen_eventbox, self.controller)
|
||||
self.fullscreen_toggle.add(eb)
|
||||
|
||||
# NOTE: disable buttons if no text is set
|
||||
for field in (
|
||||
[name, self.player_box],
|
||||
[steam, self.steam_box],
|
||||
[bm, self.bm_box],
|
||||
):
|
||||
if field[0] == "":
|
||||
field[1].get_children()[1].set_sensitive(False)
|
||||
fs = True
|
||||
|
||||
self.fullscreen_toggle.set_suboption_active(fs)
|
||||
self.distance_toggle.set_suboption_active(miles)
|
||||
|
||||
dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ)
|
||||
if dayz_version is None:
|
||||
@@ -444,10 +445,6 @@ class Options(Gtk.Box):
|
||||
self.dayz_version_label.set_text(dayz_version)
|
||||
self.dayz_exp_version_label.set_text(dayz_exp_version)
|
||||
|
||||
# TODO: bicolumn list store with no cell renderer on index 1, use raw command names
|
||||
active_combo = query.get_client_index(config["client"])
|
||||
self.client_combo.set_active(active_combo)
|
||||
|
||||
start_tab = self.controller.query_config(Preferences.START_TAB)
|
||||
self.start_tab_combo.set_active(start_tab)
|
||||
|
||||
@@ -460,19 +457,5 @@ class Options(Gtk.Box):
|
||||
self, toggle.get_children()[0], "_on_radio_toggled", state
|
||||
)
|
||||
|
||||
def _on_icon_release(
|
||||
self,
|
||||
widget: Gtk.Entry,
|
||||
icon_pos: Gtk.EntryIconPosition,
|
||||
event: Gdk.Event,
|
||||
) -> None:
|
||||
visible = widget.get_visibility()
|
||||
if visible:
|
||||
icon, state = VIEW_REVEAL, False
|
||||
else:
|
||||
icon, state = VIEW_CONCEAL, True
|
||||
widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon)
|
||||
widget.set_visibility(state)
|
||||
|
||||
def grab_content_area(self) -> None:
|
||||
return
|
||||
self.grab_focus()
|
||||
@@ -11,6 +11,7 @@ from dzgui.util.keys import is_ctrl_mask
|
||||
from dzgui.util.localize import number
|
||||
from dzgui.strings.server_mods import checkmark
|
||||
from dzgui.strings import preconnect
|
||||
from dzgui.views.dialogs.generic import DebugDialog
|
||||
from dzgui.views.components.frame import HeadingFrame
|
||||
from dzgui.views.trees.tree_server_mods import ServerModTreeView
|
||||
|
||||
@@ -120,6 +121,12 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
spacing=5,
|
||||
)
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
||||
|
||||
# NOTE: -d flag
|
||||
if self.controller.get_prefs().is_debug:
|
||||
debug = Gtk.Button(label=preconnect.debug, halign=Gtk.Align.START)
|
||||
debug.connect("clicked", self._on_debug_clicked)
|
||||
box.add(debug)
|
||||
for button in self.back, self.ok, self.connect_last:
|
||||
box.add(button)
|
||||
self.button_box.add(box)
|
||||
@@ -150,8 +157,7 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
self.tree_box.add(self.scrolled)
|
||||
self.tree_box.add(self.progress_box)
|
||||
|
||||
# TODO: strings
|
||||
self.mods_placeholder = Placeholder("This server has no mods.")
|
||||
self.mods_placeholder = Placeholder(preconnect.placeholder_no_mods)
|
||||
self.tree_box.add(self.mods_placeholder)
|
||||
|
||||
self.tree_frame = HeadingFrame.new_with_widget_and_label(
|
||||
@@ -161,8 +167,7 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
# TODO: abstract into components
|
||||
self.warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.warning_tree = MaskedTree(WARNING)
|
||||
# TODO: strings
|
||||
self.warning_placeholder = Placeholder("No warnings.")
|
||||
self.warning_placeholder = Placeholder(preconnect.placeholder_no_warnings)
|
||||
self.warning_box.add(self.warning_tree)
|
||||
self.warning_box.add(self.warning_placeholder)
|
||||
self.warning_frame = HeadingFrame.new_with_widget_and_label(
|
||||
@@ -171,8 +176,7 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
|
||||
self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.error_tree = MaskedTree(ERROR)
|
||||
# TODO: strings
|
||||
self.error_placeholder = Placeholder("No errors.")
|
||||
self.error_placeholder = Placeholder(preconnect.no_errors)
|
||||
self.error_box.add(self.error_tree)
|
||||
self.error_box.add(self.error_placeholder)
|
||||
self.error_frame = HeadingFrame.new_with_widget_and_label(
|
||||
@@ -223,6 +227,12 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
def _on_connect_last_clicked(self, button: Gtk.Button) -> None:
|
||||
self.controller.update_and_load_to_menu()
|
||||
|
||||
def _on_debug_clicked(self, button: Gtk.Button) -> None:
|
||||
args = self.controller.get_debug_args()
|
||||
d = DebugDialog(self.controller, args)
|
||||
d.show_all()
|
||||
d.run()
|
||||
|
||||
def _on_ok_clicked(self, button: Gtk.Button) -> None:
|
||||
self.controller.update_and_connect()
|
||||
|
||||
@@ -233,52 +243,48 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
warnings: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
resync_msg = (
|
||||
f"If you recently installed {prereqs.build} or moved it to a different drive,\n"
|
||||
"restart Steam to allow these changes to synchronize, then try again."
|
||||
)
|
||||
resync_msg = preconnect.resync.format(prereqs.build)
|
||||
|
||||
"""Errors"""
|
||||
if len(prereqs.invalid_mods) > 0:
|
||||
pairs = [": ".join(sub) for sub in prereqs.invalid_mods]
|
||||
lines = "\n".join(pairs)
|
||||
msg = preconnect.invalid_mods.format(lines)
|
||||
errors.append(msg)
|
||||
if prereqs.binary_missing:
|
||||
errors.append(
|
||||
f"Remote server is running the build '{prereqs.build}', but it is not installed.\n{resync_msg}"
|
||||
)
|
||||
msg = preconnect.version_missing.format(prereqs.build)
|
||||
msg += f"\n{resync_msg}"
|
||||
errors.append(msg)
|
||||
elif prereqs.local_version != prereqs.remote_version:
|
||||
errors.append(
|
||||
f"Local client version '{prereqs.local_version}' does not match remote version '{prereqs.remote_version}'.\n{resync_msg}"
|
||||
msg = preconnect.version_mismatch.format(
|
||||
prereqs.local_version, prereqs.remote_version
|
||||
)
|
||||
msg += f"\n{resync_msg}"
|
||||
errors.append(msg)
|
||||
if prereqs.required_space > prereqs.available_space:
|
||||
required_pretty = number(prereqs.required_space)
|
||||
available_pretty = number(prereqs.available_space)
|
||||
errors.append(
|
||||
f"Need to update {required_pretty} MiB of mods, but installation path only has {available_pretty} MiB."
|
||||
)
|
||||
msg = preconnect.not_enough_space.format(required_pretty, available_pretty)
|
||||
errors.append(msg)
|
||||
if len(prereqs.mods) > 0 and prereqs.game_mode:
|
||||
errors.append("Use Desktop Mode to download mods on Steam Deck")
|
||||
errors.append(preconnect.use_desktop_mode)
|
||||
|
||||
if prereqs.steam_proc.is_running is False:
|
||||
client = prereqs.steam_proc.name
|
||||
errors.append(
|
||||
f"'{client}' is set as the default Steam client, but it is either not installed or not running."
|
||||
)
|
||||
msg = preconnect.steam_not_running.format(client)
|
||||
errors.append(msg)
|
||||
|
||||
"""Warnings"""
|
||||
if prereqs.passworded:
|
||||
warnings.append(
|
||||
"Protected: you will be prompted for a password when connecting to this server."
|
||||
)
|
||||
warnings.append(preconnect.protected_server)
|
||||
if prereqs.dayz_running is True:
|
||||
warnings.append(
|
||||
"It looks like DayZ is already running in the background. Exit DayZ before connecting."
|
||||
)
|
||||
warnings.append(preconnect.dayz_running)
|
||||
if len(prereqs.mods) > 0 and prereqs.download_paused:
|
||||
warnings.append(preconnect.downloads_paused)
|
||||
|
||||
allows_dl, running_app = prereqs.allows_downloads
|
||||
if len(prereqs.mods) > 0 and allows_dl is False:
|
||||
msg = (
|
||||
f"The game '{running_app}' is currently running in Steam, but background downloads are not enabled.\n"
|
||||
"Either stop the game first, or update your global Steam settings or the game's local settings.\n"
|
||||
"Otherwise, mods may be queued for download but never update."
|
||||
)
|
||||
msg = preconnect.running_app.format(running_app)
|
||||
warnings.append(msg)
|
||||
|
||||
self.add_warnings(warnings)
|
||||
@@ -317,7 +323,7 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
|
||||
if prereqs.required_space != 0:
|
||||
pretty = number(prereqs.required_space)
|
||||
suffix = f" Need to download {pretty} MiB of mod updates."
|
||||
suffix = preconnect.required_space.format(pretty)
|
||||
prefix = preconnect.total_mods
|
||||
self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}")
|
||||
else:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
|
||||
from typing import Any, Optional, TYPE_CHECKING
|
||||
from warnings import deprecated
|
||||
|
||||
from dzgui.const.constants import APP_NAME, SEPARATOR
|
||||
from dzgui.util.keys import is_ctrl_mask, is_navkey
|
||||
@@ -173,7 +172,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
|
||||
(model, pathlist) = sels
|
||||
return (model, pathlist)
|
||||
|
||||
@deprecated("Currently unused")
|
||||
# @deprecated("Currently unused")
|
||||
# def get_mpath(self) -> Optional[Gtk.TreePath]:
|
||||
# (model, pathlist) = self.get_model_and_pathlist()
|
||||
# if len(pathlist) < 1:
|
||||
@@ -195,7 +194,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
|
||||
return True
|
||||
return False
|
||||
|
||||
@deprecated("unused")
|
||||
# @deprecated("unused")
|
||||
# def get_selected_row(self) -> Optional[Gtk.TreeModelRow]:
|
||||
# ind = self.get_selected_row_index()
|
||||
# model = self.get_model()
|
||||
|
||||
@@ -53,7 +53,7 @@ class LogTreeView(ContextMixin, TreeView): # type: ignore
|
||||
model = ModelFactory().new_model_from_logfile(filepath)
|
||||
_filter = model.filter_new()
|
||||
_filter.set_visible_func(self._filter_rows)
|
||||
sortable = Gtk.TreeModelSort(_filter)
|
||||
sortable = Gtk.TreeModelSort.new_with_model(_filter)
|
||||
self.set_model(sortable)
|
||||
_filter.refilter()
|
||||
path = Gtk.TreePath.new_from_indices([0])
|
||||
|
||||
@@ -66,7 +66,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
|
||||
self.handler_id: int
|
||||
self.queue: Queue = Queue()
|
||||
|
||||
self.seen_cache: list[str] = []
|
||||
self.seen_cache: list[Record] = []
|
||||
|
||||
prefs = self.controller.get_prefs()
|
||||
columns = prefs.paths.columns
|
||||
@@ -252,7 +252,6 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
|
||||
case Gdk.KEY_c:
|
||||
self.controller.menu_action(ContextMenu.COPY_SERVER_IP, self)
|
||||
case Gdk.KEY_r:
|
||||
# TODO: unimplemented, needs threading
|
||||
self.controller.menu_action(ContextMenu.REFRESH_PLAYERS, self)
|
||||
else:
|
||||
match event.keyval:
|
||||
@@ -295,6 +294,12 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
|
||||
) -> None:
|
||||
if self.loaded is False:
|
||||
return
|
||||
# NOTE: signal triggers twice on right-click events
|
||||
# due to deselect-then-select behavior in GTK
|
||||
sel = self.get_selection()
|
||||
model, row = sel.get_selected()
|
||||
if row is None:
|
||||
return
|
||||
self.start_distcalc()
|
||||
|
||||
def get_name(self) -> str:
|
||||
@@ -335,16 +340,24 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
|
||||
control = self.proxy_man.get_control()
|
||||
return model, control
|
||||
|
||||
@staticmethod
|
||||
def ping_server(
|
||||
self,
|
||||
model: "FastInsertListStore",
|
||||
_iter: Gtk.TreeIter,
|
||||
ip: str,
|
||||
qport: int,
|
||||
ping_column: int,
|
||||
record: Record,
|
||||
) -> None:
|
||||
_ping = ping(ip, qport)
|
||||
GLib.idle_add(lambda: model.set(_iter, ping_column, _ping))
|
||||
_ping = ping(record.ip, record.qport)
|
||||
GLib.idle_add(self._redraw, model, _iter, _ping)
|
||||
|
||||
def _redraw(
|
||||
self, model: "FastInsertListStore", _iter: Gtk.TreeIter, _ping: int
|
||||
) -> None:
|
||||
ping_column = 9
|
||||
model.set(_iter, ping_column, _ping)
|
||||
self.queue_draw()
|
||||
|
||||
def wipe_cache(self) -> None:
|
||||
self.seen_cache = []
|
||||
|
||||
def _get_ping(
|
||||
self,
|
||||
@@ -357,13 +370,12 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
|
||||
|
||||
addr_column = 7
|
||||
qport_column = 8
|
||||
ping_column = 9
|
||||
|
||||
addr = model.get_value(_iter, addr_column).split(":")
|
||||
ip = addr[0]
|
||||
gameport = addr[1]
|
||||
gameport = int(addr[1])
|
||||
qport = model.get_value(_iter, qport_column)
|
||||
record = f"{addr}:{gameport}:{qport}"
|
||||
record = Record(ip, gameport, qport)
|
||||
|
||||
if record in self.seen_cache:
|
||||
return
|
||||
@@ -372,7 +384,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
|
||||
thread = threading.Thread(
|
||||
daemon=True,
|
||||
target=self.ping_server,
|
||||
args=(model, _iter, ip, qport, ping_column),
|
||||
args=(model, _iter, record),
|
||||
)
|
||||
thread.start()
|
||||
|
||||
|
||||
+5
-2
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
|
||||
authors = [
|
||||
{name = "aclist"}
|
||||
]
|
||||
version = "7.0.0b17"
|
||||
version = "7.0.0b28"
|
||||
license = "GPL-3.0-or-later"
|
||||
license-files = ["LICENSE"]
|
||||
readme = "README.md"
|
||||
@@ -47,6 +47,7 @@ docs = [
|
||||
]
|
||||
test = [
|
||||
"black==25.1.0",
|
||||
"hypothesis==6.167.1",
|
||||
"pre-commit==4.4.0",
|
||||
"pytest==8.4.2",
|
||||
"pytest-cov==7.0.0",
|
||||
@@ -107,11 +108,13 @@ config-settings-package = { pygobject-stubs = { config = "Gtk3,Gdk3,Soup2" } }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"apitest: checks remote endpoints",
|
||||
"config: config file keys/values",
|
||||
"realconfig: depends on actual generated local config file",
|
||||
"mods: tests mod metadata/link creation",
|
||||
"pefile: validate PE files",
|
||||
"post_install: requires a completed installation",
|
||||
"redact: log redaction mechanisms",
|
||||
"slow: long-running tests",
|
||||
"webtest: checks remote endpoints"
|
||||
"webtest: depends on remote endpoint",
|
||||
]
|
||||
+1
-1
@@ -27,7 +27,7 @@ def fetch(url: str, destination: str) -> None:
|
||||
|
||||
def get_cpython() -> None:
|
||||
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}"
|
||||
outfile = unquote(build)
|
||||
fetch(url, outfile)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def iterate(search_str: str) -> None:
|
||||
# TODO: exclude files from .gitignore
|
||||
print()
|
||||
print(f"Searching for '{search_str}'")
|
||||
print()
|
||||
files = Path(".").rglob("*")
|
||||
ignore = [
|
||||
"scripts/",
|
||||
".git",
|
||||
"egg",
|
||||
"CHANGELOG",
|
||||
".mypy",
|
||||
"build/",
|
||||
"test.py",
|
||||
"bugs",
|
||||
"pyproject.toml",
|
||||
]
|
||||
for file in files:
|
||||
if file.is_dir():
|
||||
continue
|
||||
if any(s in str(file) for s in ignore):
|
||||
continue
|
||||
try:
|
||||
text = file.read_text()
|
||||
if search_str in text:
|
||||
c = text.count(search_str)
|
||||
if 0 < c < 2:
|
||||
print(file, c)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
imports = ["Pango", "GObject", "GLib", "Gdk", "Gtk"]
|
||||
for i in imports:
|
||||
iterate(i)
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
"users"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"AccountName" "STEAMUSER"
|
||||
"PersonaName" "STEAMUSER"
|
||||
"RememberPassword" "0"
|
||||
"WantsOfflineMode" "0"
|
||||
"SkipOfflineModeWarning" "0"
|
||||
"AutoLogin" "1"
|
||||
"timestamp" "0"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
"users"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"AccountName" "STEAMUSER"
|
||||
"PersonaName" "STEAMUSER"
|
||||
"RememberPassword" "0"
|
||||
"WantsOfflineMode" "0"
|
||||
"SkipOfflineModeWarning" "0"
|
||||
"AllowAutoLogin" "1"
|
||||
"MostRecent" "1"
|
||||
"timestamp" "0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"users"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"AccountName" "STEAMUSER"
|
||||
"PersonaName" "STEAMUSER"
|
||||
"RememberPassword" "0"
|
||||
"WantsOfflineMode" "0"
|
||||
"SkipOfflineModeWarning" "0"
|
||||
"AllowAutoLogin" "1"
|
||||
"MostRecent" "0"
|
||||
"timestamp" "0"
|
||||
}
|
||||
"1"
|
||||
{
|
||||
"AccountName" "STEAMUSER2"
|
||||
"PersonaName" "STEAMUSER2"
|
||||
"RememberPassword" "0"
|
||||
"WantsOfflineMode" "0"
|
||||
"SkipOfflineModeWarning" "0"
|
||||
"AllowAutoLogin" "1"
|
||||
"MostRecent" "1"
|
||||
"timestamp" "10"
|
||||
}
|
||||
}
|
||||
+26
-13
@@ -1,20 +1,33 @@
|
||||
import re
|
||||
import pytest
|
||||
|
||||
from importlib import resources
|
||||
from dzgui.const.constants import APP_NAME_LOWER, CHANGELOG_PATH
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def changelog():
|
||||
path = resources.files(APP_NAME_LOWER).joinpath(CHANGELOG_PATH)
|
||||
return path
|
||||
def changelog(request) -> None:
|
||||
root = request.config.rootpath
|
||||
changelog = Path(root).joinpath("CHANGELOG.md").read_text()
|
||||
return changelog
|
||||
|
||||
|
||||
def test_headings(changelog):
|
||||
with open(changelog, "r") as f:
|
||||
lines = f.readlines()
|
||||
for line in lines:
|
||||
if line.startswith("#"):
|
||||
pass
|
||||
# TODO: use regex
|
||||
pass
|
||||
def count_hash(line: str) -> int:
|
||||
cnt = 0
|
||||
for c in line:
|
||||
if c == "#":
|
||||
cnt += 1
|
||||
return cnt
|
||||
|
||||
|
||||
def test_changelog_prefix(changelog) -> None:
|
||||
r = r".*(\[.*\]).*"
|
||||
lines = changelog.splitlines()
|
||||
sort = sorted(lines)
|
||||
match = [line for line in sort if line.startswith("#")]
|
||||
for m in match:
|
||||
if "Changelog" in m:
|
||||
assert count_hash(m) == 1
|
||||
elif re.match(r, m) is not None:
|
||||
assert count_hash(m) == 2
|
||||
else:
|
||||
assert count_hash(m) == 3
|
||||
@@ -0,0 +1,19 @@
|
||||
import pytest
|
||||
|
||||
from dzgui.const.constants import FLATPAK_RUN_CMD, STEAM_CMD
|
||||
from dzgui.controllers.mc import Controller
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected", [(FLATPAK_RUN_CMD, "Flatpak"), (STEAM_CMD, "Steam")]
|
||||
)
|
||||
def test_human_name(monkeypatch, value, expected) -> None:
|
||||
def mock_query_config(*args) -> str:
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(
|
||||
"dzgui.controllers.mc.Controller.query_config", mock_query_config
|
||||
)
|
||||
controller = Controller()
|
||||
client = controller.get_client_name()
|
||||
assert client == expected
|
||||
@@ -0,0 +1,58 @@
|
||||
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 DummyWidget(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() -> DummyWidget:
|
||||
return DummyWidget()
|
||||
|
||||
|
||||
@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
|
||||
@@ -7,13 +7,12 @@ from dzgui.config.xdg import get_xdg_paths, parse_filepaths
|
||||
from dzgui.config import convert
|
||||
from tests.fixtures import fixture_path
|
||||
|
||||
pytestmark = pytest.mark.config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def legacy_config():
|
||||
return fixture_path("dztuirc_1")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unset_values():
|
||||
return fixture_path("dztuirc_3")
|
||||
@@ -22,7 +21,6 @@ def unset_values():
|
||||
@pytest.fixture
|
||||
def keys():
|
||||
return [
|
||||
"bm_api",
|
||||
"fav_server",
|
||||
"fav_label",
|
||||
"name",
|
||||
@@ -38,6 +36,7 @@ def keys():
|
||||
|
||||
# TODO: use a static fixture instead of system config
|
||||
@pytest.fixture
|
||||
@pytest.mark.realconfig
|
||||
def config():
|
||||
paths = get_xdg_paths()
|
||||
xdg = parse_filepaths(paths)
|
||||
@@ -45,18 +44,21 @@ def config():
|
||||
|
||||
|
||||
@pytest.mark.post_install
|
||||
@pytest.mark.realconfig
|
||||
def test_invalid_config_value(config):
|
||||
with pytest.raises(Exception):
|
||||
assert config["foo"] is None
|
||||
|
||||
|
||||
@pytest.mark.post_install
|
||||
@pytest.mark.realconfig
|
||||
def test_default_config_values(keys, config):
|
||||
for key in keys:
|
||||
assert config[key] is not None
|
||||
|
||||
|
||||
@pytest.mark.post_install
|
||||
@pytest.mark.realconfig
|
||||
def test_contains_invalid_values(keys, config):
|
||||
for key in config:
|
||||
assert key in keys
|
||||
@@ -81,7 +83,6 @@ def test_key_conversion(legacy_config):
|
||||
j = convert.rc2json(legacy_config)
|
||||
j = json.loads(j)
|
||||
keys = [
|
||||
"api_key",
|
||||
"staging_dir",
|
||||
"src_path",
|
||||
"steam_path",
|
||||
@@ -91,6 +92,7 @@ def test_key_conversion(legacy_config):
|
||||
for key in keys:
|
||||
assert key not in j
|
||||
|
||||
|
||||
def test_missing_values(unset_values):
|
||||
j = convert.rc2json(unset_values)
|
||||
j = json.loads(j)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from dzgui.api.servers import Details
|
||||
from dzgui.views.dialogs.servers import ServerDetailsDialog
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa E402
|
||||
|
||||
|
||||
class MockController:
|
||||
def __init__(self) -> None:
|
||||
self.window = Gtk.Window()
|
||||
|
||||
def get_window(self) -> Gtk.Window:
|
||||
return self.window
|
||||
|
||||
def get_server_name(self) -> str:
|
||||
return "My server"
|
||||
|
||||
def get_emitter(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_html_escaping() -> None:
|
||||
name = "Test server"
|
||||
desc = "<Server description> Game & mods"
|
||||
gametime = "05:30"
|
||||
day_accel = 5.0
|
||||
night_accel = 1.0
|
||||
details = Details([["0", "1"]], name, desc, gametime, day_accel, night_accel)
|
||||
controller = MockController()
|
||||
dialog = ServerDetailsDialog(controller, details)
|
||||
dialog.destroy()
|
||||
|
||||
assert dialog.description.get_text() == desc
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user