diff --git a/.gitignore b/.gitignore index 7063a6a..456bad6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ build/ .pytest_cache/ .mypy_cache/ notes.otl +uv.lock +.coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f46415f..df18b26 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 52eec94..30c1729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DEVELOPERS.md b/DEVELOPERS.md new file mode 100644 index 0000000..da5b8d6 --- /dev/null +++ b/DEVELOPERS.md @@ -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 diff --git a/README.md b/README.md index 58c68fc..969add7 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,10 @@ Refer to the documentation for installation and setup instructions: - [GitHub](https://aclist.github.io/dzgui/index.html) - [Mirror (Codeberg)](https://aclist.codeberg.page) - ![A screenshot of DZGUI](/images/example.png) ## Attribution Geolocation records from [DB-IP](https://db-ip.com) under [CC 4.0 license](https://creativecommons.org/licenses/by/4.0/) -# TODO: mention a2s/dayzquery +# TODO: mention third party libraries diff --git a/docs/source/conf.py b/docs/source/conf.py index 28a28fc..4c746e2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -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" diff --git a/docs/source/development.rst b/docs/source/development.rst index 879408b..5c6868c 100644 --- a/docs/source/development.rst +++ b/docs/source/development.rst @@ -10,3 +10,10 @@ file. All contributors are expected to act in accordance with the project's `code of conduct `_, 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. diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 280e743..cb51ce8 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -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 diff --git a/docs/source/locale.rst b/docs/source/locale.rst index 4d04242..7a981ac 100644 --- a/docs/source/locale.rst +++ b/docs/source/locale.rst @@ -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. diff --git a/docs/source/venv.rst b/docs/source/venv.rst new file mode 100644 index 0000000..d23b798 --- /dev/null +++ b/docs/source/venv.rst @@ -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 + + +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`` diff --git a/dzgui/api/bm.py b/dzgui/api/bm.py index c2597f0..c19a6eb 100644 --- a/dzgui/api/bm.py +++ b/dzgui/api/bm.py @@ -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 diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index c929085..944df15 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -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") diff --git a/dzgui/api/pefile.py b/dzgui/api/pefile.py index dee55d3..d9498eb 100644 --- a/dzgui/api/pefile.py +++ b/dzgui/api/pefile.py @@ -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("> 16 & 0xFFFF major = struct.unpack("> 0 & 0xFFFF build = struct.unpack("> 0 & 0xFFFF - revision = struct.unpack("> 16 & 0xFFFF - return FileVersion(major, minor, build, revision) + # NOTE: 0-pad revision number + revision = str(struct.unpack("> 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(" 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 diff --git a/dzgui/api/probe.py b/dzgui/api/probe.py index d2ce200..6ecd788 100644 --- a/dzgui/api/probe.py +++ b/dzgui/api/probe.py @@ -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: diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 9a07193..75df672 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -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)) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index ab24526..4ede7cb 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -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): - # @;@; + 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//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 diff --git a/dzgui/config/convert.py b/dzgui/config/convert.py index 60571a6..8061adb 100644 --- a/dzgui/config/convert.py +++ b/dzgui/config/convert.py @@ -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) diff --git a/dzgui/config/freedesktop.py b/dzgui/config/freedesktop.py index a9aa87c..147d3b9 100644 --- a/dzgui/config/freedesktop.py +++ b/dzgui/config/freedesktop.py @@ -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) diff --git a/dzgui/config/ipdb.py b/dzgui/config/ipdb.py index 4d64264..53c89db 100644 --- a/dzgui/config/ipdb.py +++ b/dzgui/config/ipdb.py @@ -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}") diff --git a/dzgui/config/query.py b/dzgui/config/query.py index 7e77af3..602b082 100644 --- a/dzgui/config/query.py +++ b/dzgui/config/query.py @@ -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 diff --git a/dzgui/config/uninstall.py b/dzgui/config/uninstall.py index fb40d2f..005c519 100644 --- a/dzgui/config/uninstall.py +++ b/dzgui/config/uninstall.py @@ -1,6 +1,7 @@ -import shutil +# import shutil from pathlib import Path + def uninstall(path: Path) -> None: pass # TODO: use xdg object? diff --git a/dzgui/config/userprefs.py b/dzgui/config/userprefs.py index d7c09f9..a06094c 100644 --- a/dzgui/config/userprefs.py +++ b/dzgui/config/userprefs.py @@ -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 diff --git a/dzgui/config/xdg.py b/dzgui/config/xdg.py index 52ac6b9..2826a0b 100644 --- a/dzgui/config/xdg.py +++ b/dzgui/config/xdg.py @@ -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 diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index c3b586f..f536ec4 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -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") diff --git a/dzgui/const/endpoints.py b/dzgui/const/endpoints.py index b27e921..cb1ca1c 100644 --- a/dzgui/const/endpoints.py +++ b/dzgui/const/endpoints.py @@ -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" diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index aa0dc9c..aad7698 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -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, diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py new file mode 100644 index 0000000..a089351 --- /dev/null +++ b/dzgui/controllers/emitter.py @@ -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 + diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index c3380eb..5de87dc 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -1,70 +1,60 @@ import logging -import shutil import threading -import textwrap -from pathlib import Path -from typing import Any, Callable, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Union -import dzgui.api.pefile as PeFile import dzgui.util._json as JSON # noqa -from dzgui.api.probe import test_steam_api, test_bm_api -from dzgui.api.mods import ( - get_delimited_mods, - get_local_mod_path, - find_stale_mods, - _hash, - remove_stale_signatures -) + from dzgui.const.enum import ( + FilterMode, Preferences, - Popup, NotebookPage, ButtonType, ContextMenu, - RowType ) -from dzgui.const.constants import ( - APPID_DAYZ, - APPID_DAYZ_EXP, - HEX_RED, - WINDOW_DEFAULT_X, - WINDOW_DEFAULT_Y -) - -from dzgui.config import update -from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs -from dzgui.controllers.model import ModelManager +from dzgui.const.constants import APP_NAME +from dzgui.controllers.emitter import Emitter +from dzgui.managers.config import ConfigManager +from dzgui.managers.connection import ConnectionManager +from dzgui.managers.contextmenu import ContextMenuManager +from dzgui.managers.mods import ModManager +from dzgui.managers.notes import NoteManager +from dzgui.model.servers import ServerModelManager from dzgui.util.diag import write_diagnostic -from dzgui.util import cooldown, strings -from dzgui.util._json import read_json, write_json -from dzgui.util.open_links import open_workshop_page, open_user_workshop -from dzgui.util.format import format_mods, format_player_count, pluralize -from dzgui.util.redact import redact_log - +from dzgui.util.format import format_player_count +from dzgui.util.open_links import open_user_workshop, open_workshop_page from dzgui.views.dialogs.filepicker import FilePicker -from dzgui.views.dialogs.generic import GenericDialog +from dzgui.views.dialogs.generic import ExceptionDialog import gi -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 -logger = logging.getLogger(__name__) +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.api.servers import Record + from dzgui.const.enum import ServerTab + from dzgui.managers.connection import Prerequisites + from dzgui.managers.filter import FilterManager + from dzgui.util.dist import Haversine + from dzgui.views.base import Notebook, Grid, OuterWindow from dzgui.views.components.buttonbox import ContextualButton - from dzgui.views.base import OuterWindow - from dzgui.views.trees.tree_base import TreeView + from dzgui.views.components.filter_panel import FilterPanel + from dzgui.views.components.right_panel import RightPanel + from dzgui.views.components.statusbar import Statusbar + from dzgui.views.pages.options import Options + from dzgui.views.pages.preconnect import PreConnectionAssistant + from dzgui.views.pages.servers import ServerNotebook + from dzgui.views.trees.tree_log import LogTreeView + from dzgui.views.trees.tree_menu import MenuTreeView from dzgui.views.trees.tree_mods import ModTreeView from dzgui.views.trees.tree_servers import ServerTreeView - from dzgui.views.trees.tree_log import LogTreeView - from dzgui.views.base import Notebook, Grid, OuterWindow - from dzgui.views.components.statusbar import Statusbar - from dzgui.views.components.right_panel import RightPanel - from dzgui.views.pages.servers import ServerNotebook + class AppNavigation: window: "OuterWindow" @@ -75,23 +65,37 @@ class AppNavigation: modtreeview: "ModTreeView" menu: "MenuTreeView" servers: "ServerNotebook" - browser: "ServerTreeView" saved: "ServerTreeView" recent: "ServerTreeView" lan: "ServerTreeView" - modtreeview: "ModTreeView" logtreeview: "LogTreeView" + filters: "FilterPanel" + preconnect: "PreConnectionAssistant" + options: "Options" -# TODO: most server contexts can be dropped from this struct -class Controller: +class Controller(GObject.GObject): def __init__(self) -> None: - self.crumbs_cache = "" + self.dist_cache: dict[str, "Haversine"] = {} self.mediator = AppNavigation() - self.prefs: UserPrefs - self.cooldown = 0 - self.model_manager = ModelManager() + self.prefs: UserPrefs + + self.emitter = Emitter() + self.emitter.connect("map_selection_changed", self._on_map_selection_changed) + self.emitter.connect("check_toggled", self._on_check_toggled) + self.emitter.connect("servers_loaded_init", self._on_servers_loaded_init) + + # NOTE: suppress requests until entire UI is loaded + self.loaded = False + self.pending_jobs = 1 + + self.exit_event = threading.Event() + self.cancel_event = threading.Event() + self.connection_man: ConnectionManager + + def get_emitter(self) -> Emitter: + return self.emitter def register_widget(self, attr: str, widget: Gtk.Widget) -> None: try: @@ -99,103 +103,35 @@ class Controller: except AttributeError: logger.critical(f"{attr} is not a valid AppNavigation attribute.") - def set_crumbs(self, text: str) -> None: - self.mediator.grid.set_breadcrumbs(text) - - def get_crumbs(self) -> str: - return self.mediator.grid.get_breadcrumbs() - - def get_server_store(self) -> Gtk.ListStore: - return self.model_manager.get_server_store() - - def get_saved_store(self) -> Gtk.ListStore: - return self.model_manager.get_saved_store() - - def get_recent_store(self) -> Gtk.ListStore: - return self.model_manager.get_recent_store() - - def get_lan_store(self) -> Gtk.ListStore: - return self.model_manager.get_lan_store() - - def get_help_store(self) -> Gtk.ListStore: - return self.model_manager.get_help_store() - - def get_map_store(self) -> Gtk.ListStore: - return self.model_manager.get_map_store() - - def get_modlist_store(self) -> Gtk.ListStore: - return self.model_manager.get_modlist_store() - - def get_mod_store(self) -> Gtk.ListStore: - return self.model_manager.get_mod_store() - - def get_log_store(self) -> Gtk.ListStore: - return self.model_manager.get_log_store() - - def terminate_process(self) -> None: - # TODO: only used by server table multiprocessing queue - self.mediator.notebook.servers.browser.terminate_process() - def get_prefs(self) -> UserPrefs: return self.prefs def set_prefs(self, prefs: UserPrefs) -> None: + self.config_man = ConfigManager(prefs, self) + self.notes_man = NoteManager(self, prefs.paths.notes) + self.prefs = prefs - def query_config(self, key: Preferences) -> str | bool | list: - config = self.prefs.paths.config - return lookup(config, key) + def query_config(self, key: Preferences) -> Any: + return self.config_man.lookup(key) def is_auto_install(self) -> bool: - return self.query_config(Preferences.INSTALL) - - def reinit_map_store(self) -> None: - self.model_manager.set_all_maps() - - def append_map(self, map_row: list) -> None: - self.model_manager.append_map(map_row) - - def unblock_signals(self) -> None: - self.block_signals(False) - - def block_signals(self, state: bool = True) -> None: - self.suppress_signal( - self.mediator.grid.right_panel.filters_vbox, - self.mediator.grid.right_panel.filters_vbox.maps_combo, - "_on_map_changed", - state, - ) - self.suppress_signal( - self.mediator.menu, - self.mediator.menu.selected_row, - "_on_tree_selection_changed", - state, - ) - self.suppress_signal(self.mediator.menu, self.mediator.menu, "_on_keypress", state) - for check in self.mediator.grid.right_panel.filters_vbox.checks: - self.suppress_signal( - self.mediator.grid.right_panel.filters_vbox, - check, - "_on_check_toggled", - state, - ) + return bool(self.query_config(Preferences.INSTALL)) def suppress_signal( - self, owner: Gtk.Widget, widget: Gtk.Widget, func_name: str, state: bool + self, owner: Any, child: Any, func_name: str, state: bool ) -> None: func = getattr(owner, func_name) if state: - widget.handler_block_by_func(func) + child.handler_block_by_func(func) else: - widget.handler_unblock_by_func(func) - # TODO: deprecated? - #self.mediator.menu.sel_blocked = state + child.handler_unblock_by_func(func) - def toggle_debug_mode(self) -> None: - self.toggle_config(Preferences.DEBUG) + def get_active_context(self) -> "ServerTab": + return self.get_active_treeview().get_enum() - def get_active_treeview(self) -> "TreeView": + def get_active_treeview(self) -> "ServerTreeView": return self.mediator.notebook.servers.get_active_treeview() def grab_active_treeview(self) -> None: @@ -203,104 +139,45 @@ class Controller: def save_res_and_quit(self, *args: Any) -> None: treeview = self.get_active_treeview() - columns = treeview.get_columns() + window = self.get_window() + self.config_man.save_res_and_quit(treeview, window) - columns_file = self.prefs.paths.columns - try: - data = JSON.read_json(columns_file) - except Exception as e: - logger.critical(e) - data = {"cols": {}} + def set_statusbar(self, enum: "NotebookPage | ServerTab", text: str) -> None: + self.mediator.statusbar.set_by_context(enum, text) - for column in columns: - title = column.get_title() - size = column.get_width() - data["cols"][title] = size + def remove_statusbar(self, context: "NotebookPage | ServerTab") -> None: + self.mediator.statusbar.pop(context) - try: - JSON.write_json(data, columns_file) - except Exception as e: - logger.critical(e) - - if self.mediator.window.props.is_maximized: - Gtk.main_quit() + def set_statusbar_dist( + self, haversine: Union["Haversine", None], enum: "ServerTab" + ) -> None: + """ + NOTE: prevents race condition when server tab changed, + but allows caching the distance in the background + """ + context = self.get_active_context() + page = self.mediator.notebook.get_page_by_enum() + if page != NotebookPage.SERVERS: + self.emitter.emit("distcalc_ended", None, context) + return + if enum != context: + self.emitter.emit("distcalc_ended", None, context) return - w, h = self.mediator.window.get_size() - data = {"res": {"width": w, "height": h}} + if haversine is None: + self.emitter.emit("distcalc_ended", None, context) + return + dist = haversine.get_rounded(self.prefs.use_miles) + self.emitter.emit("distcalc_ended", dist, context) - res_path = self.prefs.paths.resolution - try: - write_json(data, res_path) - except Exception as e: - logger.critical(e) - - Gtk.main_quit() - - def get_statusbar(self) -> str: - return self.mediator.grid.statusbar.get_text() - - def set_statusbar(self, text: str) -> None: - self.mediator.grid.statusbar.set_text(text) - - def delete_multiple_mods(self) -> None: - sel = self.mediator.modtreeview.get_selection() - model, pathlist = sel.get_selected_rows() - # NOTE: reverse when multiple selection - for path in reversed(pathlist): - self.delete_single_mod(path) - - total_mods, total_size = self.calc_mod_size() - self.update_mod_statusbar() - - def load_mods(self) -> None: - model = self.model_manager.get_mod_store() - model.clear() - path = self.query_config(Preferences.DEFAULT) - mods = get_delimited_mods(Path(path)) - - for mod in mods: - # NOTE: holds color column - mod.append(None) - model.append(mod) - - self.update_mod_statusbar() - - def toggle_config(self, context: Preferences) -> None: - config = self.prefs.paths.config - try: - update.toggle_config(config, context) - except Exception as e: - logger.critical(e) - self.spawn_dialog(strings.something_wrong, Popup.NOTIFY) + def toggle_config(self, key: Preferences) -> None: + # NOTE: Preferences.DIST is dynamic + if key == Preferences.DIST: + self.prefs.use_miles = not self.prefs.use_miles + self.config_man.toggle_config(key) def update_config(self, key: Preferences, value: str) -> None: - try: - update.write_config(self.prefs.paths.config, key, value) - except Exception as e: - logger.critical(e) - self.spawn_dialog(strings.something_wrong, Popup.NOTIFY) - # TODO: suppress signals - # then reenable (or it spawns dialog twice) - self.mediator.grid.notebook.settings.populate_settings() - return - - # NOTE: disabled for now - #def present_toast(self, text: str) -> None: - # self.mediator.window.toast.set_text_and_fade(text) - - def start_cooldown(self) -> None: - self.cooldown = cooldown.get_time() - - def get_cooldown(self) -> None: - return self.cooldown - - def manage_cooldown(self) -> bool: - if cooldown.is_elapsed(self.cooldown): - self.cooldown = cooldown.get_time() - return True - else: - return False + self.config_man.update_config(key, value) def open_keybindings(self) -> None: notebook = self.mediator.grid.notebook @@ -310,37 +187,8 @@ class Controller: notebook = self.mediator.grid.notebook notebook.focus_current() - def spawn_dialog(self, msg: str, mode: Popup) -> bool: - """ - Spawns a GenericDialog transient to the OuterWindow - """ - msg = textwrap.dedent(msg) - dialog = GenericDialog(self, msg, mode) - response = dialog.run() - dialog.destroy() - - match response: - case Gtk.ResponseType.OK: - return False - case Gtk.ResponseType.CANCEL | Gtk.ResponseType.DELETE_EVENT: - return True - return False - - def set_statusbar_by_row(self, row: "RowType") -> None: - self.mediator.grid.statusbar.refresh(row) - - def toggle_server_panels(self, state: bool) -> None: - self.mediator.grid.toggle_filter_panel(state) - self.mediator.grid.toggle_connect_panel(state) - self.mediator.grid.toggle_refresh_button(state) - - def toggle_mod_panel(self, state: bool) -> None: - self.mediator.grid.right_panel.sel_panel.set_visible(state) - def show_developers_page(self) -> None: self.open_page(NotebookPage.DEVELOPERS) - # TODO: put cursor on first row - #self.mediator.developers.focus_first_row() def open_page(self, page: NotebookPage) -> None: self.mediator.grid.notebook.set_page_by_enum(page) @@ -349,214 +197,72 @@ class Controller: # TODO: consolidate methods with set_page_by_enum match button.context: case ButtonType.EXIT: - logger.info("Normal user exit") self.save_res_and_quit() return case ButtonType.OPTIONS: - self.mediator.grid.notebook.settings.populate_settings() + try: + # TODO: where to put config file check + self.mediator.grid.notebook.settings.populate_settings() + except Exception: + return case ButtonType.MODS: self.load_mods() - case ButtonType.HELP: - self.mediator.grid.statusbar.refresh(RowType.CHANGELOG) - pass - case ButtonType.SERVERS: - self.mediator.notebook.set_page_by_enum(button.opens) - # TODO: use cache - self.update_server_status() - return - self.mediator.notebook.set_page_by_enum(button.opens) - self.set_crumbs(button.get_label()) + self.open_page(button.opens) + + def get_help_row(self) -> str: + return self.mediator.menu.get_row_enum() def open_user_workshop(self, uid: str) -> None: # NOTE: uid may contain leading zeroes, not a real integer client = self.query_config(Preferences.CLIENT) open_user_workshop(uid, client) - def copy_log(self, paths: list[Gtk.TreePath]) -> str: - if len(paths) < 1: - return "" - final = [] - for path in paths: - record = self.log_store[path] - r = [el for el in record] - concat = strings.delimiter.join(r) - final.append(concat) - text = "\n".join(final) - return text + def load_mods(self) -> None: + self.mod_man = ModManager(self) - def copy_clipboard(self, text: str) -> None: - self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) - self.clipboard.set_text(text, -1) - - def open_mod_page(self, path: Gtk.TreePath) -> None: - mod, it = self.get_mod_from_tree_path(path) - cmd = self.query_config(Preferences.CLIENT) - open_workshop_page(mod, cmd) - - # TODO: put in model manager (dedicated manager for mod store) - def get_mod_from_tree_path(self, tree_path: Gtk.TreePath) -> tuple[str, Gtk.TreeIter]: - model = self.model_manager.get_mod_store() - tree_iter = model.get_iter(tree_path) - mod = model.get(tree_iter, 2)[0] - return mod, tree_iter - - def delete_single_mod(self, tree_path: Gtk.TreePath) -> None: - config = self.prefs.paths.config - - mod, it = self.get_mod_from_tree_path(tree_path) - - path = lookup(config, Preferences.DEFAULT) - steam_path = Path(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 - - model = self.model_manager.get_mod_store() - model.remove(it) - - - def update_mod_statusbar(self) -> None: - total_mods, total_size = self.calc_mod_size() - msg = format_mods(total_size, total_mods) - self.mediator.grid.statusbar.set_text(msg) - - def calc_mod_size(self) -> tuple[int, int]: - model = self.model_manager.get_mod_store() - total_mods = len(model) - total_size = 0 - for mod in model: - total_size += mod[3] - return total_mods, total_size - - - def menu_action(self, action: ContextMenu, path: Gtk.TreePath) -> None: - match action: - # NOTE: manipulates server stores - # TODO: unimplemented - case ContextMenu.ADD_SERVER: - pass - case ContextMenu.ADD_FAV: - pass - case ContextMenu.ADD_NOTE: - pass - case ContextMenu.COPY_CLIPBOARD: - pass - case ContextMenu.COPY_NAME: - pass - case ContextMenu.REFRESH_PLAYERS: - pass - case ContextMenu.REMOVE_HISTORY: - pass - case ContextMenu.REMOVE_SERVER: - pass - case ContextMenu.SHOW_DETAILS: - pass - case ContextMenu.SHOW_MODS: - pass - - # NOTE: manipulates mod store - case ContextMenu.DELETE_MOD: - self.delete_single_mod(path) - self.update_mod_statusbar() - remove_stale_signatures( - self.prefs.paths.config, - self.prefs.paths.version - ) - - case ContextMenu.OPEN_WORKSHOP: - self.open_mod_page(path) + def uncolorize_mods(self) -> None: + self.mod_man.uncolorize_mods() + def highlight_stale(self) -> None: + self.mod_man.highlight_stale() def toggle_mod_selection(self, state: bool) -> None: - sel = self.mediator.modtreeview.get_selection() - if state: - sel.select_all() - else: - sel.unselect_all() - self.mediator.modtreeview.grab_focus() + self.mod_man.toggle_mod_selection(state) + + def delete_mods(self) -> None: + self.mod_man.delete_mods() + + def get_mod_store(self) -> Gtk.TreeModel | None: + return self.mediator.modtreeview.get_model() + + def set_fav(self, name: str, record: str, simple_ip: str) -> None: + try: + self.config_man.set_fav(name, record) + self.emitter.emit("fav_server_changed", name, simple_ip) + except Exception as e: + logger.critical(e) + return + + def menu_action(self, action: ContextMenu, tree: "ServerTreeView") -> None: + context_man = ContextMenuManager(tree, self) + context_man.process(action) def populate_log(self) -> None: log = self.prefs.paths.debug - store = self.model_manager.get_log_store() - store.clear() - - # TODO: pop dialog if log is missing - with open(log, "r") as f: - lines = [line.split(strings.delimiter) for line in f.read().splitlines()] - for record in lines: - clean = redact_log(record) - store.append(clean) - self.open_page(NotebookPage.LOG) + try: + self.mediator.logtreeview.populate_log(log) + self.open_page(NotebookPage.LOG) + except Exception as e: + dialog = ExceptionDialog(self, str(e)) + dialog.run() def select_colorized(self) -> None: - model = self.model_manager.get_mod_store() - sel = self.mediator.modtreeview.get_selection() - for mod in model: - it = mod.iter - path = model.get_path(it) - if mod[4] == HEX_RED: - sel.select_path(path) + self.mod_man.select_colorized() - def uncolorize_mods(self) -> None: - model = self.model_manager.get_mod_store() - for mod in model: - it = mod.iter - path = model.get_path(it) - model[path][4] = None - - def colorize_mods(self) -> None: - model = self.model_manager.get_mod_store() - stale = find_stale_mods(self.prefs.paths.config) - for mod in model: - it = mod.iter - path = model.get_path(it) - # TODO: consider storing in ListStore as int - if int(mod[2]) in stale: - model[path][4] = HEX_RED - - self.destroy_on_idle() - - def unselect_all_mods(self) -> None: - self.mediator.modtreeview.get_selection().unselect_all() - - def highlight_stale(self) -> None: - self.call_on_thread(self.colorize_mods) - - def call_on_thread(self, func: Callable, *args) -> None: - self.wait_dialog = GenericDialog(self, strings.dialog.fetching, Popup.WAIT) - self.wait_dialog.show_all() - thread = threading.Thread(target=func, args=args) - thread.start() - - def destroy_on_idle(self) -> None: - self.wait_dialog.destroy() - # TODO: improve upon this - func = self.callback["func"] - if func is not None: - args = self.callback["args"] - GLib.idle_add(func, *args) - self.set_callback(None, None) - - def set_callback(self, callback: Callable | None, *args) -> None: - """ - Lets calling widgets register a callback function - when spawning a threaded process - """ - self.callback = { "func": callback, "args": args } + def get_filter_man(self) -> "FilterManager": + """Each ServerTreeView has an atomic FilterManager""" + return self.get_active_treeview().get_filter_man() def dump_diagnostics(self) -> None: picker = FilePicker(self.mediator.window) @@ -565,115 +271,232 @@ class Controller: try: write_diagnostic(self.prefs.paths.config, file) except Exception as e: - self.spawn_dialog(str(e), Popup.NOTIFY) + dialog = ExceptionDialog(self, str(e)) + dialog.run() - def test_api_response(self, text: str, key: Preferences) -> 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) - self.set_callback(None, None) - self.destroy_on_idle() - else: - self.destroy_on_idle() - self.spawn_dialog(strings.api_error, Popup.NOTIFY) - - - def update_api_key(self, text: str, key: Preferences) -> None: - self.call_on_thread(self.test_api_response, text, key) + def update_api_key(self, key: Preferences, text: str) -> None: + self.config_man.update_api_key(key, text) def set_resolution(self, window: "OuterWindow") -> None: - if self.prefs.is_game_mode: - window.fullscreen() - return - elif self.query_config(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) - - def update_server_status(self) -> None: - treeview = self.mediator.notebook.servers.get_active_treeview() - model = treeview.get_model() - status = format_player_count(model) - self.mediator.statusbar.set_text(status) + self.config_man.set_resolution(window) def propagate_column_width(self, col: Gtk.TreeViewColumn) -> None: GLib.idle_add(self.mediator.servers.update_tab_widths, col) - def set_crumbs_cache(self, text: str) -> None: - self.crumbs_cache = text - - def get_crumbs_cache(self) -> str: - return self.crumbs_cache - def refresh_tree(self) -> None: - treeview = self.mediator.notebook.servers.get_active_treeview() + treeview = self.get_active_treeview() treeview.set_loaded(False) - self.populate_model() + ServerModelManager(self, treeview).refresh() - def populate_model(self) -> None: - # TODO: always use same server model, store in servertreeview class - treeview = self.mediator.notebook.servers.get_active_treeview() - if treeview.get_loaded() is False: - new_model = self.model_manager.new_model() - # NOTE: set_query_func() - func = treeview.get_query_func() - if func is not None: - model = treeview.get_model() - model.clear() - data = func() - # TODO: threading - model.append(data) - treeview.set_loaded(True) - self.update_server_status() - treeview.grab_focus() + def get_player_count(self) -> str: + treeview = self.get_active_treeview() + model, control = treeview.get_model_and_control_model() + count = format_player_count(model, control) + return count - def focus_button_box(self) -> None: - self.mediator.right_panel.focus_button_box() + def get_statusbar(self) -> "Statusbar": + return self.mediator.statusbar - def present_servers(self) -> None: - self.grab_active_treeview() - self.update_server_status() - crumbs = self.mediator.servers.get_cached_label() - self.set_crumbs(crumbs) + def populate_model(self, tv: "ServerTreeView") -> None: + # NOTE: skip on previously loaded tabs + if tv.is_loaded(): + self.emitter.emit("servers_loaded", tv.get_enum()) + return + self.mediator.statusbar.set_by_context(tv.get_enum(), "") + ServerModelManager(self, tv).load() - def toggle_check(self, event: Gdk.EventKey) -> None: - keyname = Gdk.keyval_name(event.keyval) - if keyname.isnumeric() and int(keyname) > 0: - digit = int(keyname) - 1 - self.mediator.grid.right_panel.filters_vbox.toggle_check(digit) + def get_dist_cache(self) -> dict[str, "Haversine"]: + return self.dist_cache + + def set_dist_cache(self, addr: str, haversine: "Haversine") -> None: + self.dist_cache[addr] = haversine + + def get_map_store(self) -> Gtk.ListStore: + filter_man = self.get_filter_man() + return filter_man.get_map_store() + + def get_enabled_filters(self) -> dict: + filter_man = self.get_filter_man() + return filter_man.get_filters() + + # TODO: rename + def _on_servers_loaded_init(self, emitter: "Emitter") -> None: + """Triggered after servers load but prior to maps loading""" + # FIXME: wipe maps store when changing tabs if model is none + # e.g. select recent, toggle map, then select lan -> not wiped + tv = self.get_active_treeview() + if tv.loaded is False: + return + store = self.get_map_store() + self.emitter.emit("load_maps", store) + + def has_server_model(self) -> bool: + treeview = self.get_active_treeview() + proxy_man = treeview.get_proxy_man() + control = proxy_man.get_control() + if control is None: + return False + if len(control) < 1: + return False + return True + + def _on_check_toggled(self, emitter: Emitter, label: str, state: bool) -> None: + filter_man = self.get_filter_man() + filter_man.set_filter(label, state) + + mode = FilterMode.TOGGLE_ON if state else FilterMode.TOGGLE_OFF + ServerModelManager(self, self.get_active_treeview()).refilter(mode) + + def _on_map_selection_changed(self, emitter: Emitter, selection: str) -> None: + smm = ServerModelManager(self, self.get_active_treeview()) + smm.refilter(FilterMode.TOGGLE_ON) + + def get_notebook(self) -> "Notebook": + return self.mediator.notebook + + def get_prior_page(self) -> NotebookPage: + return self.mediator.notebook.get_prior_page() + + def get_servers(self) -> "ServerNotebook": + return self.mediator.servers + + def get_server_notebook(self) -> Gtk.Notebook: + return self.mediator.servers.notebook + + def get_window(self) -> "OuterWindow": + return self.mediator.window + + def get_menu(self) -> "MenuTreeView": + return self.mediator.menu + + def has_favorites(self) -> bool: + favs = self.config_man.get_favorites() + if len(favs) < 1: + return False + return True + + def get_config_man(self) -> ConfigManager: + return self.config_man + + def add_by_str(self, addr: str) -> None: + saved_tree = self.get_servers().get_saved() + ServerModelManager(self, saved_tree).add_by_str(addr) + + def add_by_record(self, record: "Record") -> None: + saved_tree = self.get_servers().get_saved() + ServerModelManager(self, saved_tree).add_by_record(record) + + def remove_by_record(self, record: "Record") -> None: + saved_tree = self.get_servers().get_saved() + ServerModelManager(self, saved_tree).remove_by_record(record) + + def add_to_history(self, record: dict[str, Any]) -> None: + tv = self.get_servers().get_recent() + ServerModelManager(self, tv).add_to_history(record) + + def remove_from_history(self, record: "Record") -> None: + # NOTE: remove action is only possible from history tree context menu, + # i.e., the active tree + tv = self.get_active_treeview() + ServerModelManager(self, tv).remove_from_history(record) + + def connect_by_str(self, addr: str) -> None: + if addr.isdigit(): + config_man = self.get_config_man() + key = config_man.lookup(Preferences.BM) + self.connection_man = ConnectionManager(self) + self.connection_man.connect_by_id(int(addr), key) else: - match event.keyval: - case Gdk.KEY_0: - self.mediator.grid.right_panel.filters_vbox.toggle_check(9) - case Gdk.KEY_minus: - self.mediator.grid.right_panel.filters_vbox.toggle_check(10) - case Gdk.KEY_backslash: - self.mediator.grid.right_panel.filters_vbox.toggle_check(11) - case _: - return False + self.connection_man = ConnectionManager(self) + self.connection_man.connect_by_ip(addr) - def get_favorite_label(self) -> str | None: - fav = str(self.query_config(Preferences.FAV_LBL)) - if len(fav) < 1: - return None - return fav + def connect_by_record(self, record: "Record") -> None: + self.connection_man = ConnectionManager(self) + self.connection_man.connect_by_record(record) + + def get_details(self, record: "Record") -> None: + ConnectionManager(self).query_details(record) + + def get_modlist(self, record: "Record") -> None: + ConnectionManager(self).query_modlist_and_present(record) + + def get_server_name(self) -> str: + tv = self.get_active_treeview() + return tv.get_name() + + def open_workshop_page(self, mod: str) -> None: + cmd = self.query_config(Preferences.CLIENT) + open_workshop_page(mod, cmd) + + def has_note(self) -> bool: + note = self.get_note() + if len(note) > 0: + return True + return False + + def get_note_by_record(self, record: str) -> str: + return self.notes_man.get_note(record) + + def get_note(self) -> str: + tv = self.get_active_treeview() + record = tv.get_record_string() + return self.notes_man.get_note(record) + + def add_note(self, note: str) -> None: + tv = self.get_active_treeview() + record = tv.get_record_string() + self.notes_man.add_note(record, note) + + def delete_note(self) -> None: + tv = self.get_active_treeview() + record = tv.get_record_string() + self.notes_man.delete_note(record) + + def refresh_players(self, record: "Record") -> None: + treeview = self.get_active_treeview() + model, treeiter = treeview.get_selection().get_selected() + ServerModelManager(self, treeview).update_playercount(treeiter, record) + + def get_active_keyword(self) -> str: + return self.get_filter_man().get_active_keyword() + + def set_active_keyword(self, keyword: str) -> None: + self.get_filter_man().set_active_keyword(keyword) + + def get_modtreeview(self) -> "ModTreeView": + return self.mediator.modtreeview + + def get_exit_event(self) -> threading.Event: + return self.exit_event + + def set_exit_event(self) -> None: + self.exit_event.set() + + def set_cancel_event(self) -> None: + self.cancel_event.set() + + def get_cancel_event(self) -> threading.Event: + return self.cancel_event + + def clear_cancel_event(self) -> None: + self.cancel_event.clear() + + def open_connection_assistant(self, prereqs: "Prerequisites") -> None: + self.open_page(NotebookPage.CONNECTION) + self.mediator.preconnect.populate(prereqs) + + def set_start_tab(self) -> None: + ind = self.config_man.get_start_tab() + self.get_servers().notebook.set_current_page(ind) + + def update_and_connect(self, raise_window: bool) -> None: + self.connection_man.update_and_connect(raise_window) + + def update_status(self) -> None: + self.mediator.preconnect.mark_finished() + + def get_steam_client_name(self) -> str: + return self.mediator.options.get_client_name() + + def present_window(self) -> None: + self.mediator.window.present_with_time(Gdk.CURRENT_TIME) diff --git a/dzgui/controllers/model.py b/dzgui/controllers/model.py deleted file mode 100644 index 8dae7e4..0000000 --- a/dzgui/controllers/model.py +++ /dev/null @@ -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) diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 52eec94..0b03c65 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -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 diff --git a/dzgui/data/app.css b/dzgui/data/app.css new file mode 100644 index 0000000..40eb4e6 --- /dev/null +++ b/dzgui/data/app.css @@ -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; +} diff --git a/dzgui/init/coords.py b/dzgui/init/coords.py index 957fdbb..7ae3afe 100644 --- a/dzgui/init/coords.py +++ b/dzgui/init/coords.py @@ -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: diff --git a/dzgui/init/dayz.py b/dzgui/init/dayz.py index 1552ecb..1dbbdbc 100644 --- a/dzgui/init/dayz.py +++ b/dzgui/init/dayz.py @@ -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) diff --git a/dzgui/init/migrate.py b/dzgui/init/migrate.py index 7ee5f0c..554e793 100644 --- a/dzgui/init/migrate.py +++ b/dzgui/init/migrate.py @@ -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) diff --git a/dzgui/init/prereqs.py b/dzgui/init/prereqs.py index 5be0040..be92e20 100644 --- a/dzgui/init/prereqs.py +++ b/dzgui/init/prereqs.py @@ -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 diff --git a/dzgui/init/proc.py b/dzgui/init/proc.py index 957205c..78acbf1 100644 --- a/dzgui/init/proc.py +++ b/dzgui/init/proc.py @@ -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 + ) diff --git a/dzgui/init/update.py b/dzgui/init/update.py index 7449a61..eb6d7db 100644 --- a/dzgui/init/update.py +++ b/dzgui/init/update.py @@ -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 diff --git a/dzgui/main.py b/dzgui/main.py index a01a86c..516d5d5 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -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) diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py new file mode 100644 index 0000000..e309bc3 --- /dev/null +++ b/dzgui/managers/config.py @@ -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) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py new file mode 100644 index 0000000..43e2293 --- /dev/null +++ b/dzgui/managers/connection.py @@ -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() diff --git a/dzgui/managers/contextmenu.py b/dzgui/managers/contextmenu.py new file mode 100644 index 0000000..33aea9a --- /dev/null +++ b/dzgui/managers/contextmenu.py @@ -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) diff --git a/dzgui/managers/filter.py b/dzgui/managers/filter.py new file mode 100644 index 0000000..52ef6a0 --- /dev/null +++ b/dzgui/managers/filter.py @@ -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) diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py new file mode 100644 index 0000000..bb1519f --- /dev/null +++ b/dzgui/managers/mods.py @@ -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) diff --git a/dzgui/managers/notes.py b/dzgui/managers/notes.py new file mode 100644 index 0000000..73beaa6 --- /dev/null +++ b/dzgui/managers/notes.py @@ -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) diff --git a/dzgui/managers/threading.py b/dzgui/managers/threading.py new file mode 100644 index 0000000..6c6e20f --- /dev/null +++ b/dzgui/managers/threading.py @@ -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) diff --git a/dzgui/model/model_factory.py b/dzgui/model/model_factory.py new file mode 100644 index 0000000..ed1ccdc --- /dev/null +++ b/dzgui/model/model_factory.py @@ -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) diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py new file mode 100644 index 0000000..9639789 --- /dev/null +++ b/dzgui/model/proxy_model.py @@ -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) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py new file mode 100644 index 0000000..6b82ee9 --- /dev/null +++ b/dzgui/model/servers.py @@ -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)) diff --git a/dzgui/strings/boot.py b/dzgui/strings/boot.py new file mode 100644 index 0000000..8a85083 --- /dev/null +++ b/dzgui/strings/boot.py @@ -0,0 +1 @@ +all_ok = "All OK. Loading UI..." diff --git a/dzgui/strings/buttons.py b/dzgui/strings/buttons.py new file mode 100644 index 0000000..faa00a0 --- /dev/null +++ b/dzgui/strings/buttons.py @@ -0,0 +1,2 @@ +workshop = "Steam Workshop" +workshop_tooltip = "Open your Workshop subscriptions" diff --git a/dzgui/strings/connect_panel.py b/dzgui/strings/connect_panel.py new file mode 100644 index 0000000..d2db974 --- /dev/null +++ b/dzgui/strings/connect_panel.py @@ -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" diff --git a/dzgui/strings/dialogs.py b/dzgui/strings/dialogs.py new file mode 100644 index 0000000..c791a47 --- /dev/null +++ b/dzgui/strings/dialogs.py @@ -0,0 +1,2 @@ +waiting_for_launch = "Waiting for DayZ to launch" +waiting_for_mods = "Waiting for Steam to update mods" diff --git a/dzgui/strings/errors.py b/dzgui/strings/errors.py new file mode 100644 index 0000000..bc5c5fd --- /dev/null +++ b/dzgui/strings/errors.py @@ -0,0 +1,3 @@ +api_validation_error = ( + "API key validation error. Key was typed incorrectly or is defunct." +) diff --git a/dzgui/strings/options.py b/dzgui/strings/options.py new file mode 100644 index 0000000..ec28776 --- /dev/null +++ b/dzgui/strings/options.py @@ -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" diff --git a/dzgui/strings/preconnect.py b/dzgui/strings/preconnect.py new file mode 100644 index 0000000..17c8a5e --- /dev/null +++ b/dzgui/strings/preconnect.py @@ -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." diff --git a/dzgui/strings/server_mods.py b/dzgui/strings/server_mods.py new file mode 100644 index 0000000..c41b443 --- /dev/null +++ b/dzgui/strings/server_mods.py @@ -0,0 +1,6 @@ +mod = "Mod" +mod_id = "ID" +up_to_date = "Up to date" +modlist = "Modlist" +checkmark = "✓" +resync="⟳" diff --git a/dzgui/util/_json.py b/dzgui/util/_json.py index 9961c53..bc8f70e 100644 --- a/dzgui/util/_json.py +++ b/dzgui/util/_json.py @@ -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 diff --git a/dzgui/util/bash.py b/dzgui/util/bash.py index 879e433..9c2a73d 100644 --- a/dzgui/util/bash.py +++ b/dzgui/util/bash.py @@ -1,4 +1,5 @@ import shlex + def concat_bash_args(command: str) -> list[str]: return shlex.split(command) diff --git a/dzgui/util/clip.py b/dzgui/util/clip.py new file mode 100644 index 0000000..58bf68e --- /dev/null +++ b/dzgui/util/clip.py @@ -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) diff --git a/dzgui/util/cooldown.py b/dzgui/util/cooldown.py deleted file mode 100644 index 413a4d0..0000000 --- a/dzgui/util/cooldown.py +++ /dev/null @@ -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 diff --git a/dzgui/util/css.py b/dzgui/util/css.py index 3c18249..44bf7cd 100644 --- a/dzgui/util/css.py +++ b/dzgui/util/css.py @@ -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() diff --git a/dzgui/util/diag.py b/dzgui/util/diag.py index 8d069c2..d027687 100644 --- a/dzgui/util/diag.py +++ b/dzgui/util/diag.py @@ -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) diff --git a/dzgui/util/dist.py b/dzgui/util/dist.py index 0d6f0d8..6a58d82 100644 --- a/dzgui/util/dist.py +++ b/dzgui/util/dist.py @@ -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 diff --git a/dzgui/util/format.py b/dzgui/util/format.py index 49695d0..871e9a7 100644 --- a/dzgui/util/format.py +++ b/dzgui/util/format.py @@ -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: diff --git a/dzgui/util/ip.py b/dzgui/util/ip.py index 5b2057b..9cf92cd 100644 --- a/dzgui/util/ip.py +++ b/dzgui/util/ip.py @@ -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 diff --git a/dzgui/util/localize.py b/dzgui/util/localize.py index 5fe9fc6..fd4b5c6 100644 --- a/dzgui/util/localize.py +++ b/dzgui/util/localize.py @@ -1,6 +1,7 @@ import os import locale + def number(num: int | float) -> str: spec = "" if type(num) is int: diff --git a/dzgui/util/open_links.py b/dzgui/util/open_links.py index 7d0f92e..60db2a6 100644 --- a/dzgui/util/open_links.py +++ b/dzgui/util/open_links.py @@ -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, } diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 037b8e6..176d259 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -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" diff --git a/dzgui/util/symlink.py b/dzgui/util/symlink.py index 59528bd..ad96e0a 100644 --- a/dzgui/util/symlink.py +++ b/dzgui/util/symlink.py @@ -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 diff --git a/dzgui/views/base.py b/dzgui/views/base.py index a1b5a13..082d218 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -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) diff --git a/dzgui/views/components/buttonbox.py b/dzgui/views/components/buttonbox.py index 8020698..496f237 100644 --- a/dzgui/views/components/buttonbox.py +++ b/dzgui/views/components/buttonbox.py @@ -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: diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 2bcbe7e..6f60814 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -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) diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 7019ea0..fbf8f6a 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -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) diff --git a/dzgui/views/components/crumbs.py b/dzgui/views/components/crumbs.py new file mode 100644 index 0000000..cd57426 --- /dev/null +++ b/dzgui/views/components/crumbs.py @@ -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) diff --git a/dzgui/views/components/entry.py b/dzgui/views/components/entry.py new file mode 100644 index 0000000..8dcd3f3 --- /dev/null +++ b/dzgui/views/components/entry.py @@ -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) diff --git a/dzgui/views/components/eventbox.py b/dzgui/views/components/eventbox.py index aa607de..67fde6e 100644 --- a/dzgui/views/components/eventbox.py +++ b/dzgui/views/components/eventbox.py @@ -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 diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index ed6c1ef..eba6735 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -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) diff --git a/dzgui/views/components/frame.py b/dzgui/views/components/frame.py new file mode 100644 index 0000000..c782d5f --- /dev/null +++ b/dzgui/views/components/frame.py @@ -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) diff --git a/dzgui/views/components/label.py b/dzgui/views/components/label.py deleted file mode 100644 index 4100a01..0000000 --- a/dzgui/views/components/label.py +++ /dev/null @@ -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 diff --git a/dzgui/views/components/labels.py b/dzgui/views/components/labels.py index df63009..9ecc52a 100644 --- a/dzgui/views/components/labels.py +++ b/dzgui/views/components/labels.py @@ -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) diff --git a/dzgui/views/components/maps_combo.py b/dzgui/views/components/maps_combo.py new file mode 100644 index 0000000..0eea10f --- /dev/null +++ b/dzgui/views/components/maps_combo.py @@ -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) diff --git a/dzgui/views/components/mod_panel.py b/dzgui/views/components/mod_panel.py index 109969a..1b56c37 100644 --- a/dzgui/views/components/mod_panel.py +++ b/dzgui/views/components/mod_panel.py @@ -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() - diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index 4f81e7e..a0566fc 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -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) diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 4e817ca..ae4544f 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -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) diff --git a/dzgui/views/dialogs/early_alert.py b/dzgui/views/dialogs/early_alert.py index 03b70ff..a404a32 100644 --- a/dzgui/views/dialogs/early_alert.py +++ b/dzgui/views/dialogs/early_alert.py @@ -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) diff --git a/dzgui/views/dialogs/entry.py b/dzgui/views/dialogs/entry.py deleted file mode 100644 index 4a520d1..0000000 --- a/dzgui/views/dialogs/entry.py +++ /dev/null @@ -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 diff --git a/dzgui/views/dialogs/filepicker.py b/dzgui/views/dialogs/filepicker.py index 75f7a22..0691bc8 100644 --- a/dzgui/views/dialogs/filepicker.py +++ b/dzgui/views/dialogs/filepicker.py @@ -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: diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index d76fb19..eb10d58 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -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 diff --git a/dzgui/views/dialogs/lan.py b/dzgui/views/dialogs/lan.py deleted file mode 100644 index dd061e1..0000000 --- a/dzgui/views/dialogs/lan.py +++ /dev/null @@ -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) diff --git a/dzgui/views/dialogs/link_dialog.py b/dzgui/views/dialogs/link_dialog.py index b5db94f..294765c 100644 --- a/dzgui/views/dialogs/link_dialog.py +++ b/dzgui/views/dialogs/link_dialog.py @@ -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 diff --git a/dzgui/views/dialogs/note.py b/dzgui/views/dialogs/note.py new file mode 100644 index 0000000..b595e90 --- /dev/null +++ b/dzgui/views/dialogs/note.py @@ -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() diff --git a/dzgui/views/dialogs/server_details.py b/dzgui/views/dialogs/server_details.py deleted file mode 100644 index 18aa3d1..0000000 --- a/dzgui/views/dialogs/server_details.py +++ /dev/null @@ -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) diff --git a/dzgui/views/dialogs/server_mods.py b/dzgui/views/dialogs/server_mods.py deleted file mode 100644 index 1a86a81..0000000 --- a/dzgui/views/dialogs/server_mods.py +++ /dev/null @@ -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) diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py new file mode 100644 index 0000000..14cb719 --- /dev/null +++ b/dzgui/views/dialogs/servers.py @@ -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) diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index 7b37313..f8c4b83 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -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 diff --git a/dzgui/views/mixins/cursor_mixin.py b/dzgui/views/mixins/cursor_mixin.py index ea61d62..8c2bd32 100644 --- a/dzgui/views/mixins/cursor_mixin.py +++ b/dzgui/views/mixins/cursor_mixin.py @@ -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 diff --git a/dzgui/views/mixins/help_menu_mixin.py b/dzgui/views/mixins/help_menu_mixin.py new file mode 100644 index 0000000..d287bb7 --- /dev/null +++ b/dzgui/views/mixins/help_menu_mixin.py @@ -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 diff --git a/dzgui/views/mixins/mods_mixin.py b/dzgui/views/mixins/mods_mixin.py index 638b8db..8cce682 100644 --- a/dzgui/views/mixins/mods_mixin.py +++ b/dzgui/views/mixins/mods_mixin.py @@ -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 diff --git a/dzgui/views/mixins/scrollable_mixin.py b/dzgui/views/mixins/scrollable_mixin.py index 4d9e406..79d83b8 100644 --- a/dzgui/views/mixins/scrollable_mixin.py +++ b/dzgui/views/mixins/scrollable_mixin.py @@ -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) - diff --git a/dzgui/views/pages/changelog.py b/dzgui/views/pages/changelog.py index 4ba2437..a9b7e2d 100644 --- a/dzgui/views/pages/changelog.py +++ b/dzgui/views/pages/changelog.py @@ -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() diff --git a/dzgui/views/pages/devs.py b/dzgui/views/pages/devs.py index cd4a1ea..7c95255 100644 --- a/dzgui/views/pages/devs.py +++ b/dzgui/views/pages/devs.py @@ -1,12 +1,14 @@ from dataclasses import fields -from typing import Union, TYPE_CHECKING +from typing import Self, Union, TYPE_CHECKING from dzgui.const.enum import NotebookPage from dzgui.util.css import add_class from dzgui.util.strings import developers from dzgui.views.components.labels import BoldLabel +from dzgui.views.trees.tree_base import TreeView import gi # noqa E402 + gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 @@ -15,20 +17,19 @@ if TYPE_CHECKING: from dzgui.config.userprefs import UserPrefs from dzgui.config.xdg import Xdg + class Developers(Gtk.ScrolledWindow): """ Shows TreeViews displaying contents of parsed XDG paths and user preferences """ + def __init__(self, controller: "Controller") -> None: - super().__init__( - ) + super().__init__() self.controller = controller self.box = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, - margin_start=10, - margin_end=10 + orientation=Gtk.Orientation.VERTICAL, margin_start=10, margin_end=10 ) heading = Gtk.Label(label=developers.header) @@ -41,40 +42,53 @@ class Developers(Gtk.ScrolledWindow): paths_label = BoldLabel(developers.paths_label) prefs_label = BoldLabel(developers.prefs_label) - paths_tree = self._make_tree(self.controller.prefs.paths) - prefs_tree = self._make_tree(self.controller.prefs) + prefs = self.controller.get_prefs() + paths_tree = self._make_tree(prefs.paths) + self.prefs_tree = self._make_tree(prefs) trees_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) path = Gtk.TreePath.new_from_indices([0]) paths_tree.set_cursor(path) - prefs_tree.set_cursor(path) + self.prefs_tree.set_cursor(path) - for el in [paths_label, paths_tree, prefs_label, prefs_tree]: + for el in [paths_label, paths_tree, prefs_label, self.prefs_tree]: trees_box.add(el) for el in [heading, back_button, trees_box]: self.box.add(el) self.add(self.box) + self.connect("map", self._on_map) def _on_back_clicked(self, button: Gtk.Button) -> None: self.controller.open_page(NotebookPage.OPTIONS) def _make_tree(self, prefs: Union["Xdg", "UserPrefs"]) -> Gtk.TreeView: - from dzgui.views.trees.tree_base import TreeView - view = TreeView(self.controller) + tree = TreeView(self.controller, menu=None) renderer = Gtk.CellRendererText() for i, col in enumerate(developers.columns): column = Gtk.TreeViewColumn(col, renderer, text=i) column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) - view.append_column(column) + tree.append_column(column) + store = self._make_store(prefs) + tree.set_model(store) + + return tree + + def _make_store(self, prefs: Union["Xdg", "UserPrefs"]) -> Gtk.ListStore: store = Gtk.ListStore(str, str) for field in fields(prefs): if field.name == "paths": - break + continue k, v = field.name, getattr(prefs, field.name) store.append((k, str(v))) + return store - view.set_model(store) - return view + def _on_map(self, widget: Self) -> None: + prefs = self.controller.get_prefs() + store = self._make_store(prefs) + self.prefs_tree.set_model(store) + + def grab_content_area(self) -> None: + return diff --git a/dzgui/views/pages/help.py b/dzgui/views/pages/help.py index 9fa4aa9..bcfbfd8 100644 --- a/dzgui/views/pages/help.py +++ b/dzgui/views/pages/help.py @@ -2,12 +2,14 @@ from typing import TYPE_CHECKING from dzgui.views.trees.tree_menu import MenuTreeView import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller + class Help(Gtk.ScrolledWindow): def __init__(self, controller: "Controller") -> None: super().__init__() @@ -15,7 +17,9 @@ class Help(Gtk.ScrolledWindow): self.add(self.treeview) self.controller = controller - self.controller.register_widget("menu", self.treeview) def get_treeview(self) -> MenuTreeView: return self.treeview + + def grab_content_area(self) -> None: + self.treeview.grab_focus() diff --git a/dzgui/views/pages/keys.py b/dzgui/views/pages/keys.py index 9fa357b..0c75c32 100644 --- a/dzgui/views/pages/keys.py +++ b/dzgui/views/pages/keys.py @@ -1,20 +1,28 @@ +from typing import TYPE_CHECKING + from dzgui.views.mixins.scrollable_mixin import ScrollableMixin from dzgui.util.strings import navigation, servers, vim, key_header, key_contexts from dzgui.util.css import add_class import gi + gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, Gdk # noqa E402 +from gi.repository import Gtk # noqa E402 + +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller -class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): +class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore """ Notebook page holding a prearranged grid of keybindings and their descriptions """ - def __init__(self) -> None: + + def __init__(self, controller: "Controller") -> None: super().__init__() + self.controller = controller self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) label = Gtk.Label(label=key_header) @@ -52,9 +60,7 @@ class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): if not sep: grid.attach(desc, col, row, w, h) else: - grid.attach_next_to( - desc, sep, Gtk.PositionType.BOTTOM, w, h - ) + grid.attach_next_to(desc, sep, Gtk.PositionType.BOTTOM, w, h) row += 1 sep = None grid.attach_next_to(frame, desc, Gtk.PositionType.RIGHT, w, h) @@ -62,18 +68,14 @@ class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): l_spacer = Gtk.Label(label="") r_spacer = Gtk.Label(label="") grid.attach(l_spacer, col, row + 1, w, h) - grid.attach_next_to( - r_spacer, l_spacer, Gtk.PositionType.RIGHT, w, h - ) + grid.attach_next_to(r_spacer, l_spacer, Gtk.PositionType.RIGHT, w, h) row += 1 return grid def build_sidebar(self, categories: list) -> Gtk.Grid: row, col = 0, 0 w, h = 1, 1 - sidebar = Gtk.Grid( - row_homogeneous=True, orientation=Gtk.Orientation.VERTICAL - ) + sidebar = Gtk.Grid(row_homogeneous=True, orientation=Gtk.Orientation.VERTICAL) for cat in categories: label = Gtk.Label(label=cat) add_class(label, "left-label") @@ -91,9 +93,7 @@ class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): row, column = 1, 1 w, h = 1, 1 - sidebar = self.build_sidebar( - key_contexts - ) + sidebar = self.build_sidebar(key_contexts) separator = Gtk.Separator() keys_box = self.build_keys(items) @@ -101,3 +101,6 @@ class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): grid.attach_next_to(separator, sidebar, Gtk.PositionType.RIGHT, w, h) grid.attach_next_to(keys_box, separator, Gtk.PositionType.RIGHT, w, h) return grid + + def grab_content_area(self) -> None: + self.box.grab_focus() diff --git a/dzgui/views/pages/log.py b/dzgui/views/pages/log.py index 057ab23..b9fbc3b 100644 --- a/dzgui/views/pages/log.py +++ b/dzgui/views/pages/log.py @@ -1,21 +1,52 @@ from typing import TYPE_CHECKING +from dzgui.const.constants import LOG_FILTERS +from dzgui.views.mixins.cursor_mixin import CursorMixin +from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin from dzgui.views.trees.tree_log import LogTreeView + import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 if TYPE_CHECKING: from dzgui.controllers.mc import Controller -class Log(Gtk.ScrolledWindow): + +class Log(CursorMixin, HelpMenuMixin, Gtk.Box): # type: ignore def __init__(self, controller: "Controller") -> None: - super().__init__() + super().__init__(orientation=Gtk.Orientation.VERTICAL) + """ + NOTE: LogTreeView is kept in a separate ScrolledWindow so that + checkboxes will be flush on bottom + """ + self.scrolled = Gtk.ScrolledWindow() self.treeview = LogTreeView(controller) - self.add(self.treeview) + self.scrolled.add(self.treeview) + + self.check_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + + for check in LOG_FILTERS: + c = Gtk.CheckButton(label=check) + self.check_bar.pack_start(c, False, False, 0) + c.set_active(True) + c.connect("clicked", self._on_checkbox_clicked) + self.add(self.scrolled) + self.add(self.check_bar) self.controller = controller self.controller.register_widget("logtreeview", self.treeview) + self.emitter = controller.get_emitter() + + self.connect("key-press-event", self._on_esc_keypress) + + def _on_checkbox_clicked(self, checkbox: Gtk.CheckButton) -> None: + label = checkbox.get_label() + self.treeview.toggle_filter(label) def get_treeview(self) -> LogTreeView: return self.treeview + + def grab_content_area(self) -> None: + self.treeview.grab_focus() diff --git a/dzgui/views/pages/mods.py b/dzgui/views/pages/mods.py index 20e381f..89509d6 100644 --- a/dzgui/views/pages/mods.py +++ b/dzgui/views/pages/mods.py @@ -1,19 +1,34 @@ -from typing import TYPE_CHECKING +from typing import Self, TYPE_CHECKING from dzgui.views.trees.tree_mods import ModTreeView import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk # noqa if TYPE_CHECKING: from dzgui.controllers.mc import Controller + class Mods(Gtk.ScrolledWindow): def __init__(self, controller: "Controller") -> None: super().__init__() self.controller = controller + self.emitter = controller.get_emitter() self.tree = ModTreeView(controller) self.add(self.tree) self.controller.register_widget("modtreeview", self.tree) + + self.connect("map", self._on_map) + self.connect("unmap", self._on_unmap) + + def _on_unmap(self, widget: Self) -> None: + self.emitter.emit("mod_page_toggled", False) + + def _on_map(self, widget: Self) -> None: + self.emitter.emit("mod_page_toggled", True) + + def grab_content_area(self) -> None: + self.tree.grab_focus() diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 8d6fe55..0ae777e 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -4,21 +4,11 @@ from typing import TYPE_CHECKING from dzgui.api import pefile as PeFile from dzgui.api.steam import find_user_id from dzgui.config import query -from dzgui.util import strings, css, open_links - -from dzgui.views.components.label import LeftLabel -from dzgui.views.components.eventbox import InfoEventBox -from dzgui.views.components.buttons import WebButton -from dzgui.views.dialogs.link_dialog import WorkshopLinkDialog - -from dzgui.const.enum import Preferences, Popup -from dzgui.const.endpoints import STEAM_API_SETUP, BM_API_SETUP from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, APPNAME_DAYZ, - APPNAME_DAYZ_EXP, - BETA_REPO, + APPNAME_DAYZ_EXP_HUMAN, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, NO_EXPAND, @@ -27,14 +17,35 @@ from dzgui.const.constants import ( STEAM_CMD, VIEW_CONCEAL, VIEW_REVEAL, - ) +) +from dzgui.const.endpoints import STEAM_API_SETUP, BM_API_SETUP +from dzgui.const.enum import Preferences, ServerTab +from dzgui.strings import errors, options +from dzgui.util import strings, css, open_links +from dzgui.views.components.buttons import SteamWorkshopButton +from dzgui.views.components.labels import LeftLabel +from dzgui.views.components.eventbox import InfoEventBox +from dzgui.views.components.buttons import WebButton +from dzgui.views.components.frame import HeadingFrame +from dzgui.views.dialogs.generic import ExceptionDialog + import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk # noqa if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter + + +class ShortHBox(Gtk.Box): + def __init__(self, widget: Gtk.Widget) -> None: + super().__init__(spacing=5, halign=Gtk.Align.START) + + self.pack_start(widget, NO_EXPAND, NO_FILL, NO_PADDING) + class Options(Gtk.Box): def __init__(self, controller: "Controller"): @@ -45,18 +56,20 @@ class Options(Gtk.Box): ) self.controller = controller + self.controller.register_widget("options", self) + emitter = controller.get_emitter() + emitter.connect("api_change_failed", self._on_api_change_failed) self.DEFAULT_WIDTH = 1 self.DEFAULT_HEIGHT = 1 - # TODO: strings label = Gtk.Label(label=strings.options.header) label.set_halign(Gtk.Align.CENTER) css.add_class(label, "page-heading") self.add(label) - self.steam_entry = None - self.bm_entry = None + self.steam_entry: Gtk.Entry + self.bm_entry: Gtk.Entry self.steam = WebButton(label=strings.options.steam_web) self.steam.connect("clicked", self._on_link_button_clicked, STEAM_API_SETUP) @@ -78,64 +91,89 @@ class Options(Gtk.Box): self.player_box = self._make_submit_field( strings.options.name_placeholder, Preferences.NAME ) + self.player_box.set_halign(Gtk.Align.START) + # TODO: make submit field a standalone class + self.player_box.get_children()[0].set_width_chars(30) # type: ignore + self.fullscreen_toggle = self.make_binary_radio( strings.options.last_used, strings.options.always_fs, Preferences.WINDOW, ) - self.client_combo = Gtk.ComboBoxText() - # TODO: strings - self.client_combo.append_text("Steam") - self.client_combo.append_text("Flatpak") - self.client_combo.append_text("Flatpak (container)") + # TODO: make this an abstract class + client_store = Gtk.ListStore(str, str) + clients = ( + ( + options.steam_combo, + STEAM_CMD, + ), + ( + options.flatpak_combo, + FLATPAK_RUN_CMD, + ), + ( + options.flatpak_container_combo, + FLATPAK_SANDBOX, + ), + ) + for client in clients: + client_store.append(client) + self.client_combo = Gtk.ComboBox.new_with_model(client_store) # Text() + renderer_text = Gtk.CellRendererText() + self.client_combo.pack_start(renderer_text, True) + self.client_combo.add_attribute(renderer_text, "text", 0) self.client_combo.set_active(0) self.client_combo.connect("changed", self._on_client_changed) - hbox = Gtk.Box(spacing=5, halign=Gtk.Align.START) - hbox.pack_start(self.client_combo, NO_EXPAND, NO_FILL, NO_PADDING) + + client_hbox = ShortHBox(self.client_combo) self.distance_toggle = self.make_binary_radio( strings.options.km, strings.options.mi, Preferences.DIST ) + combo_store = Gtk.ListStore(str, object) + tabs = ( + (options.server_combo, ServerTab.BROWSER), + (options.saved_combo, ServerTab.SAVED), + (options.recent_combo, ServerTab.RECENT), + (options.lan_combo, ServerTab.LAN), + ) + for tab in tabs: + combo_store.append(tab) + self.start_tab_combo = Gtk.ComboBox.new_with_model(combo_store) + renderer_text = Gtk.CellRendererText() + self.start_tab_combo.pack_start(renderer_text, True) + self.start_tab_combo.add_attribute(renderer_text, "text", 0) + self.start_tab_combo.set_active(0) + + start_tab_hbox = ShortHBox(self.start_tab_combo) + self.start_tab_combo.connect("changed", self._on_start_tab_changed) + pref_rows = [ - [LeftLabel(strings.options.client), hbox], + [LeftLabel(strings.options.client), client_hbox], [LeftLabel(strings.options.window_size), self.fullscreen_toggle], [LeftLabel(strings.options.distance), self.distance_toggle], + [LeftLabel(options.start_tab), start_tab_hbox], [LeftLabel(strings.options.name), self.player_box], ] - self.mod_install_toggle = self.make_binary_radio( - strings.options.manual_dl, strings.options.auto_dl, Preferences.INSTALL + eb = InfoEventBox(options.workshop_eventbox, controller) + + workshop_button = SteamWorkshopButton() + workshop_button.connect( + "clicked", lambda _: self.controller.open_user_workshop(self.uid) ) - self.force_button = Gtk.Button(label=strings.options.update) - self.force_button.connect("clicked", self._on_force_update_clicked) - - # NOTE: sensitivity state is updated after config file is loaded - self.force_button.set_sensitive(False) - - eb = InfoEventBox(strings.options.dl_eventbox, self) - eb2 = InfoEventBox(strings.options.force_eventbox, self) - mod_rows = [ - [LeftLabel(strings.options.install_mode), self.mod_install_toggle, eb], - [LeftLabel(strings.options.force_update), self.force_button, eb2], + [LeftLabel(options.workshop_label), workshop_button, eb], ] self.dayz_version_label = Gtk.Label(label=strings.null) self.dayz_exp_version_label = Gtk.Label(label=strings.null) - self.branch_combo = Gtk.ComboBoxText() - self.branch_combo.append_text(strings.options.stable) - self.branch_combo.append_text(strings.options.testing) - self.branch_combo.set_active(0) - self.branch_combo.connect("changed", self._on_branch_changed) - self.branch_eb = InfoEventBox("", self) - version_rows = [ [LeftLabel(APPNAME_DAYZ), self.dayz_version_label], - [LeftLabel(APPNAME_DAYZ_EXP), self.dayz_exp_version_label], - [LeftLabel(strings.options.branch), self.branch_combo, self.branch_eb], + [LeftLabel(APPNAME_DAYZ_EXP_HUMAN), self.dayz_exp_version_label], ] api_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) @@ -148,7 +186,7 @@ class Options(Gtk.Box): margin_top=5, margin_bottom=10, homogeneous=True, - spacing=10 + spacing=10, ) api_links_box.add(self.steam) api_links_box.add(self.bm) @@ -166,31 +204,31 @@ class Options(Gtk.Box): hexpand=True, ) - developers=Gtk.Button(label="Developers", halign=Gtk.Align.START) + developers = Gtk.Button(label=options.developers, halign=Gtk.Align.START) developers.connect("clicked", self._on_developers_clicked) prefs = self.controller.get_prefs() - is_developer = prefs.is_developer - if is_developer: - grid.attach( - developers, 1, 0, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT - ) + if prefs.is_debug: + grid.attach(developers, 1, 0, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT) for frame in [ - self.make_frame(api_box, strings.options.api_keys), - self.make_frame(prefs_grid, strings.options.prefs), - self.make_frame(mods_grid, strings.options.mods), - self.make_frame(version_grid, strings.options.version), + HeadingFrame(api_box, strings.options.api_keys), + HeadingFrame(prefs_grid, strings.options.prefs), + HeadingFrame(mods_grid, strings.options.mods), + HeadingFrame(version_grid, strings.options.version), ]: - grid.attach( - frame, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT - ) + grid.attach(frame, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT) row += 1 self.scrollable = Gtk.ScrolledWindow(vexpand=True) self.scrollable.add(grid) self.add(self.scrollable) + def get_client_name(self) -> str: + model = self.client_combo.get_model() + ind = self.client_combo.get_active() + return str(model[ind][0]) + def block_text_entry(self) -> None: for entry in self.steam_entry, self.bm_entry: entry.set_position(-1) @@ -220,13 +258,11 @@ class Options(Gtk.Box): entry.connect("insert-text", self._on_text_typed, context, button) entry.connect("activate", self._on_field_activated, context, button) entry.get_property("buffer").connect( - "deleted-text", self._on_text_deleted, context, button + "deleted-text", self._on_text_deleted, context, button ) if private: - entry.set_icon_from_icon_name( - Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL - ) + entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL) entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) entry.connect("icon-release", self._on_icon_release) entry.set_visibility(False) @@ -243,7 +279,7 @@ class Options(Gtk.Box): return box def _on_field_activated( - self, entry: Gtk.Entry, context: Preferences, button: Gtk.Button + self, entry: Gtk.Entry, context: Preferences, button: Gtk.Button ) -> None: text = entry.get_text() if not self._is_valid_text(text, context): @@ -264,9 +300,7 @@ class Options(Gtk.Box): for record in rows: col = 1 for el in record: - grid.attach( - el, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT - ) + grid.attach(el, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT) col += 1 row += 1 return grid @@ -275,6 +309,9 @@ class Options(Gtk.Box): self, button: Gtk.Button, entry: Gtk.Entry, enum: Preferences ) -> None: old_text = self.controller.query_config(enum) + self.old_text = old_text + self.old_entry = entry + button.set_sensitive(False) match enum: case Preferences.NAME: @@ -282,8 +319,12 @@ class Options(Gtk.Box): self.controller.update_config(enum, value) case Preferences.BM | Preferences.STEAM: text = "".join(entry.get_text().split()) - self.controller.set_callback(self.restore_api_text, old_text, entry) - self.controller.update_api_key(text, enum) + self.controller.update_api_key(enum, text) + + def _on_api_change_failed(self, emitter: "Emitter") -> None: + self.old_entry.set_text(self.old_text) + dialog = ExceptionDialog(self.controller, errors.api_validation_error) + dialog.run() def restore_api_text(self, text: str, entry: Gtk.Entry) -> None: entry.set_text(text) @@ -295,38 +336,22 @@ class Options(Gtk.Box): self.bm_entry.set_text(self.old_bm) pass - def _on_force_update_clicked(self, button: Gtk.Button) -> None: - # TODO: unimplemented - print("UNIMPLEMENTED") - #wait_msg = strings.dialog.updating_mods - #show_wait_dialog = True - #call_on_thread(show_wait_dialog, cmd, wait_msg, "") + def _on_start_tab_changed(self, combo: Gtk.ComboBoxText) -> None: + _iter = combo.get_active_iter() + if _iter is None: + raise ValueError(f"No active iterator set on {combo}") + enum = combo.get_model()[_iter][1] + index = enum.value + self.controller.update_config(Preferences.START_TAB, index) def _on_client_changed(self, combo: Gtk.ComboBoxText) -> None: - # TODO: use two columns or constants here, not strings - client = combo.get_active_text() - match client: - case "Steam": - value = STEAM_CMD - case "Flatpak": - value = FLATPAK_RUN_CMD - case "Flatpak (container)": - value = FLATPAK_SANDBOX - self.controller.update_config(Preferences.CLIENT, value) + _iter = combo.get_active_iter() + if _iter is None: + raise ValueError(f"No active iterator set on {combo}") + real_cmd = combo.get_model()[_iter][1] + self.controller.update_config(Preferences.CLIENT, real_cmd) - def _on_branch_changed(self, combo: Gtk.ComboBoxText) -> None: - branch = combo.get_active_text() - print("UNIMPLEMENTED") - print(branch) - ## TODO: needs to trigger download process - #self.controller.toggle_branch(branch) - #branch = combo.get_active_text().lower() - #self.controller.update_config("branch", branch) - #scripts/update - - def _on_radio_toggled( - self, button: Gtk.RadioButton, context: Preferences - ) -> None: + def _on_radio_toggled(self, button: Gtk.RadioButton, context: Preferences) -> None: try: self.controller.toggle_config(context) except Exception: @@ -334,19 +359,6 @@ class Options(Gtk.Box): self.populate_settings() button.handler_unblock_by_func(self._on_radio_toggled) - if context == Preferences.INSTALL: - if self.controller.is_auto_install(): - self.force_button.set_sensitive(True) - WorkshopLinkDialog( - self.controller, - strings.options.manual_sub_msg, - strings.self_workshop, - self.uid - ) - else: - self.force_button.set_sensitive(False) - - def _is_valid_text(self, text: str, context: Preferences) -> bool: if text.isspace(): return False @@ -409,37 +421,26 @@ class Options(Gtk.Box): return hbox - def make_frame(self, widget: Gtk.Widget, text: str) -> Gtk.Box: - label = Gtk.Label(label=text) - label.set_halign(Gtk.Align.START) - css.add_class(label, "settings-subheading") - - frame = Gtk.Frame(hexpand=True) - frame.add(widget) - - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - box.add(label) - box.add(frame) - - return box - def populate_settings(self) -> None: - # TODO: controller.get_config() prefs = self.controller.get_prefs() + # NOTE: re-check in case file was removed by user between runs if prefs.paths.config.is_file() is False: - # NOTE: in case file got deleted locally - self.controller.spawn_dialog(strings.config_not_found, Popup.QUIT) - return + dialog = ExceptionDialog(self.controller, strings.config_not_found) + dialog.run() + raise Exception config = query.get_config(prefs.paths.config) - name = config["name"] - default_steam_path = config["default_steam_path"] - steam = config["steam_api"] - bm = config["bm_api"] - install = config["auto_install"] + + # TODO: use newer config enums + name = self.controller.query_config(Preferences.NAME) + default_steam_path = self.controller.query_config(Preferences.DEFAULT) + steam = self.controller.query_config(Preferences.STEAM) + bm = self.controller.query_config(Preferences.BM) steam_path = Path(default_steam_path) - self.uid = find_user_id(steam_path) + # NOTE: this is a best effort guess at the most recent user + uid = find_user_id(steam_path) + self.uid = "" if uid is None else uid self.old_steam = steam self.old_bm = bm @@ -447,15 +448,15 @@ class Options(Gtk.Box): self.steam_entry.set_text(steam) self.bm_entry.set_text(bm) - self.player_box.get_children()[0].set_text(name) + p = self.player_box.get_children()[0] + if hasattr(p, "set_text"): + p.set_text(name) # NOTE: suppress toggle signal until radios are built self._suppress_toggles(True) - self.force_button.set_sensitive(install) for el, conf_state in [ - (self.mod_install_toggle, install), (self.fullscreen_toggle, config["fullscreen"]), - (self.distance_toggle, config["use_miles"]) + (self.distance_toggle, config["use_miles"]), ]: el.get_children()[conf_state].set_active(True) self._suppress_toggles(False) @@ -469,22 +470,12 @@ class Options(Gtk.Box): if field[0] == "": field[1].get_children()[1].set_sensitive(False) - try: - pe_file_path = PeFile.get_pefile_path( - steam_path, APPID_DAYZ - ) - vers = PeFile.get_dayz_version(pe_file_path) - dayz_version = PeFile.dayz_version_to_str(vers) - except Exception: + dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ) + if dayz_version is None: dayz_version = strings.null - try: - exp_file_path = PeFile.get_pefile_path( - steam_path, APPID_DAYZ_EXP - ) - vers = PeFile.get_dayz_version(exp_file_path) - dayz_exp_version = PeFile.dayz_version_to_str(vers) - except Exception: + dayz_exp_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) + if dayz_exp_version is None: dayz_exp_version = strings.null self.dayz_version_label.set_text(dayz_version) @@ -494,27 +485,17 @@ class Options(Gtk.Box): active_combo = query.get_client_index(config["client"]) self.client_combo.set_active(active_combo) - - active_combo = 1 if config["branch"] == BETA_REPO else 0 - - self.controller.suppress_signal(self, self.branch_combo, "_on_branch_changed", True) - self.branch_combo.set_active(active_combo) - self.branch_combo.set_sensitive(prefs.allow_updates) - self.controller.suppress_signal(self, self.branch_combo, "_on_branch_changed", False) - - if prefs.allow_updates is True: - msg = strings.options.self_update - else: - msg = strings.options.no_self_update - self.branch_eb.set_text(msg) + start_tab = self.controller.query_config(Preferences.START_TAB) + self.start_tab_combo.set_active(start_tab) def _suppress_toggles(self, state: bool) -> None: for toggle in [ - self.mod_install_toggle, self.fullscreen_toggle, self.distance_toggle, ]: - self.controller.suppress_signal(self, toggle.get_children()[0], "_on_radio_toggled", state) + self.controller.suppress_signal( + self, toggle.get_children()[0], "_on_radio_toggled", state + ) def _on_icon_release( self, @@ -529,3 +510,6 @@ class Options(Gtk.Box): icon, state = VIEW_CONCEAL, True widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) widget.set_visibility(state) + + def grab_content_area(self) -> None: + return diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py new file mode 100644 index 0000000..bad0cd4 --- /dev/null +++ b/dzgui/views/pages/preconnect.py @@ -0,0 +1,329 @@ +from dataclasses import dataclass +from typing import Self, Sequence, TYPE_CHECKING + +from dzgui.const.constants import ( + ERROR, + WARNING, +) +from dzgui.const.enum import NotebookPage +from dzgui.util.css import add_class +from dzgui.util.keys import is_ctrl_mask +from dzgui.util.localize import number +from dzgui.strings.server_mods import checkmark +from dzgui.strings import preconnect +from dzgui.views.components.frame import HeadingFrame +from dzgui.views.trees.tree_server_mods import ServerModTreeView + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gdk, Gtk # type: ignore # noqa E402 + + +if TYPE_CHECKING: + from dzgui.managers.connection import Prerequisites + from dzgui.controllers.mc import Controller + + +@dataclass +class Warnings: + passworded: bool + dayz_running: bool + no_steam: bool + wrong_version: bool + + +@dataclass +class Errors: + no_space: bool + no_dayz_exp: bool + no_dayz: bool + + +class Placeholder(Gtk.Label): + def __init__(self, text: str) -> None: + super().__init__( + label=text, + halign=Gtk.Align.START, + valign=Gtk.Align.START, + margin_start=10, + margin_bottom=5, + ) + + +class MaskedTree(Gtk.TreeView): + def __init__(self, icon: str) -> None: + super().__init__(headers_visible=False, can_focus=False) + + self.icon = icon + self.store = Gtk.ListStore(str, str) + self.set_model(self.store) + self.get_selection().set_mode(Gtk.SelectionMode.NONE) + + icon_renderer = Gtk.CellRendererPixbuf() + # NOTE: adjust vertical offset between columns + icon_renderer.set_property("yalign", 0.6) + icon_column = Gtk.TreeViewColumn("", icon_renderer) + icon_column.add_attribute(icon_renderer, "icon-name", 0) + icon_column.set_fixed_width(50) + + text_renderer = Gtk.CellRendererText() + text_column = Gtk.TreeViewColumn("", text_renderer, text=1) + + self.append_column(icon_column) + self.append_column(text_column) + add_class(self, "masked-tree") + + def append(self, item: Sequence[str]) -> None: + if len(item) > 1: + raise ValueError("This method only accepts one item") + self.store.append([self.icon, item]) + + def extend(self, items: list[str]) -> None: + self.store.clear() + for item in items: + self.store.append([self.icon, item]) + + +class PreConnectionAssistant(Gtk.ScrolledWindow): + def __init__(self, controller: "Controller") -> None: + super().__init__() + + self.controller = controller + + self.button_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.END, + valign=Gtk.Align.END, + hexpand=True, + vexpand=True, + spacing=10, + ) + + self.controller.register_widget("preconnect", self) + + self.back = Gtk.Button( + label=preconnect.back, halign=Gtk.Align.END, hexpand=True + ) + self.ok = Gtk.Button(label=preconnect.update_mods, halign=Gtk.Align.END) + + # TODO: abstract + self.raise_window = Gtk.CheckButton( + label="Foreground DZGUI while downloading", + halign=Gtk.Align.END, + hexpand=True, + valign=Gtk.Align.END, + visible=False, + has_tooltip=True, + sensitive=False, + tooltip_text="Foreground the DZGUI window after mod downloads are queued", + active=True, + ) + self.button_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + valign=Gtk.Align.END, + vexpand=True, + spacing=5, + ) + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + for button in self.back, self.ok: + box.add(button) + for el in self.raise_window, box: + self.button_box.add(el) + + self.back.connect("clicked", self._on_back_clicked) + self.ok.connect("clicked", self._on_ok_clicked) + + self.title = Gtk.Label(label="") + add_class(self.title, "preconnect-heading") + + self.tree = ServerModTreeView(self.controller) + self.mod_count = Gtk.Label( + label="", + halign=Gtk.Align.START, + margin_start=10, + margin_top=10, + margin_bottom=10, + ) + self.progress_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=15) + self.progress_box.add(self.mod_count) + + self.scrolled = Gtk.ScrolledWindow() + self.scrolled.add(self.tree) + self.scrolled.set_size_request(600, 400) + + self.tree_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.tree_box.add(self.scrolled) + self.tree_box.add(self.progress_box) + + # TODO: strings + self.mods_placeholder = Placeholder("This server has no mods.") + self.tree_box.add(self.mods_placeholder) + + self.tree_frame = HeadingFrame(self.tree_box, preconnect.mods) + + # TODO: abstract into components + # TODO: strings + self.warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.warning_tree = MaskedTree(WARNING) + self.warning_placeholder = Placeholder("No warnings.") + self.warning_box.add(self.warning_tree) + self.warning_box.add(self.warning_placeholder) + self.warning_frame = HeadingFrame(self.warning_box, preconnect.warnings) + + # TODO: strings + self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.error_tree = MaskedTree(ERROR) + self.error_placeholder = Placeholder("No errors.") + self.error_box.add(self.error_tree) + self.error_box.add(self.error_placeholder) + self.error_frame = HeadingFrame(self.error_box, preconnect.errors) + + self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + self.box.add(self.title) + self.box.add(self.tree_frame) + self.box.add(self.warning_frame) + self.box.add(self.error_frame) + self.box.add(self.button_box) + + self.add(self.box) + + self.connect("key-press-event", self._on_keypress) + self.connect("map", self._on_map) + + def _on_map(self, widget: Self) -> None: + widgets = ( + self.tree_frame, + self.progress_box, + self.error_placeholder, + self.warning_placeholder, + ) + for child in widgets: + child.set_visible(True) + + self.raise_window.set_visible(False) + self.raise_window.set_sensitive(False) + self.ok.set_sensitive(True) + self.ok.set_label(preconnect.update_mods) + + def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: + if event.keyval == Gdk.KEY_Escape: + self.back.emit("clicked") + if is_ctrl_mask(event): + if event.keyval == Gdk.KEY_u: + self.ok.emit("clicked") + + def _on_ok_clicked(self, button: Gtk.Button) -> None: + self.controller.update_and_connect(self.raise_window.get_active()) + + def _on_back_clicked(self, button: Gtk.Button) -> None: + self.controller.open_page(NotebookPage.SERVERS) + + def _process_warnings(self, prereqs: "Prerequisites") -> None: + warnings: list[str] = [] + errors: list[str] = [] + + resync_msg = ( + f"If you recently installed {prereqs.build} or moved it to a different drive,\n" + "restart Steam to allow these changes to synchronize, then try again." + ) + + """Errors""" + if prereqs.binary_missing: + errors.append( + f"Remote server is running the build '{prereqs.build}', but it is not installed.\n{resync_msg}" + ) + elif prereqs.local_version != prereqs.remote_version: + errors.append( + f"Local client version '{prereqs.local_version}' does not match remote version '{prereqs.remote_version}'.\n{resync_msg}" + ) + if prereqs.required_space > prereqs.available_space: + required_pretty = number(prereqs.required_space) + available_pretty = number(prereqs.available_space) + errors.append( + f"Need to update {required_pretty} MiB of mods, but installation path only has {available_pretty} MiB." + ) + if len(prereqs.mods) > 0 and prereqs.game_mode: + errors.append("Use Desktop Mode to download mods on Steam Deck") + + if prereqs.steam_proc.is_running is False: + client = prereqs.steam_proc.name + errors.append( + f"'{client}' is set as the default Steam client, but it is either not installed or not running." + ) + + """Warnings""" + if prereqs.passworded: + warnings.append( + "Protected: you will be prompted for a password when connecting to this server." + ) + if prereqs.dayz_running is True: + warnings.append( + "It looks like DayZ is already running in the background. Exit DayZ before connecting." + ) + + self.add_warnings(warnings) + self.add_errors(errors) + + if len(warnings) > 0: + self.warning_placeholder.set_visible(False) + if len(errors) > 0: + self.error_placeholder.set_visible(False) + self.ok.set_sensitive(False) + + def _hide_mod_area(self) -> None: + self.scrolled.set_visible(False) + self.progress_box.set_visible(False) + self.mods_placeholder.set_visible(True) + + def _show_mod_area(self) -> None: + self.scrolled.set_visible(True) + self.progress_box.set_visible(True) + self.mods_placeholder.set_visible(False) + + def populate(self, prereqs: "Prerequisites") -> None: + mods = prereqs.mods + self.tree.populate(mods) + total_mods = len(mods) + + name = prereqs.name + self.title.set_text(name) + + if total_mods < 1: + self._hide_mod_area() + else: + self._show_mod_area() + msg = "All mods are up to date." + self.mod_count.set_text(msg) + + if prereqs.required_space == 0: + self.ok.set_label(preconnect.connect) + self.raise_window.set_visible(False) + else: + self.raise_window.set_visible(True) + self.raise_window.set_sensitive(True) + + pretty = number(prereqs.required_space) + suffix = f" Need to download {pretty} MiB of mod updates." + prefix = preconnect.total_mods + self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}") + + self._process_warnings(prereqs) + if self.tree.is_visible(): + self.tree.grab_focus() + else: + self.grab_focus() + + def mark_finished(self) -> None: + self.mod_count.set_label(preconnect.all_updated) + model = self.tree.get_model() + if model is None: + return + for row in model: + row[2] = checkmark + + def add_errors(self, errors: list[str]) -> None: + self.error_tree.extend(errors) + + def add_warnings(self, warnings: list[str]) -> None: + self.warning_tree.extend(warnings) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 9e8fc1a..4c0ef6c 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -2,130 +2,165 @@ import logging from typing import Self, TYPE_CHECKING -from dzgui.const.enum import ContextMenuGroup +from dzgui.const.constants import APP_NAME +from dzgui.const.enum import ContextMenuGroup, ServerTab from dzgui.views.trees.tree_servers import ServerTreeView from dzgui.util.strings import server_labels import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk, GLib # noqa E402 -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.controllers.mc import Emitter + + +class ScrollableTree(Gtk.ScrolledWindow): + def __init__(self, tree: "ServerTreeView") -> None: + super().__init__() + + self.tree = tree + self.add(tree) + + def get_tree(self) -> "ServerTreeView": + return self.tree + class ServerNotebook(Gtk.ScrolledWindow): def __init__(self, controller: "Controller"): super().__init__() - self.tab_cache = "" self.controller = controller + self.emitter = self.controller.get_emitter() + self.controller.register_widget("servers", self) self.notebook = Gtk.Notebook(show_tabs=True) - self.browser = ServerTreeView(controller) - self.saved = ServerTreeView(controller) - self.recent = ServerTreeView(controller) - self.lan = ServerTreeView(controller) - - self.lan.set_query_func(self.query_test) - # TODO: set context menu on init - self.browser.set_context_menu(ContextMenuGroup.SERVER_BROWSER) - self.saved.set_context_menu(ContextMenuGroup.SAVED) - self.recent.set_context_menu(ContextMenuGroup.RECENT) - self.lan.set_context_menu(ContextMenuGroup.SCAN_LAN) - - # TODO: set model manager for each tab on init - # TODO: add set_ and get_model_manager() methods + self.browser = ServerTreeView( + controller, ServerTab.BROWSER, ContextMenuGroup.SERVER_BROWSER + ) + self.saved = ServerTreeView(controller, ServerTab.SAVED, ContextMenuGroup.SAVED) + self.recent = ServerTreeView( + controller, ServerTab.RECENT, ContextMenuGroup.RECENT + ) + self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN) tabs = [ - (self.browser, server_labels.browser, self.controller.get_server_store), - (self.saved, server_labels.saved, self.controller.get_saved_store), - (self.recent, server_labels.recent, self.controller.get_recent_store), - (self.lan, server_labels.lan, self.controller.get_lan_store), - ] + (self.browser, server_labels.browser), + (self.saved, server_labels.saved), + (self.recent, server_labels.recent), + (self.lan, server_labels.lan), + ] - for tree, label, func in tabs: - store = func() - if label == "LAN": - pass - #store.append(["BAR", "a", "a", "a", 1, 1, 1, "1:1", 0, 0, "a", False]) - else: - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - store.append(["BAR", "a", "a", "a", 0, 0, 0, "1:1", 0, 0, "a", False]) - tree.loaded = True - # TODO: set model on init of servertreeview - tree.set_model(store) - - scrolled = Gtk.ScrolledWindow() - scrolled.add(tree) + for tree, label in tabs: + scrolled = ScrollableTree(tree) self.notebook.append_page(scrolled, Gtk.Label(label=label)) self.add(self.notebook) self.notebook.connect_after("switch-page", self._on_page_changed) self.connect("key-press-event", self._on_keypress) + self.connect("map", self._on_map) + self.connect("unmap", self._on_unmap) - def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: + self.emitter.connect("servers_loaded", self._on_servers_loaded) + self.emitter.connect("saved_servers_changed", self._on_saved_servers_changed) + def _on_saved_servers_changed(self, emitter: "Emitter") -> None: + # TODO: can be dropped/consolidated? + saved = 1 + cur_page = self.notebook.get_current_page() + if cur_page == saved: + return + page = self.notebook.get_nth_page(saved) + if not page: + return + label = self.notebook.get_tab_label(page) + if label is None: + return + if hasattr(label, "set_text"): + label.set_text(f"{server_labels.saved}*") + + def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: + # NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None + # TODO: this should be internal to servers page + tv = self.get_active_treeview() + if tv is None: + return + state = False if tv.get_model() is None else True + tv.set_headers_visible(state) + tv.set_loaded(True) + tv.focus_first_row() + tv.grab_focus() + + def _on_map(self, widget: Self) -> None: + self.emitter.emit("server_page_toggled", True) + + def _on_unmap(self, widget: Self) -> None: + self.emitter.emit("server_page_toggled", False) + + def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> bool: + # NOTE: abort if modifier mask is active + if event.state != 0: + return False match event.keyval: case Gdk.KEY_n: self.notebook.next_page() + self.grab_content_area() + return True case Gdk.KEY_p: self.notebook.prev_page() + self.grab_content_area() + return True case _: - return - self.get_active_treeview().grab_focus() + return False + def grab_content_area(self) -> None: + tv = self.get_active_treeview() + if tv is not None: + tv.grab_focus() - def _on_page_changed(self, notebook: Gtk.Notebook, child: Gtk.Widget, index: int) -> None: - # TODO: abstract - # TODO: load on first run - label = self.notebook.get_tab_label_text(child) - if label is None: + def get_current_tab_text(self) -> str: + ind = self.notebook.get_current_page() + child = self.notebook.get_nth_page(ind) + if child is None: + return "" + text = self.notebook.get_tab_label_text(child) + if text is None: + return "" + return text + + def _on_page_changed( + self, notebook: Gtk.Notebook, child: ScrollableTree, index: int + ) -> None: + if self.controller.loaded is False: return - # TODO: strings - text = label.strip("*") - self.notebook.set_tab_label_text(child, text) - # TODO: strings - string = f"Servers > {text}" - self.controller.set_crumbs(string) - self.tab_cache = string + label = self.notebook.get_tab_label_text(child) + if label is not None: + # TODO: strings + text = label.strip("*") + self.notebook.set_tab_label_text(child, text) - child.grab_focus() + tree = child.get_tree() + self.emitter.emit("server_page_changed", tree) - # FIXME: doesn't fire on first run - self.controller.update_server_status() - self.controller.populate_model() - - # TODO: put in controller - def query_test(self) -> None: - data = (["BAR", "a", "a", "a", 1, 1, 1, "1:1", 0, 0, "a", False]) - return data - - def get_cached_label(self) -> str: - return self.tab_cache + # NOTE: spawns a thread + tv = self.get_active_treeview() + if tv is not None: + self.controller.populate_model(tv) def get_active_treeview(self) -> ServerTreeView: index = self.notebook.get_current_page() scrollable = self.notebook.get_nth_page(index) - treeview = scrollable.get_children()[0] - return treeview + if scrollable is not None and hasattr(scrollable, "get_children"): + tv = scrollable.get_children()[0] + if isinstance(tv, ServerTreeView): + return tv + raise ValueError("No treeview set") def add_notification(self) -> None: saved = self.notebook.get_nth_page(1) @@ -149,9 +184,22 @@ class ServerNotebook(Gtk.ScrolledWindow): continue for col in tab.get_columns(): if col.get_title() == title: - self.controller.suppress_signal(tab, col, "_on_col_width_changed", True) + self.controller.suppress_signal( + tab, col, "_on_col_width_changed", True + ) col.set_fixed_width(width) - self.controller.suppress_signal(tab, col, "_on_col_width_changed", False) + self.controller.suppress_signal( + tab, col, "_on_col_width_changed", False + ) - def get_tabs(self) -> tuple: - return (self.browser, self.saved, self.recent, self.lan) + def get_browser(self) -> ServerTreeView: + return self.browser + + def get_saved(self) -> ServerTreeView: + return self.saved + + def get_recent(self) -> ServerTreeView: + return self.recent + + def get_lan(self) -> ServerTreeView: + return self.lan diff --git a/dzgui/views/pages/thanks.py b/dzgui/views/pages/thanks.py index e59b807..4adc39c 100644 --- a/dzgui/views/pages/thanks.py +++ b/dzgui/views/pages/thanks.py @@ -1,26 +1,37 @@ +from typing import TYPE_CHECKING + from dzgui.util.format import format_pango from dzgui.util.strings import thanks +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 # TODO: wrap and center long values +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller -class Thanks(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore + +class Thanks(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ignore """ Special thanks page recognizing contributors to the project in alpha-order """ - def __init__(self) -> None: + + def __init__(self, controller: "Controller") -> None: super().__init__() + self.controller = controller self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) label = format_pango(thanks.header) header = Gtk.Label() header.set_markup(label) - description = Gtk.Label(label=thanks.description, justify=Gtk.Justification.CENTER) + description = Gtk.Label( + label=thanks.description, justify=Gtk.Justification.CENTER + ) users = [f"- {user}" for user in sorted(thanks.users, key=str.lower)] pretty_users = "\n".join(users) @@ -32,3 +43,7 @@ class Thanks(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() diff --git a/dzgui/views/trees/tree_base.py b/dzgui/views/trees/tree_base.py index 2886990..ac0733a 100644 --- a/dzgui/views/trees/tree_base.py +++ b/dzgui/views/trees/tree_base.py @@ -1,23 +1,28 @@ import logging -from typing import Callable, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING +from warnings import deprecated -from dzgui.const.constants import SEPARATOR +from dzgui.const.constants import APP_NAME, SEPARATOR from dzgui.util.keys import is_navkey from dzgui.views.mixins.cursor_mixin import CursorMixin import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.controllers.mc import Controller from dzgui.const.enum import ContextMenuGroup + class TreeView(CursorMixin, Gtk.TreeView): # type: ignore - def __init__(self, controller: "Controller", menu: "ContextMenuGroup" = None) -> None: + def __init__( + self, controller: "Controller", menu: Optional["ContextMenuGroup"] + ) -> None: super().__init__( enable_search=False, search_column=-1, @@ -28,12 +33,13 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore self.controller = controller self.sel_blocked = False self.set_row_separator_func(self._separate) + self.emitter = self.controller.get_emitter() self.selected_row = self.get_selection() self.selected_row.set_mode(Gtk.SelectionMode.SINGLE) self.selected_row.connect("changed", self._on_tree_selection_changed) - self.connect("row-activated", self._on_row_activated) + self.connect("key-press-event", self._on_keypress) self.connect("key-release-event", self._on_key_release) @@ -41,82 +47,81 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore def generic_treesel_changed(self, selection: Gtk.TreeSelection) -> None: pass - @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, - arg_types=(Gtk.TreePath, Gtk.TreeViewColumn) + @GObject.Signal( + flags=GObject.SignalFlags.RUN_LAST, arg_types=(Gtk.TreePath, Gtk.TreeViewColumn) ) - def generic_row_activated(self, - path: Gtk.TreePath, - column: Gtk.TreeViewColumn + def generic_row_activated( + self, path: Gtk.TreePath, column: Gtk.TreeViewColumn ) -> None: pass - def signal_emission(func: Callable) -> Callable: - def wrapper(self, *args, **kwargs): - self.controller.block_signals() - func(self, *args, **kwargs) - self.controller.unblock_signals() - - return wrapper - - def _separate(self, model: Gtk.ListStore, iter_: Gtk.TreeIter) -> bool: - if model[iter_][0] == SEPARATOR: + def _separate(self, model: Gtk.ListStore, _iter: Gtk.TreeIter) -> bool: + if model[_iter][0] == SEPARATOR: return True return False - def get_current_iter(self) -> Gtk.TreeIter | None: + def get_current_iter(self) -> Optional[Gtk.TreeIter]: it = self.get_selection().get_selected()[1] return it def get_focused_row_iter(self) -> Gtk.TreeIter: path = self.get_focused_row_path() model = self.get_model() + if model is None: + raise AttributeError("No model attached to tree") return model.get_iter(path) def get_focused_row_path(self) -> Gtk.TreePath: - return self.get_cursor().path + path, column = self.get_cursor() + return path def get_focused_row_index(self) -> int: - return self.get_cursor().path[0] + path, column = self.get_cursor() + index = path.get_indices()[0] + return index def get_selected_records(self) -> list: sel = self.get_selection() model, rows = sel.get_selected_rows() return [model[row] for row in rows] - def _on_keypress( - self, treeview: Gtk.TreeView, event: Gdk.EventKey - ) -> None: - + def _on_keypress(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None: if is_navkey(event.keyval): - # TODO: investigate this - tv = self.controller.get_active_treeview() + if self.get_model() is None: + return if self.sel_blocked is False: + self.sel_blocked = True self.controller.suppress_signal( - tv, - tv.selected_row, + self, + self.selected_row, "_on_tree_selection_changed", True, ) self._vim_nav(event) return - def _on_key_release( - self, treeview: Gtk.TreeView, event: Gdk.EventKey - ) -> None: - # TODO: explain this better + def _on_key_release(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None: """ Suppresses spamming on keydown + TODO: explain this better """ - #if event.keyval is Gdk.KEY_space: + # TODO: multisel + # if event.keyval is Gdk.KEY_space: # it = self.get_focused_row_iter() # self.get_selection().select_iter(it) # return True + model = self.get_model() + if model is None: + return + if len(model) < 2: + return if is_navkey(event.keyval): if self.sel_blocked is True: + self.sel_blocked = False self.controller.suppress_signal( - self.controller.mediator.treeview, - self.controller.mediator.treeview.selected_row, + self, + self.selected_row, "_on_tree_selection_changed", False, ) @@ -135,12 +140,11 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore self.get_selection().unselect_path(path) def focus_first_row(self) -> None: - self.set_cursor(0) + path = Gtk.TreePath.new_from_indices([0]) + self.set_cursor(path) - def get_value_at_index(self, index: int) -> str: - select = self.get_selection() - sels = select.get_selected_rows() - (model, pathlist) = sels + def get_value_at_index(self, index: int) -> Any: + (model, pathlist) = self.get_model_and_pathlist() if len(pathlist) < 1: return "" path = pathlist[0] @@ -150,22 +154,26 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore def get_name(self) -> str: name = self.get_value_at_index(0) - return name + return str(name) - def select_first_row(self): + def select_first_row(self) -> None: sel = self.get_selection() self._on_tree_selection_changed(sel) - def get_mpath(self) -> Gtk.TreePath | None: + def get_model_and_pathlist(self) -> tuple: select = self.get_selection() sels = select.get_selected_rows() (model, pathlist) = sels - if len(pathlist) < 1: - return None - path = pathlist[0] - return path + return (model, pathlist) + + @deprecated("Currently unused") + # def get_mpath(self) -> Optional[Gtk.TreePath]: + # (model, pathlist) = self.get_model_and_pathlist() + # if len(pathlist) < 1: + # return None + # path = pathlist[0] + # return path - @signal_emission def _on_row_activated( self, treeview: Gtk.TreeView, @@ -175,18 +183,23 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore self.emit("generic_row_activated", path, col) def is_selection_empty(self) -> bool: - # TODO: reduce duplicated methods - sel = self.get_selection() - sels = sel.get_selected_rows() - (model, pathlist) = sels + (model, pathlist) = self.get_model_and_pathlist() if len(pathlist) < 1: return True return False - def get_selected_row(self) -> Gtk.TreeModelRow | None: - ind = self.get_selected_row_index() + @deprecated("unused") + # def get_selected_row(self) -> Optional[Gtk.TreeModelRow]: + # ind = self.get_selected_row_index() + # model = self.get_model() + # if model is None: + # return None + # row = model[ind] + # return row + + def get_col_value_by_path_index(self, path: Gtk.TreePath, index: int) -> Any: model = self.get_model() if model is None: return None - row = model[ind] - return row + value = model[path][index] + return value diff --git a/dzgui/views/trees/tree_log.py b/dzgui/views/trees/tree_log.py index 535ae97..c4c71f7 100644 --- a/dzgui/views/trees/tree_log.py +++ b/dzgui/views/trees/tree_log.py @@ -1,74 +1,98 @@ import logging -from typing import TYPE_CHECKING +from typing import Any, TYPE_CHECKING +from dzgui.const.constants import APP_NAME, LOG_FILTERS from dzgui.const.enum import ContextMenuGroup +from dzgui.model.model_factory import ModelFactory from dzgui.util import strings from dzgui.views.trees.tree_base import TreeView from dzgui.views.mixins.context_mixin import ContextMixin import gi + gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 if TYPE_CHECKING: + from pathlib import Path from dzgui.controllers.mc import Controller -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) -class LogTreeView(ContextMixin, TreeView): + +class LogTreeView(ContextMixin, TreeView): # type: ignore def __init__(self, controller: "Controller") -> None: super().__init__(controller, menu=ContextMenuGroup.LOG) self.controller = controller self.controller.register_widget("logtreeview", self) - # TODO: maybe put this in init self.set_headers_visible(True) self.set_fixed_height_mode(True) + self.set_vexpand(True) self.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) - model = self.controller.get_log_store() - self.set_model(model) + self.set_model(None) + self.filters = list(LOG_FILTERS) for i, column_title in enumerate(strings.log_cols): renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn( - column_title, renderer, text=i - ) + column = Gtk.TreeViewColumn(column_title, renderer, text=i) column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) column.set_resizable(True) column.set_sort_column_id(i) + if i == 1: + column.set_fixed_width(100) self.append_column(column) - self.connect("button-press-event", self._on_log_button_press) - self.connect("button-release-event", self._on_log_button_release) + self.connect("button-press-event", self._on_log_buttonpress) self.connect("key-press-event", self._on_log_keypress) - self.s = self.get_selection().get_selected_rows() + def populate_log(self, filepath: "Path") -> None: + model = ModelFactory().new_model_from_logfile(filepath) + _filter = model.filter_new() + _filter.set_visible_func(self._filter_rows) + sortable = Gtk.TreeModelSort(_filter) + self.set_model(sortable) + _filter.refilter() + path = Gtk.TreePath.new_from_indices([0]) + self.set_cursor(path) + + def toggle_filter(self, _filter: str) -> None: + if _filter in self.filters: + self.filters.remove(_filter) + else: + self.filters.append(_filter) + # NOTE: unwrap TreeModelSort and TreeModelFilter + self.get_model().get_model().refilter() # type: ignore + + def _filter_rows( + self, filter_model: Gtk.TreeModelFilter, _iter: Gtk.TreeIter, data: Any + ) -> bool: + if len(self.filters) < 1: + return False + return filter_model[_iter][1] in self.filters + + def _on_log_buttonpress(self, widget: Gtk.Widget, event: Gdk.EventButton) -> bool: + if event.button == Gdk.BUTTON_SECONDARY: + self.present_menu(widget, event) + return True + return False def _on_log_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: self.present_menu(widget, event) - def _on_log_button_press(self, - widget: Gtk.Widget, - event: Gdk.EventButton - ) -> bool: - - """ - Prevents context menu from deselecting rows - """ - if event.button == 3: - self.s = self.get_selection().get_selected_rows() - return True - return False - - def _on_log_button_release(self, - widget: Gtk.Widget, - event: Gdk.EventButton - ) -> None: - if event.button == 3: - self.present_menu(widget, event) - self.get_selection().unselect_all() - for row in self.s[1]: - self.get_selection().select_path(row) + def concatenate_rows(self) -> str | None: + # NOTE: LogTreeView uses Gtk.SelectionMode.MULTIPLE + final = [] + model, records = self.get_selection().get_selected_rows() + if len(records) < 1: + return None + for record in records: + raw_record = model[record] + els: tuple[Any] = tuple(raw_record) # type: ignore + concat = strings.delimiter.join(map(str, els)) + final.append(concat) + text = "\n".join(final) + return text diff --git a/dzgui/views/trees/tree_menu.py b/dzgui/views/trees/tree_menu.py index da9cd9e..04a17f2 100644 --- a/dzgui/views/trees/tree_menu.py +++ b/dzgui/views/trees/tree_menu.py @@ -1,26 +1,31 @@ import logging from typing import TYPE_CHECKING +from dzgui.const.constants import APP_NAME from dzgui.const.enum import RowType, NotebookPage +from dzgui.model.model_factory import ModelFactory from dzgui.util.open_links import open_link_by_rowtype 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 -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.controllers.mc import Controller + class MenuTreeView(TreeView): """ Simple Gtk.ListStore representation of main menu options """ + def __init__(self, controller: "Controller") -> None: - super().__init__(controller) + super().__init__(controller, menu=None) self.controller = controller @@ -29,30 +34,24 @@ class MenuTreeView(TreeView): column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) self.append_column(column) - help_store = self.controller.get_help_store() + help_store = ModelFactory().make_help_store() self.set_model(help_store) self.selected_row = self.get_selection() + self.controller.register_widget("menu", self) + self.set_row_separator_func(self._separate) self.connect("generic_row_activated", self._parent_row_activated) - self.connect("generic_treesel_changed", self._parent_selection_changed) - def _parent_row_activated(self, - tree: TreeView, - path: Gtk.TreePath, - column: Gtk.TreeViewColumn - ) -> None: + def _parent_row_activated( + self, tree: TreeView, path: Gtk.TreePath, column: Gtk.TreeViewColumn + ) -> None: row_type = self.get_value_at_index(1) match row_type: case RowType.THANKS: self.controller.open_page(NotebookPage.THANKS) - case RowType.SERVER_BROWSER: - # TODO: add threading - # TODO: handle recent, saved, lan, etc. - # self.controller.open_page(NotebookPage.SERVERS) - return case RowType.SHOW_LOG: self.controller.populate_log() case RowType.CHANGELOG: @@ -69,15 +68,13 @@ class MenuTreeView(TreeView): RowType.SPONSOR, ] if row_type in docs: - # FIXME: prior method is returning a str, not enum open_link_by_rowtype(row_type) - def _parent_selection_changed( - self, - base_class: TreeView, - sel: Gtk.TreeSelection - ) -> None: - row = self.get_value_at_index(1) - if row == "": - return - self.controller.set_statusbar_by_row(row) + def get_row_enum(self) -> str: + # NOTE: col 1 contains a RowType enum + model = self.get_model() + if model is None: + raise ValueError("Trying to call a method on non-existent model") + _iter = self.get_focused_row_iter() + rowtype = model.get_value(_iter, 1) + return str(rowtype.dict["tooltip"]) diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index b7a39f0..130708f 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -1,49 +1,50 @@ import logging from typing import Any, TYPE_CHECKING +from dzgui.const.constants import APP_NAME, HEX_RED +from dzgui.const.enum import ContextMenuGroup from dzgui.util import strings, localize -from dzgui.views.trees.tree_base import TreeView from dzgui.views.mixins.context_mixin import ContextMixin from dzgui.views.mixins.mods_mixin import ModsMixin -from dzgui.const.enum import ( - ContextMenuGroup, - ) +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.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter -logger = logging.getLogger(__name__) +logger = logging.getLogger(APP_NAME) -class ModTreeView(ModsMixin, ContextMixin, TreeView): + +class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore def __init__(self, controller: "Controller") -> None: super().__init__(controller, menu=ContextMenuGroup.MOD) self.controller = controller + emitter = self.controller.get_emitter() + emitter.connect("mods_updated", self._on_mods_updated) + emitter.connect("mods_highlighted", self._on_mods_highlighted) self.set_fixed_height_mode(True) self.set_headers_visible(True) - mod_store = self.controller.get_mod_store() - self.set_model(mod_store) + self.set_model(None) for i, column_title in enumerate(strings.mod_cols): renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn( - column_title, renderer, text=i, foreground=4 - ) + column = Gtk.TreeViewColumn(column_title, renderer, text=i) + column.set_cell_data_func(renderer, self._format_color, func_data=None) if i == 3: - column.set_cell_data_func( - renderer, self._format_float, func_data=None - ) + column.set_cell_data_func(renderer, self._format_float, func_data=None) + if column_title == "Mod": column.set_fixed_width(500) else: column.set_fixed_width(150) - # NOTE: hidden color property column column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) column.set_sort_column_id(i) if i != 4: @@ -54,32 +55,47 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): self.connect("generic_row_activated", self._on_mod_row_activated) self.connect("generic_treesel_changed", self._parent_selection_changed) - self.connect("button-press-event", self._on_mods_button_press) - self.connect("key-press-event", self._on_mods_keypress) + self.connect("button-press-event", self.present_menu) + self.connect("key-press-event", self.present_menu) - self.focus_first_row() - self.s = self.get_selection().get_selected_rows() + def _on_mods_highlighted(self, emitter: "Emitter") -> None: + self.get_selection().unselect_all() - def _on_mods_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: - #if event.keyval is Gdk.KEY_space: - # it = self.get_focused_row_iter() - # self.get_selection().select_iter(it) - # path = self.get_focused_row_path() - # self.set_cursor(path) - # return False - # TODO: parse keys - self.present_menu(widget, event) + def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None: + if mods < 1: + return + path = Gtk.TreePath.new_from_indices([0]) + self.set_cursor(path) - def _on_mods_button_press(self, - widget: Gtk.Widget, - event: Gdk.EventButton + def get_selected_mod(self) -> str: + path = self.get_focused_row_path() + model = self.get_model() + if model is None: + raise AttributeError("Trying to call a method on a non-existent model") + tree_iter = model.get_iter(path) + mod = model.get_value(tree_iter, 2) + return str(mod) + + def _parent_selection_changed( + self, base_class: TreeView, sel: Gtk.TreeSelection ) -> None: - if event.button == 3: - self.present_menu(widget, event) - - def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection): pass + def _format_color( + self, + column: Gtk.TreeViewColumn, + cell: Gtk.CellRendererText, + model: Gtk.TreeModel, + it: Gtk.TreeIter, + data: Any, + ) -> Any: + state = model[it][4] + if state is True: + cell.set_property("foreground", HEX_RED) + else: + cell.set_property("foreground", None) + return + def _format_float( self, column: Gtk.TreeViewColumn, @@ -88,7 +104,6 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): it: Gtk.TreeIter, data: Any, ) -> Any: - # https://docs.huihoo.com/pygtk/2.0-tutorial/sec-CellRenderers.html val = model[it][3] formatted = localize.number(val) cell.set_property("text", formatted) diff --git a/dzgui/views/trees/tree_server_mods.py b/dzgui/views/trees/tree_server_mods.py index df44134..0659c31 100644 --- a/dzgui/views/trees/tree_server_mods.py +++ b/dzgui/views/trees/tree_server_mods.py @@ -1,104 +1,75 @@ -import logging - -import gi -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa - -from dzgui.const.enum import ( - ContextMenuGroup, - Popup, - RowType, - WindowContext, - ) -from dzgui.util.open_links import open_workshop_page -from dzgui.util import strings, localize -from typing import Any - +from dzgui.const.enum import ContextMenuGroup +from dzgui.model.model_factory import ModelFactory +from dzgui.strings import server_mods +from dzgui.views.mixins.context_mixin import ContextMixin from dzgui.views.trees.tree_base import TreeView -class ModDialog(GenericDialog): - def __init__(self, record: str): - # TODO: center secondary text - msg = strings.workshop - super().__init__(textwrap.dedent(msg), Popup.MODLIST) +from typing import TYPE_CHECKING - dialogBox = self.get_content_area() - self.set_default_response(Gtk.ResponseType.OK) - self.set_size_request(800, 500) +import gi - 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) +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib, Gdk, Pango # noqa - self.connect("generic_row_activated", self._on_mod_row_activated) - self.connect("generic_treesel_changed", self._parent_selection_changed) +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller - for i, column_title in enumerate(strings.server_mod_cols): + +class ServerModTreeView(ContextMixin, TreeView): # type: ignore + def __init__(self, controller: "Controller") -> None: + super().__init__(controller, menu=ContextMenuGroup.SERVER_MOD) + + self.mod_store = ModelFactory().make_server_mod_store() + + self.set_fixed_height_mode(True) + self.set_headers_visible(True) + self.set_model(self.mod_store) + + self.connect("button-press-event", self.present_menu) + self.connect("key-press-event", self.present_menu) + self.connect("row-activated", self._on_row_activated) + + 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) + self.append_column(column) column.set_sort_column_id(i) match column_title: - case strings.mod: + case server_mods.mod: column.set_fixed_width(350) - case strings._id: + case server_mods.mod_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() - - def _on_mod_row_activated(self, + # TODO: could be problematic if user downloads mods out of band + def _on_row_activated( + self, treeview: Gtk.TreeView, path: Gtk.TreePath, - column: Gtk.TreeViewColumn + col: Gtk.TreeViewColumn, ) -> None: - uid = self.get_value_at_index(1) - self.controller.open_mod_page(uid) + mod = self.get_value_at_index(1) + self.controller.open_workshop_page(mod) - def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection): - pass + def get_selected_mod(self) -> str: + path = self.get_focused_row_path() + model = self.get_model() + if model is None: + raise AttributeError("Trying to call a method on a non-existent model") + tree_iter = model.get_iter(path) + mod = model.get_value(tree_iter, 1) + return str(mod) - # TODO: threading shouldn't take place inside this dialog, should be external to controller - #def _background(self, dialog: "GenericDialog", record: str) -> None: - # def _load(): - # dialog.destroy() - # # TODO: natively implemented - # #if data.returncode == 1: - # # spawn_dialog(strings.server_error, Popup.NOTIFY) - # # return - # self.show_all() - # self.set_markup(f"Modlist ({mod_count} mods)") - # self.run() - # self.destroy() - - # record = treeview.get_record() - # if not record: - # return - - # # TODO: thread - # try: - # 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 populate(self, mods: list[list[str]]) -> None: + self.mod_store.clear() + for mod in mods: + self.mod_store.append(mod) + path = Gtk.TreePath.new_from_indices([0]) + self.set_cursor(path) diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 47edc6a..7013d0c 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -1,66 +1,70 @@ import logging -import multiprocessing -import subprocess +import threading -import gi -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa +from queue import Queue +from typing import Any, Self, Union +from warnings import deprecated -from dzgui.const.enum import ( - ContextMenu, - ContextMenuGroup, - RowType, - ) -from dzgui.const.constants import UDP_PORT -from dzgui.api.servers import Record -from dzgui.util.dist import CalcDist -from dzgui.util.keys import is_navkey +from dzgui.api.servers import ping, Record +from dzgui.const.constants import APP_NAME +from dzgui.const.enum import ContextMenu, ContextMenuGroup, ServerTab +from dzgui.managers.filter import FilterManager +from dzgui.model.proxy_model import ProxyModelManager from dzgui.util import strings -from typing import Any, Callable, Literal, TYPE_CHECKING +from dzgui.util.dist import CalcDist +from dzgui.util.keys import is_ctrl_mask +from dzgui.views.mixins.context_mixin import ContextMixin +from typing import Literal, TYPE_CHECKING + from dzgui.views.trees.tree_base import TreeView import dzgui.util._json as JSON # noqa -logger = logging.getLogger(__name__) +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa + + +logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.controllers.emitter import Emitter + from dzgui.model.model_factory import FastInsertListStore -# TODO: restore missing methods -# TODO: add multiprocessing queue -# TODO: fix cache +QUEUE_CHECK_DELAY = 200 -class EnumeratedMenuItem(Gtk.MenuItem): - def __init__(self, enum: ContextMenu): - super().__init__(label=enum.dict["label"]) + +class ServerTreeView(ContextMixin, TreeView): # type: ignore + def __init__( + self, controller: "Controller", enum: ServerTab, menu: ContextMenuGroup + ) -> None: + super().__init__(controller, menu=menu) + + self.controller = controller + self.emitter = controller.get_emitter() self.enum = enum - -class ServerTreeView(TreeView): - __gsignals__ = { - "on_distcalc_started": (GObject.SignalFlags.RUN_FIRST, None, ()) - } - def __init__(self, controller: "Controller") -> None: - super().__init__(controller) - - QUEUE_CHECK_DELAY = 200 - self.loaded = False - self.query_func: Callable = None - self.menu = Gtk.Menu() - self.menu.connect("key-press-event", self._on_key) - self.controller = controller + self.filter_man = FilterManager() + self.proxy_man = ProxyModelManager(self.filter_man) + model = self.proxy_man.get_proxy_model() + self.set_model(model) self.set_fixed_height_mode(True) - self.set_headers_visible(True) + # NOTE: headers become visible on model load + self.set_headers_visible(False) - self.current_proc = None - self.queue = multiprocessing.Queue() + self.queue_id: int + self.handler_id: int + self.queue: Queue = Queue() + + self.seen_cache: list[str] = [] prefs = self.controller.get_prefs() columns = prefs.paths.columns - try: data = JSON.read_json(columns) valid_json = True @@ -68,6 +72,14 @@ class ServerTreeView(TreeView): logger.critical(e) valid_json = False + width_map = { + "Name": 800, + "Map": 300, + "IP": 240, + } + + # TODO: abstract column population logic + # TODO: resize col width func causes snapping behavior browser_cols = strings.browser_cols for i, column_title in enumerate(browser_cols): renderer = Gtk.CellRendererText() @@ -84,55 +96,90 @@ class ServerTreeView(TreeView): column.set_fixed_width(saved_size) column.set_expand(True) else: - if column_title == "Name": - column.set_fixed_width(800) - if column_title == "Map": - column.set_fixed_width(300) + w = width_map[column_title] + column.set_fixed_width(w) + if column_title == "Ping": + column.set_cell_data_func(renderer, self._get_ping) column.connect("notify::fixed-width", self._on_col_width_changed) self.append_column(column) - self.connect("on_distcalc_started", self._on_calclat_started) - self.connect("button-release-event", self._on_server_button_release) - self.connect("key-press-event", self._on_server_keypress) + self.connect("button-press-event", self.present_menu) self.connect("generic_row_activated", self._parent_row_activated) self.connect("generic_treesel_changed", self._parent_selection_changed) + self.connect("key-press-event", self._on_server_keypress) + self.connect("key-press-event", self.present_menu) + self.connect("map", self._on_map) + self.connect("unmap", self._on_unmap) - GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) + self.set_has_tooltip(True) + self.connect("query-tooltip", self._on_tooltip) - def _on_key(self, menu: Gtk.Menu, event: Gdk.EventKey) -> bool | None: - if not is_navkey(event.keyval): + # TODO: why is this being saved? + # self.thread = None + + def _on_tooltip( + self, + treeview: Self, + x: int, + y: int, + keyboard_mode: bool, + tooltip: Gtk.Tooltip, + ) -> bool: + """ + Present record data for the hovered row even if it is unfocused + """ + coords_x, coords_y = treeview.convert_widget_to_bin_window_coords(x, y) + path = self.get_path_at_pos(coords_x, coords_y) + if path is None or path[0] is None: return False - sel = menu.get_selected_item() - children = menu.get_children() - for i, child in enumerate(children): - if sel is child: - ind = i - break + model = self.get_model() + if model is None: + return False + tree_iter = model.get_iter(path[0]) + ip = model.get_value(tree_iter, 7) + qport = model.get_value(tree_iter, 8) + addr = ip + ":" + str(qport) + note = self.controller.get_note_by_record(addr) - match event.keyval: - case Gdk.KEY_j: - if ind == len(children) - 1: - return True - menu.select_item(children[ind+1]) - case Gdk.KEY_k: - if ind - 1 < 0: - return True - menu.select_item(children[ind-1]) - case Gdk.KEY_g: - menu.select_item(children[0]) - case Gdk.KEY_G: - ind = len(children) - 1 - menu.select_item(children[ind]) - case _: - return False - return True + if len(note) > 0: + tooltip.set_text(note) + self.set_tooltip_row(tooltip, path[0]) + return True + return False - def set_query_func(self, func: Callable) -> None: - self.query_func = func + def start_queue_checker(self) -> None: + self.queue_id = GLib.timeout_add(QUEUE_CHECK_DELAY, self._check_result_queue) - def get_query_func(self) -> Callable | None: - return self.query_func + def get_filter_man(self) -> FilterManager: + return self.filter_man + + def get_proxy_man(self) -> ProxyModelManager: + return self.proxy_man + + def get_enum(self) -> ServerTab: + return self.enum + + def _on_map(self, widget: Self) -> None: + # TODO: disable filter panel if current model is None + if self.get_enum() is ServerTab.LAN: + self.emitter.emit("lan_tab_toggled", True) + + store = self.filter_man.get_map_store() + + # FIXME: if model is none, wipe maps + # distinguish this signal from changing map combo in-situ + self.emitter.emit("load_maps", store) + self.handler_id = self.emitter.connect("statusbar_loaded", self.start_distcalc) + self.start_queue_checker() + self.start_distcalc() + + def _on_unmap(self, tree: Self) -> None: + # NOTE: removes queue checker for this tab + GLib.Source.remove(self.queue_id) + self.emitter.disconnect(self.handler_id) + if self.get_enum() is ServerTab.LAN: + self.emitter.emit("lan_tab_toggled", False) def _on_col_width_changed( self, col: Gtk.TreeViewColumn, width: GObject.ParamSpecInt @@ -140,169 +187,213 @@ class ServerTreeView(TreeView): """ Propagate width change to other tabs """ - title = col.get_title() - size = col.get_width() - # NOTE: get final width after drag action completes GLib.idle_add(self.controller.propagate_column_width, col) - def terminate_process(self) -> None: - if self.current_proc and self.current_proc.is_alive(): - self.current_proc.terminate() + def start_distcalc(self, emitter: Union["Emitter", None] = None) -> None: + self.emitter.emit("distcalc_started") + record = self.get_record() + if record is None: + context = self.get_enum() + self.emitter.emit("distcalc_ended", None, context) + return - def _on_calclat_started(self, treeview): - server_tooltip[0] = format_tooltip() - server_tooltip[1] = server_tooltip[0] + "| Distance: calculating..." - self.update_statusbar(server_tooltip[1]) + cache = self.controller.get_dist_cache() + + if record.ip in cache: + haversine = cache[record.ip] + self.controller.set_statusbar_dist(haversine, self.get_enum()) + return + + enum = self.get_enum() + thread = threading.Thread( + daemon=True, + target=CalcDist, + args=(record.ip, enum, self.queue, self.controller, cache), + ) + thread.start() def _check_result_queue(self) -> Literal[True]: latest_result = None + # FIXME: empty results cause statusbar to be emptied while not self.queue.empty(): latest_result = self.queue.get() + # FIXME: cache is being checked in two passes + cache = self.controller.get_dist_cache() + if latest_result: - addr = latest_result[0] - km = latest_result[1] - cache[addr] = km - # FIXME - self.statusbar.append_distance(km) + addr, haversine, tab = latest_result + if addr not in cache: + self.controller.set_dist_cache(addr, haversine) + # TODO: should be emitting a statusbar signal here instead? + self.controller.set_statusbar_dist(haversine, self.get_enum()) return True - def _on_server_keypress( - self, treeview: Gtk.TreeView, event: Gdk.EventKey - ) -> bool | None: - # TODO: use mixins - # CONTROL_MASK + KEY_l - if event.state is Gdk.ModifierType.CONTROL_MASK: + def _on_server_keypress(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None: + if is_ctrl_mask(event): match event.keyval: - case Gdk.KEY_l: - self._on_server_button_release(self, event) - case Gdk.KEY_r: - self.refresh_player_count() case Gdk.KEY_f: - # TODO: register filter panel instead of mediating thru right panel - self.controller.mediator.grid.right_panel.filters_vbox.keyword_entry.grab_focus() + self.emitter.emit("request_keyword_focus") case Gdk.KEY_m: - self.controller.mediator.grid.right_panel.filters_vbox.maps_entry.grab_focus() + self.emitter.emit("request_maps_focus") case Gdk.KEY_i: - self.controller.mediator.grid.conpan.entry.grab_focus() + self.emitter.emit("request_ip_entry_focus") + case Gdk.KEY_d: + self.emitter.emit("request_default_port_focus") + case Gdk.KEY_n: + if self.enum is ServerTab.LAN: + self.emitter.emit("request_custom_port_focus") + case Gdk.KEY_c: + self.controller.menu_action(ContextMenu.COPY_SERVER_IP, self) + case Gdk.KEY_r: + # TODO: unimplemented, needs threading + self.controller.menu_action(ContextMenu.REFRESH_PLAYERS, self) else: match event.keyval: case Gdk.KEY_l | Gdk.KEY_Right: - self.controller.mediator.right_panel.focus_button_box() + self.emitter.emit("request_button_box_focus") case _: - self.controller.toggle_check(event) + self.emitter.emit("check_button_pressed", event.keyval) - def set_context_menu(self, items: ContextMenuGroup) -> None: - # TODO: if debug is on, add raw command copy to context menu - for item in items.value: - menu_item = EnumeratedMenuItem(item) - menu_item.connect("activate", self._on_menu_click) - self.menu.append(menu_item) - self.menu.show_all() + def is_modded(self) -> bool: + select = self.get_selection() + sels = select.get_selected_rows() + (model, pathlist) = sels + path = pathlist[0] + tree_iter = model.get_iter(path) + has_mods = model.get_value(tree_iter, 11) + return bool(has_mods) - def _on_menu_click(self, item) -> None: - print(f"UNIMPLEMENTED: {item.enum}") - pass + # def get_selected_row(self) -> Gtk.TreeModelRow: + # sel = self.get_selection() + # sels = sel.get_selected_rows() + # print(type(sels[0])) + # return sels[0] - def _on_server_button_release( - self, widget: Gtk.Widget, event: Gdk.EventButton + def is_in_favs(self) -> bool: + record = self.get_record_string() + if self.controller.get_config_man().is_in_favs(record): + return True + return False + + def _parent_row_activated( + self, tree: TreeView, path: Gtk.TreePath, column: Gtk.TreeViewColumn ) -> None: - # TODO: use ContextMixin - if event.type is Gdk.EventType.BUTTON_RELEASE and event.button != 3: - return - - 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 - - mod_context_items = [ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD] - - # TODO: dynamic menu entries - #for row in items: - # if row == ContextMenu.ADD_SERVER: - # if self.is_in_favs(): - # row = ContextMenu.REMOVE_SERVER - # item = Gtk.MenuItem(label=row.dict["label"]) - # item.type = row - # item.action = row.dict["action"] - # self.menu.append(item) - # if row == ContextMenu.SHOW_MODS: - # if not self.has_mods(): - # item.set_sensitive(False) - # if row == ContextMenu.ADD_NOTE: - # if self.get_record_string() in notes_cache: - # item.set_label(strings.edit_note) - - if event.type is Gdk.EventType.KEY_PRESS and event.keyval is Gdk.KEY_l: - if self.is_selection_empty(): - return - self.menu.popup_at_widget( - widget, Gdk.Gravity.CENTER, Gdk.Gravity.WEST - ) - else: - self.menu.popup_at_pointer(event) - self.menu.select_first(False) - - def _parent_row_activated(self, - tree: TreeView, - path: Gtk.TreePath, - column: Gtk.TreeViewColumn - ) -> None: - # TODO: process server connection - # TODO: get record - print(self.get_value_at_index(0)) - - def _parent_selection_changed(self, base_class: TreeView, sel: Gtk.TreeSelection): - - self.terminate_process() record = self.get_record() - if record is None: return + self.controller.connect_by_record(record) - #model = self.get_model() - #if record is None: - # self.controller.update_server_status(model) - # return + def _parent_selection_changed( + self, base_class: TreeView, sel: Gtk.TreeSelection + ) -> None: + if self.loaded is False: + return + self.start_distcalc() - ip = record.ip - # TODO - #self.emit("on_distcalc_started") - #self.current_proc = CalcDist(self, record.ip, self.queue, cache) - #self.current_proc.start() + def get_name(self) -> str: + return str(self.get_value_at_index(0)) + + def get_simplified_ip(self) -> str: + addr = self.get_value_at_index(7) + qport = self.get_value_at_index(8) + ip = addr.split(":")[0] + return f"{ip}:{qport}" def get_record_string(self) -> str: addr = self.get_value_at_index(7) qport = self.get_value_at_index(8) return f"{addr}:{qport}" - def get_record(self) -> dict | None: - # TODO: delegate to controller - select = self.get_selection() - sels = select.get_selected_rows() - (model, pathlist) = sels - if len(pathlist) < 1: + def get_record(self) -> Record | None: + if self.loaded is False: return None - path = pathlist[0] - model = self.get_model() - if not model: + try: + r = self.get_record_string() + ip, gameport, qport = r.split(":") + return Record(ip, int(gameport), int(qport)) + except ValueError as e: + logger.critical(e) return None - addr = model[path][7] - qport = model[path][8] - ip = addr.split(":")[0] - gameport = int(addr.split(":")[1]) - return Record(ip, gameport, qport) - def get_loaded(self) -> bool: + def is_loaded(self) -> bool: return self.loaded def set_loaded(self, status: bool) -> None: self.loaded = status + + def get_model_and_control_model( + self, + ) -> tuple[Union[Gtk.TreeModel, None], list[Any]]: + model = self.get_model() + control = self.proxy_man.get_control() + return model, control + + @staticmethod + def ping_server( + model: "FastInsertListStore", + _iter: Gtk.TreeIter, + ip: str, + qport: int, + ping_column: int, + ) -> None: + _ping = ping(ip, qport) + GLib.idle_add(lambda: model.set(_iter, ping_column, _ping)) + + def _get_ping( + self, + column: Gtk.TreeViewColumn, + cell: Gtk.CellRendererText, + model: Gtk.TreeModel, + _iter: Gtk.TreeIter, + data: Any, + ) -> None: + + addr_column = 7 + qport_column = 8 + ping_column = 9 + + addr = model.get_value(_iter, addr_column).split(":") + ip = addr[0] + gameport = addr[1] + qport = model.get_value(_iter, qport_column) + record = f"{addr}:{gameport}:{qport}" + + if record in self.seen_cache: + return + self.seen_cache.append(record) + + thread = threading.Thread( + daemon=True, + target=self.ping_server, + args=(model, _iter, ip, qport, ping_column), + ) + thread.start() + + @deprecated("Currently unused") + def _lazy_load( + self, + column: Gtk.TreeViewColumn, + cell: Gtk.CellRendererText, + model: Gtk.TreeModel, + it: Gtk.TreeIter, + col_index: int, + ) -> Any: + """ + Lazy load contents from model manager into visible CellRenderers on demand + N.B., all row data types will be stringified and must be fetched + from separate model manager, not internally from TreeView + cf. Gtk.TreeViewColumn.set_cell_data_func() + """ + path = model.get_path(it) + row_index = path.get_indices()[0] + try: + start, end = self.get_visible_range() + except Exception: + return + if row_index >= start[0] <= end[0]: + # NOTE: fetch raw data rows + real_model = self.proxy_man.get_control() + value = real_model[row_index][col_index] + cell.set_property("text", str(value)) diff --git a/pyproject.toml b/pyproject.toml index 6f6ef5f..1bf83b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "packaging==25.0", "psutil==7.1.3", "python-a2s", - "requests==2.32.5" + "requests==2.32.5", ] [project.scripts] @@ -52,6 +52,7 @@ lint = [ "ruff==0.11.13", "PyGObject-stubs==2.13.0", "types-requests>=2.32.4", + "types-psutil==7.2.2.20260508", ] dev = [ "dzgui[docs,test,lint]" @@ -64,7 +65,8 @@ build-backend = "setuptools.build_meta" [tool.ruff] line-length = 88 indent-width = 4 -extend-exclude = ["tree_servers.py", "tree_server_mods.py", "dialogs", "right_panel.py", "filter_panel.py", "base.py", "*lib/a2s", "dist.py"] +extend-exclude = ["*lib/a2s"] +lint.unfixable = ["F401"] [tool.ruff.format] quote-style = "double" diff --git a/scripts/untracked.py b/scripts/untracked.py new file mode 100644 index 0000000..f53714d --- /dev/null +++ b/scripts/untracked.py @@ -0,0 +1,16 @@ +import os +import subprocess +import sys + +cmd = ["git", "ls-files", "--others", "--exclude-standard"] +result = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True +) +with open(".gitignore", "r") as f: + ignored = [line.rstrip() for line in f] + +untracked = [os.path.basename(line.rstrip()) for line in result.stdout] +for file in untracked: + if not any(file in item for item in ignored): + print(f"File '{file}' is untracked. Add to .gitignore file if this is intended.") + sys.exit(1) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index e3ee98d..3eae186 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1,5 +1,17 @@ -def test_headings(): - with open("CHANGELOG.md", "r") as f: +import pytest + +from importlib import resources +from dzgui.const.constants import APP_NAME_LOWER, CHANGELOG_PATH + + +@pytest.fixture +def changelog(): + path = resources.files(APP_NAME_LOWER).joinpath(CHANGELOG_PATH) + return path + + +def test_headings(changelog): + with open(changelog, "r") as f: lines = f.readlines() for line in lines: if line.startswith("#"): diff --git a/tests/test_config.py b/tests/test_config.py index a1c1a7a..4cfd754 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,28 +8,30 @@ from tests.fixtures import fixture_path pytestmark = pytest.mark.config + @pytest.fixture def legacy_config(): return fixture_path("dztuirc_one") + @pytest.fixture def keys(): return [ - "bm_api", - "fav_server", - "fav_label", - "name", - "debug", - "branch", - "fullscreen", - "steam_api", - "auto_install", - "default_steam_path", - "client", - "ip_list", - "use_miles" - ] + "bm_api", + "fav_server", + "fav_label", + "name", + "fullscreen", + "steam_api", + "default_steam_path", + "client", + "ip_list", + "use_miles", + "start_tab", + ] + +# TODO: use a static fixture instead of system config @pytest.fixture def config(): paths = get_xdg_paths() @@ -55,25 +57,34 @@ def test_contains_invalid_values(keys, config): assert key in keys -@pytest.mark.parametrize("fixture, expect", [ - ("dztuirc_one", (False, True, True)), - ("dztuirc_two", (False, False, False)), -]) +@pytest.mark.parametrize( + "fixture, expect", + [ + ("dztuirc_one", (False, True, True)), + ("dztuirc_two", (False, False, False)), + ], +) def test_bool_conversion(fixture, expect): fixture = fixture_path(fixture) j = convert.rc2json(fixture) j = json.loads(j) assert j["fullscreen"] == expect[0] - assert j["debug"] == expect[1] - assert j["auto_install"] == expect[2] assert not j["use_miles"] def test_key_conversion(legacy_config): j = convert.rc2json(legacy_config) j = json.loads(j) - keys = ["api_key", "staging_dir", "src_path", "steam_path", "preferred_client"] + keys = [ + "api_key", + "staging_dir", + "src_path", + "steam_path", + "preferred_client", + "debug", + ] for key in keys: assert key not in j + # TODO: test that when a config file is created from scratch, it contains all values diff --git a/tests/test_cooldown.py b/tests/test_cooldown.py deleted file mode 100644 index 32ce40c..0000000 --- a/tests/test_cooldown.py +++ /dev/null @@ -1,19 +0,0 @@ -import pytest -import time - -from dzgui.util import cooldown - - -def test_time_under(): - time_before = cooldown.get_time() - time.sleep(1) - result = cooldown.is_elapsed(time_before) - assert result is False - - -@pytest.mark.slow -def test_time_over(): - time_before = cooldown.get_time() - time.sleep(31) - result = cooldown.is_elapsed(time_before) - assert result is True diff --git a/tests/test_pefile.py b/tests/test_pefile.py index afd5fa3..47edc2e 100644 --- a/tests/test_pefile.py +++ b/tests/test_pefile.py @@ -2,13 +2,14 @@ import pytest from pathlib import Path import dzgui.api.pefile as PeFile -from dzgui.api.pefile import (VDFLoadError, AppNotInstalledError, AppMovedError) +from dzgui.api.pefile import VDFLoadError, AppNotInstalledError, AppMovedError from dzgui.config.query import get_config from dzgui.config.xdg import get_xdg_paths, parse_filepaths from dzgui.const.constants import APPID_DAYZ from tests.fixtures import fixture_path + pytestmark = pytest.mark.pefile @@ -16,6 +17,7 @@ pytestmark = pytest.mark.pefile def second_drive(): return fixture_path("in_library_on_second_drive.vdf") + @pytest.fixture def default_steam_path(): paths = get_xdg_paths() @@ -23,17 +25,17 @@ def default_steam_path(): conf = get_config(xdg.config) return conf["default_steam_path"] + def test_pefile_length(default_steam_path): try: - pe_file_path = PeFile.get_pefile_path( - Path(default_steam_path), APPID_DAYZ - ) + pe_file_path = PeFile.get_pefile_path(Path(default_steam_path), APPID_DAYZ) vers = PeFile.get_dayz_version(pe_file_path) st = PeFile.dayz_version_to_str(vers).split(".") except Exception as e: raise e assert len(st) == 3 + def test_invalid_pefile_path(): with pytest.raises(VDFLoadError): try: @@ -42,11 +44,14 @@ def test_invalid_pefile_path(): raise e -@pytest.mark.parametrize("fixture, exception", [ - ("not_in_library.vdf", AppNotInstalledError), - ("in_library_but_bad_path.vdf", AppMovedError), - ("malformed.vdf", VDFLoadError), - ]) +@pytest.mark.parametrize( + "fixture, exception", + [ + ("not_in_library.vdf", AppNotInstalledError), + ("in_library_but_bad_path.vdf", AppMovedError), + ("malformed.vdf", VDFLoadError), + ], +) def test_not_in_library(fixture, exception): fixture = fixture_path(fixture) with pytest.raises(exception): @@ -55,6 +60,7 @@ def test_not_in_library(fixture, exception): except Exception as e: raise e + def test_on_second_drive(second_drive): path = PeFile.get_app_path(second_drive, APPID_DAYZ) assert path == Path("/tmp")