mirror of
https://github.com/aclist/dztui.git
synced 2026-10-25 12:12:31 +01:00
Merge pull request #31 from aclist/feat/server-threading
feat: server threading
This commit is contained in:
121 files changed
+7349
-3913
No files matched your search
@@ -7,3 +7,5 @@ build/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
notes.otl
|
||||
uv.lock
|
||||
.coverage
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||

|
||||
|
||||
## 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
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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)
|
||||
@@ -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
@@ -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
|
||||
@@ -1,6 +1,7 @@
|
||||
import shutil
|
||||
# import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def uninstall(path: Path) -> None:
|
||||
pass
|
||||
# TODO: use xdg object?
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,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
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
all_ok = "All OK. Loading UI..."
|
||||
@@ -0,0 +1,2 @@
|
||||
workshop = "Steam Workshop"
|
||||
workshop_tooltip = "Open your Workshop subscriptions"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,2 @@
|
||||
waiting_for_launch = "Waiting for DayZ to launch"
|
||||
waiting_for_mods = "Waiting for Steam to update mods"
|
||||
@@ -0,0 +1,3 @@
|
||||
api_validation_error = (
|
||||
"API key validation error. Key was typed incorrectly or is defunct."
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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."
|
||||
@@ -0,0 +1,6 @@
|
||||
mod = "Mod"
|
||||
mod_id = "ID"
|
||||
up_to_date = "Up to date"
|
||||
modlist = "Modlist"
|
||||
checkmark = "✓"
|
||||
resync="⟳"
|
||||
+4
-9
@@ -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,4 +1,5 @@
|
||||
import shlex
|
||||
|
||||
|
||||
def concat_bash_args(command: str) -> list[str]:
|
||||
return shlex.split(command)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,6 +1,7 @@
|
||||
import os
|
||||
import locale
|
||||
|
||||
|
||||
def number(num: int | float) -> str:
|
||||
spec = ""
|
||||
if type(num) is int:
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -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
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in new issue
Block a user