Merge pull request #31 from aclist/feat/server-threading

feat: server threading
This commit is contained in:
aclist authored and GitHub committed 2026-05-16 21:30:07 +09:00
commit 4a2f8a1073
121 files changed
+7349 -3913

No files matched your search

+2
View File
@@ -7,3 +7,5 @@ build/
.pytest_cache/
.mypy_cache/
notes.otl
uv.lock
.coverage
+4
View File
@@ -14,3 +14,7 @@ repos:
name: Copy CHANGELOG.md to package data dir
entry: python scripts/copy_changelog.py
language: system
- id: untracked
name: Check for untracked files not present in .gitignore
entry: python scripts/untracked.py
language: system
+52 -6
View File
@@ -1,8 +1,7 @@
## Added
- Add pyproject.toml file
- requests, packaging.version, psutil
- Lockfile
- Changelog text wrapping and formatting
- Changelog ships with source
- Documentation ships with source
- Decouple UI components into modules
- Choose from kilometer or miles distance display
- More granular haversine calculation
@@ -10,17 +9,64 @@
- Dedicated mods page
- Dedicated thanks page
- Rearrange menus/breadcrumbs
- Documentation ships with source
- Open filepicker when generating system log
- Developers page (and -d flag)
- Redact API key in log table
- Integrate add/connect widgets into main menu
- Favorite/connect/LAN panels integrated with server views
- Abort on first LAN server found
- Colorized IP/ID validation fields
- Copy favorite server IP to clipboard
- Set favorite server from tables
- Detailed/copyable trace in critical error dialogs
- Visual icons
- Integrated server notebook
- Propagate width changes to all tables
- Remember tree position in menus
- Show hidden server count after filtering
- Atomic map filters per server context
- Use concurrency when checking stale mods (performance uplift)
- Copy version by clicking label
- Add server name to server mod dialogs
- Lazy load ping cell data func
- Refresh servers button
- Early load alerts button
- Preconnect dialog
- Preconnect warnings/failsafes like filesize
## Changed
- Reduce padding on keys button
- Add icon to keys button
- Add icons to API buttons
- Boldface breadcrumbs
- 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
- Load new model into view without flushing
- Cull servers with abnormal queue values (integer overflow: 2147483647)
- Packaging structure
- Suppress log messages from imported modules
- Embed Workshop link in Options menu
## Dropped
- Debug mode
- 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
- Center server title text on server dialogs
- Rare segfaults when changing maps (threading)
- Moved dialogs out of threads
## Unreleased
- Load offline mods
- Choose to jump into splash screen instead of server
- Setup wizard
- Local documentation
- Raw debug command in context menu
## Developer-facing
- Add pyproject.toml file
- Show deprecation warnings
- Options -> Dev page
+13
View File
@@ -0,0 +1,13 @@
# TODO: link to here from sphinx docs
# Fetching submodules
git submodule update --recursive --init
# Installing dev dependencies
uv pip install -e .[dev]
# Building documentation
sphinx-build -M html source build -a
# Building installer package
TBD
+1 -2
View File
@@ -18,11 +18,10 @@ Refer to the documentation for installation and setup instructions:
- [GitHub](https://aclist.github.io/dzgui/index.html)
- [Mirror (Codeberg)](https://aclist.codeberg.page)
![A screenshot of DZGUI](/images/example.png)
## Attribution
Geolocation records from [DB-IP](https://db-ip.com) under [CC 4.0 license](https://creativecommons.org/licenses/by/4.0/)
# TODO: mention a2s/dayzquery
# TODO: mention third party libraries
+3 -3
View File
@@ -30,11 +30,11 @@ extensions = [
"sphinx.ext.githubpages"
]
html_theme = "sphinx_rtd_theme"
html_static_path = ['_static']
#html_static_path = ['_static']
html_show_sphinx = False
html_show_copyright = False
html_show_sourcelink = False
today_fmt = "%Y-%m-%d"
release = "5.8.x"
version = "5.8.x"
release = "7.x.x"
version = "7.x.x"
+7
View File
@@ -10,3 +10,10 @@ file.
All contributors are expected to act in accordance with the project's
`code of conduct <https://github.com/aclist/dztui/blob/main/CODE_OF_CONDUCT.md>`_,
and follow the project's standards and guidelines.
To install DZGUI and related dependencies for linting, testing, and version control,
invoke the command ``uv pip install .[dev]``.
After this process, install the applicable pre-commit hooks by invoking the command ``pre-commit install``.
These pre-commit hooks enforce code checks that prevent making commits unless the relevant tests pass.
+78 -32
View File
@@ -1,8 +1,8 @@
Installation
==================
Dependencies
----------------------
Basic runtime dependencies
---------------------------------
If not already installed, the below can be found in your system’s package manager.
@@ -10,7 +10,6 @@ If any dependencies are missing when the application starts, it will warn you, s
- curl
- jq
- PyGObject (python-gobject)
- steam
- wmctrl or xdotool
- zenity
@@ -18,43 +17,90 @@ If any dependencies are missing when the application starts, it will warn you, s
.. note::
All dependencies are installed out of the box on Steam Deck.
Downloading
------------------
UI dependencies (Python and PyGObject)
---------------------------------------
.. important::
As of version 6.0.0, you must have a valid Python 3.13 and matching PyGObject environment.
Depending on your distribution, this may require additional manual intervention.
Automatic installation
Arch Linux (vanilla variants)
^^^^^^^^^^^^^^^^^^^^^^
You will need the ``python3.13`` package, if it is not already installed.
Install the following build dependencies:
.. code:: console
sudo pacman -S cmake gcc pkgconf python-cairo
.. include:: venv.rst
CachyOS (Arch)
^^^^^^^^^^^^^^^^^^^^^^^
Invoke the command below from a terminal:
Refer to the build instructions for Arch Linux first.
The ``python3.13`` package is not available on CachyOS, so it is recommended to
use ``uv`` to manage Python versions.
.. code:: console
sudo pacman -S uv
.. include:: venv.rst
Manjaro 26 (Arch)
^^^^^^^^^^^^^^^^^^^^^^^
If Python 3.13 is not already available out of the box, refer to the build instructions for :ref:`Arch Linux (vanilla variants)`.
Debian 13 variants
^^^^^^^^^^^^^^^^^^^^^^^
Python 3.13 and PyGObject are available out of the box. No additional steps are necessary.
You can proceed to the :ref:`Installscript`.
Fedora 43 variants
^^^^^^^^^^^^^^^^^^^^^^^
Fedora 43 ships with a newer version of Python (3.14), so you must set up Python 3.13 in its own
virtual environment (venv) to prevent it from conflicting with the system package.
Install the following build dependencies:
.. code:: console
sudo dnf install cairo cairo-devel cairo-gobject-devel gcc python3.13 python3.13-devel
.. include:: venv.rst
Ubuntu 24 variants
^^^^^^^^^^^^^^^^^^^^^^
Ubuntu ships with an older version of Python (3.12). You must install additional dependencies.
Add the PPA below to add Python 3.13 to your sources:
.. code:: console
sudo add-apt-repository ppa:deadsnakes/ppa
Install the following build dependencies:
.. code:: console
sudo apt install python3.13 python3.13-venv python3.13-dev build-essential python3-gi-cairo pkg-config libgirepository-2.0-dev libcairo2-dev
.. include:: venv.rst
Installscript
-----------------------
Invoke the command below from a terminal. This script fetches the main DZGUI script from the repository,
makes it executable, and launches DZGUI's first-time setup. After running this installscript, you will
have the file ``dzgui.sh`` wherever you invoked the command.
.. code:: console
curl -s "https://codeberg.org/aclist/dztui/raw/branch/dzgui/install.sh" | bash
Manual installation
^^^^^^^^^^^^^^^^^^^^
Clone the repository:
.. code:: console
git clone https://codeberg.org/aclist/dztui.git
Set the executable bit on ``dzgui.sh``:
.. code:: console
chmod +x dzgui.sh
Launch the script:
.. code:: console
./dzgui.sh
nixOS installation
^^^^^^^^^^^^^^^^^^^
Two packages are independently maintained by other contributors. Each takes a slightly different approach:
----------------------
Two packages are independently maintained by other contributors. Each takes a slightly different approach.
nixOS packages are standalone and do not require cloning the git repository.
- https://github.com/lelgenio/dzgui-nix
- https://github.com/jiriks74/dzgui.flake
+1 -1
View File
@@ -7,6 +7,6 @@ This is used for thousands separators in long numbers and decimal separators in
If you wish to use a specific regional numbering preference while retaining a different base system language (e.g., English language with German-style numbering), pass the desired locale as a variable before launching DZGUI:
``LC_ALL=de_DE.UTF-8 ./dzgui.sh``
``LC_NUMERIC=de_DE.UTF-8 ./dzgui``
If you intend to use this frequently, you could wrap the above in a script or alias.
+43
View File
@@ -0,0 +1,43 @@
After satisfying the dependencies above, create a virtual environment (venv) and install PyGObject:
If you have ``uv`` as your Python version manager, invoke the following:
.. code:: console
uv venv --python 3.13 $HOME/.virtualenvs/dzgui
source $HOME/.virtualenvs/dzgui/bin/activate
uv pip install PyGObject
If you installed the ``python3.13`` package directly instead of ``uv``, use the below:
.. code:: console
python3.13 -m venv $HOME/.virtualenvs/dzgui
source $HOME/.virtualenvs/dzgui/bin/activate
pip install PyGObject
You can now proceed to the :ref:`Installscript`.
After installation, refer to the additional remarks below.
.. important::
Since PyGObject is localized to a venv, it is advisable to create a wrapper script to ensure
the venv is activated on subsequent invocations of DZGUI.
.. code:: console
#!/usr/bin/env bash
source $HOME/.virtualenvs/dzgui/bin/activate
<insert your path to dzgui.sh>
Make sure to update the placeholder path on the last line. For example, if you ran the
installscript from your home directory, the wrapper script would look like:
.. code:: console
#!/usr/bin/env bash
source $HOME/.virtualenvs/dzgui/bin/activate
$HOME/dzgui.sh
Save this wrapper script in a location of your choice, e.g., ``mywrapper.sh`` and set the
executable bit: ``chmod +x mywrapper.sh`` This script will be your entrypoint for
future invocations of DZGUI, so you should launch it, not ``dzgui.sh``
+16 -14
View File
@@ -1,24 +1,25 @@
import logging
import requests
from pathlib import Path
from typing import Any, Optional, TYPE_CHECKING
from dzgui.api.servers import Record
from dzgui.config.query import lookup
from dzgui.const.constants import APP_NAME
from dzgui.const.endpoints import BM_SERVERS
from dzgui.const.enum import Preferences
logger = logging.getLogger(__name__)
logger = logging.getLogger(APP_NAME)
def get_attributes(config: Path, uid: int) -> str:
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
key = lookup(config, Preferences.BM)
hdr = {"Authorization": "Bearer " + key}
payload: dict[str, str] = {
"filter[game]": "dayz",
"sort": "-players",
"filter[ids][whitelist]": str(uid)
"filter[ids][whitelist]": str(uid),
}
res = requests.get(BM_SERVERS, params=payload, headers=hdr)
res.raise_for_status()
@@ -26,13 +27,14 @@ def get_attributes(config: Path, uid: int) -> str:
return j
def map_id_to_record(config: Path, uid: int) -> Record | None:
def map_id_to_record(key: str, uid: int) -> Optional["Record"]:
from dzgui.api.servers import Record
try:
record = get_attributes(config, uid)
record = get_attributes(key, uid)
ip = record["ip"]
port = record["port"]
qport = record["portQuery"]
port = int(record["port"])
qport = int(record["portQuery"])
return Record(ip, port, qport)
except Exception as e:
logger.warn(e)
except Exception:
return None
+82 -42
View File
@@ -1,26 +1,28 @@
import dayzquery
import hashlib
import logging
import shlex
from concurrent.futures import wait
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
import dzgui.api.pefile as PeFile
from dzgui.api.servers import Record, get_rules
from dzgui.api.servers import get_rules, fqip_to_record
from dzgui.const.constants import (
APP_NAME,
APPID_DAYZ,
LIBRARYFOLDERS_PATH,
WORKSHOP_PATH,
)
from dzgui.util.strings import checkmark
from dzgui.config.query import lookup
from dzgui.const.enum import Preferences
from typing import Any
logger = logging.getLogger(__name__)
logger = logging.getLogger(APP_NAME)
@dataclass
class ModMeta:
@@ -37,17 +39,17 @@ def get_local_mod_ids(steam_path: Path) -> list[int]:
def get_local_mod_path(steam_path: Path) -> Path:
p = PeFile.get_app_path(steam_path / LIBRARYFOLDERS_PATH, APPID_DAYZ)
p = PeFile.get_app_path(steam_path / Path(LIBRARYFOLDERS_PATH), APPID_DAYZ)
workshop_path = p / WORKSHOP_PATH
return workshop_path
def get_local_mods(workshop_path: Path) -> list[Path]:
mods = [file for file in workshop_path.iterdir() if file.is_dir()]
return mods
# TODO: TEST: mock bad meta files with fixtures and remove them
def parse_meta(file: Path) -> ModMeta:
def parse_meta(file: Path) -> ModMeta | None:
mod = file / "meta.cpp"
if mod.exists() is False:
return None
@@ -63,13 +65,16 @@ def parse_meta(file: Path) -> ModMeta:
if tok == "protocol" or tok == "publishedid":
ntok = lex.get_token()
elif tok == "timestamp":
# some malformed .NET tick conversions result in numbers < 0
# NOTE: some malformed .NET tick conversions result in numbers < 0
ntok = lex.get_token()
if ntok == "-":
ntok += str(lex.get_token())
elif tok == "name":
ntok = lex.get_token().split('"')[1]
v.append(ntok)
ntok = lex.get_token()
if ntok is not None:
ntok = ntok.split('"')[1]
if ntok is not None:
v.append(ntok)
meta = ModMeta(*v)
return meta
@@ -78,7 +83,7 @@ def get_mod_size(path: Path) -> float:
s = 0
for f in path.rglob("*"):
s += f.stat().st_size
size = round(s / (1024 * 1024), 3)
size = round(s / (1024**2), 3)
return size
@@ -95,8 +100,9 @@ def get_delimited_mods(steam_path: Path) -> list[Any]:
if meta is None:
continue
size = get_mod_size(mod)
clean.append([meta.name, symlink, mod_dir, size])
clean.sort(key=lambda row: row[0])
# NOTE: final col is cell renderer highlight toggle
clean.append([meta.name, symlink, mod_dir, size, False])
clean.sort(key=lambda row: str(row[0]).casefold())
return clean
@@ -104,22 +110,6 @@ def get_missing_mods(local: list, remote: list) -> list:
return [mod for mod in remote if mod not in local]
def get_server_modlist(server: Record, steam: Path) -> list:
try:
rules = dayzquery.dayz_rules((server.ip, server.qport))
except Exception as e:
raise e
remote_mods = [[mod.name, mod.workshop_id] for mod in rules.mods]
remote_mods.sort(key=lambda row: row[0])
local_mods = get_local_mod_ids(steam)
for mod in remote_mods:
if mod[1] in local_mods:
mod.append(checkmark)
else:
mod.append("")
return remote_mods
def _hash(uid: str) -> str:
md5 = hashlib.md5()
md5.update(uid.encode("ascii"))
@@ -128,7 +118,8 @@ def _hash(uid: str) -> str:
def remove_stale_signatures(config: Path, versions: Path) -> None:
if versions.is_file() is False:
logger.warning("No mod signatures file found")
logger.warning("Creating new version signatures file")
versions.touch()
return
path = lookup(config, Preferences.DEFAULT)
steam_path = Path(path)
@@ -145,23 +136,72 @@ def remove_stale_signatures(config: Path, versions: Path) -> None:
def find_stale_mods(config: Path) -> list[int]:
def push_record(rec: str) -> list:
record = fqip_to_record(rec)
if record is None:
return []
try:
mods = get_rules(record)
except Exception:
return []
return [mod.workshop_id for mod in mods]
steam = lookup(config, Preferences.DEFAULT)
steam_path = Path(steam)
local = get_local_mod_ids(steam_path)
servers = lookup(config, Preferences.IP_LIST)
records = lookup(config, Preferences.IP_LIST)
all_mods = []
for server in servers:
split = server.split(":")
ip = split[0]
qport = split[2]
remote_mods = []
mods = get_rules(ip, qport)
all_mods += mods
with ThreadPoolExecutor() as executor:
futures = [executor.submit(push_record, record) for record in records]
wait(futures)
for future in futures:
res = future.result()
remote_mods.extend(res)
stale = []
for mod in local:
if mod not in all_mods:
stale.append(mod)
unique_mods = set(remote_mods)
stale = [mod for mod in local if mod not in unique_mods]
return stale
def get_mod_dir_size(path: Path) -> int:
"""
This is not a guarantee of parity, as user may adjusted contents of local mods,
so upstream epoch time is still used
"""
size = 0
for i in Path(path).iterdir():
if i.is_file():
size += i.stat().st_size
elif i.is_dir():
size += get_mod_dir_size(i)
return size
def version_file_to_dict(file: Path) -> dict[str, str]:
versions = file.read_text().splitlines()
d: dict[str, str] = {}
for version in versions:
line = version.split(",")
mod = line[0]
stamp = line[1]
d[mod] = stamp
return d
def update_signatures(
mods: list[tuple[str, str, int, int]], version_file: Path
) -> None:
d = version_file_to_dict(version_file)
for _title, mod, stamp, size in mods:
d[mod] = str(stamp)
versions: list[str] = []
for k, v in d.items():
row = ",".join([k, v])
versions.append(row)
with open(version_file, "w") as f:
for version in versions:
f.write(f"{version}\n")
+40 -44
View File
@@ -4,10 +4,7 @@ import struct
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from shlex import shlex
from typing import BinaryIO, Union
from packaging.version import Version
from typing import BinaryIO, Self, Union
from dzgui.const.constants import (
APPID_DAYZ,
@@ -36,44 +33,44 @@ class VersionMatch(Enum):
SAME_VERSION = 3
class u8:
class u8(int):
fmt = "B"
class u16:
class u16(int):
fmt = "H"
class u32:
class u32(int):
fmt = "L"
class u64:
class u64(int):
fmt = "Q"
class i8:
class i8(int):
fmt = "b"
class i16:
class i16(int):
fmt = "h"
class i32:
class i32(int):
fmt = "l"
class i64:
class i64(int):
fmt = "q"
class PackedData:
@classmethod
def unpack(cls, data: BinaryIO):
def unpack(cls, data: BinaryIO) -> Self:
r = []
for key, value in cls.__annotations__.items():
if value == str:
if value is str:
f = data.read(8).rstrip(b"\x00\x00").decode()
else:
fmt = endian + (value.fmt)
@@ -242,7 +239,7 @@ class FileVersion:
major: int
minor: int
build: int
revision: int
revision: str
@dataclass(slots=True, frozen=True)
@@ -269,6 +266,7 @@ class AppNotInstalledError(Exception):
class AppMovedError(Exception):
"""VDF points to a nonexistent location on disk"""
pass
@@ -283,8 +281,11 @@ def parse_version_number(data: BinaryIO) -> FileVersion:
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
revision = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
return FileVersion(major, minor, build, revision)
# 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)
def seek_to_hex(address: str, data: BinaryIO) -> None:
@@ -313,7 +314,8 @@ def get_dayz_version(file: Path) -> DayZVersion:
def dayz_version_to_str(v: DayZVersion) -> str:
return ".".join(str(el) for el in [v.major, v.minor, v.patch])
return ".".join(el for el in list(map(str, [v.major, v.minor, v.patch])))
# return ".".join(str(el) for el in [v.major, v.minor, v.patch])
def dayz_version_from_str(v: str) -> DayZVersion:
@@ -332,6 +334,7 @@ def get_version(file: Path) -> FileVersion:
magic = hex(struct.unpack("<H", (blob[0:2]))[0])
f.seek(pos)
OBJW: OPTIONAL_HDR_WIN_X86 | OPTIONAL_HDR_WIN_X64
if magic == PE32_x86:
OPTIONAL_HDR_X86.unpack(f)
OBJW = OPTIONAL_HDR_WIN_X86.unpack(f)
@@ -365,18 +368,15 @@ def get_version(file: Path) -> FileVersion:
table = RESOURCE_DIRECTORY_TABLE.unpack(f)
total = table.number_of_name_entries + table.number_of_id_entries
for entry in range(total):
for _iter in range(total):
entry = RESOURCE_DIRECTORY_ENTRY.unpack(f)
if entry.name_or_id == VERSION_RESOURCE:
while entry.data_or_subdir & (1 << 31):
shift = entry.data_or_subdir & ~(1 << 31)
seek_to_hex(hex(offset + shift), f)
table = RESOURCE_DIRECTORY_TABLE.unpack(f)
total = (
table.number_of_name_entries
+ table.number_of_id_entries
)
for entry in range(total):
total = table.number_of_name_entries + table.number_of_id_entries
for _iter in range(total):
entry = RESOURCE_DIRECTORY_ENTRY.unpack(f)
break
if entry.name_or_id > VERSION_RESOURCE:
@@ -388,7 +388,7 @@ def get_version(file: Path) -> FileVersion:
offset = data.data_rva - hdr.virtual_address + hdr.pointer_to_raw_data
seek_to_hex(hex(offset), f)
hdr = VS_VERSION_INFO_HDR.unpack(f)
VS_VERSION_INFO_HDR.unpack(f)
# https://learn.microsoft.com/en-us/windows/win32/menurc/vs-versioninfo
byte_len = len(VS_VERSION_INFO_ID.encode("utf-16le"))
label = f.read(byte_len).decode("utf-16le")
@@ -423,6 +423,7 @@ def get_pefile_path(steam_path: Path, appid: int) -> Path:
pe_path = app_path / f"steamapps/common/{name}/{binary}"
return pe_path
def get_app_path(folders_path: Path, appid: int) -> Path:
app_path = None
@@ -434,32 +435,27 @@ def get_app_path(folders_path: Path, appid: int) -> Path:
for obj in j["libraryfolders"]:
if str(appid) in j["libraryfolders"][obj]["apps"]:
app_path = j["libraryfolders"][obj]["path"]
break
if Path(app_path).exists():
break
if app_path is None:
raise AppNotInstalledError(
f"Failed to find a libraryfolder for the appid {appid}"
)
app_path = Path(app_path)
if app_path.exists() is False:
if Path(app_path).exists() is False:
raise AppMovedError(
f"Path '{app_path}' specified in libraryfolders does not exist"
f"The location '{app_path}' pointed to by '{appid}' no longer exists and may have been changed on the disk."
)
return app_path
return Path(app_path)
# TODO: tests
def compare_versions(local: DayZVersion, remote: DayZVersion) -> VersionMatch:
local_str = Version(dayz_version_to_str(local))
remote_str = Version(dayz_version_to_str(remote))
if local_str == remote_str:
return VersionMatch.SAME_VERSION
if local_str < remote_str:
return VersionMatch.LOCAL_OLDER
if local_str > remote_str:
return VersionMatch.LOCAL_NEWER
def get_pretty_version(steam_path: Path, appid: int) -> str | None:
try:
pe_file_path = get_pefile_path(steam_path, appid)
vers = get_dayz_version(pe_file_path)
dayz_version = dayz_version_to_str(vers)
return dayz_version
except Exception as e:
print(e)
return None
+9 -8
View File
@@ -3,23 +3,27 @@ import requests
from typing import Union, TYPE_CHECKING
from dzgui.const.constants import APPID_DAYZ, REQUEST_TIMEOUT
from dzgui.const.constants import APPID_DAYZ, APP_NAME, REQUEST_TIMEOUT
from dzgui.const import endpoints
logger = logging.getLogger(__name__)
logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING:
from requests import Response
def test_steam_api(key: str) -> bool:
appid = APPID_DAYZ
payload: dict[str, Union[int, str]] = {
"filter": r"\appid" + fr"\{appid}",
"filter": r"\appid" + rf"\{appid}",
"limit": 10,
"key": key,
}
try:
res = requests.get(endpoints.STEAM_SERVERS, params=payload, timeout=REQUEST_TIMEOUT)
res = requests.get(
endpoints.STEAM_SERVERS, params=payload, timeout=REQUEST_TIMEOUT
)
return is_remote_up(res)
except Exception as e:
logger.critical(e)
@@ -44,10 +48,7 @@ def test_bm_api(key: str) -> bool:
hdr = {"Authorization": "Bearer " + key}
try:
res = requests.get(
endpoints.BM_SERVERS,
params=payload,
headers=hdr,
timeout=REQUEST_TIMEOUT
endpoints.BM_SERVERS, params=payload, headers=hdr, timeout=REQUEST_TIMEOUT
)
return is_remote_up(res)
except Exception as e:
+189 -111
View File
@@ -1,5 +1,4 @@
import ipaddress
import json
import logging
import math
import os
@@ -7,22 +6,27 @@ import re
import requests
import socket
import subprocess
import typing # noqa
import threading
from dataclasses import dataclass
from typing import Union
from typing import Any, Optional, TYPE_CHECKING, Union
from dzgui.const.constants import REQUEST_TIMEOUT
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 import strings
import a2s
import dayzquery
if TYPE_CHECKING:
from a2s import SourceInfo
from dayzquery import DayzMod
logger = logging.getLogger(__name__)
logger = logging.getLogger(APP_NAME)
# TODO: confirm that patches from testing are incorporated here
# particularly around malformed values in JSON response. check commit log
params = [
r"\nor\1\map\chernarusplus\nor\1\map\sakhal\nor\1\map\enoch\empty\1\nor\1\map\namalsk", # noqa
@@ -37,18 +41,69 @@ params = [
]
class BmAPIError(Exception):
pass
class BmIdError(Exception):
pass
class InvalidIpError(Exception):
pass
@dataclass(slots=True, frozen=True)
class Res:
status: int
parsed: bool
json: Union[dict[str, Any], None]
@dataclass(slots=True, frozen=True)
class Details:
data: Union[list, None]
description: str
success: bool
@dataclass(slots=True)
class Record:
"""
The gameport field is manipulated by the RowType.CONN_BY_IP method
"""
ip: str
gameport: int
qport: int
class A2SInfo:
def __init__(self, record: Record, info: a2s.SourceInfo | None) -> None:
self.record = record
self.info = info
def get_record(self) -> Record:
return self.record
def get_info(self) -> a2s.SourceInfo | None:
return self.info
def as_row(self) -> dict[str, Any]:
if self.info is None:
raise AttributeError("No record to convert")
ip = self.record.ip
qport = self.record.qport
try:
return source_info_to_dict(ip, qport, self.info)
except Exception as e:
logger.warning(e)
raise e
def is_modded(self) -> bool:
if self.info is None:
raise ValueError("Cannot call this method on Nonetype")
try:
kw = self.info.keywords.split(",")
state = True if "mod" in kw else False
return state
except Exception as e:
logger.warning(e)
raise e
def get_netmask() -> str:
hostname = os.uname()[1]
i = socket.gethostbyname(hostname)
@@ -56,7 +111,10 @@ def get_netmask() -> str:
return netmask
def test_ip(suffix: int, port: int) -> dict | None:
# TODO: rename
def test_ip(suffix: int, port: int, event: threading.Event) -> dict | None:
if event.is_set():
return None
netmask = get_netmask()
hostname = f"{netmask}.{str(suffix)}"
ping = ["ping", "-c1", "-i", "0.1", "-w", "1"]
@@ -67,6 +125,9 @@ def test_ip(suffix: int, port: int) -> dict | None:
def sanitize(name: str) -> str:
"""
Strips extraneous padding and spammy junk chars in server names
"""
name = re.sub(r"\r", r"", name)
name = re.sub(r"\n", r"", name)
name = re.sub(r"\x01", r"", name)
@@ -124,7 +185,7 @@ def parse_json(json: list) -> list:
view = strings.filter_3pp
if "external" in r:
provider = strings.filter_unoffiical
provider = strings.filter_unofficial
else:
provider = strings.filter_official
@@ -136,6 +197,8 @@ def parse_json(json: list) -> list:
try:
r = row["gametype"].split("lqs")
queue = r[1].split(",")[0]
if int(queue) > 255:
continue
except IndexError:
queue = 0
@@ -162,7 +225,7 @@ def parse_json(json: list) -> list:
mapname = row["map"].lower()
players = row["players"]
max_players = row["max_players"]
raw = [
raw = (
name,
mapname,
view,
@@ -175,15 +238,13 @@ def parse_json(json: list) -> list:
ping,
provider,
modded,
]
)
rows.append(raw)
return rows
def query_direct(ip: str, qport: int, TIMEOUT: float=3.0) -> dict | None:
def source_info_to_dict(ip: str, qport: int, info: "SourceInfo") -> dict[str, Any]:
try:
info = a2s.info((ip, qport), TIMEOUT)
name = info.server_name
mapname = info.map_name
address = ip + ":" + str(qport)
@@ -208,67 +269,24 @@ def query_direct(ip: str, qport: int, TIMEOUT: float=3.0) -> dict | None:
res["gameport"] = gameport
res["ping"] = ping
return res
except TimeoutError:
return None
except KeyError:
return None
except Exception as e:
# TODO: generalized function
logger.critical(f"{type(e).__name__}: {e} ({ip}:{qport})")
raise e
@dataclass(slots=True, frozen=True)
class Res:
status: int
parsed: bool
json: Union[str, None]
@dataclass(slots=True, frozen=True)
class Ping:
addr: str
iteration: int
ping: int
@dataclass(slots=True, frozen=True)
class Details:
data: Union[list, None]
description: str
success: bool
@dataclass(slots=True, frozen=True)
class Prereqs:
password: bool
gameport: int
appid: Union[int, None]
version: Union[str, None]
@dataclass(slots=True)
class Record:
"""
The gameport field is manipulated by the RowType.CONN_BY_IP method
"""
ip: str
gameport: int
qport: int
# TODO: refactor this
def get_prereqs(ip: str, qport: int) -> Prereqs:
def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] | None:
try:
info = a2s.info((ip, qport))
except TimeoutError:
return Prereqs(False, 0, None, None)
gameport = getattr(info, "port", 0)
is_password = getattr(info, "password_protected", False)
appid = getattr(info, "game_id", None)
version = getattr(info, "version", None)
return Prereqs(is_password, gameport, appid, version)
info = a2s.info((ip, qport), timeout)
return source_info_to_dict(ip, qport, info)
except Exception as e:
logger.warning(e)
return None
def details(ip: str, qport: int) -> Details:
def get_details(record: Record) -> Details:
ip = record.ip
qport = record.qport
default_str = strings.none_provided
try:
@@ -276,8 +294,8 @@ def details(ip: str, qport: int) -> Details:
except TimeoutError:
return Details(None, default_str, False)
try:
rules = dayzquery.dayz_rules((ip, int(qport)))
except TimeoutError:
rules = dayzquery.dayz_rules((ip, qport))
except Exception:
return Details(None, default_str, False)
try:
@@ -361,52 +379,40 @@ def details(ip: str, qport: int) -> Details:
return Details(rows, description, True)
def ping(iteration: int, row: list) -> Ping:
addr = row[7]
qport = row[8]
res = None
if row[9] != 9999:
return Ping(addr, iteration, row[9])
try:
ip = addr.split(":")[0]
except IndexError:
ping = 9999
def ping(ip: str, qport: int) -> int:
try:
res = query_direct(ip, qport, 0.5)
if res is None:
return 9999
else:
return int(res["ping"])
except Exception:
pass
if res is None:
ping = 9999
else:
ping = res["ping"]
return Ping(addr, iteration, ping)
return 9999
def query_api(key: str, appid: int, param: str) -> Res:
LIMIT = 10000
payload: dict[str, Union[int, str]] = {
"filter": r"\appid" + fr"\{appid}" + param,
"filter": r"\appid" + rf"\{appid}" + param,
"limit": LIMIT,
"key": key,
}
try:
"""
NOTE: the global default timeout is None;
using a low timeout (~5s) sometimes fails
"""
res = requests.get(STEAM_SERVERS, params=payload, timeout=REQUEST_TIMEOUT)
res.raise_for_status()
parsed = True
data = res.json()
except Exception as e:
status = res.status_code
status = 200
json = res.json()
except Exception:
parsed = False
data = None
json = None
status = 403
finally:
return Res(status, parsed, data)
return Res(status, parsed, json)
def validate_ip(addr: str) -> Record:
@@ -435,10 +441,82 @@ def validate_ip(addr: str) -> Record:
record = Record(ip, 0, qport)
return record
def get_rules(ip: str, qport: int) -> list[int]:
def get_rules(record: Record) -> list["DayzMod"]:
ip = record.ip
qport = record.qport
mods = dayzquery.dayz_rules((ip, qport)).mods
return [mod for mod in mods]
def query_playercount(record: Record) -> tuple[int, int] | None:
try:
mods = dayzquery.dayz_rules((ip, int(qport))).mods
return [mod for mod in mods]
res = query_direct(record.ip, record.qport)
if res is None:
return None
players = int(res["players"])
r = res["gametype"].split("lqs")
try:
queue = int(r[1].split(",")[0])
except IndexError:
queue = 0
return players, queue
except Exception as e:
logger.critical(e)
return []
return None
def query_by_ip(addr: str) -> A2SInfo:
record = short_ip_to_record(addr)
if record is None:
return A2SInfo(Record("0", 0, 0), None)
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)
if update_gameport:
record = Record(record.ip, info.port, record.qport)
return A2SInfo(record, info)
except Exception as e:
logger.warning(e)
return A2SInfo(record, None)
def short_ip_to_record(addr: str) -> Optional[Record]:
r = addr.split(":")
# TODO: raise exceptions instead of Nonetype
if len(r) != 2:
return None
return Record(r[0], 0, int(r[1]))
def fqip_to_record(addr: str) -> Optional[Record]:
r = addr.split(":")
if len(r) != 3:
return None
return Record(r[0], int(r[1]), int(r[2]))
def response_to_fqip(res: dict) -> str:
ip = res["addr"].split(":")[0]
gameport = res["gameport"]
qport = res["addr"].split(":")[1]
return f"{ip}:{gameport}:{qport}"
def response_to_record(res: dict) -> Record:
ip = res["addr"].split(":")[0]
gameport = res["gameport"]
qport = res["addr"].split(":")[1]
return Record(ip, int(gameport), int(qport))
+107 -68
View File
@@ -1,75 +1,111 @@
import json
import logging
import requests
import subprocess
from shlex import shlex
from pathlib import Path
from dzgui.const.constants import APP_NAME
from dzgui.const.endpoints import STEAM_PUBLISHED_FILES
from dzgui.util.bash import concat_bash_args
logger = logging.getLogger(__name__)
def query_defunct():
pass
# TODO: unimplemented
#cf.
#!/usr/bin/env bash
#query_defunct(){
# readarray -t modlist <<< "$@"
# local max=${#modlist[@]}
# concat(){
# for ((i=0;i<$max;i++)); do
# echo "publishedfileids[$i]=${modlist[$i]}&"
# done | awk '{print}' ORS=''
# }
# payload(){
# echo -e "itemcount=${max}&$(concat)"
# }
# post(){
# curl -s \
# -X POST \
# -H "Content-Type:application/x-www-form-urlencoded"\
# -d "$(payload)" 'https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/?format=json'
# }
# post | jq
# return
# local result=$(post | jq -r '
# .[].publishedfiledetails[]
# | select(.result==1)
# | select(.filename|contains("screenshot")|not)
# | "\(.file_size) \(.publishedfileid)"')
# <<< "$result" awk '{print $2}'
#}
#
#query_defunct "3576065083"
logger = logging.getLogger(APP_NAME)
def concat_mods(mods: list[str]) -> str:
for mod in mods:
mods[mod] = "@" + mod
return ";".join(mods)
from dzgui.util.symlink import _hash
# TEST: set config to name=user, use official server and no mods,
# ensure that formatted string is identical to fixture
def connect(addr: str, appid: int, name: str, mods: list):
# TODO: get name from configs
# TODO: concat_mods(mods):
# @<mod>;@<mod>;
hashes = []
for mod in mods:
md5sum = _hash(mod)
hashes.append(md5sum)
return ";".join(hashes)
def get_local_signatures(version_file: Path) -> dict[str, int]:
hashes: dict[str, int] = {}
lines = version_file.read_text().splitlines()
for line in lines:
data = line.split(",")
_id = data[0]
_hash = int(data[1])
hashes[_id] = _hash
return hashes
def enqueue_mod(mod: str, appid: int) -> None:
args = [
"steam",
f"steam://url/CommunityFilePage/{mod}+workshop_download_item",
str(appid),
mod,
]
subprocess.Popen(["/usr/bin/env", "bash", *args])
def get_needs_update(
version_file: Path, remote_hashes: list[tuple[str, str, int, int]]
) -> list[tuple[str, str, int, int]]:
local_hashes = get_local_signatures(version_file)
needs_update: list[tuple[str, str, int, int]] = []
for title, _id, _hash, size in remote_hashes:
if _id not in local_hashes:
needs_update.append((title, _id, _hash, size))
elif _hash != local_hashes[_id]:
needs_update.append((title, _id, _hash, size))
else:
continue
return needs_update
def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]:
"""
Attempts to continue connecting even if signatures are empty
"""
payload: dict[str, str | int] = {}
payload["itemcount"] = len(mods)
for i, mod in enumerate(mods):
payload[f"publishedfileids[{i}]"] = mod
try:
r = requests.post(STEAM_PUBLISHED_FILES, payload)
except Exception as e:
logger.critical(e)
return []
if r.status_code != 200:
return []
hashes: list[tuple[str, str, int, int]] = []
j = r.json()
rows = j["response"]["publishedfiledetails"]
for row in rows:
title = str(row["title"])
_id = str(row["publishedfileid"])
time = int(row["time_updated"])
size = int(row["file_size"])
hashes.append((title, _id, time, size))
return hashes
# 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:
concat = concat_mods(mods)
client_args = concat_bash_args(client)
# TODO: test flatpak arg expansion
params = [
f"-connect={addr}",
"-nolauncher",
"-nosplash",
"-skipintro",
f"-name={name}",
f"-mod={concat}"
]
# TODO: get steam launch command from configs
# args = concat_bash_args()
#
proc = subprocess.Popen([*args, "-applaunch", appid, *params])
# check proc.returncode
"-applaunch",
str(appid),
f"-connect={addr}",
"-nolauncher",
"-nosplash",
"-skipintro",
f"-name={name}",
f"-mod={concat}",
]
proc = subprocess.run([*client_args, *params])
return proc.returncode
def find_user_id(path: Path) -> str | None:
@@ -79,7 +115,7 @@ def find_user_id(path: Path) -> str | None:
j = json.loads(vdf)
for user in j["users"]:
if j["users"][user]["MostRecent"] == "1":
return user
return str(user)
return None
except Exception as e:
logger.warn(e)
@@ -87,7 +123,7 @@ def find_user_id(path: Path) -> str | None:
def vdf2json(path: Path) -> str:
def _istr(indent, string):
def _istr(indent: int, string: str) -> str:
return (indent * " ") + string
jbuf = "{\n"
@@ -105,7 +141,8 @@ def vdf2json(path: Path) -> str:
indent -= 1
jbuf += _istr(indent, "}")
ntok = lex.get_token()
lex.push_token(ntok)
if ntok is not None:
lex.push_token(ntok)
if ntok and ntok != "}":
jbuf += ","
jbuf += "\n"
@@ -115,12 +152,14 @@ def vdf2json(path: Path) -> str:
jbuf += _istr(indent, tok + ": {\n")
indent += 1
else:
jbuf += _istr(indent, tok + ": " + ntok)
ntok = lex.get_token()
lex.push_token(ntok)
if ntok != "}":
jbuf += ","
jbuf += "\n"
if ntok is not None:
jbuf += _istr(indent, tok + ": " + ntok)
ntok = lex.get_token()
if ntok is not None:
lex.push_token(ntok)
if ntok != "}":
jbuf += ","
jbuf += "\n"
def gen_shortcut() -> None:
@@ -130,8 +169,8 @@ def gen_shortcut() -> None:
show user account name, select outer steam id
steam/userdata/<steamid_32>/config/shortcuts.vdf
"""
#STEAMID_MAGIC = 76561197960265728
# STEAMID_MAGIC = 76561197960265728
# STEAMID_64 - STEAMID_MAGIC = STEAMID32
# or get right-most 32 bits
#STEAMID_64 & 0xFFFFFFFF
# STEAMID_64 & 0xFFFFFFFF
pass
+30 -10
View File
@@ -1,12 +1,14 @@
import json
import shlex
from pathlib import Path
from typing import Any
"""
Convert legacy dztuirc to config.json
"""
class UnsupportedKey(Exception):
pass
@@ -33,40 +35,58 @@ def rc2json(file: Path) -> str:
lex = shlex.shlex(f.read())
lex.whitespace += "="
d = {}
keys: dict[str, Any] = {}
ips: list[str] = []
toggles = ["auto_install", "debug", "fullscreen"]
deprecated = ["staging_dir", "src_path", "steam_path"]
toggles = ["fullscreen"]
deprecated = [
"staging_dir",
"src_path",
"steam_path",
"debug",
"branch",
"auto_install",
]
while True:
tok = lex.get_token()
ntok = lex.get_token()
value: str | bool
if ntok is not None:
ntok = ntok.strip('""')
value = ntok
if tok in deprecated:
continue
elif tok in toggles:
ntok = str2bool(ntok)
if ntok is not None:
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().strip('""')
ntok = lex.get_token()
if ntok is not None:
ntok = ntok.strip('""')
value = ntok
if ntok == ")":
break
ips.append(ntok)
# TODO: make test for this
# NOTE: strip malformed records from ancient config file versions
if ntok is not None:
if len(ntok.split(":")) == 3 and ntok.split(":")[2] != "":
ips.append(ntok)
continue
if not tok:
break
d[tok] = ntok
keys[tok] = value
d["ip_list"] = ips
d["use_miles"] = False
return json.dumps(d, indent=2)
keys["ip_list"] = ips
keys["use_miles"] = False
keys["start_tab"] = 0
return json.dumps(keys, indent=2)
+1 -24
View File
@@ -1,7 +1,7 @@
import shutil
import textwrap
from pathlib import Path
def write_desktop_file(share_path: Path) -> None:
template = f"""\
[Desktop Entry]
@@ -16,26 +16,3 @@ def write_desktop_file(share_path: Path) -> None:
file = share_path / "dzgui.desktop"
file.write_text(textwrap.dedent(template))
def write_shim_file(share_path: Path) -> None:
# FIXME: expects sys.prefix to be a venv
file = share_path / "launch.sh"
template=f"""\
source {venv}/bin/activate
python3.13 dzgui
"""
try:
file.write_text(textwrap.dedent(template))
file.chmod(file.stat().st_mode | stat.S_IEXEC)
except OSError as e:
logger.critical(e)
return
clear_old_path()
write_desktop_file(share_path)
def clear_old_path() -> None:
home = Path.home()
path = home / ".local/share/dzgui"
shutil.rmtree(path)
+8 -6
View File
@@ -6,10 +6,11 @@ import shutil
from pathlib import Path
from dzgui.const.constants import REQUEST_TIMEOUT
from dzgui.const.constants import APP_NAME, REQUEST_TIMEOUT
from dzgui.const.endpoints import DB_IP
logger = logging.getLogger(__name__)
logger = logging.getLogger(APP_NAME)
def find_download_url(url: str) -> str | None:
res = requests.get(url, timeout=REQUEST_TIMEOUT)
@@ -20,6 +21,7 @@ def find_download_url(url: str) -> str | None:
return None
return str(urls[0])
def get_ipdb(ips_path: Path) -> None:
url = find_download_url(DB_IP)
if url is None:
@@ -28,10 +30,10 @@ def get_ipdb(ips_path: Path) -> None:
date = find_date(url)
month_file = ips_path.parent / ".month"
if ips_path.exists() and month_file.exists():
old_date = month_file.read_text().rstrip("\n")
if old_date == date:
logger.info(f"IP DB date matches: {date}")
return
old_date = month_file.read_text().rstrip("\n")
if old_date == date:
logger.info(f"IP DB date matches: {date}")
return
# TODO: log additional output
logger.info(f"Fetching IPDB for {date} from {url}")
+14 -12
View File
@@ -23,32 +23,32 @@ def lookup(path: Path, enum: Preferences) -> Any:
return None
def get_config(path: Path) -> dict:
# TODO: is this being called twice?
def get_config(path: Path) -> Any:
# TODO: is this being called multiple times?
try:
json = read_json(path)
return json
except Exception as e:
raise e
return json
def get_favorites(path: Path) -> list[str]:
try:
conf = get_config(path)
except Exception as e:
except Exception:
pass
return conf["ip_list"]
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 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 enum.dict["key"]
return str(enum.dict["key"])
def get_client_index(client: str) -> int:
@@ -56,5 +56,7 @@ def get_client_index(client: str) -> int:
return 0
elif client == FLATPAK_RUN_CMD:
return 1
else:
elif client == FLATPAK_SANDBOX:
return 2
else:
return 0
+2 -1
View File
@@ -1,6 +1,7 @@
import shutil
# import shutil
from pathlib import Path
def uninstall(path: Path) -> None:
pass
# TODO: use xdg object?
+12 -6
View File
@@ -1,13 +1,19 @@
from dataclasses import dataclass
from dzgui.config.xdg import Xdg
from dzgui.util.ip import Coords
from typing import TYPE_CHECKING, Union
@dataclass(slots=True, frozen=True)
if TYPE_CHECKING:
from dzgui.config.xdg import Xdg
from dzgui.util.ip import Coords
# NOTE: mutable dataclass, 'use_miles' key changes on demand
@dataclass(slots=True)
class UserPrefs:
is_steam_deck: bool
is_game_mode: bool
is_developer: bool
coords: Coords | None
is_debug: bool
coords: Union["Coords", None]
version: str
allow_updates: bool
paths: Xdg
paths: "Xdg"
use_miles: bool
+4 -1
View File
@@ -54,7 +54,10 @@ def get_xdg_paths() -> dict:
resolved_paths = {}
for path in xdg_paths:
rp = os.environ.get(path)
resolved_paths[path] = Path(rp) / APP_NAME_LOWER if is_writeable(rp) else xdg_paths[path] / APP_NAME_LOWER
if rp is not None and is_writeable(rp):
resolved_paths[path] = Path(rp)
else:
resolved_paths[path] = xdg_paths[path] / APP_NAME_LOWER
return resolved_paths
+19 -5
View File
@@ -4,10 +4,11 @@ UDP_PORT = 27016
VM_FILE = "/proc/sys/vm/max_map_count"
MIN_COUNT = 1048576
REQUEST_TIMEOUT = 3
REQUEST_TIMEOUT = 10
APPNAME_DAYZ = "DayZ"
APPNAME_DAYZ_EXP = "DayZ Experimental"
APPNAME_DAYZ_EXP = "DayZ Exp"
APPNAME_DAYZ_EXP_HUMAN = "DayZ Experimental"
DAYZ_BINARY = "DayZ_x64.exe"
LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf"
@@ -30,15 +31,22 @@ APP_NAME_ABBR = "dzg"
HEX_RED = "#FF0000"
HEX_ORANGE = "#FFAC1C"
CLIPBOARD = "edit-copy-symbolic"
HELP_BUBBLE = "help-about-symbolic"
INPUT_KEYBOARD = "input-keyboard-symbolic"
LIST_ADD = "list-add-symbolic"
REFRESH_ICON = "view-refresh-symbolic"
SEARCH_ICON = "system-search-symbolic"
STEAM_ICON = "steam_tray_mono"
VIEW_CONCEAL = "view-conceal-symbolic"
VIEW_REVEAL = "view-reveal-symbolic"
INPUT_KEYBOARD = "input-keyboard-symbolic"
HELP_BUBBLE = "help-about-symbolic"
REFRESH_ICON = "view-refresh-symbolic"
ERROR = "dialog-error-symbolic"
WARNING = "dialog-warning-symbolic"
WEB_BROWSER = "web-browser-symbolic"
SEPARATOR = "SEPARATOR"
# TODO: drop
NO_PADDING = 0
NO_EXPAND = False
NO_FILL = False
@@ -55,3 +63,9 @@ LEGACY_IPS_PATH = ".local/share/dzgui/helpers/ips.csv"
DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG"
SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG"
CHANGELOG_PATH = "data/CHANGELOG.md"
CSS_PATH = "data/app.css"
LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG")
# TODO: drop
FOREGROUND_CMDS = ("wmctrl", "xdotool")
+1
View File
@@ -1,4 +1,5 @@
# internal
STEAM_PUBLISHED_FILES = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/?format=json"
STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1/?"
BM_SERVERS = "https://api.battlemetrics.com/servers?"
GITHUB = "https://github.com/aclist"
+97 -233
View File
@@ -1,5 +1,5 @@
from enum import Enum
from typing import Self
from typing import Any, Self
from dzgui.util import strings
@@ -9,25 +9,23 @@ class Port(Enum):
CUSTOM = 2
class ServerTab(Enum):
BROWSER = 0
SAVED = 1
RECENT = 2
LAN = 3
class Popup(Enum):
WAIT = 1
NOTIFY = 2
CONFIRM = 3
ENTRY = 4
RETURN = 5
MODLIST = 6
DETAILS = 7
QUIT = 8
class Command(Enum):
INTERACTIVE = 1
ONESHOT = 2
HELP = 3
TOGGLE = 4
THANKS = 5
class VAdjustment(Enum):
UP = 1
DOWN = 2
@@ -43,15 +41,13 @@ class CursorPosition(Enum):
class FilterMode(Enum):
KEYWORD = 1
MAP = 2
INITIAL = 3
TOGGLE_OFF = 4
TOGGLE_ON = 5
INITIAL = 1
TOGGLE_OFF = 2
TOGGLE_ON = 3
class EnumWithAttrs(Enum):
def __new__(cls, *args, **kwargs) -> Self:
def __new__(cls, *args: Any, **kwargs: Any) -> Self:
value = len(cls.__members__) + 1
obj = object.__new__(cls)
obj._value_ = value
@@ -60,274 +56,141 @@ class EnumWithAttrs(Enum):
def __init__(self, d: dict):
self.dict = d
class Preferences(EnumWithAttrs):
STEAM = {"key": "steam_api",}
BM = {"key": "bm_api",}
CLIENT = {"key": "client",}
WINDOW = {"key": "fullscreen",}
DIST = {"key": "use_miles",}
NAME = {"key": "name",}
INSTALL = {"key": "auto_install",}
DEBUG = {"key": "debug",}
DEFAULT = {"key": "default_steam_path",}
FAV_LBL = {"key": "fav_label",}
FAV_SRV = {"key": "fav_server",}
IP_LIST = {"key": "ip_list",}
BRANCH = {"key": "branch",}
STEAM = {
"key": "steam_api",
}
BM = {
"key": "bm_api",
}
CLIENT = {
"key": "client",
}
WINDOW = {
"key": "fullscreen",
}
DIST = {
"key": "use_miles",
}
NAME = {
"key": "name",
}
INSTALL = {
"key": "auto_install",
}
DEBUG = {
"key": "debug",
}
DEFAULT = {
"key": "default_steam_path",
}
FAV_LBL = {
"key": "fav_label",
}
FAV_SRV = {
"key": "fav_server",
}
IP_LIST = {
"key": "ip_list",
}
BRANCH = {
"key": "branch",
}
START_TAB = {
"key": "start_tab",
}
class NotebookPage(EnumWithAttrs):
CHANGELOG = {"crumbs": strings.crumbs.changelog, "statusbar": False}
KEYS = {"crumbs": strings.crumbs.keys, "statusbar": False}
LOG = {"crumbs": strings.crumbs.log, "statusbar": False}
CHANGELOG = {"crumbs": strings.crumbs.changelog, "statusbar": True}
DEVELOPERS = {"crumbs": strings.crumbs.developers, "statusbar": False}
HELP = {"crumbs": strings.crumbs._help, "statusbar": True}
KEYS = {"crumbs": strings.crumbs.keys, "statusbar": True}
LOG = {"crumbs": strings.crumbs.log, "statusbar": True}
MODS = {"crumbs": strings.crumbs.mods, "statusbar": True}
OPTIONS = {"crumbs": strings.crumbs.options, "statusbar": False}
SERVERS = {"crumbs": strings.crumbs.servers, "statusbar": True}
THANKS = {"crumbs": strings.crumbs.thanks, "statusbar": False}
DEVELOPERS = {"crumbs": strings.crumbs.developers, "statusbar": False}
THANKS = {"crumbs": strings.crumbs.thanks, "statusbar": True}
CONNECTION = {"crumbs": "Connect", "statusbar": True}
class RowType(EnumWithAttrs):
@classmethod
def str2rowtype(cls, string: str) -> "RowType":
for member in cls:
if string == member.dict["label"]:
return member
return RowType.DYNAMIC
# specialized behavior
# TODO: deprecated
DYNAMIC = {
"label": None,
"tooltip": None,
}
RESOLVE_IP = {
"label": "Resolve IP",
"tooltip": None,
"wait_msg": "Resolving remote IP",
}
HIGHLIGHT = {
"label": "Highlight stale",
"tooltip": None,
"wait_msg": "Looking for stale mods",
}
# pages
SERVER_BROWSER = {
"label": "Server browser",
"tooltip": "Used to browse the global server list",
"type": "server",
}
SAVED_SERVERS = {
"label": "My saved servers",
"tooltip": "Browse your saved servers. Unreachable servers will be excluded",
"type": "server",
}
RECENT_SERVERS = {
"label": "Recent servers",
"tooltip": "Shows the last 10 servers you connected to (includes attempts)",
"type": "server",
}
SCAN_LAN = {
"label": "Scan LAN servers",
"tooltip": "Search for servers on your local network",
"type": "server",
}
LIST_MODS = {
"label": "Mods",
"tooltip": "Browse a list of locally-installed mods",
"quad_label": "Mods",
"type": "mods",
}
# TODO: strings
CHANGELOG = {
"label": "View changelog",
"tooltip": "Opens the DZGUI changelog",
}
OPTIONS = {"label": "Options", "tooltip": None}
KEYBINDINGS = {"label": "Keybindings", "tooltip": None}
SHOW_LOG = {
"label": "Show debug log",
"tooltip": "Read the DZGUI log generated since startup",
"quad_label": "Debug log",
}
# interactive dialogs
CONN_BY_IP = {
"label": "Connect by IP",
"tooltip": "Connect to a server by IP",
"prompt": "Enter IP in IP:Queryport format (e.g. 192.168.1.1:27016)",
"link_label": None,
"type": Command.INTERACTIVE,
}
CONN_BY_ID = {
"label": "Connect by ID",
"tooltip": "Connect to a server by Battlemetrics ID",
"prompt": "Enter server ID",
"link_label": "Open Battlemetrics",
"type": Command.INTERACTIVE,
}
SEPARATOR = {
"label" : "SEPARATOR",
"label": "SEPARATOR",
"tooltip": "",
}
ADD_BY_IP = {
"label": "Add server by IP",
"tooltip": "Add a server by IP",
"prompt": "Enter IP in IP:Queryport format (e.g. 192.168.1.1:27016)",
"link_label": None,
"type": Command.INTERACTIVE,
}
ADD_BY_ID = {
"label": "Add server by ID",
"tooltip": "Add a server by Battlemetrics ID",
"prompt": "Enter server ID",
"link_label": "Open Battlemetrics",
"type": Command.INTERACTIVE,
}
CHNG_FAV = {
"label": "Change favorite server",
"tooltip": "Update your quick-connect server",
"prompt": "Enter IP in IP:Queryport format (e.g. 192.168.1.1:27016)",
"link_label": None,
"alt": None,
"default": "unset",
"val": "fav_label",
"type": Command.INTERACTIVE,
}
CHNG_PLAYER = {
"label": "Change player name",
"tooltip": "Update your in-game name (required by some servers)",
"prompt": "Enter new nickname",
"link_label": None,
"alt": None,
"default": None,
"val": "name",
"type": Command.INTERACTIVE,
}
CHNG_STEAM_API = {
"label": "Change Steam API key",
"tooltip": "Can be used if you revoked an old API key",
"prompt": "Enter new API key",
"link_label": "Open Steam API page",
"type": Command.INTERACTIVE,
}
CHNG_BM_API = {
"label": "Change Battlemetrics API key",
"tooltip": "Can be used if you revoked an old API key",
"link_label": "Open Battlemetrics API page",
"prompt": "Enter new API key",
"type": Command.INTERACTIVE,
}
# oneshot commands
QUICK_CONNECT = {
"label": "Quick-connect to favorite server",
"tooltip": "Connect to your favorite server",
"wait_msg": "Working",
"default": "unset",
"alt": None,
"val": "fav_label",
"type": Command.ONESHOT,
}
FORCE_UPDATE = {
"label": "Force update local mods",
"tooltip": "Synchronize local mods with remote versions (experimental)",
"wait_msg": "Updating mods",
"type": Command.ONESHOT,
}
DUMP_LOG = {
"label": "Output system info to log file",
"tooltip": "Dump diagnostic data for troubleshooting",
"wait_msg": "Generating log",
"type": Command.ONESHOT,
}
HANDSHAKE = {
"label": "Handshake",
"tooltip": None,
"wait_msg": "Waiting for DayZ",
"type": Command.ONESHOT,
}
HANDSHAKE_EXP = {
"label": "Handshake_EXP",
"tooltip": None,
"wait_msg": "Waiting for DayZ",
"type": Command.ONESHOT,
}
DELETE_SELECTED = {
"label": "Delete selected mods",
"tooltip": None,
"wait_msg": "Deleting mods",
"type": Command.ONESHOT,
}
# help pages
DOCS = {
"label": "Documentation/help files (GitHub) ⧉",
"tooltip": "Open the DZGUI documentation in a browser",
"type": Command.HELP,
}
DOCS_FALLBACK = {
"label": "Documentation/help files (Codeberg mirror) ⧉",
"tooltip": "Open the DZGUI documentation in a browser",
"type": Command.HELP,
}
BUGS = {
"label": "Report a bug (GitHub) ⧉",
"tooltip": "Open the DZGUI issue tracker in a browser",
"type": Command.HELP,
}
FORUM = {
"label": "DZGUI Subreddit ⧉",
"tooltip": "Open the DZGUI discussion forum in a browser",
"type": Command.HELP,
}
SPONSOR = {
"label": "Sponsor (GitHub) ⧉",
"tooltip": "Sponsor development of the DZGUI project",
"type": Command.HELP,
}
THANKS = {
"label": "Special thanks",
"tooltip": "A list of contributors, testers, and sponsors",
"type": Command.THANKS,
}
# TODO: rename to ContextItem
# TODO: rename to ContextMenuItem
class ContextMenu(EnumWithAttrs):
ADD_SERVER = {"label": strings.add}
ADD_FAV = {"label": strings.add_fav}
REMOVE_SERVER = {"label": strings.remove}
COPY_NAME = {"label": strings.copy_name}
COPY_CLIPBOARD = {"label": strings.copy_ip}
ADD_NOTE = {"label": strings.add_note}
SHOW_MODS = {"label": strings.show_mods}
SHOW_DETAILS = {"label": strings.show_details}
ADD_SERVER = {"label": strings.add}
CONNECT = {"label": strings.connect}
COPY_LOG_CLIPBOARD = {"label": strings.copy_log}
COPY_SERVER_IP = {"label": strings.copy_ip}
COPY_SERVER_NAME = {"label": strings.copy_name}
DELETE_MOD = {"label": strings.delete_mod}
OPEN_WORKSHOP = {"label": strings.open_workshop}
REFRESH_PLAYERS = {"label": strings.refresh_players}
REMOVE_HISTORY = {"label": strings.remove_history}
OPEN_WORKSHOP = {"label": strings.open_workshop}
DELETE_MOD = {"label": strings.delete_mod}
COPY_LOG_CLIPBOARD = {"label": strings.copy_log}
CONNECT = {"label": strings.connect}
REMOVE_SERVER = {"label": strings.remove}
SET_FAV = {"label": strings.add_fav}
SHOW_DETAILS = {"label": strings.show_details}
SHOW_MODS = {"label": strings.show_mods}
class ContextMenuGroup(Enum):
"""
Groupings of context menu items
"""
MOD = (
ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD
)
LOG = (
ContextMenu.COPY_LOG_CLIPBOARD,
)
SERVER_MOD = (ContextMenu.OPEN_WORKSHOP,)
MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD)
LOG = (ContextMenu.COPY_LOG_CLIPBOARD,)
SERVER_BROWSER = (
ContextMenu.CONNECT,
ContextMenu.ADD_SERVER,
ContextMenu.ADD_FAV,
ContextMenu.COPY_NAME,
ContextMenu.COPY_CLIPBOARD,
ContextMenu.SET_FAV,
ContextMenu.COPY_SERVER_NAME,
ContextMenu.COPY_SERVER_IP,
ContextMenu.ADD_NOTE,
ContextMenu.SHOW_MODS,
ContextMenu.SHOW_DETAILS,
@@ -335,9 +198,9 @@ class ContextMenuGroup(Enum):
)
SCAN_LAN = (
ContextMenu.CONNECT,
ContextMenu.ADD_FAV,
ContextMenu.COPY_NAME,
ContextMenu.COPY_CLIPBOARD,
ContextMenu.SET_FAV,
ContextMenu.COPY_SERVER_NAME,
ContextMenu.COPY_SERVER_IP,
ContextMenu.ADD_NOTE,
ContextMenu.SHOW_MODS,
ContextMenu.SHOW_DETAILS,
@@ -345,10 +208,10 @@ class ContextMenuGroup(Enum):
)
SAVED = (
ContextMenu.CONNECT,
ContextMenu.ADD_FAV,
ContextMenu.SET_FAV,
ContextMenu.REMOVE_SERVER,
ContextMenu.COPY_NAME,
ContextMenu.COPY_CLIPBOARD,
ContextMenu.COPY_SERVER_NAME,
ContextMenu.COPY_SERVER_IP,
ContextMenu.ADD_NOTE,
ContextMenu.SHOW_MODS,
ContextMenu.SHOW_DETAILS,
@@ -357,16 +220,17 @@ class ContextMenuGroup(Enum):
RECENT = (
ContextMenu.CONNECT,
ContextMenu.ADD_SERVER,
ContextMenu.ADD_FAV,
ContextMenu.SET_FAV,
ContextMenu.REMOVE_HISTORY,
ContextMenu.COPY_NAME,
ContextMenu.COPY_CLIPBOARD,
ContextMenu.COPY_SERVER_NAME,
ContextMenu.COPY_SERVER_IP,
ContextMenu.ADD_NOTE,
ContextMenu.SHOW_MODS,
ContextMenu.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS,
)
class ModButton(EnumWithAttrs):
SELECT_ALL = {
"label": strings.mod_panel.select_all,
@@ -374,7 +238,7 @@ class ModButton(EnumWithAttrs):
}
UNSELECT_ALL = {
"label": strings.mod_panel.unselect_all,
"tooltip": strings.mod_panel.unselect_all_tooltip
"tooltip": strings.mod_panel.unselect_all_tooltip,
}
HIGHLIGHT_STALE = {
"label": strings.mod_panel.highlight_stale,
@@ -389,8 +253,8 @@ class ModButton(EnumWithAttrs):
"tooltip": strings.mod_panel.delete_selected_tooltip,
}
SELECT_STALE = {
"label": strings.mod_panel.select_stale,
"tooltip": strings.mod_panel.select_stale_tooltip,
"label": strings.mod_panel.select_stale,
"tooltip": strings.mod_panel.select_stale_tooltip,
}
@@ -408,8 +272,8 @@ class ButtonType(EnumWithAttrs):
OPTIONS = {
"label": strings.buttons.options_label,
"tooltip": strings.buttons.options_tooltip,
"opens": NotebookPage.OPTIONS
}
"opens": NotebookPage.OPTIONS,
}
HELP = {
"label": strings.buttons.help_label,
"tooltip": strings.buttons.help_tooltip,
+152
View File
@@ -0,0 +1,152 @@
from typing import TYPE_CHECKING
import gi
gi.require_version("Gtk", "3.0")
from gi.repository.Gtk import ListStore # noqa E402
from gi.repository import Gdk, GObject # noqa E402
if TYPE_CHECKING:
from dzgui.const.enum import NotebookPage, ServerTab
# TODO: if servers fail to load, may leave dangling widgets waiting for a signal
class Emitter(GObject.GObject):
def __init__(self) -> None:
super().__init__()
# TODO: rename signals to e.g. maps_keybinding_pressed
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def request_keyword_focus(self) -> None:
"""User invoked Ctrl-f keybinding from ServerTreeView"""
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def saved_servers_changed(self) -> None:
"""Record was added/removed from Saved Servers model"""
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def request_maps_focus(self) -> None:
"""User invoked Ctrl-m keybinding from ServerTreeView"""
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def request_ip_entry_focus(self) -> None:
"""User invoked Ctrl-i keybinding from ServerTreeView"""
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def request_custom_port_focus(self) -> None:
"""User invoked Ctrl-n keybinding from ServerTreeView"""
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def request_default_port_focus(self) -> None:
"""user invoked ctrl-d keybinding from servertreeview"""
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def request_button_box_focus(self) -> None:
"""User invoked right movement keybinding from ServerTreeView"""
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str, str))
def fav_server_changed(self, name: str, addr: str) -> None:
"""Change favorite server via context menu"""
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(int,))
def check_button_pressed(self, keyval: int) -> None:
"""User toggled filter panel check button via keyboard"""
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def server_page_changed(self, keyval: int) -> None:
"""Triggered on 'switch-page' signal within tabs of NotebookPage.SERVERS."""
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,))
def server_page_toggled(self, keyval: int) -> None:
"""Triggered on map/unmap signal from NotebookPage.SERVERS. Shows/hides grid panels."""
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def statusbar_loaded(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def distcalc_started(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,))
def mod_page_toggled(self, state: bool) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,))
def lan_tab_toggled(self, state: bool) -> None:
pass
@GObject.Signal(
flags=GObject.SignalFlags.RUN_LAST,
arg_types=(
object,
object,
),
)
def distcalc_ended(
self, dist: str | None, context: "ServerTab | NotebookPage"
) -> None:
pass
@GObject.Signal(
flags=GObject.SignalFlags.RUN_LAST,
arg_types=(
str,
bool,
),
)
def check_toggled(self, label: str, state: bool) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def servers_loaded(self, tag: "ServerTab") -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def servers_loaded_init(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str,))
def map_selection_changed(self, map: str) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def load_maps(self, store: ListStore) -> None:
pass
@GObject.Signal(
flags=GObject.SignalFlags.RUN_LAST,
arg_types=(
str,
int,
),
)
def mods_updated(self, msg: str, mods: int) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def mods_highlighted(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def api_change_failed(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def already_saved_server(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str,))
def keyword_set(self, keyword: str) -> None:
pass
+356 -533
View File
File diff suppressed because it is too large. Load diff
-353
View File
@@ -1,353 +0,0 @@
import re
from dataclasses import dataclass
from typing import Self
from dzgui.const.enum import FilterMode, HELP_MENU_ROWS
from dzgui.util import strings
import gi
gi.require_version("Gtk", "3.0")
from gi.repository.Gtk import ListStore # noqa E402
from gi.repository import GObject, GLib # noqa E402
@dataclass
@dataclass(slots=True, frozen=True)
class ServerColumns:
name: str
_map: str
perspective: str
gametime: str
players: int
_max: int
queue: int
ip: str
qport: int
ping: int
provider: str
modded: bool
@dataclass(slots=True, frozen=True)
class ModCols:
name: str
symlink: str
directory: str
size: float
color: str
@dataclass(slots=True, frozen=True)
class LogCols:
timestamp: str
flag: str
traceback: str
msg: str
@dataclass(slots=True, frozen=True)
class ServerModCols:
name: str
uid: GObject.TYPE_INT64
installed: str
@dataclass(slots=True, frozen=True)
class MenuCols:
name: str
hidden: GObject.TYPE_PYOBJECT
class ModelManager:
"""
Manages access to cached ListStore resources and
performs filtering on behalf of TreeViews.
Methods are not thread-safe in themselves.
"""
def __init__(self) -> None:
self.filter_cache: tuple
self.ping_cache: dict[str, int] = {}
self.map_store = ListStore(str)
self.help_store = self.new_model_from_class(MenuCols)
self.mod_store = self.new_model_from_class(ModCols)
self.log_store = self.new_model_from_class(LogCols)
self.modlist_store = self.new_model_from_class(ServerModCols)
self.server_store = self.new_model()
self.saved_store = self.new_model()
self.recent_store = self.new_model()
self.lan_store = self.new_model()
for row in HELP_MENU_ROWS:
label = row.dict["label"]
self.help_store.append([label, row])
self.control_model = None
self.filtered = None
self.success = True
#def __new__(cls) -> Self:
# if not hasattr(cls, "instance"):
# cls.instance = super(ModelManager, cls).__new__(cls)
# return cls.instance
def get_recent_store(self) -> ListStore:
return self.recent_store
def get_lan_store(self) -> ListStore:
return self.lan_store
def get_saved_store(self) -> ListStore:
return self.saved_store
def get_server_store(self) -> ListStore:
return self.server_store
def new_model_from_class(self, cls: type) -> ListStore:
store = ListStore(*[ftype for field, ftype in cls.__annotations__.items()])
return store
def get_map_store(self) -> ListStore:
return self.map_store
#def get_row_store(self) -> ListStore:
# return self.row_store
def get_help_store(self) -> ListStore:
return self.help_store
def get_mod_store(self) -> ListStore:
return self.mod_store
def get_modlist_store(self) -> ListStore:
return self.modlist_store
def get_log_store(self) -> ListStore:
return self.log_store
def filter(self, mode: FilterMode, *args, **kwargs) -> None:
"""
Native Gtk.TreeView.refilter() method was not performant enough
when running in the main loop with 40k+ records
"""
filters = AppNav.right_panel.filters_vbox.get_filters()
if filters in self.filter_cache:
cache = self.filter_cache[filters]
self.set_store(cache[0])
self.set_filtered(cache[1])
GLib.idle_add(AppNav.treeview._filter_cleanup)
return
match mode:
case FilterMode.INITIAL:
rows = self.filter_initial(filters)
case FilterMode.MAP:
panel = AppNav.right_panel.filters_vbox
prior_map = panel.get_prior_map()
if prior_map == "All maps":
rows = self.filter_map(filters)
else:
rows = self.filter_toggle_on(filters, *args)
case FilterMode.KEYWORD:
rows = self.filter_toggle_on(filters, *args)
case FilterMode.TOGGLE_OFF:
for f in filters[2:]:
self.set_filtered(self.filter_toggle_off(filters, f))
rows = self.filtered
case FilterMode.TOGGLE_ON:
rows = self.filter_toggle_on(filters, *args)
if mode is not FilterMode.INITIAL:
for row in rows:
if row[7] in self.ping_cache:
row[9] = self.ping_cache[row[7]]
if len(rows) > 0:
clone = self.new_model()
rows = self.sort_rows(rows)
for row in rows:
clone.append(row)
else:
clone = None
self.set_cache(filters, clone, rows)
self.set_store(clone)
GLib.idle_add(AppNav.treeview._filter_cleanup)
def sort_rows(self, rows: list) -> list:
rows.sort(key=lambda x: re.sub(r"[^A-Za-z0-9]+", "", x[0].lower()))
return rows
def filter_initial(self, filters: tuple) -> list:
"""
Simply culls the control model of any disabled filters
"""
self.set_filtered(self.control_model)
for f in filters[2:]:
self.set_filtered(self.filter_toggle_off(filters, f))
return self.filtered
def filter_map(self, filters: tuple) -> list:
"""
Multi-filtration for any context starts by narrowing by map
"""
rows = self.filtered
panel = AppNav.right_panel.filters_vbox
sel_map = panel.get_selected_map()
if sel_map == "All maps":
return rows
rows = [row for row in rows if row[1] == sel_map]
return rows
def filter_keyword(self, filters: tuple) -> list:
keyword = AppNav.right_panel.filters_vbox.get_keyword_filter()
rows = self.filtered
if keyword == "":
return rows
filtered = [
row
for row in rows
if keyword in row[0].lower()
or keyword in row[1].lower()
or keyword in row[7].lower()
]
return filtered
def filter_toggle_off(self, filters: tuple, filter_type: str) -> list:
"""
Sub-filtration of the current model
"""
pairs = {
strings.filter_3pp: strings.filter_1pp,
strings.filter_day: strings.filter_night,
strings.filter_official: strings.filter_unofficial
}
for k, v in pairs.items():
if k in filters and v in filters:
self.set_filtered(None)
return []
rows = self.filtered
match filter_type:
case strings.filter_3pp:
rows = [row for row in rows if row[2] != strings.filter_3pp]
case strings.filter_1pp:
rows = [row for row in rows if row[2] != strings.filter_1pp]
case strings.filter_official:
rows = [row for row in rows if row[10] != strings.filter_official]
case strings.filter_unofficial:
rows = [row for row in rows if row[10] != strings.filter_unofficial]
case strings.filter_empty:
rows = [row for row in rows if row[4] != 0]
case strings.filter_full:
rows = [row for row in rows if row[4] != row[5]]
case strings.filter_duplicate:
seen = []
final = []
for row in rows:
if row[0] in seen:
continue
seen.append(row[0])
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])]
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])]
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]]
return rows
def filter_toggle_on(self, filters: tuple, *args: str) -> list:
"""Effectively applies all filters"""
self.set_filtered(self.control_model)
self.set_filtered(self.filter_map(filters))
self.set_filtered(self.filter_keyword(filters))
for f in filters[2:]:
self.set_filtered(self.filter_toggle_off(filters, f))
return self.filtered
def set_cache(
self, filters: tuple, model: ListStore | None, rows: list
) -> None:
self.filter_cache[filters] = (model, rows)
def new_model(self) -> ListStore:
store = self.new_model_from_class(ServerColumns)
return store
def resync_model(self, addr: str, qport: int) -> None:
"""
Handle in-situ updates to model during
row deletion actions. Skipped for ephemeral
actions like player count/ping updates
"""
for row in self.control_model:
if row[7] == addr and row[8] == qport:
self.control_model.remove(row)
self.wipe_cache()
filters = AppNav.right_panel.filters_vbox.get_filters()
refiltered = self.filter_toggle_on(filters)
self.set_filtered(refiltered)
self.set_success(True)
GLib.idle_add(AppNav.treeview._filter_cleanup)
def convert_model_to_list(self, model: ListStore) -> list:
return [[el for el in row] for row in model]
def set_filtered(self, rows: list | None) -> None:
if rows is None:
rows = []
self.filtered = rows
def get_filtered(self) -> list:
return self.filtered
def set_store(self, model: ListStore | None) -> None:
self.store = model
def get_store(self) -> ListStore | None:
return self.store
def set_control(self, rows: list) -> None:
self.control_model = rows
def set_success(self, result: bool) -> None:
self.success = result
def get_success(self) -> bool:
return self.success
def wipe_cache(self, full=False) -> None:
self.success = True
self.filtered = None
self.filter_cache = {}
self.ping_cache = {}
if full:
self.control_model = None
def set_all_maps(self) -> None:
self.map_store.clear()
self.map_store.append(["All maps"])
def append_map(self, row: list) -> None:
self.map_store.append(row)
+48 -6
View File
@@ -1,8 +1,7 @@
## Added
- Add pyproject.toml file
- requests, packaging.version, psutil
- Lockfile
- Changelog text wrapping and formatting
- Changelog ships with source
- Documentation ships with source
- Decouple UI components into modules
- Choose from kilometer or miles distance display
- More granular haversine calculation
@@ -10,17 +9,60 @@
- Dedicated mods page
- Dedicated thanks page
- Rearrange menus/breadcrumbs
- Documentation ships with source
- Open filepicker when generating system log
- Developers page (and -d flag)
- Redact API key in log table
- Integrate add/connect widgets into main menu
- Favorite/connect/LAN panels integrated with server views
- Abort on first LAN server found
- Colorized IP/ID validation fields
- Copy favorite server IP to clipboard
- Set favorite server from tables
- Detailed/copyable trace in critical error dialogs
- Visual icons
- Integrated server notebook
- Propagate width changes to all tables
- Remember tree position in menus
- Show hidden server count after filtering
- Atomic map filters per server context
- Use concurrency when checking stale mods (performance uplift)
- Copy version by clicking label
- Add server name to server mod dialogs
- Lazy load ping cell data func
- Refresh servers button
- Early load alerts button
- Preconnect dialog
## Changed
- Reduce padding on keys button
- Add icon to keys button
- Add icons to API buttons
- Boldface breadcrumbs
- Bold labels inside frames
- Sidebar buttos do not steal focus
- Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method
- Load new model into view without flushing
- Cull servers with abnormal queue values (integer overflow: 2147483647)
- Packaging structure
## Dropped
- Debug mode
- Branch switching
## Fixed
- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input
- Center server title text on server dialogs
- Rare segfaults when changing maps (threading)
- Moved dialogs out of threads
## Unreleased
- Load offline mods
- Choose to jump into splash screen instead of server
- Setup wizard
- Move debug mode to developers only
- Local documentation
## Developer-facing
- Add pyproject.toml file
- Show deprecation warnings
- Options -> Dev page
- Raw debug command
- Moved debug log to this mode
+37
View File
@@ -0,0 +1,37 @@
.invalid-entry {
border-color: red;
}
.frame {
border: 0px;
}
.toast-label {
background-color: black;
border: 1px solid white;
}
.frame > border {
border-radius: 5px;
padding: 5px;
}
.page-heading {
font-size: 1.5rem;
font-weight: 800;
margin-bottom: 0.5rem;
}
.settings-subheading {
font-size: 1.2rem;
font-weight: 700;
}
.left-label {
font-size: 1.3rem;
}
.details-heading {
font-size: 1.2rem
}
.preconnect-heading {
font-size: 1.2rem;
font-weight: 800;
}
.masked-tree {
background-color: transparent;
}
+2 -1
View File
@@ -1,12 +1,13 @@
import logging
from typing import TYPE_CHECKING
from dzgui.const.constants import APP_NAME
from dzgui.util.ip import get_local_ip, get_coords, Coords
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
logger = logging.getLogger(APP_NAME)
def get_local_coords(path: "Path") -> Coords | None:
+4 -3
View File
@@ -4,13 +4,14 @@ import sys
from pathlib import Path
from dzgui.config.query import lookup
from dzgui.const.constants import LIBRARYFOLDERS_PATH, APPID_DAYZ
from dzgui.const.constants import APPID_DAYZ, APP_NAME, LIBRARYFOLDERS_PATH
from dzgui.const.enum import Preferences
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
import dzgui.api.pefile as PeFile
logger = logging.getLogger(__name__)
logger = logging.getLogger(APP_NAME)
def is_dayz_installed(config: Path) -> None:
try:
@@ -18,5 +19,5 @@ def is_dayz_installed(config: Path) -> None:
PeFile.get_app_path(Path(path) / LIBRARYFOLDERS_PATH, APPID_DAYZ)
except Exception as e:
logger.critical(e)
EarlyAlertDialog(e)
EarlyAlertDialog(str(e))
sys.exit(1)
+3 -2
View File
@@ -7,12 +7,12 @@ from dzgui.config.convert import rc2json
from dzgui.util._json import read_json, write_json
# TODO: move to setup wizard
def migrate_legacy_conf(config: Path) -> None:
old_conf = Path.home() / LEGACY_CONFIG_PATH
if old_conf.is_file():
j = rc2json(old_conf)
# FIXME: superfluous use of Path()
Path(config).parent.mkdir(parents=True, exist_ok=True)
config.parent.mkdir(parents=True, exist_ok=True)
config.write_text(j)
else:
print("Unimplemented. You must have a working dztuirc.")
@@ -52,4 +52,5 @@ def copy_ipdb(ips_path: Path) -> None:
return
if legacy.is_file() is False:
return
# TODO: ensure that month file is copied
shutil.copy(legacy, ips_path)
+3 -6
View File
@@ -2,9 +2,9 @@ import logging
import shutil
import subprocess
from dzgui.const.constants import STEAM_CMD, FLATPAK_APPID, FLATPAK_CMD
from dzgui.const.constants import APP_NAME, FLATPAK_APPID, FLATPAK_CMD, STEAM_CMD
logger = logging.getLogger(__name__)
logger = logging.getLogger(APP_NAME)
def has_steam_client() -> bool:
@@ -14,10 +14,7 @@ def has_steam_client() -> bool:
return False
try:
res = subprocess.run(
[FLATPAK_CMD, "list"],
capture_output=True,
text=True,
check=True
[FLATPAK_CMD, "list"], capture_output=True, text=True, check=True
)
if FLATPAK_APPID in res.stdout:
return True
+72 -13
View File
@@ -1,22 +1,53 @@
import psutil
import sys
import subprocess
import shutil
from dzgui.const.constants import DAYZ_BINARY, STEAM_CMD
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
from dzgui.util.strings import init
from warnings import deprecated
from dzgui.const.constants import (
DAYZ_BINARY,
STEAM_CMD,
FLATPAK_APPID,
FLATPAK_CMD,
FLATPAK_RUN_CMD,
FLATPAK_SANDBOX,
)
def is_dayz_running() -> None:
if is_running(DAYZ_BINARY) is True:
EarlyAlertDialog(init.is_dayz_running)
sys.exit(1)
# TODO: move to util.proc
def is_dayz_running() -> bool:
"""Subprocesses spawned from Steam will not show up in regular process tree"""
procs = []
substring = DAYZ_BINARY
for proc in psutil.process_iter():
try:
procs.append(proc.cmdline())
except Exception:
continue
return any(substring in item for sublist in procs for item in sublist)
def is_steam_running() -> None:
# TODO: check proc name of flatpak steam
if is_running(STEAM_CMD) is False:
EarlyAlertDialog(init.is_steam_running)
sys.exit(1)
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:
return is_flatpak_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)
lines = proc.splitlines()
if FLATPAK_APPID in lines:
return True
return False
def is_running(proc: str) -> bool:
@@ -24,3 +55,31 @@ def is_running(proc: str) -> bool:
if process.info["name"] == proc:
return True
return False
def has_cmd(cmd: str) -> bool:
if shutil.which(cmd) is not None:
return True
return False
@deprecated("dropped in favor of Gtk native methods")
def foreground(cmd: str, pid: int) -> None:
if cmd == "wmctrl":
proc = subprocess.run(["wmctrl", "-ilp"], capture_output=True, text=True)
lines = proc.stdout.splitlines()
for line in lines:
els = line.split(" ")
if str(pid) in els:
wid = els[0]
break
subprocess.run(["wmctrl", "-ia", wid])
elif cmd == "xdotool":
args = [cmd, "search", "--pid", str(pid)]
proc = subprocess.run([*args], capture_output=True, text=True)
lines = proc.stdout.splitlines()
## NOTE: some forked subprocesses may fail, so skip over them
for line in lines:
subprocess.run(
["xdotool", "windowactivate", line], stderr=subprocess.DEVNULL
)
+12 -8
View File
@@ -4,16 +4,18 @@ import subprocess
import sys
from importlib import resources
from packaging.version import Version, InvalidVersion
from packaging.version import Version
from dzgui.const.constants import APP_NAME_LOWER, REQUEST_TIMEOUT
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, REQUEST_TIMEOUT
from dzgui.const.endpoints import GITHUB_RELEASES, CODEBERG_RELEASES
from dzgui.init.prefix import is_prefix_writeable
logger = logging.getLogger(__name__)
logger = logging.getLogger(APP_NAME)
def get_latest_release() -> str | None:
tag = None
# TODO: check order; github often has gateway errors
for url in [GITHUB_RELEASES, CODEBERG_RELEASES]:
try:
res = requests.get(url, timeout=REQUEST_TIMEOUT)
@@ -25,18 +27,20 @@ def get_latest_release() -> str | None:
continue
return tag
def allow_updates(allow: bool) -> bool:
if allow is False:
return False
if allow is True:
return is_prefix_writeable()
def check_updates(version: str) -> None:
latest = get_latest_release()
prefix = sys.prefix
if latest is None:
return
try:
latest = get_latest_release()
prefix = sys.prefix
if latest is None:
return
if Version(version) >= Version(latest):
return
@@ -50,5 +54,5 @@ def check_updates(version: str) -> None:
# TODO: pop a dialog
pass
sys.exit(proc)
except InvalidVersion:
except Exception:
return
+52 -31
View File
@@ -2,15 +2,18 @@ import argparse
import logging
import os
import sys
import warnings
from typing import TYPE_CHECKING
from dzgui.api.mods import remove_stale_signatures
from dzgui.const.constants import APP_NAME
from dzgui.const.enum import Preferences
from dzgui.config.ipdb import get_ipdb
from dzgui.config.query import lookup
from dzgui.config.userprefs import UserPrefs
from dzgui.config.xdg import get_xdg_paths, parse_filepaths
from dzgui.const.update import ALLOW_UPDATES
from dzgui.init.coords import get_local_coords
from dzgui.init.dayz import is_dayz_installed
from dzgui.init.flock import lock_acquire
@@ -22,9 +25,10 @@ from dzgui.init.migrate import (
)
from dzgui.init.prefix import get_version
from dzgui.init.prereqs import has_steam_client
from dzgui.init.proc import is_dayz_running, is_steam_running
from dzgui.init.update import allow_updates, check_updates
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.localize import set_locale
@@ -34,23 +38,23 @@ from dzgui.util.strings import init, flags
from dzgui.views.base import App
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
logger = logging.getLogger(__name__)
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", "--developers", action="store_true", help=flags.developers)
args = parser.parse_args()
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(APP_NAME)
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)
args = parser.parse_args()
# TODO: profile load time
def make_parents(path: "Path") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
def uninstall() -> None:
# TODO: uninstall data files (-u)
# -u removes state, log, freedesktop
@@ -59,16 +63,30 @@ def uninstall() -> None:
# XDG_DATA_HOME/dzgui
pass
def setup_logger(log_path: "Path") -> None:
_format = (
"%(asctime)s␞%(levelname)s␞%(filename)s::%(funcName)s::%(lineno)s␞%(message)s"
)
fh = logging.FileHandler(log_path)
formatter = logging.Formatter(_format)
fh.setFormatter(formatter)
fh.setLevel(logging.DEBUG)
logger.setLevel(logging.DEBUG)
logger.addHandler(fh)
def main() -> None:
lock = lock_acquire() # noqa
if args.version is True:
print(get_version())
sys.exit(0)
if args.uninstall is True:
uninstall()
sys.exit(0)
if args.debug is True:
warnings.filterwarnings("default", category=DeprecationWarning)
version = get_version()
set_locale()
@@ -84,12 +102,13 @@ def main() -> None:
make_parents(XDG.debug)
if has_new_config(XDG.config) is False:
# TODO: handle this in assistant
migrate_legacy_conf(XDG.config)
migrate_cols_file(XDG.columns)
# TODO: copy notes file
copy_state_files(xdg_paths["XDG_STATE_HOME"])
_format = "%(asctime)s␞%(levelname)s␞%(filename)s::%(funcName)s::%(lineno)s␞%(message)s"
logging.basicConfig(filename=XDG.debug, format=_format, level=logging.DEBUG)
setup_logger(XDG.debug)
with open(XDG.debug, "w") as f:
f.truncate(0)
@@ -104,33 +123,35 @@ def main() -> None:
if allow is True:
check_updates(version)
# TODO: config wizard: check has_steam_client() prior to VDF exploration
if _is_steam_deck is False:
# TODO: sudo escalation dialog
count = get_map_count()
# count = get_map_count()
# TODO: move into module
if has_steam_client() is False:
EarlyAlertDialog(init.requires_steam)
sys.exit(1)
is_dayz_installed(XDG.config)
is_dayz_running()
is_steam_running()
# NOTE: clear versions file of unlinked mods
rebuild_symlinks(XDG.config)
remove_stale_signatures(XDG.config, XDG.version)
is_dayz_installed(XDG.config)
# TODO: handle IP DB failure and use coords fallback
get_ipdb(XDG.ips)
local_coords = get_local_coords(XDG.ips)
use_miles = lookup(XDG.config, Preferences.DIST)
prefs = UserPrefs(
_is_steam_deck,
_is_game_mode,
args.developers,
local_coords,
version,
allow,
XDG
is_steam_deck=_is_steam_deck,
is_game_mode=_is_game_mode,
is_debug=args.debug,
coords=local_coords,
version=version,
allow_updates=allow,
paths=XDG,
use_miles=use_miles,
)
print("All OK. Loading UI...")
# TODO: drop allow updates
print(boot.all_ok)
App(prefs)
+219
View File
@@ -0,0 +1,219 @@
import logging
import traceback
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.const.enum import Preferences
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.views.dialogs.generic import ExceptionDialog
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
# import dzgui.api.servers as Servers
if TYPE_CHECKING:
from dzgui.config.userprefs import UserPrefs
from dzgui.controllers.mc import Controller
from dzgui.views.base import OuterWindow
from dzgui.views.trees.tree_servers import ServerTreeView
logger = logging.getLogger(APP_NAME)
class ConfigManager:
def __init__(self, prefs: "UserPrefs", controller: "Controller") -> None:
self.prefs = prefs
self.config = prefs.paths.config
self.controller = controller
self.emitter = controller.get_emitter()
self.thread_man = ThreadingManager(controller)
def lookup(self, enum: Preferences) -> Any:
key = self.enum_to_key(enum)
config = self.get_config()
try:
return config[key]
except KeyError:
return None
def add_saved_server(self, record: str) -> None:
ips = self.lookup(Preferences.IP_LIST)
ips.append(record)
self.update_config(Preferences.IP_LIST, ips)
def update_history_file(self, records: list[Any]) -> None:
with open(self.prefs.paths.history, "w") as f:
for record in records:
addr = f"{record[7]}:{record[8]}"
f.write(f"{addr}\n")
def remove_saved_server(self, record: str) -> None:
ips = self.lookup(Preferences.IP_LIST)
ips.remove(record)
self.update_config(Preferences.IP_LIST, ips)
# TODO: drop/reroute
def update_config(self, key: Preferences, value: str) -> None:
self.write_config(key, value)
# TODO: suppress signals
# then reenable (or it spawns dialog twice)
# TODO: do this on demand if/when in options page
# self.mediator.grid.notebook.settings.populate_settings()
def get_config(self) -> Any:
# TODO: is this being called twice?
try:
return read_json(self.config)
except Exception as e:
logger.critical(e)
raise e
# TODO: strings
@call_on_thread("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)
if res is True:
self.update_config(key, text)
else:
self.thread_man.set_cleanup_func(
StoredFunc(lambda: self.emitter.emit("api_change_failed"))
)
return
def get_start_tab(self) -> Any:
return int(self.lookup(Preferences.START_TAB))
def get_favorites(self) -> list[str]:
return list(self.lookup(Preferences.IP_LIST))
def get_favorite(self) -> tuple[str, str] | None:
fav = str(self.lookup(Preferences.FAV_LBL))
if len(fav) < 1:
return None
ip = str(self.lookup(Preferences.FAV_SRV))
addr = ip.split(":")
return fav, f"{addr[0]}:{addr[2]}"
def is_in_favs(self, record: str) -> bool:
favs = self.get_favorites()
if record in favs:
return True
return False
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)
_json = self.get_config()
cur_val = not _json[real_key]
_json[real_key] = cur_val
write_json(_json, self.config)
except Exception as e:
logger.critical(e)
raise e
def set_fav(self, name: str, record: str) -> None:
try:
self.write_config(Preferences.FAV_LBL, name)
self.write_config(Preferences.FAV_SRV, record)
except Exception:
return
def write_config(self, key: Preferences, value: str) -> None:
try:
real_key = self.enum_to_key(key)
conf = self.get_config()
conf[real_key] = value
write_json(conf, self.config)
except Exception as e:
logger.critical(e)
trace = traceback.format_exc()
dialog = ExceptionDialog(self.controller, trace)
dialog.run()
raise e
def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None:
columns = tv.get_columns()
columns_file = self.prefs.paths.columns
try:
data = read_json(columns_file)
except Exception as e:
logger.critical(e)
data = {"cols": {}}
for column in columns:
title = column.get_title()
size = column.get_width()
data["cols"][title] = size
try:
write_json(data, columns_file)
except Exception as e:
logger.critical(e)
logger.info("Normal user exit")
if window.props.is_maximized:
main_quit()
return
w, h = window.get_size()
data = {"res": {"width": w, "height": h}}
res_path = self.prefs.paths.resolution
try:
write_json(data, res_path)
except Exception as e:
logger.critical(e)
main_quit()
def set_resolution(self, window: "OuterWindow") -> None:
if self.prefs.is_game_mode:
window.fullscreen()
return
elif self.lookup(Preferences.WINDOW) is True:
window.fullscreen()
try:
data = read_json(self.prefs.paths.resolution)
valid_json = True
except Exception as e:
valid_json = False
logger.critical(e)
if valid_json:
res = data["res"]
w, h = res["width"], res["height"]
logger.info(f"Restoring window size to {w},{h}")
window.set_default_size(w, h)
else:
w = WINDOW_DEFAULT_X
h = WINDOW_DEFAULT_Y
logger.info(f"Using default window size {w},{h}")
window.set_default_size(w, h)
+316
View File
@@ -0,0 +1,316 @@
import logging
import shutil
import time
from dataclasses import dataclass
from packaging.version import Version
from pathlib import Path
from typing import TYPE_CHECKING
import dzgui.api.pefile as PeFile
import dzgui.api.servers as Servers
from dzgui.api.steam import (
connect,
enqueue_mod,
get_remote_signatures,
get_needs_update,
)
from dzgui.api.mods import (
get_mod_dir_size,
get_local_mod_ids,
get_local_mod_path,
update_signatures,
)
from dzgui.const.constants import (
APP_NAME,
APPID_DAYZ,
APPID_DAYZ_EXP,
APPNAME_DAYZ,
APPNAME_DAYZ_EXP_HUMAN,
)
from dzgui.const.enum import NotebookPage, Preferences
from dzgui.init.proc import is_dayz_running, is_steam_running
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings.dialogs import waiting_for_launch, waiting_for_mods
from dzgui.strings.server_mods import checkmark, resync
from dzgui.util.format import format_mib
from dzgui.util.strings import dialog, server_timeout
from dzgui.util.symlink import rebuild_symlinks
from dzgui.views.dialogs.generic import ExceptionDialog
from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib # noqa E402
if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo, Record
from dzgui.controllers.mc import Controller
logger = logging.getLogger(APP_NAME)
@dataclass(slots=True, frozen=True)
class SteamProcess:
name: str
is_running: bool
@dataclass(slots=True, frozen=True)
class Prerequisites:
name: str
appid: int
local_version: Version
remote_version: Version
build: str
binary_missing: bool
required_space: float
available_space: float
passworded: bool
dayz_running: bool
steam_proc: SteamProcess
mods: list[list[str]]
game_mode: bool
class ConnectionManager:
def __init__(self, controller: "Controller") -> None:
self.controller = controller
self.thread_man = ThreadingManager(controller)
self.appid: int
self.record: Record
self.workshop: Path
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)
self._prepare_connection(res)
@call_on_thread(dialog.querying)
def connect_by_record(self, record: Servers.Record) -> None:
res = Servers.query_by_record(record)
self._prepare_connection(res)
def _prepare_connection(self, res: "A2SInfo") -> None:
failure_func = StoredFunc(self._server_timeout)
info = res.get_info()
if info is None:
self.thread_man.set_cleanup_func(failure_func, destroy_first=True)
return
try:
self.history = res.as_row()
except Exception:
self.thread_man.set_cleanup_func(failure_func, destroy_first=True)
return
record = res.get_record()
# NOTE: store metadata for later connection
self.appid = info.game_id
self.record = record
builds = {APPID_DAYZ: APPNAME_DAYZ, APPID_DAYZ_EXP: APPNAME_DAYZ_EXP_HUMAN}
build = builds[self.appid]
binary_missing = False
required_mib = 0.0
free_mib = 0.0
steam_path = Path(self.controller.query_config(Preferences.DEFAULT))
self.workshop = get_local_mod_path(steam_path)
local_version = PeFile.get_pretty_version(steam_path, info.game_id)
if local_version is None:
local_version = "0.0.0"
binary_missing = True
prefs = self.controller.get_prefs()
remote_mods: list[list[str]] = []
if res.is_modded():
try:
remote_mods = self._query_modlist(record)
self.remote_mod_ids = [mod[1] for mod in remote_mods]
except Exception as e:
logger.warning(e)
self.thread_man.set_cleanup_func(failure_func, destroy_first=True)
return
hashes = get_remote_signatures(self.remote_mod_ids)
version_file = prefs.paths.version
self.missing_mods = get_needs_update(version_file, hashes)
for mod in remote_mods:
if any(mod[1] in tuple for tuple in self.missing_mods):
mod[2] = resync
if local_version is not None:
pefile_path = PeFile.get_pefile_path(steam_path, info.game_id)
total, used, free = shutil.disk_usage(pefile_path)
if len(self.missing_mods) > 0:
required_size = sum(row[2] for row in self.missing_mods)
required_mib = format_mib(required_size)
free_mib = format_mib(required_size)
dayz_running = is_dayz_running()
client_name = self.controller.get_steam_client_name()
client = self.controller.query_config(Preferences.CLIENT)
running = is_steam_running(client)
steam_proc = SteamProcess(client_name, running)
game_mode = prefs.is_game_mode
prereqs = Prerequisites(
name=info.server_name,
appid=info.game_id,
local_version=Version(local_version),
remote_version=Version(info.version),
build=build,
binary_missing=binary_missing,
required_space=required_mib,
available_space=free_mib,
passworded=info.password_protected,
dayz_running=dayz_running,
steam_proc=steam_proc,
mods=remote_mods,
game_mode=game_mode,
)
func = StoredFunc(self.controller.open_connection_assistant, prereqs)
self.thread_man.set_cleanup_func(func, destroy_first=True)
@call_on_thread(dialog.querying)
def query_details(self, record: Servers.Record) -> None:
details = Servers.get_details(record)
if details.success is False:
self.thread_man.set_cleanup_func(
StoredFunc(self._server_timeout), destroy_first=True
)
return
self.thread_man.set_cleanup_func(
StoredFunc(self._present_details_dialog, details), destroy_first=True
)
def _query_modlist(self, record: Servers.Record) -> list[list[str]]:
mods = Servers.get_rules(record)
steam_path = self.controller.query_config(Preferences.DEFAULT)
local = get_local_mod_ids(steam_path)
alpha_mods: list[list[str]] = [
[
mod.name,
str(mod.workshop_id),
checkmark if mod.workshop_id in local else "",
]
for mod in mods
]
alpha_mods.sort(key=lambda x: x[0])
return alpha_mods
@call_on_thread(dialog.querying)
def query_modlist_and_present(self, record: Servers.Record) -> None:
try:
mods = self._query_modlist(record)
except Exception:
self.thread_man.set_cleanup_func(
StoredFunc(self._server_timeout), destroy_first=True
)
return
self.thread_man.set_cleanup_func(
StoredFunc(self._present_modlist_dialog, mods),
destroy_first=True,
)
def _present_modlist_dialog(self, mods: list[list[str]]) -> None:
dialog = ServerModDialog(self.controller, mods)
dialog.run()
def _present_details_dialog(self, details: Servers.Details) -> None:
dialog = ServerDetailsDialog(self.controller, details)
dialog.run()
def _server_timeout(self) -> None:
dialog = ExceptionDialog(self.controller, server_timeout)
dialog.run()
def _connect_steam(self) -> None:
addr = f"{self.record.ip}:{self.record.gameport}"
playername = self.controller.query_config(Preferences.NAME)
client = self.controller.query_config(Preferences.CLIENT)
rc = connect(client, addr, self.appid, playername, self.remote_mod_ids)
if rc != 0:
# TODO: log/pop the error
func = StoredFunc(self.controller.update_status)
self.thread_man.set_cleanup_func(func)
return
self.thread_man.show_cancel(False)
self.thread_man.update_dialog(waiting_for_launch)
while True:
# TODO: check cancel event
if self.controller.get_exit_event().is_set():
# TODO: some facility to also close spawned steam process
return
if is_dayz_running():
break
time.sleep(1)
func = StoredFunc(self._add_to_history_and_return)
self.thread_man.set_cleanup_func(func)
def _add_to_history_and_return(self) -> None:
self.controller.add_to_history(self.history)
self.controller.open_page(NotebookPage.SERVERS)
def _update_mods(self, raise_window: bool) -> None:
# NOTE: fast enqueue all mods in auto mode
prefs = self.controller.get_prefs()
for title, mod, stamp, size in self.missing_mods:
# TODO: check cancel and exit events
enqueue_mod(mod, self.appid)
time.sleep(2)
if raise_window is True:
logger.info("Bringing window to foreground")
GLib.idle_add(self.controller.present_window)
for title, mod, stamp, size in self.missing_mods:
mod_path = self.workshop / mod
# NOTE: Steam updates mod chunks in parallel, will finish at the same time
while mod_path.is_dir() is False:
time.sleep(1)
while True:
if self.controller.get_exit_event().is_set():
return
if self.controller.get_cancel_event().is_set():
self.controller.clear_cancel_event()
return
cur_size = get_mod_dir_size(mod_path)
if cur_size == size:
break
time.sleep(1)
update_signatures(self.missing_mods, prefs.paths.version)
# TODO: just push steam path directly
rebuild_symlinks(prefs.paths.config)
self._connect_steam()
@call_on_thread(waiting_for_mods, show_cancel=True)
def update_and_connect(self, raise_window: bool) -> None:
if len(self.missing_mods) > 0:
self._update_mods(raise_window)
else:
self._connect_steam()
+117
View File
@@ -0,0 +1,117 @@
import gi
import logging
from typing import TYPE_CHECKING
from dzgui.const.constants import APP_NAME
from dzgui.const.enum import ContextMenu, Preferences
from dzgui.util.clip import copy_clipboard
from dzgui.util.open_links import open_workshop_page
from dzgui.views.dialogs.note import NoteDialog
from dzgui.views.trees.tree_mods import ModTreeView
from dzgui.views.trees.tree_log import LogTreeView
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:
from dzgui.controllers.mc import Controller
class ContextMenuManager:
def __init__(
self,
treeview: LogTreeView | ModTreeView | ServerTreeView | ServerModTreeView,
controller: "Controller",
) -> None:
self.controller = controller
self.treeview = treeview
def process(self, action: ContextMenu) -> None:
if isinstance(self.treeview, ServerTreeView):
record = self.treeview.get_record()
if record is None:
return
match action:
# UNTHREADED
case ContextMenu.ADD_NOTE:
dialog = NoteDialog(self.controller)
dialog.run()
case ContextMenu.COPY_SERVER_IP:
self.copy_server_ip()
case ContextMenu.COPY_SERVER_NAME:
self.copy_server_name()
case ContextMenu.SET_FAV:
name = self.treeview.get_value_at_index(0)
# NOTE: fully qualified ip
fqip = self.treeview.get_record_string()
# NOTE: short ip for display purposes
simple = self.treeview.get_simplified_ip()
self.controller.set_fav(name, fqip, simple)
# THREADED
case ContextMenu.ADD_SERVER:
self.controller.add_by_record(record)
case ContextMenu.CONNECT:
self.controller.connect_by_record(record)
case ContextMenu.REFRESH_PLAYERS:
self.controller.refresh_players(record)
case ContextMenu.REMOVE_HISTORY:
self.controller.remove_from_history(record)
case ContextMenu.REMOVE_SERVER:
self.controller.remove_by_record(record)
case ContextMenu.SHOW_DETAILS:
self.controller.get_details(record)
case ContextMenu.SHOW_MODS:
self.controller.get_modlist(record)
if isinstance(self.treeview, ModTreeView):
match action:
case ContextMenu.DELETE_MOD:
self.controller.delete_mods()
case ContextMenu.OPEN_WORKSHOP:
self.open_mod_page()
if isinstance(self.treeview, LogTreeView):
match action:
case ContextMenu.COPY_LOG_CLIPBOARD:
self.copy_log()
if isinstance(self.treeview, ServerModTreeView):
match action:
case ContextMenu.OPEN_WORKSHOP:
self.open_mod_page()
def copy_server_ip(self) -> None:
if not isinstance(self.treeview, ServerTreeView):
return
record = self.treeview.get_simplified_ip()
copy_clipboard(record)
def copy_server_name(self) -> None:
name = self.treeview.get_value_at_index(0)
if name is None:
return
copy_clipboard(name)
def open_mod_page(self) -> None:
if not hasattr(self.treeview, "get_selected_mod"):
return
mod = self.treeview.get_selected_mod()
cmd = self.controller.query_config(Preferences.CLIENT)
open_workshop_page(mod, cmd)
def copy_log(self) -> None:
if not isinstance(self.treeview, LogTreeView):
return
log = self.treeview.concatenate_rows()
if log is None:
return
copy_clipboard(log)
+105
View File
@@ -0,0 +1,105 @@
from typing import TYPE_CHECKING
from dzgui.model.model_factory import ModelFactory
from dzgui.util import strings
from dzgui.util.strings import all_maps
if TYPE_CHECKING:
from dzgui.model.model_factory import FastInsertListStore
class FilterManager:
def __init__(self) -> None:
self.map_store = ModelFactory().make_map_store()
# TODO: namespace under own file: strings.filters
self.default_filters = {
strings.filter_1pp: True,
strings.filter_day: True,
strings.filter_empty: False,
strings.filter_3pp: True,
strings.filter_night: True,
strings.filter_full: False,
strings.filter_lowpop: True,
strings.filter_nonascii: False,
strings.filter_duplicate: False,
strings.filter_official: True,
strings.filter_unofficial: True,
strings.filter_modded: True,
}
self.active_keyword = ""
self.active_map = (0, all_maps)
self.prior_map = all_maps
self.filters: list
self.enabled_filters = dict(self.default_filters)
def set_prior_map(self, name: str) -> None:
self.prior_map = name
def get_active_map_name(self) -> str:
return self.active_map[1]
def get_active_map(self) -> tuple[int, str]:
return self.active_map
def set_active_map(self, ind: int, name: str) -> None:
self.active_map = (ind, name)
def get_active_keyword(self) -> str:
return self.active_keyword
def set_active_keyword(self, word: str) -> None:
self.active_keyword = word
def get_default_filters(self) -> dict:
"""Deep copy of defaults"""
return dict(self.default_filters)
def get_filters(self) -> dict:
return self.enabled_filters
def set_filter(self, label: str, state: bool) -> None:
self.enabled_filters[label] = state
def get_map_store(self) -> "FastInsertListStore":
return self.map_store
def reinit_map_store(self) -> None:
model = ModelFactory().make_map_store()
model.append([all_maps])
self.map_store = model
self.active_map = (0, all_maps)
def append_map(self, row: list[str]) -> None:
self.map_store.append(row)
def set_unique_maps(self, maps: list[str]) -> None:
if maps is None:
return
if len(maps) < 1:
return
self.reinit_map_store()
maps.sort()
for m in maps:
if m == "All maps":
continue
self.append_map([m])
def get_unique_maps(self) -> list[str]:
return [row[0] for row in self.map_store if row != "All maps"]
def get_all_filters(self) -> tuple:
map_name = self.get_active_map_name()
enabled = self.get_filters()
kw = self.get_active_keyword()
filters = []
filters.append(map_name)
filters.append(kw)
for filt in enabled:
if enabled[filt] is False:
filters.append(filt)
return tuple(filters)
+204
View File
@@ -0,0 +1,204 @@
import logging
import shutil
from pathlib import Path
from typing import Any, TYPE_CHECKING
from dzgui.api.mods import (
get_delimited_mods,
get_local_mod_path,
find_stale_mods,
_hash,
remove_stale_signatures,
)
from dzgui.const.constants import APP_NAME, APPID_DAYZ, APPID_DAYZ_EXP
from dzgui.const.enum import Preferences
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.model.model_factory import FastInsertListStore, ModelFactory
from dzgui.util.format import format_mods
from dzgui.util.strings import server_timeout
from dzgui.views.dialogs.generic import ExceptionDialog
import dzgui.api.pefile as PeFile
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
logger = logging.getLogger(APP_NAME)
class ModManager:
"""
Because mods may be dynamically updated on the system,
this manager is instantiated each time the Mods page is opened
"""
def __init__(self, controller: "Controller") -> None:
self.controller = controller
self.emitter = controller.get_emitter()
self.prefs = controller.get_prefs()
self.path = controller.query_config(Preferences.DEFAULT)
self.treeview = self.controller.get_modtreeview()
self.store: FastInsertListStore
self.thread_man = ThreadingManager(controller)
self._get_mods()
# TODO: dialog strings
@call_on_thread("getting mods")
def _get_mods(self) -> None:
mods = get_delimited_mods(self.path)
if len(mods) < 1:
msg = self.format_mod_statusbar()
func = StoredFunc(lambda: self.emitter.emit("mods_updated", msg, 0))
self.thread_man.set_cleanup_func(func)
return
func = StoredFunc(self._on_mods_loaded, mods)
self.thread_man.set_cleanup_func(func)
def _on_mods_loaded(self, mods: list[list[Any]]) -> None:
self.store = ModelFactory().make_mod_store()
self.store.extend(mods)
self.treeview.set_model(self.store)
msg = self.format_mod_statusbar()
total_mods = len(self.store)
self.emitter.emit("mods_updated", msg, total_mods)
def delete_mods(self) -> None:
sel = self.treeview.get_selection()
model, pathlist = sel.get_selected_rows()
# NOTE: reverse when multiple selection
mods: list[tuple[str, Gtk.TreeIter]] = []
for path in reversed(pathlist):
res = self.get_mod_from_tree_path(path)
if res is None:
continue
mod, _iter = res
mods.append((mod, _iter))
self.delete_mods_on_system(mods)
def get_mod_from_tree_path(
self, tree_path: Gtk.TreePath
) -> tuple[str, Gtk.TreeIter] | None:
model = self.treeview.get_model()
if model is None:
return None
tree_iter = model.get_iter(tree_path)
mod = model.get_value(tree_iter, 2)
return mod, tree_iter
# TODO: strings
@call_on_thread("deleting mods")
def delete_mods_on_system(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None:
for mod, _iter in mods:
self.delete_single_mod(mod)
iters = [_iter for mod, _iter in mods]
func = StoredFunc(self._on_mods_deleted, iters)
self.thread_man.set_cleanup_func(func)
def delete_single_mod(self, mod: str) -> None:
steam_path = Path(self.path)
mods_path = get_local_mod_path(steam_path)
app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
md5 = _hash(mod)
symlink = app_path / md5
symlink.unlink()
shutil.rmtree(mods_path / mod)
# NOTE: second pass to unlink DAYZ_EXP mods
# TODO: test this with working APPID_DAYZ_EXP installation
try:
app_path_exp = PeFile.get_nested_app_path(steam_path, APPID_DAYZ_EXP)
symlink = app_path_exp / md5
symlink.unlink()
except PeFile.AppNotInstalledError:
pass
def _on_mods_deleted(self, iters: list[Gtk.TreeIter]) -> None:
for _iter in iters:
self.store.remove(_iter)
remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version)
model = self.treeview.get_model()
if model is None:
return
mods = len(model)
msg = self.format_mod_statusbar()
self.emitter.emit("mods_updated", msg, mods)
def uncolorize_mods(self) -> None:
model = self.treeview.get_model()
if model is None:
return
for mod in model:
_iter = mod.iter
path = model.get_path(_iter)
model[path][4] = None
path = Gtk.TreePath.new_from_indices([0])
self.treeview.set_cursor(path)
def toggle_mod_selection(self, state: bool) -> None:
sel = self.treeview.get_selection()
if state:
sel.select_all()
else:
sel.unselect_all()
self.treeview.grab_focus()
def format_mod_statusbar(self) -> str:
total_mods, total_size = self.calc_mod_size()
msg = format_mods(total_size, total_mods)
return msg
def calc_mod_size(self) -> tuple[int, int]:
model = self.treeview.get_model()
if model is None:
return 0, 0
total_mods = len(model)
total_size = 0
for mod in model:
total_size += mod[3]
return total_mods, total_size
def _on_stale_mods_found(self, stale_mods: list) -> None:
"""Manipulates attached ListStore in the main event loop"""
model = self.treeview.get_model()
if model is None:
return
for mod in model:
it = mod.iter
path = model.get_path(it)
if int(mod[2]) in stale_mods:
model[path][4] = True
self.emitter.emit("mods_highlighted")
# TODO: dialog strings
@call_on_thread("working")
def highlight_stale(self) -> None:
stale = find_stale_mods(self.prefs.paths.config)
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:
return
sel = self.treeview.get_selection()
for mod in model:
it = mod.iter
path = model.get_path(it)
if mod[4] is True:
sel.select_path(path)
+42
View File
@@ -0,0 +1,42 @@
import json
import logging
from typing import TYPE_CHECKING
from dzgui.const.constants import APP_NAME
from dzgui.util._json import read_json
import gi
gi.require_version("Gtk", "3.0")
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
from pathlib import Path
logger = logging.getLogger(APP_NAME)
class NoteManager:
def __init__(self, controller: "Controller", path: "Path") -> None:
self.notes_path = path
self.cache: dict[str, str] = read_json(self.notes_path)
def add_note(self, server: str, note: str) -> None:
self.cache[server] = note
self.write_file()
def delete_note(self, server: str) -> None:
del self.cache[server]
self.write_file()
def get_note(self, server: str) -> str:
try:
text = self.cache[server]
return text
except Exception:
return ""
def write_file(self) -> None:
j = json.dumps(self.cache, indent=2)
self.notes_path.write_text(j)
+127
View File
@@ -0,0 +1,127 @@
import inspect
import logging
import threading
from typing import Any, Literal, TYPE_CHECKING
from functools import wraps
from typing import Callable
from dzgui.const.constants import APP_NAME
from dzgui.views.dialogs.generic import WaitDialog
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib # noqa E402
logger = logging.getLogger(APP_NAME)
def call_on_thread(
dialog_str: str, show_dialog: bool = True, show_cancel: bool = False
) -> Callable:
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> None:
self = args[0]
stored = StoredFunc(func, *args, **kwargs)
if not hasattr(self, "thread_man"):
raise AttributeError
if type(self.thread_man) is not ThreadingManager:
raise TypeError(
"Attribute 'thread_man' must be of type 'ThreadingManager'"
)
self.thread_man.call_on_thread(dialog_str, stored, show_dialog, show_cancel)
return wrapper
return decorator
class StoredFunc:
def __init__(self, func: Callable, *args: Any, **kwargs: Any) -> None:
sig = inspect.signature(func)
self.func = func
self.bindings = sig.bind(*args, **kwargs)
def call(self) -> None:
self.func(*self.bindings.args, *self.bindings.kwargs)
class ThreadingManager:
def __init__(self, controller: "Controller") -> None:
self.controller = controller
self.jobs = 1
self.cleanup_func: StoredFunc | None = None
self.destroy_first = False
def call_on_thread(
self,
dialog_str: str,
func: StoredFunc,
show_dialog: bool = True,
show_cancel: bool = False,
) -> None:
def callback() -> None:
func.call()
GLib.idle_add(self._destroy_on_idle)
if show_dialog:
self.wait_dialog = WaitDialog(
self.controller, dialog_str, jobs=self.jobs, show_cancel=show_cancel
)
self.wait_dialog.show_all()
thread = threading.Thread(target=callback)
thread.start()
def set_job_count(self, jobs: int) -> None:
self.jobs = jobs
def update_dialog(self, msg: str) -> None:
GLib.idle_add(lambda: self.wait_dialog.update_text(msg))
def increment_dialog(self) -> None:
GLib.idle_add(self.wait_dialog.increment)
def increment_dialog_with_str(self, text: str) -> None:
GLib.idle_add(lambda: self.wait_dialog.increment(text))
def set_cleanup_func(
self, func: StoredFunc | None, destroy_first: bool = False
) -> None:
if type(func) not in (StoredFunc, type(None)):
msg = f"Callback function '{func}' is not of type StoredFunc or None"
logger.critical(msg)
raise TypeError(msg)
self.destroy_first = destroy_first
self.cleanup_func = func
def get_cleanup_func(self) -> StoredFunc | None:
return self.cleanup_func
def destroy_dialog(self) -> None:
if hasattr(self, "wait_dialog"):
self.wait_dialog.destroy()
def _destroy_on_idle(self) -> Literal[False]:
if self.destroy_first:
self.destroy_dialog()
func = self.get_cleanup_func()
if func is not None:
func.call()
self.set_cleanup_func(None)
if not self.destroy_first:
self.destroy_dialog()
return False
def get_wait_dialog(self) -> WaitDialog:
return self.wait_dialog
def show_cancel(self, state: bool) -> None:
GLib.idle_add(self.wait_dialog.show_cancel, state)
+167
View File
@@ -0,0 +1,167 @@
from dataclasses import dataclass
from typing import Any, Sequence, TYPE_CHECKING
from dzgui.const.enum import HELP_MENU_ROWS
from dzgui.util.redact import redact_log
from dzgui.util.strings import delimiter
import gi
gi.require_version("Gtk", "3.0")
from gi.repository.Gtk import ListStore, TreeIter # noqa E402
from gi.repository import GObject # noqa E402
GTYPE_TO_PYTHON = {
GObject.type_from_name(GObject.type_name(ptype)): ptype
for ptype in (int, float, str, bool, object)
}
if TYPE_CHECKING:
from pathlib import Path
@dataclass(slots=True, frozen=True)
class ServerCols:
name: str
_map: str
perspective: str
gametime: str
players: int
_max: int
queue: int
ip: str
qport: int
ping: int
provider: str
modded: bool
@dataclass(slots=True, frozen=True)
class ModCols:
name: str
symlink: str
directory: str
size: float
color: bool
@dataclass(slots=True, frozen=True)
class LogCols:
timestamp: str
flag: str
traceback: str
msg: str
@dataclass(slots=True, frozen=True)
class ServerModCols:
name: str
uid: str
installed: str
@dataclass(slots=True, frozen=True)
class MenuCols:
name: str
hidden: object
class FastInsertListStore(ListStore):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
def _is_same_length(self, lists: Sequence[Any]) -> bool:
first_len = len(lists[0])
if not all(len(sublist) == first_len for sublist in lists):
return False
return True
def _is_type_homogeneous(self, lists: Sequence[Any]) -> bool:
transposed = zip(*lists)
for column in transposed:
first_type = type(column[0])
if not all(type(item) is first_type for item in column):
return False
return True
def extend(self, rows: Sequence[Any]) -> None:
"""
Compared to calling append() directly, introduces negligible overhead,
but guarantees type and length equivalence prior to insertion
"""
n_cols = self.get_n_columns()
expected_types = [
GTYPE_TO_PYTHON[self.get_column_type(i)] for i in range(n_cols)
]
if len(rows) == 0:
return
if not self._is_same_length(rows):
raise ValueError("Sublists are not of uniform length")
if not self._is_type_homogeneous(rows):
raise TypeError("Sublists are not type homogeneous")
if not all(isinstance(a, b) for a, b in zip(rows[0], expected_types)):
raise TypeError("Sublist types are not same as ListStore")
if len(rows[0]) != n_cols:
raise ValueError("Sublist column length is not same as ListStore")
for row in rows:
self.append(row)
def append(self, row: list[Any] | tuple[Any, ...] | None = ...) -> TreeIter: # type: ignore
"""
Optimized for speed, but makes no assurances about row homogeneity
and may segfault if types and length are not identical to ListStore.
For this reason, it is recommended to use the extend() method to insert
an entire list of lists
"""
if row is not None:
total = len(row)
i = len(self)
tree_iter = self.insert_with_values(i, tuple(range(total)), row)
else:
tree_iter = super().append(row)
return tree_iter
class ModelFactory:
def __init__(self) -> None:
pass
def new_model_from_logfile(self, path: "Path") -> FastInsertListStore:
store = self.make_log_store()
with open(path, "r") as f:
lines = [line.split(delimiter) for line in f.read().splitlines()]
for record in lines:
# NOTE: strips PII and API keys
clean = redact_log(record)
store.append(clean)
return store
def new_model_from_class(self, cls: type) -> FastInsertListStore:
store = FastInsertListStore(
*[ftype for field, ftype in cls.__annotations__.items()]
)
return store
def make_map_store(self) -> FastInsertListStore:
return FastInsertListStore(str)
def make_help_store(self) -> FastInsertListStore:
store = self.new_model_from_class(MenuCols)
rows = [[row.dict["label"], row] for row in HELP_MENU_ROWS]
store.extend(rows)
return store
def make_mod_store(self) -> FastInsertListStore:
return self.new_model_from_class(ModCols)
def make_log_store(self) -> FastInsertListStore:
return self.new_model_from_class(LogCols)
def make_server_mod_store(self) -> FastInsertListStore:
return self.new_model_from_class(ServerModCols)
def make_server_store(self) -> FastInsertListStore:
return self.new_model_from_class(ServerCols)
+300
View File
@@ -0,0 +1,300 @@
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
from dzgui.util import strings
if TYPE_CHECKING:
from dzgui.managers.filter import FilterManager
from dzgui.model.model_factory import FastInsertListStore
from dzgui.api.servers import Record
from dzgui.model.servers import NewPlayerCount
class ProxyModelManager:
"""
Manages access to cached FastInsertListStore resources and
performs filtering on behalf of atomic TreeViews,
which share the same column structure.
A ProxyModelManager is attached to each ServerTreeView.
Raw data is cached before being packed into a FastInsertListStore, see get_control()
Filtration creates a proxy of the TreeView's model, see get_proxy_model()
"""
def __init__(self, filter_man: "FilterManager") -> None:
self.filter_cache: dict[
tuple[str],
list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]],
] = {}
self.proxy_model: "FastInsertListStore"
self.filter_man = filter_man
self.control_model: (
list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]]
| None
) = None
self.filtered: list[
tuple[str, str, str, str, int, int, int, str, int, int, str, bool]
] = []
self.success = True
def has_control_model(self) -> bool:
if self.control_model is None:
return False
return True
def append_row(self, row: list) -> None:
self.proxy_model.append(row)
def append_row_to_control(
self, row: tuple[str, str, str, str, int, int, int, str, int, int, str, bool]
) -> None:
if self.control_model is None:
raise AttributeError("Trying to add rows to a non-existent model")
self.control_model.append(row)
self.filter(FilterMode.INITIAL, skip_cache=True)
def update_playercount(self, playercount: "NewPlayerCount") -> None:
treeiter = playercount.treeiter
self.proxy_model[treeiter][4] = playercount.players
self.proxy_model[treeiter][6] = playercount.queue
# TODO: use dataclass for record rows
def append_row_to_history(
self,
history: tuple[str, str, str, str, int, int, int, str, int, int, str, bool],
) -> None:
addr = history[7]
qport = history[8]
if self.control_model is None:
raise AttributeError("Trying to append row to empty model")
found = False
for i, row in enumerate(self.control_model):
if addr == row[7] and qport == row[8]:
item = self.control_model.pop(i)
self.control_model.append(item)
found = True
break
if found is False:
self.control_model.append(history)
if len(self.control_model) == 11:
del self.control_model[0]
self.filter(FilterMode.INITIAL, skip_cache=True)
def remove_row_from_control(self, record: "Record") -> None:
if self.control_model is None:
return
addr = f"{record.ip}:{record.gameport}"
qport = record.qport
for row in self.control_model:
if addr == row[7] and qport == row[8]:
self.control_model.remove(row)
break
self.wipe_cache()
self.filter(FilterMode.INITIAL, skip_cache=True)
def clear_proxy_model(self) -> None:
self.proxy_model.clear()
def get_proxy_model(self) -> Union["FastInsertListStore", None]:
if hasattr(self, "proxy_model"):
return self.proxy_model
return None
def filter(
self, mode: FilterMode, skip_cache: bool = False
) -> Union["FastInsertListStore", None]:
"""
Native Gtk.TreeView.refilter() method was not performant enough
when running in the main loop with 40k+ records
skip_cache: used when updating a record on Saved Servers/History and forcing a refilter
"""
# TODO: filter cache: return a dataclass object with clearly enumerated map, keyword, and filter values
# instead of just a serial list of strings
filters = self.filter_man.get_all_filters()
if skip_cache is False:
if filters in self.filter_cache:
cached_rows = self.filter_cache[filters]
clone = ModelFactory().make_server_store()
clone.extend(cached_rows)
self.set_proxy_model(clone)
self.set_filtered(cached_rows)
return None
match mode:
case FilterMode.INITIAL:
rows = self.filter_initial(filters)
case FilterMode.TOGGLE_ON:
rows = self.filter_toggle_on(filters)
case FilterMode.TOGGLE_OFF:
for f in filters[2:]:
self.set_filtered(self.filter_toggle_off(filters, f))
rows = self.filtered
# NOTE: FastInsertListStore manipulation must remain local to the thread
clone = ModelFactory().make_server_store()
if len(rows) > 0:
rows = self.sort_rows(rows)
clone.extend(rows)
self.set_cache(filters, rows)
self.set_proxy_model(clone)
return clone
def sort_rows(self, rows: list) -> list:
rows.sort(key=lambda x: re.sub(r"[^A-Za-z0-9]+", "", x[0].lower()))
return rows
def filter_initial(self, filters: tuple) -> list:
"""
Simply culls the control model of any disabled filters
"""
self.set_filtered(self.control_model)
for f in filters[2:]:
self.set_filtered(self.filter_toggle_off(filters, f))
return self.filtered
def filter_map(self, filters: tuple) -> list:
"""
Multi-filtration for any context starts by narrowing by map
"""
rows = self.filtered
sel_map = self.filter_man.get_active_map_name()
if sel_map == strings.all_maps:
return rows
rows = [row for row in rows if row[1] == sel_map]
return rows
def filter_keyword(self, filters: tuple) -> list:
keyword = self.filter_man.get_active_keyword()
rows = self.filtered
if keyword == "":
return rows
filtered = [
row
for row in rows
if keyword in row[0].lower()
or keyword in row[1].lower()
or keyword in row[7].lower()
]
return filtered
def filter_toggle_off(self, filters: tuple, filter_type: str) -> list:
"""
Sub-filtration of the current model
"""
pairs = {
strings.filter_3pp: strings.filter_1pp,
strings.filter_day: strings.filter_night,
strings.filter_official: strings.filter_unofficial,
}
for k, v in pairs.items():
if k in filters and v in filters:
self.set_filtered(None)
return []
rows = self.filtered
match filter_type:
case strings.filter_3pp:
rows = [row for row in rows if row[2] != strings.filter_3pp]
case strings.filter_1pp:
rows = [row for row in rows if row[2] != strings.filter_1pp]
case strings.filter_official:
rows = [row for row in rows if row[10] != strings.filter_official]
case strings.filter_unofficial:
rows = [row for row in rows if row[10] != strings.filter_unofficial]
case strings.filter_empty:
rows = [row for row in rows if row[4] != 0]
case strings.filter_full:
rows = [row for row in rows if row[4] != row[5]]
case strings.filter_duplicate:
seen = set()
final = []
for row in rows:
if row[0] not in seen:
seen.add(row[0])
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])]
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])]
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]]
return rows
def filter_toggle_on(self, filters: tuple[str]) -> list:
"""Effectively applies all filters"""
self.set_filtered(self.control_model)
self.set_filtered(self.filter_map(filters))
self.set_filtered(self.filter_keyword(filters))
f: str
for f in filters[2:]:
self.set_filtered(self.filter_toggle_off(filters, f))
return self.filtered
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 = []
else:
self.filtered = rows
def get_filtered(self) -> list:
return self.filtered
def set_proxy_model(self, model: "FastInsertListStore") -> None:
"""
FastInsertListStore representation of the raw model after filtration
"""
self.proxy_model = model
def set_control(self, rows: list) -> None:
"""
Raw data model prior to filtration
"""
self.control_model = rows
def get_control(self) -> list[Any]:
if self.control_model is None:
raise AttributeError(
"Expected a populated control model, but it is Nonetype"
)
return self.control_model
def wipe_cache(self, full: bool = False) -> None:
self.filtered = []
self.filter_cache = {}
def push(self, data: list[Any]) -> None:
self.wipe_cache()
self.set_control(data)
self.filter(FilterMode.INITIAL)
+437
View File
@@ -0,0 +1,437 @@
import logging
import threading
from concurrent.futures import as_completed
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING
import dzgui.api.servers as Servers
from dzgui.const.constants import (
APPID_DAYZ,
APPID_DAYZ_EXP,
APP_NAME,
)
from dzgui.const.enum import FilterMode, Preferences, ServerTab
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.util.strings import api_warn_msg, dialog
from dzgui.views.dialogs.generic import ExceptionDialog
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo
from dzgui.controllers.mc import Controller
from dzgui.model.proxy_model import ProxyModelManager
from dzgui.views.trees.tree_servers import ServerTreeView
logger = logging.getLogger(APP_NAME)
LAN_TIMEOUT = 0.5
API_TIMEOUT = 3
@dataclass
class NewPlayerCount:
treeiter: Gtk.TreeIter
players: int
queue: int
class ServerModelManager:
def __init__(self, controller: "Controller", tv: "ServerTreeView") -> None:
self.tv = tv
self.enum = tv.get_enum()
self.controller = controller
self.emitter = controller.get_emitter()
self.first_iteration: bool
self.preserve_on_fail = False
self.jobs = 1
self.proxy_man = tv.get_proxy_man()
self.thread_man = ThreadingManager(self.controller)
def load(self) -> None:
"""
Load is not called on init. There may be cases where you want to instantiate this class without dumping servers,
e.g., adding saved servers from another tab
"""
self.first_iteration = True
match self.enum:
case ServerTab.BROWSER:
# NOTE: extra DAYZ_EXP param
self.thread_man.set_job_count(len(Servers.params) + 1)
self._dump_api()
case ServerTab.SAVED:
self._dump_favorites()
case ServerTab.RECENT:
self._dump_history()
case ServerTab.LAN:
# NOTE: LAN tab is only loaded on demand
pass
case _:
pass
def refresh(self) -> None:
self.preserve_on_fail = True
self.load()
@call_on_thread(dialog.fetching)
def _dump_api(self) -> None:
# TODO: pass api key a priori in .load() call?
failure_func = StoredFunc(self._cleanup_on_failure)
config_man = self.controller.get_config_man()
key = config_man.lookup(Preferences.STEAM)
job = Servers.query_api
params = Servers.params
servers = []
with ThreadPoolExecutor() as executor:
futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params]
for future in as_completed(futures):
try:
# NOTE: faciliates early aborting via sigint
# TODO: make this logic available to all dump contexts
if self.controller.get_exit_event().is_set():
return
self.thread_man.increment_dialog()
res = future.result(timeout=API_TIMEOUT)
if res.status != 200 or not res.parsed:
self.thread_man.set_cleanup_func(
failure_func, destroy_first=True
)
return
j = res.json
if j is not None:
servers.extend(j["response"]["servers"])
except Exception as e:
logger.critical(e)
self.thread_man.set_cleanup_func(failure_func, destroy_first=True)
return
# NOTE: this step is allowed to fail, since this metadata is incidental
res = Servers.query_api(key, APPID_DAYZ_EXP, "")
if res.status == 200 and res.parsed is True:
j = res.json
if j is not None:
servers.extend(j["response"]["servers"])
parsed = Servers.parse_json(servers)
self._push_data(parsed)
@call_on_thread(dialog.scanning)
def dump_lan(self, port: int, early_abort: bool) -> None:
servers = []
ports = range(1, 256)
failure_func = StoredFunc(self._cleanup_on_failure)
event = threading.Event()
with ThreadPoolExecutor() as executor:
futures = [executor.submit(Servers.test_ip, i, port, event) for i in ports]
for future in as_completed(futures):
try:
if self.controller.get_exit_event().is_set():
return
res = future.result(timeout=LAN_TIMEOUT)
if res is None:
continue
if early_abort is True:
# NOTE: on first non-empty hit, flag pending threads to close
event.set()
servers.append(res)
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_success)
)
return
servers.append(res)
except Exception as e:
logger.critical(e)
self.thread_man.set_cleanup_func(failure_func, destroy_first=True)
if len(servers) == 0:
self.thread_man.set_cleanup_func(failure_func, destroy_first=True)
return
parsed = Servers.parse_json(servers)
self._push_data(parsed)
@call_on_thread(dialog.fetching)
def _dump_ips(self, ips: list[str]) -> None:
job = Servers.query_direct
servers = []
with ThreadPoolExecutor() as executor:
futures = [
executor.submit(
job,
ip.split(":")[0],
int(ip.split(":")[2]),
)
for ip in ips
]
for future in as_completed(futures):
# 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
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)
self._parse_single_record(res)
@call_on_thread(dialog.querying)
def add_by_record(self, record: Servers.Record) -> None:
# NOTE: rationale: a raw Record as shown in server browser may resolve to a different IP
res = Servers.query_by_record(record)
self._parse_single_record(res)
@call_on_thread(dialog.querying)
def remove_by_record(self, record: Servers.Record) -> None:
res = Servers.query_by_record(record)
self._parse_single_record(res, delete=True)
def update_history(
self,
rows: (
list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]]
| None
) = None,
) -> None:
proxy_man = self._get_proxy_man()
config_man = self.controller.get_config_man()
if rows is not None:
records = rows
else:
control_model = proxy_man.get_control()
records = control_model
config_man.update_history_file(records)
config_man.update_history_file(records)
self._sort_unique_maps(records)
proxy = proxy_man.get_proxy_model()
self.tv.set_model(proxy)
self.emitter.emit("servers_loaded", self.enum)
filter_man = self.tv.get_filter_man()
maps = self._get_new_maps()
filter_man.set_unique_maps(maps)
self.first_iteration = False
self.emitter.emit("servers_loaded_init")
def add_to_history(self, record: dict[str, Any]) -> None:
proxy_man = self._get_proxy_man()
rows = Servers.parse_json([record])
try:
proxy_man.append_row_to_history(rows[0])
except Exception:
self.update_history(rows)
return
self.update_history()
def remove_from_history(self, record: Servers.Record) -> None:
proxy_man = self._get_proxy_man()
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
) -> None:
res = Servers.query_playercount(record)
if res is None:
return
players, queue = res
self.playercount = NewPlayerCount(treeiter, players, queue)
self.thread_man.set_cleanup_func(StoredFunc(self._push_playercount))
def _push_playercount(self) -> None:
proxy_man = self._get_proxy_man()
proxy_man.update_playercount(self.playercount)
def _parse_single_record(self, response: "A2SInfo", delete: bool = False) -> None:
self.preserve_on_fail = True
try:
row = response.as_row()
except Exception as e:
logger.warning(e)
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_failure), destroy_first=True
)
return
# NOTE: expected to only contain one item
records = Servers.parse_json([row])
server = records[0]
if server is None:
return
proxy_man = self._get_proxy_man()
config_man = self.controller.get_config_man()
fqip = Servers.response_to_fqip(row)
record = response.get_record()
# TODO: less convoluted
if delete:
# NOTE: abort early if Saved Servers tab was not loaded yet
config_man.remove_saved_server(fqip)
if proxy_man.has_control_model() is False:
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_when_no_model)
)
return
proxy_man.remove_row_from_control(record)
else:
if config_man.is_in_favs(fqip):
func = StoredFunc(lambda: self.emitter.emit("already_saved_server"))
self.thread_man.set_cleanup_func(func)
return
config_man.add_saved_server(fqip)
# FIXME: this is valid if saved servers tab is already open,
# but not if app was just booted; causes single row to appear prematurely
if proxy_man.has_control_model() is False:
proxy_man.push(records)
else:
proxy_man.append_row_to_control(server)
control_model = proxy_man.get_control()
self._sort_unique_maps(control_model)
self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_single_ip))
def _cleanup_when_no_model(self) -> None:
self.emitter.emit("saved_servers_changed")
def _dump_history(self) -> None:
history = self.controller.get_prefs().paths.history
try:
with open(history, "r") as f:
rows = [row.rstrip("\n") for row in f]
except OSError:
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_failure, False), destroy_first=True
)
return
if len(rows) == 0:
# TODO: customize statusbar to mention how records are added after connecting
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_failure, False), destroy_first=True
)
return
self.thread_man.set_job_count(len(rows))
self._dump_ips(rows)
def _dump_favorites(self) -> None:
config_man = self.controller.get_config_man()
ips = config_man.lookup(Preferences.IP_LIST)
self.thread_man.set_job_count(len(ips))
if len(ips) == 0:
# TODO: customize statusbar to mention how records can be added via contextmenu
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_failure, False), destroy_first=True
)
return
self._dump_ips(ips)
def _cleanup_single_ip(self) -> None:
proxy = self._get_proxy_man().get_proxy_model()
self.tv.set_model(proxy)
self.emitter.emit("servers_loaded", self.enum)
filter_man = self.tv.get_filter_man()
# NOTE: maps are set outside of thread because it triggers map changed signals
maps = self._get_new_maps()
filter_man.set_unique_maps(maps)
self.first_iteration = False
self.emitter.emit("servers_loaded_init")
self.emitter.emit("saved_servers_changed")
def _update_maps(self) -> None:
filter_man = self.tv.get_filter_man()
filter_man.set_unique_maps(self._get_new_maps())
self.emitter.emit("servers_loaded_init")
self.first_iteration = False
def _cleanup_on_success(self) -> None:
proxy = self._get_proxy_man().get_proxy_model()
self.tv.set_model(proxy)
# TODO: servers_loaded vs servers_reloaded
self.emitter.emit("servers_loaded", self.enum)
if self.first_iteration:
self._update_maps()
def _cleanup_on_failure(self, show_dialog: bool = True) -> None:
# TODO: disable map, keyword, and filter widgets if model is None
# signal driven (servers_empty, servers_failed_to_load)
if self.preserve_on_fail is False:
self.tv.set_model(None)
filter_man = self.tv.get_filter_man()
filter_man.set_unique_maps([])
# 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.run()
def _push_data(self, data: list[Any]) -> None:
self._get_proxy_man().push(data)
self._sort_unique_maps(data)
self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success))
def _sort_unique_maps(self, data: list) -> None:
u_maps = set([row[1] for row in data])
self._set_new_maps(sorted(u_maps))
def _set_new_maps(self, maps: list[str]) -> None:
self.new_maps: list[str] = maps
def _get_new_maps(self) -> list[str]:
return self.new_maps
def _get_proxy_man(self) -> "ProxyModelManager":
return self.proxy_man
@call_on_thread(dialog.filtering)
def refilter(self, mode: FilterMode) -> None:
# FIXME: causes two wait dialogs when map selection change signal emits after loading servers
self.first_iteration = False
proxy_man = self._get_proxy_man()
proxy_man.filter(mode)
self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success))
+1
View File
@@ -0,0 +1 @@
all_ok = "All OK. Loading UI..."
+2
View File
@@ -0,0 +1,2 @@
workshop = "Steam Workshop"
workshop_tooltip = "Open your Workshop subscriptions"
+24
View File
@@ -0,0 +1,24 @@
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)"
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_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"
+2
View File
@@ -0,0 +1,2 @@
waiting_for_launch = "Waiting for DayZ to launch"
waiting_for_mods = "Waiting for Steam to update mods"
+3
View File
@@ -0,0 +1,3 @@
api_validation_error = (
"API key validation error. Key was typed incorrectly or is defunct."
)
+12
View File
@@ -0,0 +1,12 @@
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)"
server_combo = "Server browser"
saved_combo = "Saved servers"
recent_combo = "Recent"
lan_combo = "LAN"
+11
View File
@@ -0,0 +1,11 @@
update_mods = "Update mods and connect"
connect = "Connect"
back = "Back"
cancel = "Cancel"
warnings = "Warnings"
errors = "Errors"
mods = "Mods"
total_mods = "Total mods: "
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."
+6
View File
@@ -0,0 +1,6 @@
mod = "Mod"
mod_id = "ID"
up_to_date = "Up to date"
modlist = "Modlist"
checkmark = "✓"
resync="⟳"
+4 -9
View File
@@ -1,27 +1,22 @@
import json
from pathlib import Path
from typing import Any
def read_json(path: Path) -> dict:
def read_json(path: Path) -> Any:
try:
with open(path, "r") as infile:
try:
data = json.load(infile)
return data
except json.decoder.JSONDecodeError as e:
raise e
except OSError as e:
raise e
return data
def write_json(data: dict, path: Path) -> None:
try:
j = json.dumps(data, indent=2)
path.write_text(j)
except Exception as e:
raise e
# TODO: Path.write_text()
try:
with open(path, "w") as outfile:
outfile.write(j)
except OSError as e:
raise e
+1
View File
@@ -1,4 +1,5 @@
import shlex
def concat_bash_args(command: str) -> list[str]:
return shlex.split(command)
+10
View File
@@ -0,0 +1,10 @@
import gi
gi.require_version("Gtk", "3.0")
from gi.repository.Gdk import SELECTION_CLIPBOARD # noqa E402
from gi.repository.Gtk import Clipboard # noqa E402
def copy_clipboard(text: str) -> None:
clipboard = Clipboard.get(SELECTION_CLIPBOARD)
clipboard.set_text(text, -1)
-16
View File
@@ -1,16 +0,0 @@
from datetime import datetime
def get_time() -> int:
return int(datetime.now().timestamp())
# TODO: deprecated
#def write_time(path: Path) -> None:
# time = get_time()
# Path.write_text(str(time))
def is_elapsed(time_before: int) -> bool:
time_now = get_time()
if time_now - time_before <= 30:
return False
else:
return True
+6 -32
View File
@@ -1,4 +1,9 @@
from importlib import resources
from dzgui.const.constants import APP_NAME_LOWER, CSS_PATH
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk # noqa E402
@@ -18,38 +23,7 @@ def remove_class(widget: Gtk.Widget, label: str) -> None:
def load_css() -> None:
# TODO: consider storing this in a data file
css = """
.invalid-entry {
border-color: red;
}
.frame {
border: 0px;
}
.toast-label {
background-color: black;
border: 1px solid white;
}
.frame > border {
border-radius: 5px;
padding: 5px;
}
.page-heading {
font-size: 1.5rem;
font-weight: 800;
margin-bottom: 0.5rem;
}
.settings-subheading {
font-size: 1.2rem;
font-weight: 700;
}
.left-label {
font-size: 1.3rem;
}
.details-heading {
font-size: 1.2rem
}
"""
css = resources.read_text(APP_NAME_LOWER, CSS_PATH)
prov = Gtk.CssProvider()
prov.load_from_data(css.encode("ascii"))
screen = Gdk.Screen.get_default()
+5 -4
View File
@@ -26,16 +26,17 @@ def get_cpu_model() -> str:
def print_servers(servers: list[str]) -> str:
s = ""
for server in servers:
s+= server + "\n"
s += server + "\n"
return s
def print_mods(mods: list[int]) -> str:
s = ""
for mod in mods:
s+= str(mod) + "\n"
s += str(mod) + "\n"
return s
def write_diagnostic(config: Path, outfile: Path) -> None:
# TODO: test availability on other distros
date = datetime.now().isoformat()
@@ -62,7 +63,7 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
servers = lookup(config, Preferences.IP_LIST)
servers_pretty = print_servers(servers)
# FIXME: extraneous newlines in lists
# FIXME: extraneous newlines in lists of mods
template = f"""\
{APP_NAME} version {version} ({branch})
Date: {date}
@@ -82,5 +83,5 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
{servers_pretty}
"""
output = "\n".join([line.lstrip() for line in template.split('\n')])
output = "\n".join([line.lstrip() for line in template.split("\n")])
outfile.write_text(output)
+47 -45
View File
@@ -1,20 +1,26 @@
import multiprocessing
import logging
from math import radians, cos, sin, asin, sqrt
from typing import TYPE_CHECKING
import dzgui.util.ip as ip
from dzgui.util.ip import GeolocationError
from dzgui.const.constants import APP_NAME
from dzgui.util.ip import get_coords
from dzgui.util.localize import number
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
if TYPE_CHECKING:
from dzgui.util.ip import Coords
from queue import Queue
from dzgui.const.enum import ServerTab
from dzgui.controllers.mc import Controller
class Haversine():
logger = logging.getLogger(APP_NAME)
class Haversine:
def __init__(self, lat1: float, lon1: float, lat2: float, lon2: float) -> None:
R = 6371 * 1000
@@ -24,7 +30,7 @@ class Haversine():
lat1 = radians(lat1)
lat2 = radians(lat2)
a = sin(dLat/2) ** 2 + cos(lat1) * cos(lat2) * sin(dLon/2) ** 2
a = sin(dLat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dLon / 2) ** 2
c = 2 * asin(sqrt(a))
self.dist = R * c
@@ -35,53 +41,49 @@ class Haversine():
def as_miles(self) -> float:
return self.dist / 1609.344
def compare(local: "Coords", remote_c: str, fmt: str) -> int | None:
if local is None:
return None
try:
# FIXME: expects XDG.ips
remote = ip.get_coords(remote_c)
except GeolocationError:
return None
def get_rounded(self, use_miles: bool) -> str:
if self is None:
return "Unknown"
if use_miles:
raw = round(self.as_miles())
separated = number(raw)
return str(separated) + " mi"
else:
raw = round(self.as_kilometers())
separated = number(raw)
return str(separated) + " km"
haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon)
if fmt == "km":
dist = haversine.as_kilometers()
else:
dist = haversine.as_miles()
return round(dist)
class CalcDist(multiprocessing.Process):
class CalcDist:
def __init__(
self,
widget: Gtk.Widget,
addr: str,
result_queue: multiprocessing.Queue,
cache: dict,
):
enum: "ServerTab",
result_queue: "Queue",
controller: "Controller",
cache: dict[str, Haversine],
) -> None:
super().__init__()
self.enum = enum
self.controller = controller
self.result_queue = result_queue
self.addr = addr
self.ip = addr.split(":")[0]
self.ip = self.addr
# TODO: pass controller correctly
def run(self) -> None:
use_miles = MainController.query_config("use_miles")
fmt = "mi" if use_miles else "km"
dist = self.compare(self.ip)
self.result_queue.put([self.addr, dist, self.enum])
# TODO: get cache accordingly
if self.addr in cache:
if fmt in cache[self.addr]:
logger.info(f"Address '{self.addr}' already in cache")
self.result_queue.put([self.addr, cache[self.addr]])
return
def compare(self, remote_ip: str) -> Haversine | None:
prefs = self.controller.get_prefs()
local = prefs.coords
if local is None:
return None
try:
remote = get_coords(prefs.paths.ips, remote_ip)
except Exception:
return None
prefs = MainController.get_prefs()
dist = compare(prefs.coords, prefs.paths.ips, self.ip, fmt)
if dist is None:
dist_pretty = "Unknown"
else:
d = number(dist)
dist_pretty = f"{d} {fmt}"
self.result_queue.put([self.addr, dist_pretty])
# TODO: handle failed remote dist
haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon)
return haversine
+23 -9
View File
@@ -1,11 +1,12 @@
import re
from dzgui.util.localize import number
from dzgui.util.strings import no_mods, no_servers, distance_suffix
from dzgui.util.strings import no_mods, no_servers, workshop
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
from gi.repository import Gtk # noqa E402
def format_pango(text: str) -> str:
@@ -46,25 +47,38 @@ def format_mods(size: int, mods: int) -> str:
l_size = number(size)
plural = pluralize("mods", mods)
# TODO: strings
suffix = "Ctrl-click to select multiple."
suffix = "Ctrl-click to select multiple; Shift-click to select a range."
return f"Found {mods:n} {plural} taking up {l_size} MiB. {suffix}"
def format_player_count(model: Gtk.TreeModel | None) -> str:
def format_mib(bits: int) -> float:
return round(bits / (1024**2), 3)
def format_server_mods(mods: int) -> str:
plural = pluralize("mods", mods)
return f"Found {mods:n} {plural}. {workshop}"
def format_player_count(model: Gtk.TreeModel | None, control: list) -> str:
players = 0
hits: int
status: str
if model is None or len(model) == 0:
if model is None:
return no_servers
else:
hits = len(model)
for row in model:
players += row[4]
players_pretty = pluralize("players", players)
control_total = len(control)
hits_pretty = pluralize("matches", hits)
suffix = distance_suffix
status = f"Found {hits:n} {hits_pretty} with {players:n} {players_pretty}"
return f"{status} | {suffix}"
players_pretty = pluralize("players", players)
hidden = control_total - hits
hidden_pretty = f" ({hidden:n} hidden)" if hidden > 0 else ""
status = (
f"Showing {hits:n} {hits_pretty}{hidden_pretty}, {players:n} {players_pretty}."
)
return status
def embolden(text: str) -> str:
+22 -22
View File
@@ -2,6 +2,7 @@ import a2s
import requests
import shutil
import subprocess
from warnings import deprecated
from dataclasses import dataclass
from typing import TYPE_CHECKING
@@ -14,6 +15,7 @@ from dzgui.const.endpoints import COORDS_API, IP_ECHO
if TYPE_CHECKING:
from pathlib import Path
@dataclass
class Coords:
lat: float
@@ -51,22 +53,24 @@ def get_coords(ips: "Path", ip: str) -> Coords:
prefix = f"^{split[0]}.{split[1]}."
proc = subprocess.run(
["/usr/bin/grep", "-E", prefix, ips],
capture_output=True,
text=True
)
if shutil.which("rg") is not None:
args = ["/usr/bin/rg", prefix, str(ips)]
else:
args = ["/usr/bin/grep", "-E", prefix, str(ips)]
proc = subprocess.run(args, capture_output=True, text=True)
if proc.returncode != 0:
raise GeolocationError("Failed to split records")
ips = proc.stdout.splitlines()
ip_list = proc.stdout.splitlines()
for address in ips:
for address in ip_list:
fields = address.split(",")
upper = fields[1]
upper_digits = ip2list(upper)
if ip == upper:
return Coords(float(fields[-2]), float(fields[-1]))
if split[2] > upper_digits[2]:
continue
@@ -79,9 +83,9 @@ def get_coords(ips: "Path", ip: str) -> Coords:
return Coords(float(fields[-2]), float(fields[-1]))
raise GeolocationError("No matching records found")
def get_local_ip() -> str:
ip = ""
# TODO: use shlex
if shutil.which("dig") is not None:
proc = subprocess.run(
[
@@ -89,10 +93,10 @@ def get_local_ip() -> str:
"-4",
"+short",
"myip.opendns.com",
"@resolver1.opendns.com"
"@resolver1.opendns.com",
],
capture_output=True,
text=True
text=True,
)
if proc.returncode == 0:
@@ -109,7 +113,7 @@ def resolve_ip(address: Record) -> Record:
"""
Multiple game modes may be hosted on the same IP and query port,
but resolve to different game ports. The canonical record must
contain the real game port. This is merely used when saving a
contain the real game port. This is merely used when saving a
record as a UUID.
"""
res = a2s.info((address.ip, address.qport))
@@ -120,17 +124,13 @@ def resolve_ip(address: Record) -> Record:
def is_valid_port(port: str) -> bool:
if len(port) < 1:
return True
if (
not port.isdigit()
or int(port) == 0
or int(port[0]) == 0
or int(port) > 65535
):
if not port.isdigit() or int(port) == 0 or int(port[0]) == 0 or int(port) > 65535:
return True
return False
# TODO: deprecated
#def get_local_coords(ip: str):
# url = COORDS_API + "/" + ip
# #local res=$(curl -Ls "$url" | jq -r '"\(.lat)\n\(.lon)"')
# return url
@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
+1
View File
@@ -1,6 +1,7 @@
import os
import locale
def number(num: int | float) -> str:
spec = ""
if type(num) is int:
-4
View File
@@ -12,10 +12,6 @@ links = {
RowType.BUGS: endpoints.GITHUB_ISSUES,
RowType.FORUM: endpoints.FORUM,
RowType.SPONSOR: endpoints.SPONSORS,
RowType.CHNG_STEAM_API: endpoints.STEAM_API_SETUP,
RowType.CHNG_BM_API: endpoints.BM_API_SETUP,
RowType.CONN_BY_ID: endpoints.BM_BROWSE,
RowType.ADD_BY_ID: endpoints.BM_BROWSE,
}
+168 -91
View File
@@ -1,7 +1,9 @@
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 "
@@ -20,6 +22,7 @@ def build_path_invalid(build: str) -> str:
)
return msg
# General
dialog_header = "DZGUI - Dialog"
dz = "DayZ"
@@ -45,16 +48,16 @@ _id = "ID"
add_note = "Add note"
edit_note = "Edit note"
show_mods = "Show server-side mods"
show_details = "Server details"
show_details = "Show server details"
refresh_players = "Refresh player count"
open_workshop = "Open in Steam Workshop"
delete_mod = "Delete mod"
copy_name = "Copy name to clipboard"
copy_ip = "Copy IP to clipboard"
copy_log = "Copy record(s) to clipboard"
add = "Add to my servers"
add = "Add to Saved Servers"
add_fav = "Set as favorite"
remove = "Remove from my servers"
remove = "Remove from Saved Servers"
remove_history = "Remove from history"
connect = "Connect"
@@ -86,27 +89,29 @@ scan_servers = "Scan LAN servers"
select_port = "Select the query port"
# Errors
error_heading = "Error"
config_not_found = "DZGUI configuration file not found. Please exit and restart."
cannot_acquire_lock = "DZGUI is already open."
something_wrong = "Something went wrong."
something_wrong = "Something went wrong. See the detailed error below."
steam_key_missing = "No Steam API key is set."
malformed_mods = "Found mods on system, but was unable to parse results."
steam_missing = "Local Steam installation is not set, possibly malformed config file."
build_corrupted = "Steam settings or DayZ installation may be corrupted. Try restarting Steam."
api_warn_msg = """No servers returned. Possible causes:
no servers in favorites/history, local network issue, or API key on cooldown.
Return to the main menu, wait 30s, and try again.
If this issue persists, your API key may be defunct.
build_corrupted = (
"Steam settings or DayZ installation may be corrupted. Try restarting Steam."
)
api_warn_msg = """No servers returned. Please wait and try again.
If this issue persists, your API key may be defunct or your network is blocking requests.
"""
server_timeout = "Timed out when querying server, check IP or try again later"
server_error = "Error while contacting server, possibly timed out. Please wait and try again."
server_protected = (
server_timeout = "Timed out when querying server, check IP or try again later."
server_error = (
"Error while contacting server, possibly timed out. Please wait and try again."
)
server_protected = (
"This server is password-protected and you will be "
"prompted when connecting. Do you want to proceed?"
)
# KeybindingsDialog
navigation = {
"Enter/space/double click": "select row item",
@@ -127,8 +132,12 @@ servers = {
"Ctrl-p": "refresh ping",
"Ctrl-f": "jump to keyword search field",
"Ctrl-m": "jump to maps field",
"Ctrl-d": "toggle dry run (debug) mode",
"Ctrl-d": "select default LAN port",
"Ctrl-n": "select custom LAN port",
"Ctrl-u": "connect to server from preconnect dialog",
"Ctrl-i": "jump to IP insert field",
"n": "navigate to next tab",
"p": "navigate to previous tab",
"ESC": "return to table",
"1-9": "toggle filter 1-9 on/off",
"0": "toggle filter 10",
@@ -140,7 +149,7 @@ vim = {
"k": "Move up a row/scroll down",
"l": "Jump to main area from sidebar",
"h": "Jump to sidebar from main area",
"gg": "Jump to first row/top of page",
"g": "Jump to first row/top of page",
"G": "Jump to last row/bottom of page",
}
key_contexts = ["Servers", "Navigation", "Vim-style keys"]
@@ -153,19 +162,12 @@ label_main_menu = "Main menu"
# buttons
ping_servers = "Ping servers"
debug_mode = "Debug mode"
keys_button = "Keys"
keys_tooltip = "Toggles the keybindings dialog"
debug_tooltip = (
"Used to perform a dry run without\n"
"actually connecting to a server"
)
ping_tooltip = (
"Refresh the ping for visible servers.\n"
"Available once per unique filter context"
"Refresh the ping for visible servers.\n" "Available once per unique filter context"
)
statusbar_helptext = "Select a row to see its detailed description"
# statusbar_helptext = "Select a row to see its detailed description"
# statusbar_helptext = "No server metadata to list."
# use e.g. filters.1pp
filter_1pp = "1PP"
@@ -234,6 +236,8 @@ class Dialog:
modlist: str
working: str
updating_mods: str
scanning: str
@dataclass(slots=True, frozen=True)
class Init:
@@ -241,6 +245,7 @@ class Init:
is_dayz_running: str
requires_steam: str
@dataclass(slots=True, frozen=True)
class Button:
servers_label: str
@@ -254,6 +259,7 @@ class Button:
exit_label: str
exit_tooltip: str
@dataclass(slots=True, frozen=True)
class Crumbs:
changelog: str
@@ -265,6 +271,8 @@ class Crumbs:
servers: str
thanks: str
developers: str
default: str
@dataclass(slots=True, frozen=True)
class Thanks:
@@ -272,6 +280,7 @@ class Thanks:
description: str
users: list[str]
@dataclass(slots=True, frozen=True)
class Options:
header: str
@@ -310,42 +319,43 @@ class Options:
testing: str
manual_sub_msg: str
init = Init(
is_steam_running = "Is Steam running? DZGUI must be run on top of Steam.",
is_dayz_running = "Is DayZ already running? DZGUI cannot launch DayZ if another process is using it.",
requires_steam = "DZGUI requires that Steam or Flatpak Steam be installed on the system.",
is_steam_running="Is Steam running? DZGUI must be run on top of Steam.",
is_dayz_running="Is DayZ already running? DZGUI cannot launch DayZ if another process is using it.",
requires_steam="DZGUI requires that Steam or Flatpak Steam be installed on the system.",
)
mod_panel = ModPanelStrings(
header = "Mod actions",
unhighlight_stale = "Unhighlight stale",
unhighlight_stale_tooltip = "Clears highlight from stale mods",
highlight_stale = "Highlight stale",
highlight_stale_tooltip = (
header="Mod actions",
unhighlight_stale="Unhighlight stale",
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"
),
delete_selected = "Delete selected",
delete_selected_tooltip = "Deletes selected mods from the system",
unselect_all = "Unselect all",
unselect_all_tooltip = "Bulk unselects all mods",
select_all = "Select all",
select_all_tooltip = "Bulk selects all mods",
bulk_select = "Bulk selects all currently highlighted mods",
clear_highlights = "Clears highlights and reverts the table to a default state",
select_stale = "Select stale",
select_stale_tooltip = "Only selects highlighted stale mods",
delete_selected="Delete selected",
delete_selected_tooltip="Deletes selected mods from the system",
unselect_all="Unselect all",
unselect_all_tooltip="Bulk unselects all mods",
select_all="Select all",
select_all_tooltip="Bulk selects all mods",
bulk_select="Bulk selects all currently highlighted mods",
clear_highlights="Clears highlights and reverts the table to a default state",
select_stale="Select stale",
select_stale_tooltip="Only selects highlighted stale mods",
)
thanks = Thanks(
header = "# Special thanks",
description = (
header="# Special thanks",
description=(
"This page recognizes beta testers, collaborators, code "
"contributors, and sponsors of the project in alphabetical order.\n"
"If you wish to be removed from this list, please submit a ticket."
),
users = [
),
users=[
"bongjutsu",
"Deku",
"dj3hac",
@@ -383,46 +393,46 @@ options = Options(
manual_dl="Manual",
auto_dl="Auto",
update="Update",
install_mode = "Mod install mode",
force_update = "Force update local mods",
dl_eventbox = (
"Manual: prompt to subscribe to mods in Steam. "
"Auto: unmanned downloads."
install_mode="Mod install mode",
force_update="Force update local mods",
dl_eventbox=(
"Manual: prompt to subscribe to mods in Steam. " "Auto: unmanned downloads."
),
force_eventbox = "Synchronize all local mods. Automatic mode must be enabled.",
self_update = (
force_eventbox="Synchronize all local mods. Automatic mode must be enabled.",
self_update=(
"Stable: only contains stable features. "
"Testing: pre-release beta, contains new features."
),
no_self_update = (
no_self_update=(
"In-app updates are disabled when DZGUI is "
"installed globally (e.g., via package manager)."
),
api_keys = "API Keys",
prefs = "Preferences",
mods = "Mods",
version = "Version",
branch = "DZGUI branch",
stable = "Stable",
testing = "Testing",
manual_sub_msg = """When switching from MANUAL to AUTO mod install mode,
api_keys="API Keys",
prefs="Preferences",
mods="Mods",
version="Version",
branch="DZGUI branch",
stable="Stable",
testing="Testing",
manual_sub_msg="""When switching from MANUAL to AUTO mod install mode,
DZGUI will manage mod installation and deletion for you.
To prevent conflicts with Steam Workshop subscriptions and old mods from being downloaded
when Steam updates, you should unsubscribe from any existing Workshop mods you manually subscribed to.
Open your Profile > Workshop Items and select 'Unsubscribe from all'
Open your Profile, then 'Workshop Items' and select 'Unsubscribe from all'
on the right-hand side.
""",
)
dialog = Dialog(
querying = "Querying server",
fetching = "Fetching server metadata",
filtering = "Filtering servers",
refreshing = "Refreshing player count",
details = "Fetching details",
modlist = "Fetching modlist",
working = "Working",
updating_mods = "Updating mods",
querying="Querying server",
fetching="Fetching server metadata",
filtering="Filtering servers",
refreshing="Refreshing player count",
details="Fetching details",
modlist="Fetching modlist",
working="Working",
updating_mods="Updating mods",
scanning="Scanning LAN ports",
)
buttons = Button(
@@ -448,37 +458,43 @@ crumbs = Crumbs(
servers="Servers > Server browser",
thanks="Help > Special thanks",
developers="Options > Developers",
default="Servers > ",
)
checkmark = "✓"
no_mods = "No local mods found."
no_servers = "No server metadata to list."
@dataclass(slots=True, frozen=True)
class Flags:
description: str
version: str
uninstall: str
developers: str
debug: str
flags = Flags(
description="DayZ server browser and mod manager",
version="Print version information",
uninstall="Uninstall data files (use prior to 'pip uninstall')",
developers="Enables developer debugging features",
debug="Enables developer debugging features",
)
@dataclass(slots=True, frozen=True)
class FilePicker:
title: str
placeholder: str
picker = FilePicker(
title = "Save diagnostic log to file",
placeholder = SYSTEM_LOG,
title="Save diagnostic log to file",
placeholder=SYSTEM_LOG,
)
api_error = "API key validation error or timeout. See 'Help > Show debug log' for details."
api_error = (
"API key validation error or timeout. See 'Help > Show debug log' for details."
)
@dataclass(slots=True, frozen=True)
@@ -488,13 +504,15 @@ class DevelopersPage:
prefs_label: str
columns: list[str]
developers = DevelopersPage(
header="Developers",
paths_label="Resolved XDG paths",
prefs_label="Boot preferences",
columns=["Key", "Value"]
columns=["Key", "Value"],
)
@dataclass(slots=True, frozen=True)
class ServerLabels:
browser: str
@@ -502,6 +520,7 @@ class ServerLabels:
recent: str
lan: str
server_labels = ServerLabels(
browser="Server Browser",
saved="Saved Servers",
@@ -509,36 +528,94 @@ server_labels = ServerLabels(
lan="LAN",
)
@dataclass(slots=True, frozen=True)
class ConnectPanel:
connect: str
add: str
edit: str
favorite: str
add_con: str
placeholder: str
entry_tooltip: str
add_tooltip: str
connect_tooltip: str
no_fav: str
connect_panel = ConnectPanel(
connect="Connect",
add="Add",
edit="Edit",
favorite="Favorite server",
add_con="Add/connect",
placeholder="Enter IP or Battlemetrics ID",
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"
"- 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 my servers",
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.",
)
distance_suffix = "Distance: calculating..."
refresh = "Refresh"
refresh_tooltip = "Refresh server data"
@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"
@dataclass(slots=True, frozen=True)
class AtomicButton:
refresh: str
refresh_tooltip: str
copy: str
keys: str
keys_tooltip: str
atomic_buttons = AtomicButton(
refresh="Refresh",
refresh_tooltip="Refresh server data",
copy="Copy",
keys="Keys",
keys_tooltip="Toggles the keybindings dialog",
)
steam_icon_missing = "Steam icon not found in IconTheme"
missing_changelog = "Error: failed to read changelog"
esc_to_return = "Press ESC to return"
question_to_return = "Press ? to return"
alert_button_tooltip = "Click to open debug log"
address_already_saved = "This address is already in your Saved Servers"
stop_scanning = "Stop scanning on first hit"
abort_tooltip = "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster"
+7 -8
View File
@@ -2,18 +2,18 @@ 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
from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME
from dzgui.const.enum import Preferences
from dzgui.config.query import lookup
import dzgui.api.pefile as PeFile
logger = logging.getLogger(__name__)
logger = logging.getLogger(APP_NAME)
def rebuild_symlinks(config: Path) -> None:
path = lookup(config, Preferences.DEFAULT)
steam_path = Path(path)
dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
# NOTE: unlink stale symlinks
for file in dayz_path.iterdir():
@@ -27,19 +27,18 @@ def rebuild_symlinks(config: Path) -> None:
source = Path(dayz_path / md5sum)
if source.exists() is False:
source.symlink_to(workshop / uid)
clone_symlinks(steam_path)
def clone_symlinks(config: Path) -> None:
def clone_symlinks(steam_path: Path) -> None:
"""
Used after any symlink operation
Shares symlinks between builds. Used after any symlink operation
"""
path = lookup(config, Preferences.DEFAULT)
steam_path = Path(path)
try:
dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
exp_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ_EXP)
except Exception as e:
logger.critical(e)
logger.warning(e)
return
# TODO: test these two operations
+104 -214
View File
@@ -1,77 +1,51 @@
import json
import locale
import logging
import multiprocessing
import os
import re
import signal
import subprocess
import textwrap
import threading
import typing # noqa
import warnings
#from concurrent.futures import wait
#from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Literal
from dzgui.const.enum import NotebookPage, VAdjustment
from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, STEAM_ICON
from dzgui.const.enum import NotebookPage
from dzgui.controllers.emitter import Emitter
from dzgui.controllers.mc import Controller
from dzgui.util import css, strings
from dzgui.util.format import embolden
from dzgui.util.keys import is_ctrl_mask
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.scrollable_mixin import ScrollableMixin
# NOTEBOOK ITEMS
# TODO: import notebook only and add components there?
from dzgui.views.pages.changelog import Changelog
from dzgui.views.components.connect_panel import ConnectPanel
from dzgui.views.pages.devs import Developers
from dzgui.views.pages.help import Help
from dzgui.views.pages.keys import Keybindings
from dzgui.views.pages.log import Log
from dzgui.views.pages.mods import Mods
from dzgui.views.pages.options import Options
from dzgui.views.pages.preconnect import PreConnectionAssistant
from dzgui.views.pages.servers import ServerNotebook
from dzgui.views.pages.thanks import Thanks
from dzgui.views.components.statusbar import Statusbar
from dzgui.views.components.mod_panel import ModSelectionPanel
from dzgui.views.components.right_panel import RightPanel
from dzgui.views.components.toast import Toast
from dzgui.views.dialogs.generic import GenericDialog
from dzgui.views.mixins.scrollable_mixin import ScrollableMixin
import gi
# TREES
from dzgui.views.trees.tree_menu import MenuTreeView
from dzgui.views.trees.tree_log import LogTreeView
from dzgui.views.trees.tree_mods import ModTreeView
from dzgui.views.trees.tree_servers import ServerTreeView
# TODO: not going to be in base anymore
import dzgui.util._json as JSON # noqa
gi.require_version("Gtk", "3.0")
gi.require_version("GLibUnix", "2.0")
from gi.repository import Gtk, GLib, GLibUnix, Gdk # type: ignore # noqa E402
if TYPE_CHECKING:
from dzgui.config.userprefs import UserPrefs
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402
logger = logging.getLogger(APP_NAME)
logger = logging.getLogger(__name__)
# TODO: drop
# https://bugzilla.gnome.org/show_bug.cgi?id=708676
warnings.filterwarnings("ignore", ".*g_value_get_int", Warning)
# TODO: move to controller
# kilometer cache; note, user may change measurement it partway through, flush cache
#cache: dict[str, int] = {}
## TODO: move to configs/servers
#def query_history() -> list | None:
# def query_history() -> list | None:
# history_file = MainController.get_prefs().paths.history
# try:
# with open(history_file, "r") as f:
@@ -80,121 +54,19 @@ warnings.filterwarnings("ignore", ".*g_value_get_int", Warning)
# rows = None
# return rows
#
#def process_tree_option(choice: RowType) -> None:
# # server tables
# if command == RowType.RESOLVE_IP:
# record = treeview.get_record()
# wait_msg = command.dict["wait_msg"]
# show_wait_dialog = True
#
# # TODO: needs threading, this is a slow process
# # TODO: needs saving into config
# # TODO: used by add/remove servers
# real_ip = ip.resolve_ip(record)
# #call_on_thread(
# # show_wait_dialog, cmd_string, wait_msg, record, choice=choice
# #)
# return
#
# if command == RowType.QUICK_CONNECT:
# record = MainController.query_config(Preferences.FAV_SRV)
# if record == "":
# spawn_dialog("No favorite server currently set", Popup.NOTIFY)
# return
#
# record = str_to_record(record)
# thread_new_with_dialog(
# treeview.prepare_connection,
# parse_shell_output,
# "Querying server",
# command,
# [record],
# )
# return
#
## TODO: belongs in model
#def str_to_record(record: str) -> Record | None:
# def str_to_record(record: str) -> Record | None:
# r = record.split(":")
# if len(r) != 3:
# return None
# return Record(r[0], int(r[1]), int(r[2]))
#
## TODO: ibid
#def record_to_str(record: Record) -> str:
# def record_to_str(record: Record) -> str:
# return f"{record.ip}:{record.gameport}:{record.qport}"
#
#
#def connect_by_ip(enum: RowType, response: str) -> None:
# def _prep(response: str) -> None:
# record = Servers.validate_ip(response)
# proc = treeview.prepare_connection(record)
# return proc
#
# thread_new_with_dialog(
# _prep, parse_shell_output, "Querying IP", enum, [response]
# )
# return
#
#
#def connect_by_id(enum: RowType, uid: str, key: str) -> None:
# def _prep(key: str, response: str) -> None:
# # TODO: if response is non numeric, raise dialog
# if response.isnumeric() is False:
# pass
# # raise error
# # raise BmIdError("ID must be numeric only")
# # return
# from dzgui.api.bm import map_id_to_record
# try:
# record = map_id_to_record(config, uid)
# except Exception as e:
# logger.critical(e)
# # raise dialog
# return
# # proc = treeview.prepare_connection(record)
# # return proc
#
# #thread_new_with_dialog(
# # _prep, parse_shell_output, "Querying API", enum, [key, response]
# #)
# #return
#
#
#def process_user_input(enum: RowType) -> None:
# prompt = enum.dict["prompt"]
# link_label = enum.dict["link_label"]
# cmd_string = enum.dict["label"]
#
# if enum == RowType.CONN_BY_ID:
# key = MainController.query_config(Preferences.BM)
# if len(key) == 0:
# spawn_dialog(
# "No Battlemetrics API key is set; see Options", Popup.NOTIFY
# )
# return
#
# user_entry = EntryDialog(prompt, Popup.ENTRY, link_label, button_type=enum)
# response = user_entry.get_input()
#
# if response is None:
# logger.info("User aborted entry dialog")
# return
# logger.info(f"User entered: '{response}'")
#
# if enum == RowType.CONN_BY_IP:
# connect_by_ip(enum, response)
# return
#
# if enum == RowType.CONN_BY_ID:
# connect_by_id(enum, response, key)
# return
#
# show_wait_dialog = True
# wait_msg = "Working"
# call_on_thread(
# show_wait_dialog, cmd_string, wait_msg, response, choice=enum
# )
# return
class OuterWindow(Gtk.Window):
def __init__(self) -> None:
@@ -214,17 +86,20 @@ class OuterWindow(Gtk.Window):
MainController.set_resolution(self)
self.show_all()
css.load_css()
MainController.open_page(NotebookPage.SERVERS)
def _on_delete_event(
self, window: "OuterWindow", event: Gdk.EventKey
) -> None:
MainController.open_page(NotebookPage.SERVERS)
MainController.set_start_tab()
self.grid.hide_widgets_on_init()
# TODO: POC, trigger page change here
MainController.loaded = True
MainController.populate_model(MainController.get_active_treeview())
def _on_delete_event(self, window: "OuterWindow", event: Gdk.EventKey) -> None:
self.halt_proc_and_quit()
def halt_proc_and_quit(self) -> None:
MainController.terminate_process()
MainController.save_res_and_quit()
@@ -241,23 +116,24 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore
super().__init__(show_tabs=False)
self.prior_page: NotebookPage
self.prior_status: str
MainController.register_widget("notebook", self)
self.help = Help(MainController)
self.clog = Changelog(MainController)
self.keys = Keybindings()
self.keys = Keybindings(MainController)
self.settings = Options(MainController)
self.servers = ServerNotebook(MainController)
self.mods = Mods(MainController)
self.thanks = Thanks()
self.thanks = Thanks(MainController)
self.log = Log(MainController)
self.developers = Developers(MainController)
self.connection = PreConnectionAssistant(MainController)
self.pages = {
self.help: NotebookPage.HELP,
self.clog: NotebookPage.CHANGELOG,
@@ -268,6 +144,7 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore
self.log: NotebookPage.LOG,
self.thanks: NotebookPage.THANKS,
self.developers: NotebookPage.DEVELOPERS,
self.connection: NotebookPage.CONNECTION,
}
self.indexes = {}
@@ -285,14 +162,18 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore
self.connect_after("switch-page", self._on_page_changed)
self.connect("key-press-event", self._on_keypress)
def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None:
def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> bool:
match event.keyval:
case Gdk.KEY_Right | Gdk.KEY_l:
if event.state is Gdk.ModifierType.CONTROL_MASK:
return
MainController.focus_button_box()
if is_ctrl_mask(event):
return False
MainController.get_emitter().emit("request_button_box_focus")
return True
case Gdk.KEY_question:
self.toggle_keybindings()
return True
case _:
return False
def return_prior(self) -> None:
"""
@@ -310,20 +191,21 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore
self.settings.unblock_text_entry()
return
self.set_page_by_enum(self.prior_page)
MainController.set_statusbar(self.prior_status)
def get_page_by_enum(self) -> NotebookPage | None:
def get_prior_page(self) -> NotebookPage:
return self.prior_page
def get_page_by_enum(self) -> NotebookPage:
for k, v in self.indexes.items():
if v == self.get_current_page():
return k
return None
raise ValueError("No notebook pages set")
def toggle_keybindings(self) -> None:
cur_page = self.get_page_by_enum()
if cur_page == NotebookPage.KEYS:
self.return_prior()
else:
self.prior_status = MainController.get_statusbar()
self.set_page_by_enum(NotebookPage.KEYS)
def focus_current(self) -> None:
@@ -331,16 +213,8 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore
if widget is None:
return
if widget is self.servers:
MainController.grab_active_treeview()
return
w = widget.get_children()[0]
try:
w.focus_first_row()
w.grab_focus()
except Exception as e:
w.grab_focus()
if hasattr(widget, "grab_content_area"):
widget.grab_content_area()
def get_page(self) -> Gtk.Widget | None:
ind = self.get_current_page()
@@ -359,22 +233,7 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore
def _on_page_changed(
self, notebook: "Notebook", page: Gtk.Widget, page_num: int
) -> None:
enum = self.get_page_by_enum()
if enum is not None:
crumbs = enum.dict["crumbs"]
status = enum.dict["statusbar"]
MainController.set_crumbs(crumbs)
is_mods = True if enum is NotebookPage.MODS else False
is_servers = True if enum is NotebookPage.SERVERS else False
MainController.toggle_mod_panel(is_mods)
MainController.toggle_server_panels(is_servers)
if status is False:
MainController.set_statusbar("")
if enum is NotebookPage.SERVERS:
MainController.present_servers()
pass
class Grid(Gtk.Grid):
@@ -388,47 +247,58 @@ class Grid(Gtk.Grid):
MainController.register_widget("grid", self)
self.statusbar = Statusbar(MainController)
self.right_panel = RightPanel(MainController)
self.breadcrumbs = Gtk.Label(halign=Gtk.Align.START)
self.set_breadcrumbs(strings.label_main_menu)
self.emitter = MainController.get_emitter()
self.notebook = Notebook()
self.conpan = ConnectPanel(MainController)
self.statusbar = Statusbar(MainController)
self.right_panel = RightPanel(MainController)
self.breadcrumbs = Breadcrumbs(MainController)
self.crumb_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.crumb_box.add(self.breadcrumbs)
self.attach(self.notebook, 0, 0, MAX_COLS, SINGLE_ROW)
els = (
(self.breadcrumbs, self.notebook, Gtk.PositionType.TOP, MAX_COLS, SINGLE_ROW),
(self.crumb_box, self.notebook, Gtk.PositionType.TOP, MAX_COLS, SINGLE_ROW),
(self.conpan, self.notebook, Gtk.PositionType.BOTTOM, MAX_COLS, SINGLE_ROW),
(self.statusbar, self.conpan, Gtk.PositionType.BOTTOM, MAX_COLS, SINGLE_ROW),
(self.right_panel, self.notebook, Gtk.PositionType.RIGHT, SINGLE_COL, MAX_ROWS),
(
self.statusbar,
self.conpan,
Gtk.PositionType.BOTTOM,
MAX_COLS,
SINGLE_ROW,
),
(
self.right_panel,
self.notebook,
Gtk.PositionType.RIGHT,
SINGLE_COL,
MAX_ROWS,
),
)
for el, sibling, pos, h_span, v_span in els:
self.attach_next_to(el, sibling, pos, h_span, v_span)
self.show_all()
def toggle_filter_panel(self, state: bool) -> None:
self.emitter.connect("server_page_toggled", self.toggle_filter_panels)
def hide_widgets_on_init(self) -> None:
self.conpan.set_lan_visible(False)
self.right_panel.sel_panel.hide()
def toggle_filter_panels(self, emitter: "Emitter", state: bool) -> None:
self.right_panel.filters_vbox.set_visible(state)
def toggle_connect_panel(self, state: bool) -> None:
self.conpan.set_visible(state)
def toggle_refresh_button(self, state: bool) -> None:
self.right_panel.refresh_button.set_visible(state)
# TODO make this method internal to Statusbar
def get_breadcrumbs(self) -> str:
return self.breadcrumbs.get_text()
def set_breadcrumbs(self, text: str) -> None:
crumbs = embolden(text)
self.breadcrumbs.set_markup(crumbs)
class App(Gtk.Application):
def __init__(self, prefs: "UserPrefs") -> None:
GLib.set_prgname(APP_NAME)
MainController.set_prefs(prefs)
@@ -443,12 +313,25 @@ class App(Gtk.Application):
)
self.win.add_accel_group(accel)
GLib.unix_signal_add(
GLib.PRIORITY_DEFAULT, signal.SIGINT, self._catch_sigint
)
self._setup_signals()
Gtk.main()
def _setup_signals(self) -> None:
SIGNAL_ADD = "signal_add"
SIGNAL_ADD_FULL = "signal_add_full"
try:
if SIGNAL_ADD in dir(GLibUnix):
func = GLibUnix.signal_add
elif SIGNAL_ADD_FULL in dir(GLibUnix):
func = GLibUnix.signal_add_full
else:
func = GLib.unix_signal_add
func(GLib.PRIORITY_DEFAULT, signal.SIGINT, self._catch_sigint)
except Exception as e:
logger.critical(e)
def _catch_sigint(self) -> Literal[True]:
MainController.set_exit_event()
self.win.halt_proc_and_quit()
return True
@@ -461,4 +344,11 @@ class App(Gtk.Application):
) -> None:
self.win.halt_proc_and_quit()
MainController = Controller()
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)
+26 -10
View File
@@ -1,26 +1,38 @@
import logging
from typing import TYPE_CHECKING
from dzgui.const.constants import APP_NAME, NO_EXPAND, NO_FILL, NO_PADDING
from dzgui.const.enum import ButtonType
from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk # noqa E402
logger = logging.getLogger(__name__)
logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING:
from dzgui.const.enum import NotebookPage
from dzgui.controllers.mc import Controller
from dzgui.controllers.emitter import Emitter
class ContextualButton(Gtk.Button):
def __init__(self, label, opens, tooltip, context):
def __init__(
self, label: str, opens: "NotebookPage", tooltip: str, context: ButtonType
) -> None:
super().__init__(
label=label,
tooltip_text=tooltip
tooltip_text=tooltip,
focus_on_click=False,
)
self.context = context
self.opens = opens
class ButtonBox(Gtk.Box):
def __init__(self, controller) -> None:
def __init__(self, controller: "Controller") -> None:
super().__init__(
spacing=6,
margin_top=0,
@@ -30,6 +42,9 @@ class ButtonBox(Gtk.Box):
)
self.controller = controller
self.emitter = controller.get_emitter()
self.emitter.connect("request_button_box_focus", self._focus_first_button)
self.buttons = list()
self.connect("key-press-event", self._on_keypress)
prefs = controller.get_prefs()
@@ -40,9 +55,8 @@ class ButtonBox(Gtk.Box):
opens=side_button.dict["opens"],
tooltip=side_button.dict["tooltip"],
context=side_button,
)
)
# FIXME: if debug log fails to load, still opens table
size = (10, 10) if prefs.is_steam_deck else (50, 50)
x, y = size
button.set_size_request(x, y)
@@ -51,8 +65,10 @@ class ButtonBox(Gtk.Box):
button.connect("clicked", self._on_selection_button_clicked)
self.pack_start(button, NO_EXPAND, NO_FILL, NO_PADDING)
def _focus_first_button(self, emitter: "Emitter") -> None:
self.buttons[0].grab_focus()
def _on_selection_button_clicked(self, button: Gtk.Button) -> None:
def _on_selection_button_clicked(self, button: ContextualButton) -> None:
self.controller.open_page_by_button(button)
def _walk_buttons(self, increment: int) -> None:
@@ -63,8 +79,8 @@ class ButtonBox(Gtk.Box):
return
if n == -1:
return
n = self.buttons[n]
n.grab_focus()
b = self.buttons[n]
b.grab_focus()
return
def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None:
+175 -13
View File
@@ -1,18 +1,60 @@
from dzgui.util.strings import refresh
from dzgui.views.components.icon import Icon
from dzgui.const.constants import REFRESH_ICON, WEB_BROWSER, INPUT_KEYBOARD
from typing import Callable, Self, TYPE_CHECKING
from dzgui.util.clip import copy_clipboard
from dzgui.util.format import pluralize
from dzgui.strings import buttons
from dzgui.util.strings import (
alert_button_tooltip,
atomic_buttons,
connect_panel,
)
from dzgui.const.constants import (
CLIPBOARD,
INPUT_KEYBOARD,
LIST_ADD,
REFRESH_ICON,
STEAM_ICON,
WARNING,
WEB_BROWSER,
)
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 gi.repository import GLib
from dzgui.controllers.mc import Controller
from dzgui.controllers.emitter import Emitter
from dzgui.const.enum import ServerTab
class Icon(Gtk.Image):
def __init__(self, name: str, l_margin: int = 0) -> None:
super().__init__(
icon_name=name,
icon_size=Gtk.IconSize.BUTTON,
margin_start=l_margin,
ypad=2,
)
class LargeIcon(Gtk.Image):
def __init__(self, name: str, l_margin: int = 5) -> None:
super().__init__(
icon_name=name, icon_size=Gtk.IconSize.LARGE_TOOLBAR, margin_start=l_margin
)
class IconButton(Gtk.Button):
def __init__(self, icon: str, margin: int = 0) -> None:
super().__init__()
i = Icon(icon, l_margin=margin)
self.set_image(i)
self.icon = Icon(icon, l_margin=margin)
self.set_image(self.icon)
self.set_image_position(Gtk.PositionType.RIGHT)
self.set_focus_on_click(False)
class IconTextButton(IconButton):
def __init__(self, icon: str, label: str) -> None:
@@ -20,6 +62,26 @@ class IconTextButton(IconButton):
self.set_label(label)
class LargeIconTextButton(IconButton):
def __init__(self, icon: str, label: str) -> None:
super().__init__(icon)
self.set_label(label)
self.set_image(LargeIcon(icon))
class ClipboardButton(IconTextButton):
def __init__(self, controller: "Controller", func: Callable) -> None:
super().__init__(CLIPBOARD, atomic_buttons.copy)
self.controller = controller
self.connect("clicked", self._on_button_clicked, func)
self.set_tooltip_text("Copy IP to clipboard")
def _on_button_clicked(self, button: Self, func: Callable) -> None:
data = func()
copy_clipboard(data)
class WebButton(IconTextButton):
def __init__(self, label: str) -> None:
super().__init__(icon=WEB_BROWSER, label=label)
@@ -27,15 +89,115 @@ class WebButton(IconTextButton):
class RefreshButton(IconTextButton):
def __init__(self) -> None:
super().__init__(icon=REFRESH_ICON, label=refresh)
def __init__(self, controller: "Controller") -> None:
super().__init__(
icon=REFRESH_ICON,
label=atomic_buttons.refresh,
)
self.controller = controller
emitter = self.controller.get_emitter()
self.loading = False
self.time = 30
self.set_margin_top(10)
self.set_margin_start(80)
self.set_margin_end(80)
self.set_tooltip_text(atomic_buttons.refresh_tooltip)
self.connect("clicked", self._on_refresh_clicked)
emitter.connect("servers_loaded", self.start_decrement)
def _on_refresh_clicked(self, button: Self) -> None:
"""Spawned in a thread"""
self.loading = True
self.controller.refresh_tree()
# TODO: get server tab enum
# if LAN tab, reload existing entries in place
def start_decrement(self, emitter: "Emitter", tab: "ServerTab") -> None:
if self.loading:
self.set_sensitive(False)
self.loading = False
self.show_time(True)
GLib.timeout_add_seconds(1, self.decrement)
def decrement(self) -> bool:
self.time -= 1
if self.time == 0:
self.time = 30
self.show_time(False)
self.set_sensitive(True)
return False
self.show_time(True)
return True
def show_time(self, state: bool) -> None:
if state is True:
self.set_label(f"{atomic_buttons.refresh} ({str(self.time)})")
else:
self.set_label(atomic_buttons.refresh)
class KeysButton(IconTextButton):
def __init__(self, controller: "Controller") -> None:
super().__init__(icon=INPUT_KEYBOARD, label=atomic_buttons.keys)
self.controller = controller
self.set_margin_top(10)
self.set_margin_start(80)
self.set_margin_end(80)
class KeysButton(IconTextButton):
self.set_tooltip_text(atomic_buttons.keys_tooltip)
self.connect("clicked", self._on_keys_clicked)
def _on_keys_clicked(self, button: Gtk.Button) -> None:
self.controller.open_keybindings()
class SteamConnectButton(LargeIconTextButton):
def __init__(self) -> None:
super().__init__(icon=STEAM_ICON, label=connect_panel.connect)
self.set_tooltip_text(connect_panel.connect_tooltip)
class SteamTextButton(SteamConnectButton):
def __init__(self, label: str) -> None:
super().__init__(icon=INPUT_KEYBOARD, label=label)
self.set_margin_top(10)
self.set_margin_start(80)
self.set_margin_end(80)
super().__init__()
self.set_label(label)
class SteamWorkshopButton(SteamTextButton):
def __init__(self) -> None:
super().__init__(label=buttons.workshop)
self.set_tooltip_text(buttons.workshop_tooltip)
class AddButton(IconTextButton):
def __init__(self) -> None:
super().__init__(icon=LIST_ADD, label=connect_panel.add)
self.set_tooltip_text(connect_panel.add_tooltip)
class LoggerAlertsButton(IconTextButton):
def __init__(self, warnings: int, errors: int) -> None:
warnings_text = ""
errors_text = ""
separator = ""
if warnings > 0:
warnings_plural = pluralize("warnings", warnings)
warnings_text = f"{warnings} {warnings_plural}"
if errors > 0:
errors_plural = pluralize("errors", errors)
errors_text = f" {errors} {errors_plural}"
if warnings + errors > 1:
separator = ","
concat = warnings_text + separator + errors_text
super().__init__(
icon=WARNING,
label=f"Loaded with{concat}",
)
self.set_halign(Gtk.Align.END)
self.set_hexpand(True)
self.set_tooltip_text(alert_button_tooltip)
+256 -84
View File
@@ -1,114 +1,286 @@
from typing import TYPE_CHECKING
from dzgui.api.servers import validate_ip
from dzgui.util.css import add_class, remove_class
from dzgui.util.strings import connect_panel
from dzgui.views.components.buttons import WebButton
from dzgui.const.constants import UDP_PORT
from dzgui.model.servers import ServerModelManager
from dzgui.strings import connect_panel
from dzgui.util.keys import is_ctrl_mask
from dzgui.views.components.buttons import (
AddButton,
ClipboardButton,
SteamConnectButton,
)
from dzgui.views.components.entry import IpEntry, PortEntry
from dzgui.views.components.labels import BoldLabel
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk # noqa E402
from gi.repository import Gtk, Gdk # noqa E402
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
from dzgui.controllers.mc import Controller, Emitter
class ConnectPanel(Gtk.Frame):
COLS = 1
ROWS = 1
class LanPanel(Gtk.Frame):
def __init__(self, controller: "Controller") -> None:
super().__init__(margin_top=10, margin_bottom=5)
COLS = 1
ROWS = 1
self.classname = "invalid-entry"
self.controller = controller
self.emitter = controller.get_emitter()
self.entry = Gtk.Entry(
placeholder_text=connect_panel.placeholder,
hexpand=True,
tooltip_text=connect_panel.entry_tooltip
label = BoldLabel(connect_panel.lan_heading)
self.set_label_widget(label)
self.default_radio = Gtk.RadioButton.new_with_label(
None, connect_panel.lan_default_button
)
self.entry.connect("key-press-event", self._on_entry_keypress)
self.entry.connect("changed", self._on_text_changed)
# TODO: get ip as well?
user_fav = self.controller.get_favorite_label()
server_name = user_fav if user_fav is not None else connect_panel.no_fav
self.fav_label = Gtk.Label(label=server_name, halign=Gtk.Align.START)
scrollable_label = Gtk.ScrolledWindow()
scrollable_label.add(self.fav_label)
self.fav_button = Gtk.Button(label=connect_panel.connect,
tooltip_text=connect_panel.connect_tooltip
)
self.fav_edit = Gtk.Button(label=connect_panel.edit)
self.edit_server = WebButton(label="EDIT")
self.conn_server= Gtk.Button(label=connect_panel.connect,
tooltip_text=connect_panel.connect_tooltip
)
self.add_server = Gtk.Button(label=connect_panel.add,
tooltip_text=connect_panel.add_tooltip
self.custom_radio = Gtk.RadioButton.new_with_label_from_widget(
self.default_radio, connect_panel.lan_custom_button
)
self.conn_server.set_sensitive(False)
self.add_server.set_sensitive(False)
if server_name is None:
self.fav_button.set_sensitive(False)
self.entry = PortEntry(controller)
self.scan = Gtk.Button(label=connect_panel.lan_scan_button)
self.scan.connect("clicked", self._on_scan_clicked)
add_label = BoldLabel(connect_panel.add_con)
conn_label = BoldLabel(connect_panel.favorite)
# TODO: strings
self.early_abort = Gtk.CheckButton(label=connect_panel.lan_checkbox)
self.early_abort.set_active(True)
self.early_abort.set_tooltip_text(connect_panel.lan_abort_tooltip)
self.entry.connect("activate", self._on_entry_activated)
self.entry.connect("string_validated", self._on_port_validated)
self.entry.connect("key-release-event", self._on_lan_keypress)
self.emitter.connect("request_custom_port_focus", self._on_custom_port_binding)
self.emitter.connect(
"request_default_port_focus", lambda _: self.default_radio.set_active(True)
)
self.default_radio.connect("toggled", self._on_radio_toggled)
self.grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5)
self.grid.attach(conn_label, 0, 0, COLS, ROWS)
els = (
(scrollable_label, conn_label, Gtk.PositionType.RIGHT, 3, ROWS),
(self.fav_button, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS),
(add_label, conn_label, Gtk.PositionType.BOTTOM, COLS, ROWS),
(self.entry, scrollable_label, Gtk.PositionType.BOTTOM, COLS, ROWS),
(self.add_server, self.fav_button, Gtk.PositionType.BOTTOM, COLS, ROWS),
(self.conn_server, self.add_server, Gtk.PositionType.RIGHT, COLS, ROWS),
self.grid.attach(self.default_radio, 0, 0, COLS, ROWS)
self.grid.attach_next_to(
self.custom_radio, self.default_radio, Gtk.PositionType.RIGHT, COLS, ROWS
)
self.grid.attach_next_to(
self.entry, self.custom_radio, Gtk.PositionType.RIGHT, COLS, ROWS
)
self.grid.attach_next_to(
self.scan, self.entry, Gtk.PositionType.RIGHT, COLS, ROWS
)
self.grid.attach_next_to(
self.early_abort, self.scan, Gtk.PositionType.RIGHT, COLS, ROWS
)
for el, sibling, pos, h_span, v_span in els:
self.grid.attach_next_to(el, sibling, pos, h_span, v_span)
self.add(self.grid)
def mark_valid(self) -> None:
self.conn_server.set_sensitive(True)
self.add_server.set_sensitive(True)
remove_class(self.entry, self.classname)
self.entry.set_sensitive(False)
def mark_invalid(self) -> None:
def _on_lan_keypress(self, widget: Gtk.Entry, event: Gdk.EventKey) -> bool:
if is_ctrl_mask(event):
if event.keyval == Gdk.KEY_d:
self.default_radio.set_active(True)
self.controller.grab_active_treeview()
return True
return False
def _on_custom_port_binding(self, emitter: "Emitter") -> None:
self.custom_radio.set_active(True)
self.entry.grab_focus()
def _on_entry_activated(self, entry: Gtk.Entry) -> None:
if self.entry.get_text() == "":
return
self.scan_ports()
def _on_scan_clicked(self, button: Gtk.Button) -> None:
self.scan_ports()
def scan_ports(self) -> None:
if self.default_radio.get_active():
port = UDP_PORT
else:
port = int(self.entry.get_text())
abort = self.early_abort.get_active()
smm = ServerModelManager(self.controller, self.controller.get_active_treeview())
smm.dump_lan(port, abort)
def _on_radio_toggled(self, button: Gtk.RadioButton) -> None:
state = button.get_active()
self.entry.set_sensitive(not state)
if state:
self.scan.set_sensitive(True)
else:
self.entry.grab_focus()
if len(self.entry.get_text()) < 1:
self.scan.set_sensitive(False)
def _on_port_validated(self, entry: Gtk.Entry, state: bool) -> None:
self.scan.set_sensitive(state)
class FavPanel(Gtk.Frame):
def __init__(self, controller: "Controller") -> None:
super().__init__(margin_top=10, margin_bottom=5)
label = BoldLabel(connect_panel.fav_heading)
self.set_label_widget(label)
self.controller = controller
emitter = self.controller.get_emitter()
emitter.connect("fav_server_changed", self._on_fav_server_changed)
self.server_name = ""
self.server_ip = ""
favorite = self.controller.get_config_man().get_favorite()
if favorite is None:
server_name = connect_panel.favs_empty
else:
self.server_name, self.server_ip = favorite
server_name = f"{self.server_name} ({self.server_ip})"
self.fav_label = Gtk.Label(
label=server_name,
track_visited_links=False,
halign=Gtk.Align.START,
hexpand=True,
)
self.fav_button = SteamConnectButton()
self.fav_button.connect("clicked", self._on_connect_clicked)
self.copy_button = ClipboardButton(self.controller, self.get_fav_ip)
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)
grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5)
grid.attach(scrollable_label, 0, 0, 3, ROWS)
grid.attach_next_to(
self.copy_button, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS
)
grid.attach_next_to(
self.fav_button, self.copy_button, Gtk.PositionType.RIGHT, COLS, ROWS
)
self.add(grid)
def toggle_buttons(self, state: bool) -> None:
for button in self.fav_button, self.copy_button:
button.set_sensitive(state)
def _on_connect_clicked(self, button: Gtk.Button) -> None:
self.controller.connect_by_str(self.server_ip)
def get_fav_ip(self) -> str:
return self.server_ip
def _on_fav_server_changed(
self, emitter: "Emitter", name: str, record: str
) -> None:
self.server_name = name
self.server_ip = record
self.fav_label.set_text(f"{name} ({record})")
if self.server_name != "":
self.toggle_buttons(True)
class AddPanel(Gtk.Frame):
def __init__(self, controller: "Controller") -> None:
super().__init__(margin_top=10, margin_bottom=5)
self.controller = controller
self.emitter = controller.get_emitter()
self.classname = "invalid-entry"
label = BoldLabel("Connect")
self.set_label_widget(label)
self.add_server = AddButton()
self.add_server.connect("clicked", self._on_add_clicked)
self.conn_server = SteamConnectButton()
self.conn_server.connect("clicked", self._on_connect_clicked)
self.conn_server.set_sensitive(False)
self.add_server.set_sensitive(False)
add_class(self.entry, self.classname)
def _on_text_changed(self, entry: Gtk.Entry) -> None:
# TODO: on submission, strip whitespace and newlines
text = entry.get_text()
if len(text) < 1:
self.conn_server.set_sensitive(False)
self.add_server.set_sensitive(False)
remove_class(entry, self.classname)
return
try:
validate_ip(text)
self.mark_valid()
except Exception:
if text.isdigit():
self.mark_valid()
else:
self.mark_invalid()
self.entry = IpEntry(controller)
self.entry.connect("activate", self._on_activate)
self.entry.connect("string_validated", self._on_ip_validated)
self.emitter.connect(
"request_ip_entry_focus", lambda _: self.entry.grab_focus()
)
self.emitter.connect("already_saved_server", self._on_duplicate_server)
def set_fav_label(self, text: str) -> None:
# TODO: called by controller when changing fav
self.fav_label.set_text(text)
self.grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5)
self.grid.attach(self.entry, 0, 0, COLS, ROWS)
self.grid.attach_next_to(
self.add_server, self.entry, Gtk.PositionType.RIGHT, COLS, ROWS
)
self.grid.attach_next_to(
self.conn_server, self.add_server, Gtk.PositionType.RIGHT, COLS, ROWS
)
def _on_entry_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> None:
if event.keyval == Gdk.KEY_Escape:
# NOTE: unselect text
entry.select_region(0, 0)
self.controller.grab_active_treeview()
self.add(self.grid)
self.pop = Gtk.Popover()
self.pop_label = Gtk.Label(
label=connect_panel.add_popover,
margin_start=10,
margin_end=10,
)
self.pop.add(self.pop_label)
# NOTE: render once to draw text in bubble
self.pop.show_all()
self.pop.set_margin_start(10)
self.pop.set_relative_to(self.entry)
self.pop.popdown()
def _on_duplicate_server(self, emitter: "Emitter") -> None:
self.pop.popup()
def _on_connect_clicked(self, button: Gtk.Button) -> None:
addr = self.entry.get_text()
self.controller.connect_by_str(addr)
def _add_server(self) -> None:
addr = self.entry.get_text()
self.controller.add_by_str(addr)
def _on_activate(self, entry: Gtk.Entry) -> None:
# NOTE: default action is to add a record, not connect
self.add_server.emit("clicked")
def _on_add_clicked(self, button: Gtk.Button) -> None:
self._add_server()
def _on_ip_validated(self, entry: Gtk.Entry, state: bool) -> None:
self.conn_server.set_sensitive(state)
self.add_server.set_sensitive(state)
class ConnectPanel(Gtk.Box):
def __init__(self, controller: "Controller") -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.controller = controller
emitter = self.controller.get_emitter()
emitter.connect("lan_tab_toggled", self._on_lan_tab_toggled)
self.lan = LanPanel(controller)
self.fav = FavPanel(controller)
self.add_panel = AddPanel(controller)
for el in self.lan, self.fav, self.add_panel:
self.add(el)
def set_lan_visible(self, state: bool) -> None:
self.lan.set_visible(state)
def _on_lan_tab_toggled(self, emitter: "Emitter", state: bool) -> None:
self.set_lan_visible(state)
+54
View File
@@ -0,0 +1,54 @@
from typing import TYPE_CHECKING
from dzgui.const.enum import NotebookPage
from dzgui.util.strings import label_main_menu, crumbs
from dzgui.util.format import embolden
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
from dzgui.views.base import Notebook
from dzgui.views.pages.servers import ServerNotebook
class Breadcrumbs(Gtk.Label):
def __init__(self, controller: "Controller") -> None:
super().__init__(halign=Gtk.Align.START)
crumbs = embolden(label_main_menu)
self.set_markup(crumbs)
notebook = controller.get_notebook()
servers = controller.get_server_notebook()
notebook.connect_after("switch-page", self._on_notebook_page_changed)
servers.connect_after("switch-page", self._on_server_tab_changed)
def _on_server_tab_changed(
self, notebook: "Notebook", page: Gtk.Widget, index: int
) -> None:
label = notebook.get_tab_label_text(page)
if label is None:
return
self.set_server_crumbs(label)
def _on_notebook_page_changed(
self, notebook: "Notebook", page: "ServerNotebook", index: int
) -> None:
enum = notebook.get_page_by_enum()
if enum == NotebookPage.SERVERS:
label = page.get_current_tab_text()
self.set_server_crumbs(label)
else:
crumbs = enum.dict["crumbs"]
self.set_crumbs(crumbs)
def set_server_crumbs(self, suffix: str) -> None:
self.set_crumbs(crumbs.default + suffix)
def set_crumbs(self, crumbs: str) -> None:
text = embolden(crumbs)
self.set_markup(text)
+142
View File
@@ -0,0 +1,142 @@
from typing import Callable, TYPE_CHECKING
from dzgui.api.servers import validate_ip
from dzgui.util.css import add_class, remove_class
from dzgui.util.strings import connect_panel, lan_panel
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GObject # noqa E402
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
def validate_port(text: str) -> bool:
if text.isdigit():
if int(text) < 1:
return False
if int(text) > 65535:
return False
return True
else:
return False
def validate_ip_or_id(text: str) -> bool:
try:
validate_ip(text)
return True
except Exception:
if text.isdigit():
return True
else:
return False
class ValidatedEntry(Gtk.Entry):
def __init__(
self,
controller: "Controller",
func: Callable,
placeholder_text: str = "",
tooltip_text: str = "",
) -> None:
super().__init__(
hexpand=True, placeholder_text=placeholder_text, tooltip_text=tooltip_text
)
self.func: Callable
self.set_validation_func(func)
self.controller = controller
self.emitter = self.controller.get_emitter()
self.classname = "invalid-entry"
self.connect("key-press-event", self._on_entry_keypress)
self.connect("changed", self._on_text_changed)
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,))
def string_validated(self, valid: bool) -> None:
pass
def mark_valid(self) -> None:
self.emit("string_validated", True)
remove_class(self, self.classname)
def mark_invalid(self) -> None:
self.emit("string_validated", False)
add_class(self, self.classname)
def mark_default(self) -> None:
self.emit("string_validated", False)
remove_class(self, self.classname)
def set_validation_func(self, func: Callable) -> None:
self.func = func
def insert_icon(self) -> None:
self.set_icon_from_icon_name(
Gtk.EntryIconPosition.SECONDARY, "edit-clear-symbolic"
)
self.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True)
self.connect("icon-release", self._on_icon_release)
def remove_icon(self) -> None:
self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "")
def _on_text_changed(self, entry: Gtk.Entry) -> None:
# TODO: on submission, strip whitespace and newlines
# (validate_ip() is permissive of whitespace)
text = entry.get_text()
if len(text) < 1:
self.mark_default()
self.remove_icon()
return
if len(text) > 0:
self.insert_icon()
if self.func(text) is True:
self.mark_valid()
else:
self.mark_invalid()
def _on_icon_release(
self,
entry: Gtk.Entry,
icon_pos: Gtk.EntryIconPosition,
event: Gdk.Event,
) -> None:
entry.set_text("")
self.remove_icon()
def _on_entry_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> None:
if event.keyval == Gdk.KEY_Escape:
# NOTE: unselect text
entry.select_region(0, 0)
self.controller.grab_active_treeview()
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,
)
self.set_placeholder_text(connect_panel.placeholder)
self.set_tooltip_text(connect_panel.entry_tooltip)
class PortEntry(ValidatedEntry):
def __init__(self, controller: "Controller") -> None:
super().__init__(
controller,
func=validate_port,
placeholder_text=connect_panel.placeholder,
tooltip_text=connect_panel.entry_tooltip,
)
self.set_placeholder_text(lan_panel.placeholder)
self.set_tooltip_text(lan_panel.entry_tooltip)
+12 -4
View File
@@ -1,14 +1,22 @@
from typing import TYPE_CHECKING
from dzgui.const.enum import NotebookPage
from dzgui.const.constants import HELP_BUBBLE
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk # noqa E402
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
class InfoEventBox(Gtk.EventBox):
def __init__(self, text: str, parent: Gtk.Widget):
def __init__(self, text: str, controller: "Controller"):
super().__init__(margin_start=10)
self.parent = parent
self.controller = controller
self.text = text
self.icon = Gtk.Image.new_from_icon_name(
@@ -26,13 +34,13 @@ class InfoEventBox(Gtk.EventBox):
self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing
) -> None:
self.icon.set_opacity(1)
self.parent.controller.set_statusbar(self.text)
self.controller.set_statusbar(NotebookPage.OPTIONS, self.text)
def _on_leave_tooltip(
self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing
) -> None:
self.icon.set_opacity(0.8)
self.parent.controller.set_statusbar("")
self.controller.remove_statusbar(NotebookPage.OPTIONS)
def set_text(self, text: str) -> None:
self.text = text
+275 -224
View File
@@ -1,195 +1,115 @@
import logging
from typing import Literal
import gi # noqa E402
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, Pango, GLib
from typing import Literal, TYPE_CHECKING
from dzgui.const.constants import (
APP_NAME,
EXPAND,
NO_EXPAND,
NO_FILL,
NO_PADDING,
SEARCH_ICON,
)
from dzgui.const.enum import FilterMode
from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING
from dzgui.util import strings
from dzgui.util.margins import set_surrounding_margins
from dzgui.model.servers import ServerModelManager
from dzgui.util.strings import all_maps
from dzgui.views.components.maps_combo import MapsCombo
from dzgui.views.components.labels import BoldLabel
logger = logging.getLogger(__name__)
import gi
class FilterPanel(Gtk.Box):
def __init__(self, controller):
super().__init__(spacing=6, vexpand=False)
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, Pango, GLib # noqa E402
self.default_filters = {
strings.filter_1pp: True,
strings.filter_day: True,
strings.filter_empty: False,
strings.filter_3pp: True,
strings.filter_night: True,
strings.filter_full: False,
strings.filter_lowpop: True,
strings.filter_nonascii: False,
strings.filter_duplicate: False,
strings.filter_official: True,
strings.filter_unofficial: True,
strings.filter_modded: True,
}
logger = logging.getLogger(APP_NAME)
self.controller = controller
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
from dzgui.controllers.emitter import Emitter
map_store = self.controller.get_map_store()
self.checks = []
self.maps_hr = []
self.enabled_filters = dict(self.default_filters)
self.keyword_filter: str
self.selected_map: str = strings.all_maps
self.prior_map: str = strings.all_maps
button_grid = Gtk.Grid(
class ButtonGrid(Gtk.Grid):
def __init__(self, controller: "Controller", defaults: dict) -> None:
super().__init__(
halign=Gtk.Align.CENTER, column_spacing=5, column_homogeneous=True
)
row = 1
col = 0
for check in self.default_filters.keys():
checkbox = Gtk.CheckButton(label=check)
label = checkbox.get_children()
label[0].set_ellipsize(Pango.EllipsizeMode.END)
if self.default_filters[check]:
self.controller = controller
self.emitter = controller.get_emitter()
self.checks: list[Gtk.CheckButton] = []
# TODO: use enumerated checks
for check in defaults.keys():
checkbox = Gtk.CheckButton(label=check)
label = checkbox.get_child()
if label is not None:
label.set_ellipsize(Pango.EllipsizeMode.END) # type: ignore
if defaults[check]:
checkbox.set_active(True)
col = col + 1
if col > 3:
row += 1
col = 1
button_grid.attach(checkbox, col, row, 1, 1)
self.attach(checkbox, col, row, 1, 1)
checkbox.connect("toggled", self._on_check_toggled)
self.checks.append(checkbox)
self.connect("button-release-event", self._on_button_release)
self.set_orientation(Gtk.Orientation.VERTICAL)
set_surrounding_margins(self, 10)
self.set_margin_top(1)
# TODO: strings
self.filters_label = BoldLabel("Filters")
self.keyword_entry = Gtk.Entry()
self.keyword_entry.set_placeholder_text("Filter by keyword")
self.keyword_entry.connect("activate", self._on_keyword_enter)
self.keyword_entry.connect(
"key-press-event", self._on_keyword_keypress
)
completion = Gtk.EntryCompletion(inline_completion=True)
completion.set_text_column(0)
completion.set_minimum_key_length(1)
completion.connect("match_selected", self._on_completer_match)
renderer_text = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END)
self.maps_combo = Gtk.ComboBox.new_with_model_and_entry(map_store)
self.maps_combo.set_entry_text_column(0)
self.maps_entry = self.maps_combo.get_child()
self.maps_entry.set_completion(completion)
self.maps_entry.set_placeholder_text("Filter by map")
self.maps_entry.connect("changed", self._on_map_completion, True)
self.maps_entry.connect("key-press-event", self._on_map_entry_keypress)
# FIXME: only giving two params to pack_start
# cf. EXPAND, NO_EXPAND
self.maps_combo.pack_start(renderer_text, True)
self.maps_combo.connect("changed", self._on_map_changed)
self.maps_combo.connect("key-press-event", self._on_combo_keypress)
for el in self.filters_label, self.keyword_entry, self.maps_combo, button_grid:
self.pack_start(el, NO_EXPAND, NO_FILL, NO_PADDING)
def set_unique_maps(self, maps: list) -> None:
if len(maps) < 1:
return
u_maps = set([row[1] for row in maps])
u_maps = sorted(u_maps)
for m in u_maps:
self.controller.append_map([m])
self.maps_hr.append(m)
def get_filters(self) -> tuple:
filters = []
filters.append(self.selected_map)
filters.append(self.keyword_filter)
for k in self.enabled_filters:
if not self.enabled_filters[k]:
filters.append(k)
return tuple(filters)
def reinit_panel(self) -> None:
self.keyword_entry.set_text("")
self.keyword_filter = ""
self.reinit_filters()
self.set_visible(False)
# TODO:
sel_panel = self.controller.mediator.grid.sel_panel
if sel_panel.is_visible():
sel_panel.set_visible(False)
def reinit_filters(self) -> None:
self.enabled_filters = dict(self.default_filters)
def block_toggles(self, state: bool) -> None:
for check in self.checks:
self.controller.suppress_signal(
self,
check,
"_on_check_toggled",
state,
)
def reload_filters(self) -> None:
checkboxes = self.checks
self.block_toggles(True)
filters = self.controller.get_enabled_filters()
for check in checkboxes:
label = check.get_label()
state = self.default_filters[label]
state = filters[label]
check.set_active(state)
self.block_toggles(False)
def _on_map_entry_keypress(
self, entry: Gtk.Entry, event: Gdk.EventKey
) -> None:
match event.keyval:
case Gdk.KEY_Return:
text = entry.get_text()
if text is None:
return
"""
If entry is exact match for value in liststore,
trigger map change function
"""
for i in enumerate(map_store): # type: ignore
if text == i[1][0]:
self.maps_combo.set_active(i[0])
self._on_map_changed(self.maps_combo)
case Gdk.KEY_Escape:
GLib.idle_add(self.restore_focus_to_treeview)
"""
This is a workaround for widget.grab_remove()
Sets cursor position to SOL when unfocusing
"""
text = self.maps_entry.get_text()
self.maps_entry.set_position(len(text))
case _:
return
def _on_check_toggled(self, button: Gtk.CheckButton) -> None:
label = button.get_label()
state = button.get_active()
logger.info(f"User toggled button '{label}' to {state}")
self.emitter.emit("check_toggled", label, state)
def _on_completer_match(
self,
completion: Gtk.EntryCompletion,
model: Gtk.ListStore,
it: Gtk.TreeIter,
) -> None:
self.maps_combo.set_active_iter(it)
def get_checkboxes(self) -> list:
return self.checks
def _on_map_completion(self, entry, editable):
text = entry.get_text()
completion = entry.get_completion()
map_store = self.controller.get_map_store()
if len(text) >= completion.get_minimum_key_length():
completion.set_model(map_store)
def restore_focus_to_treeview(self) -> Literal[False]:
view = self.controller.get_active_treeview()
view.grab_focus()
return False
class KeywordEntry(Gtk.Entry):
def __init__(self, controller: "Controller") -> None:
# TODO :strings
super().__init__(placeholder_text="Filter by keyword")
def _on_keyword_keypress(
self, entry: Gtk.Entry, event: Gdk.EventKey
) -> bool:
self.keyword = ""
self.controller = controller
self.emitter = controller.get_emitter()
self.connect("activate", self._on_activated)
self.connect("key-press-event", self._on_keypress)
self.connect("icon-release", lambda *args: self.activate())
self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, SEARCH_ICON)
self.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True)
self.emitter.connect("load_maps", self._on_maps_loaded)
def _on_maps_loaded(self, emitter: "Emitter", store: Gtk.ListStore) -> None:
kw = self.controller.get_active_keyword()
self.keyword = kw
self.set_text(kw)
def _on_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> bool:
match event.keyval:
case Gdk.KEY_Up:
return True
@@ -200,82 +120,213 @@ class FilterPanel(Gtk.Box):
return True
return False
def _on_combo_keypress(
self, combo: Gtk.ComboBox, event: Gdk.EventKey
) -> bool:
def restore_focus_to_treeview(self) -> Literal[False]:
view = self.controller.get_active_treeview()
view.grab_focus()
return False
def _on_activated(self, entry: Gtk.Entry) -> None:
keyword = entry.get_text().lower()
if keyword == self.keyword:
return
if keyword.isspace():
return
self.keyword = keyword
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:
super().__init__(
spacing=6,
vexpand=False,
orientation=Gtk.Orientation.VERTICAL,
margin_top=1,
margin_bottom=10,
margin_left=10,
margin_right=10,
)
self.controller = controller
self.controller.register_widget("filters", self)
self.emitter = self.controller.get_emitter()
self.sel_map = all_maps
filter_man = self.controller.get_filter_man()
defaults = filter_man.get_default_filters()
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)
# TODO: strings
self.filters_label = BoldLabel("Filters")
self.emitter.connect(
"request_keyword_focus", lambda _: self.keyword_entry.grab_focus()
)
self.emitter.connect(
"request_maps_focus", lambda _: self.maps_entry.grab_focus()
)
self.emitter.connect("check_button_pressed", self.toggle_check_by_key)
self.emitter.connect("load_maps", self._on_maps_loaded)
completion = MapsCombo()
completion.connect("match_selected", self._on_completer_match)
renderer_text = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END)
self.maps_combo = Gtk.ComboBox.new_with_model_and_entry(self.map_store)
self.maps_combo.set_entry_text_column(0)
self.maps_entry: Gtk.Entry = self.maps_combo.get_child() # type: ignore
self.maps_entry.set_completion(completion)
# TODO: strings
self.maps_entry.set_placeholder_text("Filter by map")
self.maps_entry.connect("changed", self._on_map_completion, True)
self.maps_entry.connect("key-press-event", self._on_map_entry_keypress)
self.maps_entry.connect("activate", self._on_maps_activated)
self.maps_combo.pack_start(renderer_text, EXPAND)
self.maps_combo.connect("changed", self._on_map_changed)
self.maps_combo.connect("key-press-event", self._on_combo_keypress)
for el in (
self.filters_label,
self.keyword_entry,
self.maps_combo,
self.button_grid,
):
self.pack_start(el, NO_EXPAND, NO_FILL, NO_PADDING)
def _on_maps_activated(self, entry: Gtk.Entry) -> None:
text = entry.get_text()
model = self.maps_combo.get_model()
if text is None:
return
if text == self.sel_map:
return
i: int
row: Gtk.TreeModelRow
for i, row in enumerate(model): # type: ignore
if text == row[0]: # type: ignore
self.maps_combo.set_active(i)
return
def _on_maps_loaded(self, emitter: "Emitter", store: Gtk.ListStore) -> None:
self.maps_combo.set_model(store)
tv = self.controller.get_active_treeview()
ind: int
name: str
ind, name = tv.filter_man.get_active_map()
# NOTE: setting active index triggers a 'changed' signal on self.maps_combo
self.block_map_change_propagation = True
self.maps_combo.set_active(ind)
self.button_grid.reload_filters()
# TODO: this chiefly applies when clicking refresh button, etc.
# and setting filters to default state
# def reinit_filters(self) -> None:
# self.enabled_filters = dict(self.default_filters)
# for check in self.checks:
# label = check.get_label()
# state = self.default_filters[label]
# check.set_active(state)
def _on_map_entry_keypress(self, entry: Gtk.Entry, event: Gdk.EventKey) -> bool:
match event.keyval:
case Gdk.KEY_Down:
case Gdk.KEY_Escape:
GLib.idle_add(self.restore_focus_to_treeview)
"""
This is a workaround for widget.grab_remove()
Sets cursor position to start of line when unfocusing
"""
text = self.maps_entry.get_text()
self.maps_entry.set_position(len(text))
return True
case _:
return False
def _on_completer_match(
self,
completion: Gtk.EntryCompletion,
model: Gtk.ListStore,
it: Gtk.TreeIter,
) -> None:
self.maps_combo.set_active_iter(it)
def _on_map_completion(self, entry: Gtk.Entry, editable: Literal[True]) -> None:
text = entry.get_text()
completion = entry.get_completion()
store = self.controller.get_map_store()
if len(text) >= completion.get_minimum_key_length():
completion.set_model(store)
def restore_focus_to_treeview(self) -> Literal[False]:
view = self.controller.get_active_treeview()
view.grab_focus()
return False
def _on_combo_keypress(self, combo: Gtk.ComboBox, event: Gdk.EventKey) -> bool:
match event.keyval:
case Gdk.KEY_Down | Gdk.KEY_Up:
self.maps_combo.popup()
return True
case _:
return False
def set_prior_map(self, mapname: str) -> None:
self.prior_map = mapname
def get_prior_map(self) -> str:
return self.prior_map
def get_selected_map(self) -> str:
return self.selected_map
def get_keyword_filter(self) -> str:
return self.keyword_filter
def _on_keyword_enter(self, entry: Gtk.Entry) -> None:
# TODO:
self.controller.mediator.window.set_keep_below(False)
keyword = entry.get_text().lower()
if keyword == self.keyword_filter:
return
if keyword.isspace():
return
logger.info(f"User filtered by keyword '{keyword}'")
self.keyword_filter = keyword
treeview = self.controller.get_active_treeview()
treeview.filter(FilterMode.KEYWORD, keyword)
def _on_button_release(self, window, button) -> Literal[True]:
def toggle_check_by_key(self, emitter: "Emitter", keyval: int) -> bool:
mappings = {
Gdk.KEY_1: 0,
Gdk.KEY_2: 1,
Gdk.KEY_3: 2,
Gdk.KEY_4: 3,
Gdk.KEY_5: 4,
Gdk.KEY_6: 5,
Gdk.KEY_7: 6,
Gdk.KEY_8: 7,
Gdk.KEY_9: 8,
Gdk.KEY_0: 9,
Gdk.KEY_minus: 10,
Gdk.KEY_backslash: 11,
}
if keyval not in mappings:
return False
index = mappings[keyval]
self.toggle_check(index)
return True
def get_active_combo(self) -> int:
return self.maps_combo.get_active()
def set_active_combo(self, row: int) -> None:
self.maps_combo.set_active(row)
def toggle_check(self, digit: int) -> None:
check = self.checks[digit]
checks = self.button_grid.get_checkboxes()
check = checks[digit]
state = check.get_active()
check.set_active(not state)
def _on_check_toggled(self, button: Gtk.CheckButton) -> None:
treeview = self.controller.get_active_treeview()
label = button.get_label()
state = button.get_active()
logger.info(f"User toggled button '{label}' to {state}")
if state:
mode = FilterMode.TOGGLE_ON
else:
mode = FilterMode.TOGGLE_OFF
self.enabled_filters[label] = state
treeview.filter(mode, label)
def _on_map_changed(self, combo: Gtk.ComboBox) -> None:
treeview = self.controller.get_active_treeview()
old_sel = self.selected_map
model = combo.get_model()
tree_iter = combo.get_active_iter()
if tree_iter is None:
ind = combo.get_active()
if ind < 0:
return
selection = model[tree_iter][0]
if selection == old_sel:
name = self.maps_entry.get_text()
self.prior_map = self.sel_map
self.sel_map = name
# TODO: abstraction into controller:
# pass ind, name
tv = self.controller.get_active_treeview()
filter_man = tv.get_filter_man()
filter_man.set_prior_map(name)
filter_man.set_active_map(ind, name)
if self.block_map_change_propagation:
self.block_map_change_propagation = False
return
if not selection:
return
logger.info(f"User selected map '{selection}'")
self.prior_map = self.selected_map
self.selected_map = selection
self.maps_entry.set_text(selection)
treeview.filter(FilterMode.MAP)
self.emitter.emit("map_selection_changed", name)
+21
View File
@@ -0,0 +1,21 @@
from dzgui.util import css
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa
class HeadingFrame(Gtk.Box):
def __init__(self, widget: Gtk.Widget, heading: str) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL)
label = Gtk.Label(label=heading)
label.set_halign(Gtk.Align.START)
css.add_class(label, "settings-subheading")
frame = Gtk.Frame(hexpand=True)
frame.add(widget)
frame.set_label_widget(label)
self.add(frame)
-12
View File
@@ -1,12 +0,0 @@
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
class LeftLabel(Gtk.Label):
def __init__(self, text: str, tooltip: str = ""):
super().__init__(
label=text,
halign=Gtk.Align.START,
)
self.set_tooltip_text(tooltip)
pass
+22
View File
@@ -1,12 +1,34 @@
from dzgui.util.format import embolden
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
class BoldLabel(Gtk.Label):
def __init__(self, text: str):
super().__init__()
label = embolden(text)
self.set_markup(label)
class LeftLabel(Gtk.Label):
def __init__(self, text: str, tooltip: str = ""):
super().__init__(
label=text,
halign=Gtk.Align.START,
)
self.set_tooltip_text(tooltip)
class IconLabel(Gtk.Box):
def __init__(self, text: str, label: str) -> None:
super().__init__(
orientation=Gtk.Orientation.HORIZONTAL, spacing=10, margin_start=10
)
self.label = Gtk.Label(label=text, halign=Gtk.Align.START, margin_bottom=2)
self.icon = Gtk.Image.new_from_icon_name(label, Gtk.IconSize.BUTTON)
for el in self.icon, self.label:
self.add(el)
+11
View File
@@ -0,0 +1,11 @@
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib # noqa E402
class MapsCombo(Gtk.EntryCompletion):
def __init__(self) -> None:
super().__init__(inline_completion=True)
self.set_text_column(0)
self.set_minimum_key_length(1)
+42 -25
View File
@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING
from typing import Self, TYPE_CHECKING
from dzgui.const.enum import ModButton
from dzgui.const.constants import NO_EXPAND, FILL, NO_PADDING
@@ -6,26 +6,39 @@ from dzgui.util.strings import mod_panel
from dzgui.views.components.labels import BoldLabel
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
from gi.repository import Gtk # noqa E402
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
from dzgui.controllers.mc import Controller, Emitter
class EnumeratedModButton(Gtk.Button):
def __init__(self, enum: ModButton) -> None:
super().__init__(margin_start=10, margin_end=10)
super().__init__(margin_start=10, margin_end=10, focus_on_click=False)
self.enum = enum
self.set_label(enum.dict["label"])
self.set_tooltip_text(enum.dict["tooltip"])
class ModPanelButton(EnumeratedModButton):
def __init__(self, enum: ModButton) -> None:
super().__init__(enum=enum)
if enum is not ModButton.HIGHLIGHT_STALE:
self.set_sensitive(False)
class ModSelectionPanel(Gtk.Box):
def __init__(self, controller: "Controller") -> None:
super().__init__(spacing=6, orientation=Gtk.Orientation.VERTICAL, margin_top=15)
self.controller = controller
emitter = controller.get_emitter()
emitter.connect("mod_page_toggled", self._on_mod_page_toggled)
emitter.connect("mods_highlighted", self._on_mods_highlighted)
emitter.connect("mods_updated", self._on_mods_updated)
header = BoldLabel(mod_panel.header)
@@ -41,31 +54,38 @@ class ModSelectionPanel(Gtk.Box):
self.main_panel.pack_start(b, NO_EXPAND, FILL, NO_PADDING)
self.stale_panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
buttons = (
ModButton.HIGHLIGHT_STALE,
ModButton.SELECT_STALE,
ModButton.UNHIGHLIGHT_STALE,
)
for button in buttons:
b = EnumeratedModButton(button)
b.connect("clicked", self._on_button_clicked)
if button is not ModButton.HIGHLIGHT_STALE:
b.set_sensitive(False)
self.highlight_stale_button = ModPanelButton(ModButton.HIGHLIGHT_STALE)
self.unhighlight_stale_button = ModPanelButton(ModButton.UNHIGHLIGHT_STALE)
self.select_stale_button = ModPanelButton(ModButton.SELECT_STALE)
for b in (self.highlight_stale_button, self.unhighlight_stale_button, self.select_stale_button):
self.stale_panel.pack_start(b, NO_EXPAND, FILL, NO_PADDING)
for el in header, self.main_panel, self.stale_panel:
self.pack_start(el, NO_EXPAND, FILL, NO_PADDING)
def after_colorize(self) -> None:
self.controller.unselect_all_mods()
self.connect("map", self._reinit_button)
def _reinit_button(self, widget: Self) -> None:
self.unhighlight_stale_button.set_sensitive(False)
self.select_stale_button.set_sensitive(False)
self.highlight_stale_button.set_sensitive(True)
def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None:
if mods < 1:
self.main_panel.set_sensitive(False)
self.stale_panel.set_sensitive(False)
def _on_mods_highlighted(self, emitter: "Emitter") -> None:
self.swap_sensitive(True)
def _on_mod_page_toggled(self, emitter: "Emitter", state: bool) -> None:
self.set_visible(state)
def swap_sensitive(self, state: bool) -> None:
for child in self.extra_panel.get_children():
child.set_sensitive(state)
for child in self.main_panel.get_children():
if child.enum == ModButton.HIGHLIGHT_STALE:
child.set_sensitive(not state)
self.highlight_stale_button.set_sensitive(not state)
self.unhighlight_stale_button.set_sensitive(state)
self.select_stale_button.set_sensitive(state)
def _on_button_clicked(self, button: EnumeratedModButton) -> None:
match button.enum:
@@ -74,14 +94,11 @@ class ModSelectionPanel(Gtk.Box):
case ModButton.UNSELECT_ALL:
self.controller.toggle_mod_selection(False)
case ModButton.DELETE_SELECTED:
self.controller.delete_multiple_mods()
self.controller.delete_mods()
case ModButton.HIGHLIGHT_STALE:
self.controller.set_callback(self.after_colorize)
self.controller.highlight_stale()
case ModButton.UNHIGHLIGHT_STALE:
self.controller.uncolorize_mods()
self.swap_sensitive(False)
case ModButton.SELECT_STALE:
self.controller.select_colorized()
+59 -78
View File
@@ -1,107 +1,88 @@
from typing import Literal, TYPE_CHECKING
import gi # noqa E402
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GLib
from dzgui.const.enum import Preferences
from dzgui.const.enum import ServerTab
from dzgui.util.clip import copy_clipboard
from dzgui.views.components.buttonbox import ButtonBox
from dzgui.views.components.filter_panel import FilterPanel
from dzgui.views.components.mod_panel import ModSelectionPanel
from dzgui.views.components.icon import Icon
from dzgui.views.components.buttons import RefreshButton, KeysButton
from dzgui.const.constants import NO_EXPAND, NO_FILL, EXPAND, FILL, INPUT_KEYBOARD, NO_PADDING
from dzgui.util.strings import refresh_tooltip, refresh, keys_button, keys_tooltip
from dzgui.const.constants import NO_EXPAND, NO_FILL, FILL, NO_PADDING
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GLib # noqa E402
if TYPE_CHECKING:
from GLib import SOURCE_REMOVE
from dzgui.controllers.mc import Controller
from dzgui.controllers.emitter import Emitter
from dzgui.views.trees.tree_servers import ServerTreeView
# TODO: refactor depends on ServerTreeView
class RightPanel(Gtk.Box):
def __init__(self, controller):
def __init__(self, controller: "Controller"):
super().__init__(spacing=6, orientation=Gtk.Orientation.VERTICAL)
self.time = 30
self.controller = controller
self.controller.register_widget("right_panel", self)
emitter = controller.get_emitter()
emitter.connect("servers_loaded", self._on_servers_loaded)
emitter.connect("server_page_changed", self._on_server_page_changed)
self.button_vbox = ButtonBox(controller)
self.filters_vbox = FilterPanel(controller)
self.sel_panel = ModSelectionPanel(controller)
self.refresh_button = RefreshButton()
self.refresh_button.set_tooltip_text(refresh_tooltip)
self.refresh_button.connect("clicked", self._on_refresh_clicked)
self.refresh_button = RefreshButton(controller)
self.keys = KeysButton(controller)
self.keys = KeysButton(keys_button)
self.keys.set_tooltip_text(keys_tooltip)
self.keys.connect("clicked", self._on_question_clicked)
self.copying = False
# TODO: strings
version = self.controller.get_prefs().version
self.version_label = Gtk.Label(
label=version,
hexpand=True,
vexpand=True,
halign=Gtk.Align.END,
valign=Gtk.Align.END,
tooltip_text="Click to copy to clipboard",
)
eb = Gtk.EventBox(halign=Gtk.Align.END, valign=Gtk.Align.END)
eb.add(self.version_label)
eb.connect("button-press-event", self._on_version_clicked)
for el in self.button_vbox, self.keys, self.filters_vbox, self.refresh_button:
self.pack_start(el, NO_EXPAND, FILL, NO_PADDING)
self.pack_start(self.sel_panel, NO_EXPAND, NO_FILL, NO_PADDING)
self.pack_start(eb, NO_EXPAND, FILL, NO_PADDING)
# TODO: move to filter panel
def reinit_maps(self, rows: list) -> None:
self.controller.reinit_map_store()
# TODO: communicate with controller
#controller.clear_map_store()
#map_store.append(["All maps"])
self.selected = "All maps"
self.filters_vbox.set_unique_maps(rows)
def _on_server_page_changed(
self, emitter: "Emitter", page: "ServerTreeView"
) -> None:
"""Initially set filter area to disabled"""
if page.loaded is True:
return
self.filters_vbox.set_sensitive(False)
def decrement(self) -> "SOURCE_REMOVE" | Literal[True]:
self.time -= 1
if self.time == 0:
self.time = 30
self.refresh_button.set_label(refresh)
self.refresh_button.set_sensitive(True)
return GLib.SOURCE_REMOVE
self.refresh_button.set_label(f"{refresh} ({str(self.time)})")
return True
def _on_servers_loaded(self, emitter: "Emitter", context: "ServerTab") -> None:
# TODO: similar logic on notebook page change
state = self.controller.has_server_model()
self.filters_vbox.set_sensitive(state)
def _on_refresh_clicked(self, button: RefreshButton) -> None:
self.refresh_button.set_sensitive(False)
self.refresh_button.set_label(f"{refresh} ({str(self.time)})")
GLib.timeout_add_seconds(1, self.decrement)
self.controller.refresh_tree()
def _on_version_clicked(self, widget: Gtk.EventBox, event: Gdk.EventButton) -> None:
def revert() -> Literal[False]:
self.copying = False
self.version_label.set_text(version)
return False
# TODO: reference
#def _on_ping_clicked(self, button: Gtk.Button) -> None:
# block_signals()
# def _update_pings():
# # TODO
# rows = ModelManager.get_filtered()
# with ThreadPoolExecutor(100) as executor:
# futures = [
# executor.submit(Servers.ping, i, row)
# for i, row in enumerate(rows)
# ]
# wait(futures)
# for future in futures:
# res = future.result()
# path = Gtk.TreePath.new_from_indices([res.iteration])
# temp_model[path][9] = res.ping
# ModelManager.ping_cache[res.addr] = res.ping
# # TODO: drop/rewrite
# treeview.set_model(temp_model)
# treeview.wait_dialog.destroy()
# treeview.grab_focus()
# # TODO:
# unblock_signals()
# temp_model = self.AppNav.treeview.get_model()
# treeview.set_model(None)
# treeview.wait_dialog = GenericDialog("Pinging servers", Popup.WAIT)
# treeview.wait_dialog.show_all()
# thread = threading.Thread(target=_update_pings, args=())
# thread.start()
def _on_question_clicked(self, button: Gtk.Button) -> None:
self.controller.open_keybindings()
def focus_button_box(self) -> None:
self.button_vbox.buttons[0].grab_focus()
if self.copying:
return
self.copying = True
version = self.version_label.get_text()
self.version_label.set_text("Copied!")
copy_clipboard(version)
GLib.timeout_add_seconds(1, revert)
+141 -40
View File
@@ -1,64 +1,165 @@
from typing import TYPE_CHECKING
from typing import Self, Union, TYPE_CHECKING
from warnings import deprecated
from dzgui.const.enum import RowType, Preferences
from dzgui.util import strings
from dzgui.const.enum import NotebookPage, ServerTab
from dzgui.util.strings import esc_to_return, question_to_return
from dzgui.views.components.buttons import LoggerAlertsButton
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
from gi.repository import Gtk, GObject # noqa E402
if TYPE_CHECKING:
from dzgui.const.enum import ServerTab
from dzgui.controllers.mc import Controller
from dzgui.controllers.emitter import Emitter
from dzgui.views.trees.tree_base import TreeView
from dzgui.views.base import Notebook
class Statusbar(Gtk.Statusbar):
class Statusbar(Gtk.Grid):
def __init__(self, controller: "Controller") -> None:
super().__init__()
super().__init__(orientation=Gtk.Orientation.HORIZONTAL)
self.controller = controller
self.controller.register_widget("statusbar", self)
self.emitter = controller.get_emitter()
help_text = strings.statusbar_helptext
self.set_text(help_text)
self.playercount = ""
self.statusbar = Gtk.Statusbar()
version = self.controller.get_prefs().version
self.status_right_label = Gtk.Label(label=version)
self.add(self.status_right_label)
self.spinner = Gtk.Spinner()
self.attach(self.statusbar, 0, 0, 3, 1)
self.attach_next_to(self.spinner, self.statusbar, Gtk.PositionType.RIGHT, 3, 1)
# TODO: pack version event box in right panel into hbox with update button
# TODO: spawns a modal or just jumps right to install page
# from dzgui.views.components.buttons import IconTextButton
# b = IconTextButton("dialog-information-symbolic", label="Updates available")
# b.set_halign(Gtk.Align.END)
# b.set_hexpand(True)
# self.attach_next_to(b, self.spinner, Gtk.PositionType.RIGHT, 3, 1)
self.players = ""
def get_text(self) -> str:
area = self.get_message_area()
label = area.get_children()[0]
return label.get_text()
controller.get_menu().connect("generic_treesel_changed", self._help_row_changed)
controller.get_notebook().connect_after(
"switch-page", self._on_notebook_page_changed
)
def set_text(self, string: str) -> None:
if string is None:
self.emitter.connect("distcalc_started", self._on_distcalc_started)
self.emitter.connect("distcalc_ended", self._on_distcalc_ended)
self.emitter.connect("servers_loaded", self._on_servers_loaded)
self.emitter.connect("mods_updated", self._on_mods_updated)
def _on_alerts_clicked(self, button: LoggerAlertsButton) -> None:
self.controller.populate_log()
def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None:
self.set_by_context(NotebookPage.MODS, msg)
def _on_notebook_page_changed(
self, notebook: "Notebook", child: Gtk.Widget, index: int
) -> None:
if self.controller.loaded is False:
return
meta = self.get_context_id("Statusbar")
self.push(meta, string)
def refresh(self, row: "RowType") -> None:
if row is None:
formatted = ""
enum = notebook.get_page_by_enum()
show_statusbar = enum.dict["statusbar"]
if show_statusbar is False:
self.set_by_context(enum, "")
return
if enum in (
NotebookPage.THANKS,
NotebookPage.CHANGELOG,
NotebookPage.LOG,
NotebookPage.CONNECTION,
):
self.set_by_context(enum, esc_to_return)
return
match enum:
case NotebookPage.HELP:
bar = self.controller.get_help_row()
case NotebookPage.KEYS:
bar = question_to_return
case _:
return
self.set_by_context(enum, bar)
# TODO: unused
def _on_notebook_page_returned(
self, statusbar: Self, prior_context: NotebookPage
) -> None:
self.pop(prior_context)
def _on_server_row_changed(self, statusbar: Self) -> None:
self.spinner.start()
def _on_distcalc_started(self, emitter: "Emitter") -> None:
self.spinner.start()
def _on_distcalc_ended(
self,
statusbar: Self,
dist: Union[str, None],
context: Union["ServerTab", NotebookPage],
) -> None:
self.spinner.stop()
# TODO: CalcDist is being called when table is not loaded
if dist is None:
self.set_by_context(context, "")
else:
formatted = self.format_metadata(row)
self.set_text(formatted)
pretty = self.append_distance(dist)
self.set_by_context(context, pretty)
def append_distance(self, dist: str) -> None:
# TODO: process strings in controller
if dist == strings.unknown:
dist = f"| Distance: {dist}"
else:
dist = f"| Distance: {dist}"
self.set_text(self.players + dist)
def append_distance(self, dist: str) -> str:
return f"{self.playercount} Distance: {dist}"
def format_metadata(self, row: "RowType") -> str:
prefix = row.dict["tooltip"]
def _on_servers_loaded(self, statusbar: Self, context: "ServerTab") -> None:
count = self.controller.get_player_count()
self.playercount = count
if row == RowType.QUICK_CONNECT or row == RowType.CHNG_FAV:
label = self.controller.query_config(Preferences.FAV_LBL)
if len(label) < 1:
label = "unset"
return f"{prefix} ({label})"
else:
return prefix
self.set_by_context(context, count)
self.emitter.emit("statusbar_loaded")
def pop(self, context: Union["ServerTab", "NotebookPage"]) -> None:
cid = self.statusbar.get_context_id(str(context))
self.statusbar.pop(cid)
@deprecated("currently unused")
# def get_text(self) -> str:
# area = self.statusbar.get_message_area()
# label = area.get_children()[0]
# return str(label.get_text())
def set_by_context(
self, context: Union[NotebookPage, "ServerTab"], string: str
) -> None:
meta = self.statusbar.get_context_id(str(context))
self.statusbar.push(meta, string)
self.set_cache(string)
def get_cache(self) -> str:
return self.cache
def set_cache(self, string: str) -> None:
self.cache = string
def set_text(self, string: str, context: str) -> int:
meta = self.statusbar.get_context_id(context)
self.statusbar.push(meta, string)
self.set_cache(string)
return meta
def _help_row_changed(self, tree: "TreeView", sel: Gtk.TreeSelection) -> None:
row = tree.get_value_at_index(1)
if self.controller.loaded is False:
return
tooltip = row.dict["tooltip"]
self.set_by_context(NotebookPage.HELP, tooltip)
+34 -14
View File
@@ -1,35 +1,55 @@
import textwrap
import sys
from typing import Self
from dzgui.util.strings import dialog_error, dialog_header
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
class EarlyAlertDialog(Gtk.MessageDialog):
def __init__(self, string):
# TODO: strings
def __init__(self, string: str) -> None:
super().__init__(
title="DZGUI - Dialog",
flags=0,
text="ERROR",
title=dialog_header,
text=dialog_error,
transient_for=None,
buttons=Gtk.ButtonsType.OK
buttons=Gtk.ButtonsType.OK,
)
ok = self.action_area.get_children()[0]
ok.set_label("OK")
ok.connect("clicked", Gtk.main_quit)
msg = textwrap.fill(string, 50)
self.format_secondary_text(msg)
self.action_area.set_margin_bottom(20)
aa = self.get_action_area()
aa.set_margin_bottom(20)
# self.action_area.set_margin_bottom(20)
self.outer = self.get_content_area()
self.outer.set_margin_start(30)
self.outer.set_margin_end(30)
self.set_default_size(250, 100)
self.connect("delete-event", Gtk.main_quit)
self.connect("destroy", Gtk.main_quit)
abort = self.get_widget_for_response(Gtk.ResponseType.OK)
if abort is not None and hasattr(abort, "set_label"):
abort.set_label("Exit")
self.show_all()
Gtk.main()
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:
sys.exit(1)
case Gtk.ResponseType.CANCEL:
return
class EarlyIgnoreDialog(EarlyAlertDialog):
def __init__(self, string: str) -> None:
super().__init__(string=string)
# TODO: reverse order
self.add_button("Ignore", Gtk.ResponseType.CANCEL)
-81
View File
@@ -1,81 +0,0 @@
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING
from dzgui.const.enum import Popup
class EntryDialog(GenericDialog):
def __init__(self, text: str, mode: Popup, link: str, button_type=None):
super().__init__(text, mode)
"""
Wraps Gtk.Entry in a dialog and provides basic response handling.
Returns user input as a string or None.
The Entry widget itself can be manipulated via the get_entry() method.
"""
self.dialog = GenericDialog(text, mode)
self.dialogBox = self.dialog.get_content_area()
self.dialog.set_default_response(Gtk.ResponseType.OK)
self.dialog.set_size_request(500, 0)
self.user_entry = Gtk.Entry()
set_surrounding_margins(self.user_entry, 20)
self.user_entry.set_margin_top(0)
self.user_entry.set_size_request(250, 0)
self.user_entry.set_activates_default(True)
self.dialogBox.pack_start(self.user_entry, NO_EXPAND, NO_FILL, NO_PADDING)
if link:
button = Gtk.Button(label=link)
button.set_margin_start(60)
button.set_margin_end(60)
button.connect("clicked", self._on_button_clicked, button_type)
self.dialogBox.pack_end(button, NO_EXPAND, NO_FILL, NO_PADDING)
self.ok = self.dialog.action_area.get_children()[1]
self.ok.set_sensitive(False)
self.user_entry.connect("insert-text", self._on_text_typed)
self.user_entry.get_property("buffer").connect(
"deleted-text", self._on_text_deleted
)
def _is_valid_text(self, text: str) -> bool:
if text.isspace():
return False
if len(text) == 0:
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.ok.set_sensitive(state)
def _on_text_typed(
self, entry: Gtk.Entry, text: str, length: int, pos: int
) -> None:
state = self._is_valid_text(text)
self.ok.set_sensitive(state)
def _on_button_clicked(self, button: Gtk.Button, enum: RowType) -> None:
result = open_links.open_link_by_rowtype(enum)
if result is False:
AppNav.window.spawn_dialog(strings.something_wrong, Popup.NOTIFY)
def get_entry(self) -> Gtk.Entry:
return self.user_entry
def get_input(self) -> str | None:
self.dialog.show_all()
response = self.dialog.run()
text = self.user_entry.get_text()
self.dialog.destroy()
if (response == Gtk.ResponseType.OK) and (text != ""):
return text
else:
return None
+5 -4
View File
@@ -1,11 +1,12 @@
from pathlib import Path
import gi # noqa E402
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GLib, GObject
from dzgui.util.strings import picker
import gi # noqa E402
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
class FilePicker(Gtk.FileChooserDialog):
def __init__(self, parent: Gtk.Window) -> None:
+203 -78
View File
@@ -1,104 +1,229 @@
import textwrap
from typing import Any, Literal, TYPE_CHECKING
from typing import Literal, Self, TYPE_CHECKING
from dzgui.const.constants import NO_EXPAND, NO_FILL
from dzgui.const.enum import Popup, ButtonType, NotebookPage
from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING, EXPAND, FILL
from dzgui.util import strings
from dzgui.views.components.buttons import ClipboardButton
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
class GenericDialog(Gtk.MessageDialog):
def __init__(self, controller: "Controller", text: str, mode: Popup):
match mode:
case Popup.WAIT:
dialog_type = Gtk.MessageType.INFO
button_type = Gtk.ButtonsType.NONE
header_text = strings.wait
case Popup.NOTIFY | Popup.RETURN | Popup.QUIT:
dialog_type = Gtk.MessageType.INFO
button_type = Gtk.ButtonsType.OK
header_text = strings.notice
case Popup.CONFIRM:
dialog_type = Gtk.MessageType.QUESTION
button_type = Gtk.ButtonsType.OK_CANCEL
header_text = strings.confirmation
case Popup.ENTRY:
dialog_type = Gtk.MessageType.QUESTION
button_type = Gtk.ButtonsType.OK_CANCEL
header_text = strings.input_required
case Popup.MODLIST:
dialog_type = Gtk.MessageType.INFO
button_type = Gtk.ButtonsType.OK
header_text = strings.modlist
case Popup.DETAILS:
dialog_type = Gtk.MessageType.INFO
button_type = Gtk.ButtonsType.OK
header_text = strings.server_details
# NOTE: steam deck prints <2> if dialog title is same as window title
Gtk.MessageDialog.__init__(
self,
transient_for=controller.mediator.window,
message_type=dialog_type,
buttons=button_type,
text=header_text,
secondary_text=textwrap.fill(text, 50),
class GenericDialog(Gtk.MessageDialog):
def __init__(
self,
controller: "Controller",
text: str,
mtype: Gtk.MessageType,
buttons: Gtk.ButtonsType,
secondary: str,
) -> None:
super().__init__(
transient_for=controller.get_window(),
message_type=mtype,
buttons=buttons,
text=text,
secondary_text=secondary,
title=strings.dialog_header,
modal=True,
)
self.controller = controller
if mode == Popup.WAIT:
dialogBox = self.get_content_area()
spinner = Gtk.Spinner()
dialogBox.pack_end(spinner, NO_EXPAND, NO_FILL, 0)
spinner.start()
self.connect("delete-event", self._on_dialog_delete)
if mode == Popup.RETURN:
button_label = strings.main_menu
ok = self.action_area.get_children()[0]
ok.set_label(button_label)
ok.connect("clicked", self._return_to_main_menu)
self.connect("delete-event", self._return_to_main_menu)
if mode == Popup.QUIT:
button_label = strings.exit_app
ok = self.action_area.get_children()[0]
ok.set_label(button_label)
ok.connect("clicked", self._quit)
self.connect("delete-event", self._quit)
self.set_default_response(Gtk.ResponseType.OK)
self.set_size_request(500, 0)
self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
self.action_area.set_layout(Gtk.ButtonBoxStyle.CENTER)
self.action_area.set_margin_bottom(20)
self.set_default_response(Gtk.ResponseType.OK)
self.get_action_area().set_layout(Gtk.ButtonBoxStyle.CENTER) # type: ignore
self.get_action_area().set_margin_bottom(20)
self.outer = self.get_content_area()
self.outer.set_margin_start(30)
self.outer.set_margin_end(30)
def _quit(self, *args: Any) -> None:
def pack(self, widget: Gtk.Widget) -> None:
content = self.get_content_area()
content.pack_start(widget, EXPAND, FILL, NO_PADDING)
class ConfirmationDialog(GenericDialog):
def __init__(self, controller: "Controller", secondary: str):
super().__init__(
controller=controller,
text=strings.confirm,
mtype=Gtk.MessageType.QUESTION,
buttons=Gtk.ButtonsType.OK_CANCEL,
secondary=secondary,
)
class IgnoreDialog(GenericDialog):
def __init__(self, controller: "Controller", secondary: str):
super().__init__(
controller=controller,
text=strings.confirm,
mtype=Gtk.MessageType.QUESTION,
buttons=Gtk.ButtonsType.OK_CANCEL,
secondary=secondary,
)
cancel = self.get_widget_for_response(Gtk.ResponseType.CANCEL)
if cancel is None:
return
if hasattr(cancel, "set_label"):
cancel.set_label("Ignore")
class NotifyDialog(GenericDialog):
def __init__(self, controller: "Controller", secondary: str):
super().__init__(
controller=controller,
text=strings.notice,
mtype=Gtk.MessageType.INFO,
buttons=Gtk.ButtonsType.OK_CANCEL,
secondary=secondary,
)
class WaitDialog(GenericDialog):
def __init__(
self,
controller: "Controller",
secondary: str,
jobs: int = 1,
show_cancel: bool = False,
):
super().__init__(
controller=controller,
text=strings.wait,
mtype=Gtk.MessageType.INFO,
buttons=Gtk.ButtonsType.NONE,
secondary=secondary,
)
self.controller = controller
self.jobs = jobs
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.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)
if self.jobs > 1:
content.pack_end(self.prog, NO_EXPAND, NO_FILL, 0)
else:
spinner.start()
if show_cancel is False:
self.connect("realize", lambda _: self.cancel.set_visible(False))
def update_text(self, msg: str) -> None:
self.format_secondary_text(msg)
def increment(self, msg: str = "") -> None:
if msg != "":
self.format_secondary_text(msg)
fraction = self.cur_job / self.jobs
self.prog.set_fraction(fraction)
self.cur_job += 1
def show_cancel(self, state: bool) -> None:
self.cancel.set_visible(state)
class QuitDialog(GenericDialog):
def __init__(self, controller: "Controller", secondary: str):
super().__init__(
controller=controller,
text=strings.wait,
mtype=Gtk.MessageType.INFO,
buttons=Gtk.ButtonsType.NONE,
secondary=secondary,
)
self.controller = controller
self.add_button(strings.exit_app, Gtk.ResponseType.OK)
self.connect("response", self._on_response)
self.connect("delete-event", self._on_response)
def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None:
# TODO: use a signal?
self.controller.save_res_and_quit()
def _on_dialog_delete(
self, response_id: Gtk.ResponseType, event: Gdk.Event
) -> Literal[True]:
"""
Prevent manual dialog destruction
"""
return True
def _return_to_main_menu(self, widget: Gtk.Widget) -> None:
self.controller.open_page(NotebookPage.MAIN)
class ExceptionDialog(GenericDialog):
"""
Error dialog with rich traceback.
Usage:
try:
foo()
except Exception:
trace = traceback.format_exc()
dialog = ExceptionDialog(Controller, trace)
dialog.run()
"""
def update_label(self, text: str) -> None:
self.format_secondary_text(text)
def __init__(self, controller: "Controller", trace: str):
super().__init__(
controller=controller,
text=strings.error_heading,
mtype=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.NONE,
secondary=strings.something_wrong,
)
self.trace = trace
# NOTE: box expands to end of content area
scrollable = Gtk.ScrolledWindow(
propagate_natural_height=True, max_content_height=500
)
box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL)
# TODO: wrap/truncate long messages
textview = Gtk.TextView(
wrap_mode=Gtk.WrapMode.WORD, editable=False, left_margin=10, right_margin=10
)
textview.set_buffer(Gtk.TextBuffer(text=self.trace))
box.pack_start(textview, EXPAND, FILL, 10)
scrollable.add(box)
content = self.get_content_area()
content.set_spacing(0)
# TODO: padding around top of content area when traceback is long
content.add(scrollable)
copy_button = ClipboardButton(controller, self.get_trace)
self.add_action_widget(copy_button, Gtk.ResponseType.NONE)
self.add_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 _on_response(
self, dialog: Self, response: Gtk.ResponseType
) -> None | Literal[True]:
match response:
case Gtk.ResponseType.OK:
self.destroy()
return None
case Gtk.ResponseType.NONE:
return True
case Gtk.ResponseType.DELETE_EVENT:
self.destroy()
return None
case _:
return None
-153
View File
@@ -1,153 +0,0 @@
from typing import Self
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING
from dzgui.util.import strings
class LanDialog(Gtk.MessageDialog):
"""
Performs integer validation on the provided port
and blocks if out of range. Returns None if user cancels
"""
def __init__(self) -> None:
super().__init__(
transient_for=AppNav.window, #FIXME
flags=0,
message_type=Gtk.MessageType.INFO,
buttons=Gtk.ButtonsType.OK_CANCEL,
text=strings.scan_servers,
secondary_text=strings.select_port,
title=strings.dialog_header,
modal=True,
)
self.set_size_request(500, 0)
self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
buttons = [
(strings.default_port, Port.DEFAULT),
(strings.custom_port, Port.CUSTOM),
]
self.button_box = Gtk.Box()
self.button_box.set_orientation(Gtk.Orientation.VERTICAL)
self.button_box.active_button = None
for k, v in buttons:
button = Gtk.RadioButton(label=k)
button.port = v
button.connect("toggled", self._on_button_toggled)
self.button_box.add(button)
if v == Port.DEFAULT:
self.button_box.active_button = button
else:
button.join_group(self.button_box.active_button)
self.entry = Gtk.Entry()
self.button_box.add(self.entry)
self.entry.set_no_show_all(True)
# TODO: strings
self.warn_label = Gtk.Label(label="Invalid port")
self.warn_label.set_no_show_all(True)
self.button_box.add(self.warn_label)
content = self.get_content_area()
content.pack_start(self.button_box, NO_EXPAND, NO_FILL, NO_PADDING)
content.set_margin_start(30)
content.set_margin_end(30)
content.show_all()
self.action_area.set_layout(Gtk.ButtonBoxStyle.CENTER)
self.action_area.set_margin_bottom(20)
self.port = None
self.ok = self.action_area.get_children()[1]
self.connect("response", self._on_dialog_response)
self.connect("key-press-event", self._on_keypress)
self.connect("delete-event", self.restore_context)
self.entry.connect("insert-text", self._on_text_typed)
self.entry.get_property("buffer").connect(
"deleted-text", self._on_text_deleted
)
def _validate(self, text: str) -> None:
state = ip.is_valid_port(text)
self.ok.set_sensitive(not state)
self.warn_label.set_visible(state)
# TODO: can put in validation method
if len(text) == 0:
self.warn_label.set_visible(False)
def _on_text_deleted(
self, buffer: Gtk.EntryBuffer, position: int, chars: int
) -> None:
text = buffer.get_text()
self._validate(text)
def _on_text_typed(
self, entry: Gtk.Entry, text: str, length: int, pos: int) -> None:
self._validate(entry.get_text() + text)
def restore_context(self, *args: Any) -> None:
context = WindowContext.MAIN_MENU
AppNav.treeview.set_view(context)
def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None:
if event.keyval == Gdk.KEY_Return:
if self.ok.is_sensitive():
self.response(Gtk.ResponseType.OK)
if self.button_box.get_children()[0].is_focus():
self.response(Gtk.ResponseType.OK)
else:
self.restore_context()
if event.keyval == Gdk.KEY_Up:
self.ok.set_sensitive(True)
self.entry.set_text("")
self.button_box.get_children()[0].grab_focus()
def _on_dialog_response(
self, dialog: Self, response: Gtk.ResponseType
) -> None:
cancel_events = [
Gtk.ResponseType.CLOSE,
Gtk.ResponseType.CANCEL,
Gtk.ResponseType.DELETE_EVENT,
]
if response in cancel_events:
self.restore_context()
return
string = self.entry.get_text()
port = self.button_box.active_button.port
match port:
case Port.DEFAULT:
self.port = UDP_PORT
case Port.CUSTOM:
if ip.is_valid_port(string):
self.stop_emission_by_name("response")
else:
self.port = int(string)
def get_selected_port(self) -> int:
return self.port
def _on_button_toggled(self, button: Gtk.Button) -> None:
if button.get_active():
self.button_box.active_button = button
match button.port:
case Port.DEFAULT:
self.entry.set_visible(False)
case Port.CUSTOM:
self.entry.set_visible(True)
self.entry.grab_focus()
self.ok.set_sensitive(False)
+17 -13
View File
@@ -1,13 +1,13 @@
import textwrap
from typing import Self, TYPE_CHECKING
from dzgui.const.enum import Popup, Preferences
from dzgui.const.constants import NO_EXPAND, NO_FILL
from dzgui.util.open_links import open_user_workshop
from dzgui.util.strings import notice
from dzgui.views.dialogs.generic import GenericDialog
from dzgui.views.components.buttons import WebButton
from dzgui.views.components.buttons import SteamTextButton
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402
@@ -17,10 +17,18 @@ if TYPE_CHECKING:
class WorkshopLinkDialog(GenericDialog):
def __init__(self, controller: "Controller", text: str, button_label: str, uid: str):
super().__init__(controller, text, Popup.NOTIFY)
text = textwrap.dedent(text)
def __init__(
self, controller: "Controller", text: str, button_label: str, uid: str
):
text = textwrap.dedent(text).replace("\n", " ")
super().__init__(
controller,
text=notice,
mtype=Gtk.MessageType.INFO,
buttons=Gtk.ButtonsType.OK,
secondary=text,
)
self.controller = controller
self.dialogBox = self.get_content_area()
self.set_default_response(Gtk.ResponseType.OK)
self.set_size_request(500, 0)
@@ -30,7 +38,7 @@ class WorkshopLinkDialog(GenericDialog):
a clickable link to their workshop subscriptions is added
"""
if uid is not None:
button = WebButton(label=button_label)
button = SteamTextButton(label=button_label)
button.set_margin_start(60)
button.set_margin_end(60)
button.connect("clicked", self._on_button_clicked, uid)
@@ -42,12 +50,8 @@ class WorkshopLinkDialog(GenericDialog):
def _on_button_clicked(self, button: Gtk.Button, uid: str) -> None:
# TODO: currently checks user id on instantiation
self.controller.open_user_workshop(uid)
#client = self.controller.query_config(Preferences.CLIENT)
#open_user_workshop(uid, client)
def _on_dialog_response(
self, dialog: Self, resp: Gtk.ResponseType
) -> None:
def _on_dialog_response(self, dialog: Self, resp: Gtk.ResponseType) -> None:
match resp:
case Gtk.ResponseType.DELETE_EVENT:
return
+84
View File
@@ -0,0 +1,84 @@
from typing import Self, TYPE_CHECKING
from dzgui.views.components.entry import ValidatedEntry
from dzgui.views.dialogs.generic import GenericDialog
import gi # noqa E402
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, Gtk # noqa E402
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
class NoteDialog(GenericDialog):
def __init__(self, controller: "Controller"):
# TODO: add to strings
title = "Add a short note/reminder."
secondary = "Limit 30 chars"
super().__init__(
controller=controller,
text=title,
buttons=Gtk.ButtonsType.NONE,
mtype=Gtk.MessageType.INFO,
secondary=secondary,
)
self.controller = controller
delete_button = Gtk.Button(label="Delete note")
delete_button.set_sensitive(False)
self.add_action_widget(delete_button, Gtk.ResponseType.CANCEL)
self.ok = self.add_button("Update note", Gtk.ResponseType.OK)
self.ok.grab_default()
self.entry = ValidatedEntry(controller, self.validate)
self.entry.set_max_length(30)
self.entry.set_size_request(250, 0)
self.pack(self.entry)
note = self.controller.get_note()
self.entry.set_text(note)
if len(note) > 0:
delete_button.set_sensitive(True)
self.format_secondary_text(f"Current note: '{note}'")
elif len(note) == 0:
self.ok.set_sensitive(False)
self.show_all()
# NOTE: explicitly deselect region and move to end of line
self.entry.set_position(-1)
self.entry.set_activates_default(True)
self.connect("response", self._on_response)
self.entry.connect("changed", self._on_text_changed)
def _on_text_changed(self, entry: Gtk.Entry) -> None:
"""
Called in conjunction with signal callback in the parent class
"""
if len(entry.get_text()) == 0:
self.ok.set_sensitive(False)
def validate(self, text: str) -> bool:
if text.isspace():
sensitive = False
else:
sensitive = True
self.ok.set_sensitive(sensitive)
return sensitive
def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None:
match response:
case Gtk.ResponseType.DELETE_EVENT:
self.destroy()
case Gtk.ResponseType.OK:
note = self.entry.get_text()
self.controller.add_note(note)
self.destroy()
case Gtk.ResponseType.CANCEL:
self.controller.delete_note()
self.destroy()
case Gtk.ResponseType.CANCEL | Gtk.ResponseType.CLOSE:
self.destroy()
-103
View File
@@ -1,103 +0,0 @@
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
from dzgui.const.constants import EXPAND, FILL, NO_PADDING
from dzgui.const.enum import Popup
class ServerDetailsDialog(GenericDialog):
def __init__(self, server_name: str, ip: str, qport: int):
super().__init__(server_name, Popup.DETAILS)
dialog_box = self.get_content_area()
self.set_default_response(Gtk.ResponseType.OK)
self.set_size_request(800, 700)
self.ip = ip.split(":")[0]
self.qport = qport
self.store = Gtk.ListStore(str, str, Pango.Weight)
self.view = Gtk.TreeView(
enable_search=False,
search_column=-1,
headers_visible=False,
fixed_height_mode=True,
)
self.view.connect("row-activated", self._on_row_activated)
for i, column_title in enumerate(["Item", "Details"]):
renderer = Gtk.CellRendererText(xalign=0)
if i == 0:
column = Gtk.TreeViewColumn(
column_title, renderer, text=i, weight=2
)
else:
column = Gtk.TreeViewColumn(column_title, renderer, text=i)
column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
if i != 2:
self.view.append_column(column)
column.set_sort_column_id(i)
column.set_expand(True)
scrollable_tree = Gtk.ScrolledWindow()
scrollable_tree.add(self.view)
scrollable_tree.set_size_request(700, 200)
scrollable_message = Gtk.ScrolledWindow()
desc = Gtk.Label(label=strings.server_message, valign=Gtk.Align.START)
css.add_class(desc, "details-heading")
box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER
)
self.description = Gtk.Label(
justify=Gtk.Justification.CENTER, wrap=True
)
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
sep.set_margin_bottom(10)
for el in desc, sep, self.description:
box.add(el)
scrollable_message.add(box)
dialog_box.pack_start(scrollable_tree, EXPAND, FILL, NO_PADDING)
dialog_box.pack_start(scrollable_message, EXPAND, FILL, NO_PADDING)
self.wait_dialog = GenericDialog(strings.details, Popup.WAIT)
self.wait_dialog.show_all()
thread = threading.Thread(
target=self._background, args=(self.wait_dialog, ip, qport)
)
thread.start()
def _on_row_activated(
self,
treeview: Gtk.TreeView,
tree_iter: Gtk.TreeIter,
col: Gtk.TreeViewColumn,
) -> None:
self.destroy()
def _load(self) -> None:
if self.wait_dialog:
self.wait_dialog.destroy()
if self.success is False:
AppNav.window.spawn_dialog(strings.server_error, Popup.NOTIFY)
return
self.show_all()
self.run()
self.destroy()
def _background(
self, dialog: "GenericDialog", ip: str, qport: int
) -> None:
response = Servers.details(self.ip, self.qport)
if response.success:
for row in response.data:
self.store.append(row + [Pango.Weight.BOLD])
self.view.set_model(self.store)
text = response.description
text = format_hyperlinks(text)
self.description.set_markup(text)
self.success = response.success
GLib.idle_add(self._load)
-94
View File
@@ -1,94 +0,0 @@
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
class ModDialog(GenericDialog):
def __init__(self, record: str):
# TODO: center secondary text
msg = strings.workshop
super().__init__(textwrap.dedent(msg), Popup.MODLIST)
dialogBox = self.get_content_area()
self.set_default_response(Gtk.ResponseType.OK)
self.set_size_request(800, 500)
self.scrollable = Gtk.ScrolledWindow()
self.view = Gtk.TreeView(
enable_search=False, search_column=-1, fixed_height_mode=True
)
self.scrollable.add(self.view)
set_surrounding_margins(self.scrollable, 20)
self.view.connect("row-activated", self._on_row_activated)
for i, column_title in enumerate(strings.server_mod_cols):
renderer = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END)
column = Gtk.TreeViewColumn(column_title, renderer, text=i)
column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
self.view.append_column(column)
column.set_sort_column_id(i)
match column_title:
case strings.mod:
column.set_fixed_width(350)
case strings.id:
column.set_fixed_width(200)
case _:
pass
dialogBox.pack_end(self.scrollable, EXPAND, FILL, 0)
wait_dialog = GenericDialog(strings.modlist, Popup.WAIT)
wait_dialog.show_all()
thread = threading.Thread(
target=self._background, args=(wait_dialog, record)
)
thread.start()
# TODO: why are we passing record here?
def _background(self, dialog: "GenericDialog", record: str) -> None:
def _load():
dialog.destroy()
# TODO: natively implement
#if data.returncode == 1:
# AppNav.window.spawn_dialog(strings.server_error, Popup.NOTIFY)
# return
self.show_all()
self.set_markup(f"Modlist ({mod_count} mods)")
self.run()
self.destroy()
record = AppNav.treeview.get_record()
if not record:
return
# TODO: thread
try:
# TODO: get default_steam_path from config
modlist = get_server_modlist(record)
mod_count = self._parse_modlist_rows(modlist)
except Exception:
# TODO: needs to pop error dialog
mod_count = 0
modlist_store.clear()
self.view.set_model(modlist_store)
GLib.idle_add(_load)
def _parse_modlist_rows(self, rows: list) -> bool | int:
for row in rows:
modlist_store.append(row)
return len(rows)
def _on_row_activated(
self,
treeview: Gtk.TreeView,
tree_iter: Gtk.TreeIter,
col: Gtk.TreeViewColumn,
) -> None:
select = treeview.get_selection()
sels = select.get_selected_rows()
(model, pathlist) = sels
if len(pathlist) < 1:
return
path = pathlist[0]
tree_iter = model.get_iter(path)
mod_id = model.get_value(tree_iter, 1)
call_bash_func("open_workshop_page", mod_id)
+187
View File
@@ -0,0 +1,187 @@
from typing import Self, TYPE_CHECKING
from dzgui.const.constants import EXPAND, FILL
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.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
if TYPE_CHECKING:
from dzgui.api.servers import Details
from dzgui.controllers.mc import Controller
class ServerDialog(GenericDialog):
def __init__(
self,
controller: "Controller",
title: str,
secondary: str,
menu: ContextMenuGroup | None,
) -> None:
super().__init__(
controller=controller,
text=title,
buttons=Gtk.ButtonsType.OK,
mtype=Gtk.MessageType.INFO,
secondary=secondary,
)
self.set_default_response(Gtk.ResponseType.OK)
self.set_size_request(800, 700)
self.view = TreeView(controller, menu)
self.view.set_fixed_height_mode(True)
self.connect("response", self._on_response)
self.view.connect("key-press-event", self._on_keypress)
self.scrollable_tree = Gtk.ScrolledWindow()
self.scrollable_tree.add(self.view)
self.scrollable_tree.set_size_request(700, 400)
self.content = self.get_content_area()
self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0)
def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> bool:
# NOTE: ESC normally unfocuses treeview instead of destroying dialog
if event.keyval == Gdk.KEY_Escape:
self.destroy()
return True
return False
def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None:
self.destroy()
class ServerDetailsDialog(ServerDialog):
def __init__(self, controller: "Controller", details: "Details"):
name = controller.get_server_name()
super().__init__(controller, strings.server_details, name, menu=None)
self.store = Gtk.ListStore(str, str, Pango.Weight)
self.view.connect("row-activated", self._on_row_activated)
for i, column_title in enumerate(["Item", "Details"]):
renderer = Gtk.CellRendererText(xalign=0)
if i == 0:
column = Gtk.TreeViewColumn(column_title, renderer, text=i, weight=2)
else:
column = Gtk.TreeViewColumn(column_title, renderer, text=i)
column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
if i != 2:
self.view.append_column(column)
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")
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)
sep.set_margin_bottom(10)
for el in desc, sep, self.description:
box.add(el)
scrollable_message.add(box)
self.pack(scrollable_message)
if details.data is None:
return
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.show_all()
def _on_row_activated(
self,
treeview: Gtk.TreeView,
tree_iter: Gtk.TreeIter,
col: Gtk.TreeViewColumn,
) -> None:
self.destroy()
class ServerModDialog(ServerDialog):
def __init__(
self,
controller: "Controller",
mods: list[list[str]],
):
name = controller.get_server_name()
super().__init__(
controller, server_mods.modlist, name, menu=ContextMenuGroup.SERVER_MOD
)
self.controller = controller
self.mod_store = ModelFactory().make_server_mod_store()
self.view.set_headers_visible(True)
self.view.set_model(self.mod_store)
self.view.connect("row-activated", self._on_row_activated)
# TODO: inherit from ServerModTreeView
columns = [
server_mods.mod,
server_mods.mod_id,
server_mods.up_to_date,
]
for i, column_title in enumerate(columns):
renderer = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END)
column = Gtk.TreeViewColumn(column_title, renderer, text=i)
column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
self.view.append_column(column)
column.set_sort_column_id(i)
match column_title:
case server_mods.mod:
column.set_fixed_width(350)
case server_mods.mod_id:
column.set_fixed_width(200)
case _:
pass
mod_count = len(mods)
self._set_footer(mod_count)
for mod in mods:
self.mod_store.append(mod)
self.show_all()
def _set_footer(self, mods: int) -> None:
footer = Gtk.Label(
valign=Gtk.Align.START, justify=Gtk.Justification.CENTER, wrap=True
)
footer_text = format_server_mods(mods)
footer.set_text(footer_text)
self.pack(footer)
def _on_row_activated(
self,
treeview: Gtk.TreeView,
path: Gtk.TreePath,
col: Gtk.TreeViewColumn,
) -> None:
mod = self.view.get_value_at_index(1)
self.controller.open_workshop_page(mod)
+91 -40
View File
@@ -1,46 +1,50 @@
import logging
from dzgui.views.trees.tree_base import TreeView
from dzgui.const.constants import APP_NAME
from dzgui.const.enum import ContextMenu
from dzgui.util.keys import is_ctrl_mask, is_navkey
from dzgui.util.strings import edit_note
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 E402
logger = logging.getLogger(APP_NAME)
logger = logging.getLogger(__name__)
class ContextMixin(TreeView):
def present_menu(self,
def present_menu(
self,
widget: Gtk.Widget,
event: Gdk.EventButton | Gdk.EventKey,
) -> None:
) -> bool:
#if self.is_selection_empty():
# return
if self.menu is None:
return False
if self.is_selection_empty():
return False
if event.type is Gdk.EventType.BUTTON_PRESS:
try:
pathinfo = self.get_path_at_pos(int(event.x), int(event.y))
if pathinfo is None:
return
(path, col, cellx, celly) = pathinfo
if path is None:
return
self.set_cursor(path, col, False)
except AttributeError:
pass
if event.type is Gdk.EventType.KEY_PRESS:
if event.state is not Gdk.ModifierType.CONTROL_MASK:
return
if event.keyval is not Gdk.KEY_l:
return
match event.type:
case Gdk.EventType.BUTTON_PRESS:
if event.button != 3:
return False
self._process_button_event(event) # type: ignore
case Gdk.EventType.KEY_PRESS:
if not is_ctrl_mask(event): # type: ignore
return False
if event.keyval is not Gdk.KEY_l: # type: ignore
return False
case _:
return False
group = self.menu
self.context_menu = Gtk.Menu()
self.context_menu.connect("key-press-event", self._on_key)
for row in group.value:
item = Gtk.MenuItem(label=row.dict["label"])
item.connect("activate", self._on_menu_click, row)
item = self._process_dynamic_row(row)
self.context_menu.append(item)
self.context_menu.show_all()
@@ -54,22 +58,69 @@ class ContextMixin(TreeView):
self.context_menu.popup_at_pointer(event)
self.context_menu.select_first(False)
return True
def _process_dynamic_row(self, row: ContextMenu) -> Gtk.MenuItem:
if row == ContextMenu.ADD_SERVER and self.is_in_favs(): # type: ignore
row = ContextMenu.REMOVE_SERVER
item = Gtk.MenuItem(label=row.dict["label"])
item.connect("activate", self._on_menu_click, row)
if row == ContextMenu.SHOW_MODS:
item.set_sensitive(self.is_modded()) # type: ignore
if row == ContextMenu.ADD_NOTE:
if self.controller.has_note():
item.set_label(edit_note)
return item
def _process_button_event(self, event: Gdk.EventButton) -> bool:
try:
pathinfo = self.get_path_at_pos(int(event.x), int(event.y))
if pathinfo is None:
return True
(path, col, cellx, celly) = pathinfo
if path is None:
return True
selection = self.get_selection()
model, selected_paths = selection.get_selected_rows()
if path not in selected_paths:
for p in selected_paths:
selection.unselect_path(p)
self.set_cursor(path, col, False)
return True
except AttributeError:
return False
def _on_menu_click(self, widget: Gtk.MenuItem, enum: ContextMenu) -> None:
"""
Local mods page allows multi selection, so ensure that only focused row
is selected. Used by ContextMenu.OPEN_WORKSHOP and ContextMenu.DELETE_MOD.
self.controller.menu_action(enum, self) # type: ignore
Mod tree right panel supports multi-delete, but context menu enforces
single deletion on the focused row.
def _on_key(self, menu: Gtk.Menu, event: Gdk.EventKey) -> bool | None:
if not is_navkey(event.keyval):
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
Debug log page allows multi selection and copy of rows, so special handling
is used.
"""
if enum == ContextMenu.COPY_LOG_CLIPBOARD:
model, records = self.get_selection().get_selected_rows()
clipboard = self.controller.copy_log(records)
self.controller.copy_clipboard(clipboard)
else:
path = self.get_focused_row_path()
self.controller.menu_action(enum, path)
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])
case Gdk.KEY_G:
menu.select_item(children[-1])
case _:
return False
return True
+16 -9
View File
@@ -1,36 +1,43 @@
from dzgui.const.enum import CursorPosition
from dzgui.const.constants import SEPARATOR
from dzgui.util.keys import is_ctrl_mask
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402
class CursorMixin:
def _vim_nav(self, event: Gdk.EventKey):
def _vim_nav(self, event: Gdk.EventKey) -> bool:
match event.keyval:
case Gdk.KEY_g:
self._move_cursor(CursorPosition.TOP)
return True
case Gdk.KEY_G:
self._move_cursor(CursorPosition.BOTTOM)
return True
case Gdk.KEY_j:
self._move_cursor(CursorPosition.DOWN)
return True
case Gdk.KEY_k:
self._move_cursor(CursorPosition.UP)
return True
case Gdk.KEY_l | Gdk.KEY_Right:
if event.state is Gdk.ModifierType.CONTROL_MASK:
return
self.controller.mediator.right_panel.focus_button_box()
if is_ctrl_mask(event):
return False
self.emitter.emit("request_button_box_focus") # type: ignore
return True
case _:
return False
def _move_cursor(self, position: CursorPosition) -> bool:
cur_row = self.get_focused_row_index()
model = self.get_model()
if model:
model = self.get_model() # type: ignore
if len(model) > 0:
end = len(model) - 1
else:
return True
return False
cur_row = self.get_focused_row_index() # type: ignore
if position == CursorPosition.DOWN:
if cur_row == end:
@@ -54,5 +61,5 @@ class CursorMixin:
dest = end
path = Gtk.TreePath.new_from_indices([dest])
self.set_cursor(path)
self.set_cursor(path) # type: ignore
return True
+13
View File
@@ -0,0 +1,13 @@
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk # noqa
class HelpMenuMixin:
def _on_esc_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> bool:
if event.keyval == Gdk.KEY_Escape:
prior = self.controller.get_prior_page() # type: ignore
self.controller.open_page(prior) # type: ignore
return True
return False
+6 -7
View File
@@ -1,16 +1,15 @@
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 E402
class ModsMixin:
def _on_mod_row_activated(self,
tree: TreeView,
path: Gtk.TreePath,
column: Gtk.TreeViewColumn
) -> None:
def _on_mod_row_activated(
self, tree: TreeView, path: Gtk.TreePath, column: Gtk.TreeViewColumn
) -> None:
path = self.get_focused_row_path()
self.controller.open_mod_page(path)
path = self.get_focused_row_path() # type: ignore
self.controller.open_mod_page(path) # type: ignore
+13 -3
View File
@@ -2,23 +2,34 @@ from dzgui.const.enum import VAdjustment
from dzgui.const.constants import SCROLL_INCREMENT
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk # noqa
class ScrollableMixin:
def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None:
def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> bool:
match event.keyval:
case Gdk.KEY_k | Gdk.KEY_Up:
self._set_adjustment(VAdjustment.UP)
return True
case Gdk.KEY_Down | Gdk.KEY_j:
self._set_adjustment(VAdjustment.DOWN)
return True
case Gdk.KEY_g:
self._set_adjustment(VAdjustment.TOP)
return True
case Gdk.KEY_G:
self._set_adjustment(VAdjustment.BOTTOM)
return True
case Gdk.KEY_l | Gdk.KEY_Right:
self.controller.get_emitter().emit("request_button_box_focus") # type: ignore
return True
case _:
return False
def _set_adjustment(self, adjustment: VAdjustment) -> None:
vadj = self.get_vadjustment()
vadj = self.get_vadjustment() # type: ignore
match adjustment:
case VAdjustment.TOP:
adj = vadj.get_lower()
@@ -29,4 +40,3 @@ class ScrollableMixin:
case VAdjustment.DOWN:
adj = vadj.get_value() + SCROLL_INCREMENT
vadj.set_value(adj)
+14 -6
View File
@@ -3,31 +3,35 @@ import logging
from typing import TYPE_CHECKING
from importlib import resources
from dzgui.const.constants import APP_NAME_LOWER
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, CHANGELOG_PATH
from dzgui.util.strings import missing_changelog
from dzgui.util.format import format_pango
from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin
from dzgui.views.mixins.scrollable_mixin import ScrollableMixin
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
logger = logging.getLogger(__name__)
logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
class Changelog(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
def __init__(self, controller: "Controller"):
super().__init__(propagate_natural_width=False)
try:
changelog = resources.read_text(APP_NAME_LOWER, "data/CHANGELOG.md")
changelog = resources.read_text(APP_NAME_LOWER, CHANGELOG_PATH)
except OSError as e:
logger.critical(e)
changelog = "Error: Failed to read changelog"
changelog = missing_changelog
# TODO: should long text be wrapped?
self.controller = controller
formatted = format_pango(changelog)
self.changelog_label = Gtk.Label(valign=Gtk.Align.START, margin=15)
self.changelog_label.set_markup(formatted)
@@ -36,3 +40,7 @@ class Changelog(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
self.add(self.box)
self.connect("key-press-event", self._on_keypress)
self.connect("key-press-event", self._on_esc_keypress)
def grab_content_area(self) -> None:
self.grab_focus()
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more