diff --git a/CHANGELOG.md b/CHANGELOG.md index e93d3ce..7657310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## [6.0.0-beta.1] 2025-07-29 +## Added +- Ping column to server browsing contexts +- Server details dialog to server browsing contexts +- Filter by modded servers +- Breadcrumbs showing current menu context to top of window +- Speed up load time and navigation of server tables +- More robust threading and cache system when filtering servers +- Dedicated changelog page +- Dedicated keybindings page +- Vim-style navigation keybindings +- Additional validation on entry dialogs to prevent submitting empty text +- Added "Return to main menu" button to dialog windows when failing to load server table +- Dynamic context menus for modded servers +- Additional keybindings for filter toggles + +## Fixed +- Entry dialog sensitivity when validating API keys +- Normalized buttons in dialogs and restored proper padding +- GTK errors being emitted to stdout when inserting debug table +- Suppress errors during pre-boot checks when mods are not installed +- Centered filter checkboxes within panel +- Do not pop unhighlight/select stale buttons if no stale mods exist +- Improved keybinding interaction with side panels +- Key stickiness when quickly navigating through entries in tables +- Suppress typeahead search in mod dialogs +- Do not trigger global API cooldown if no LAN/favorite servers are found +- Fix table column expansion in server mod dialogs + +## Changed +- Reduced global API cooldown from 60s to 30s +- Clarify dialog messages when DayZ path could not be found +- Auto-focus first item when opening context menus +- Opacity setting on side buttons when in a different context +- Refactored BM API key validation to account for new key format + +## Dropped +- Ping readout in statusbar + ## [5.8.0-beta.2] 2025-07-06 ## Added - Filter servers by official/unofficial status diff --git a/dzgui.sh b/dzgui.sh index 05505d4..0fa394e 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -o pipefail -version=5.8.0-beta.2 +version=6.0.0.beta-1 #CONSTANTS aid=221100 @@ -542,13 +542,13 @@ fetch_a2s(){ logger INFO "Updated A2S helper to sha '$sha'" } fetch_dzq(){ - local sum="9caed1445c45832f4af87736ba3f9637" + local sum="0a334e1e144e76e560419d155435c91e" local file="$helpers_path/a2s/dayzquery.py" if [[ -f $file ]] && [[ $(get_hash "$file") == $sum ]]; then logger INFO "DZQ is current" return 0 fi - local sha=3088bbfb147b77bc7b6a9425581b439889ff3f7f + local sha=a22a9f428cbe075d7dda62f78000296955eea92a local author="yepoleb" local repo="dayzquery" local url="https://raw.githubusercontent.com/$author/$repo/$sha/dayzquery.py" @@ -585,11 +585,11 @@ fetch_helpers_by_sum(){ [[ -f "$config_file" ]] && source "$config_file" declare -A sums sums=( - ["ui.py"]="f128a97e744e9e11036d707198feb8a8" + ["ui.py"]="d6db191dca9e50bec32458a04eaac3a6" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" - ["funcs"]="94287c75a5ae0a06a95dd439711e6fba" - ["lan"]="c62e84ddd1457b71a85ad21da662b9af" + ["funcs"]="1cef1a9fab4b6428fdba5572ad002614" + ["servers.py"]="51839211af7f65bdc04bab5557bf9466" ) local author="aclist" local repo="dztui" @@ -631,7 +631,6 @@ fetch_helpers_by_sum(){ logger INFO "Updated '$full_path' to sum '$sum'" fi [[ $file == "funcs" ]] && chmod +x "$full_path" - [[ $file == "lan" ]] && chmod +x "$full_path" done return 0 } @@ -926,7 +925,7 @@ create_config(){ } varcheck(){ local msg="Config file '$config_file' missing. Start first-time setup now?" - local msg2="The Steam paths set in your config file appear to be invalid. Restart first-time setup now?" + local msg2="The Steam paths set in your config file appear to be invalid (DayZ was moved or uninstalled). Restart first-time setup now?" if [[ ! -f $config_file ]]; then qdialog "$msg" "Yes" "Exit" if [[ $? -eq 1 ]]; then @@ -1006,6 +1005,7 @@ legacy_cols(){ mv $cols_file.new $cols_file } stale_mod_signatures(){ + [[ ! -f "$versions_file" ]] && return local workshop_dir="$steam_path/steamapps/workshop/content/$aid" if [[ -d $workshop_dir ]]; then readarray -t old_mod_ids < <(awk -F, '{print $1}' $versions_file) diff --git a/helpers/funcs b/helpers/funcs index 31b5f59..46ec683 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -o pipefail -version="5.8.0" +version="6.0.0-beta.1" #CONSTANTS aid=221100 @@ -114,6 +114,7 @@ declare -A funcs=( ["get_dist"]="get_dist" ["test_cooldown"]="test_cooldown" ["query_config"]="query_config" +["query_favorites"]="query_favorites" ["start_cooldown"]="start_cooldown" ["List installed mods"]="list_mods" ["Delete selected mods"]="delete_local_mod" @@ -146,6 +147,12 @@ lan_scan(){ printf "%s\n" "$res" fi } +query_favorites(){ + if [[ -z "${ip_list[@]}" ]]; then + return 1 + fi + printf "%s\n" "${ip_list[@]}" +} find_stale_mods(){ local res local mods=() @@ -723,8 +730,8 @@ test_cooldown(){ local old_time=$(< $_cache_cooldown) local cur_time=$(date +%s) local delta=$(($cur_time - $old_time)) - if [[ $delta -lt 60 ]]; then - local remains=$((60 - $delta)) + if [[ $delta -lt 30 ]]; then + local remains=$((30 - $delta)) local suffix=$(pluralize "seconds" $remains) printf "Global API cooldown in effect. Please wait %s %s." "$remains" "$suffix" exit 1 @@ -752,20 +759,20 @@ dump_servers(){ case "$subcontext" in *Server[[:space:]]browser*) local file="$_cache_servers" - if [[ ! $subcontext =~ Name ]]; then + if [[ ! $context =~ filter ]]; then initialize_remote_servers fi ;; *My[[:space:]]saved[[:space:]]servers*) local file="$_cache_my_servers" - if [[ ! $subcontext =~ Name ]]; then + if [[ ! $context =~ filter ]]; then [[ -f $file ]] && rm $file _iterate "$file" "${ip_list[@]}" fi ;; *Recent[[:space:]]servers*) local file="$_cache_history" - if [[ ! $subcontext =~ Name ]]; then + if [[ ! $context =~ filter ]]; then [[ -f $file ]] && rm $file readarray -t iters < <(cat $history_file) _iterate "$file" "${iters[@]}" @@ -774,7 +781,7 @@ dump_servers(){ *Scan[[:space:]]LAN[[:space:]]servers*) local port=$(<<< "$subcontext" awk -F: '{print $2}') local file="$_cache_lan" - if [[ ! $subcontext =~ Name ]]; then + if [[ ! $context =~ filter ]]; then [[ -f $file ]] && rm $file local lan=$(lan_scan $port) readarray -t iters <<< "$lan" @@ -1095,10 +1102,10 @@ test_steam_api(){ } test_bm_api(){ local key="$1" - if [[ ! $key =~ ^[0-9]+$ ]]; then + if [[ -z $key ]]; then + echo 1 return fi - [[ -z $key ]] && return 1 local code=$(curl -ILs "$bm_api" \ -H "Authorization: Bearer "$key"" -G \ -d "filter[game]=$game" \ @@ -1112,6 +1119,10 @@ update_config_val(){ case $1 in "Change player name") key="name" + if [[ -z "${value// }" ]]; then + printf "Invalid name" + return 2 + fi ;; "Change Steam API key") key="steam_api" @@ -1348,6 +1359,9 @@ update_history(){ echo "$record" >> "$history_file" } update_symlinks(){ + if [[ ! -d "$workshop_dir" ]]; then + return + fi legacy_symlinks symlinks } diff --git a/helpers/servers.py b/helpers/servers.py new file mode 100644 index 0000000..12f65ce --- /dev/null +++ b/helpers/servers.py @@ -0,0 +1,334 @@ +import json +import math +import os +import re +import socket +import subprocess +import sys +import typing # noqa + +from dataclasses import dataclass +from urllib import request, parse +from urllib.error import HTTPError +from typing import Union + +sys.path.append("a2s") +import a2s # noqa +from a2s import dayzquery + +params = [ + r"\nor\1\map\chernarusplus\nor\1\map\sakhal\nor\1\map\enoch\empty\1\nor\1\map\namalsk", + r"\map\namalsk\empty\1", + r"\map\namalsk\noplayers\1", + r"\map\chernarusplus\empty\1", + r"\map\chernarusplus\noplayers\1", + r"\map\sakhal\empty\1", + r"\map\sakhal\noplayers\1", + r"\map\enoch\empty\1", + r"\map\enoch\noplayers\1", +] + + +def get_netmask() -> str: + hostname = os.uname()[1] + i = socket.gethostbyname(hostname) + netmask = i.rsplit(".", 1)[0] + return netmask + + +def test_ip(suffix: int, port: int) -> dict | None: + netmask = get_netmask() + hostname = f"{netmask}.{str(suffix)}" + ping = ["ping", "-c1", "-i", "0.1", "-w", "1"] + output = subprocess.run(ping + [hostname], capture_output=True) + if output.returncode == 0: + return query_direct(hostname, port) + return None + + +def sanitize(name: str) -> str: + name = re.sub(r"\r", r"", name) + name = re.sub(r"\n", r"", name) + name = re.sub(r"\x01", r"", name) + name = re.sub(r"\ufeff", r"", name) + name = re.sub(r"(^!\s*)", r"-", name) + name = re.sub(r"(^-\s*)", r"", name) + name = re.sub(r"(^-)", r"", name) + name = re.sub(r"(^\s*)", r"", name) + name = re.sub(r"\t", "", name) + return name + + +def parse_json(json: list) -> list: + """ + Server metadata is underspecified and server operators + tend to insert random garbage in the headers. In case + sanitization failed, discard malformed rows rather than + aborting outright. + """ + rows = [] + for row in json: + try: + name = sanitize(row["name"]) + if name == "": + continue + except KeyError: + continue + + for key in [ + "map", + "gametype", + "players", + "max_players", + "addr", + "gameport", + ]: + try: + row[key] + except KeyError: + continue + + r = row["gametype"].split(",") + if "no3rd" in r: + view = "1PP" + else: + view = "3PP" + + if "external" in r: + provider = "Unoffic." + else: + provider = "Official" + + if "mod" in r: + modded = True + else: + modded = False + + try: + r = row["gametype"].split("lqs") + queue = r[1].split(",")[0] + except IndexError: + queue = 0 + + try: + test_time = re.search(r"[0-9]{2}:[0-9]{2}", row["gametype"]) + if test_time: + time = test_time.group(0) + else: + time = "Unknown" + except AttributeError: + time = "Unknown" + + try: + ip = row["addr"].split(":")[0] + ":" + str(row["gameport"]) + qport = row["addr"].split(":")[1] + except IndexError: + continue + + try: + ping = row["ping"] + except KeyError: + ping = 9999 + + mapname = row["map"].lower() + players = row["players"] + max_players = row["max_players"] + raw = [ + name, + mapname, + view, + time, + int(players), + int(max_players), + int(queue), + ip, + int(qport), + ping, + provider, + modded, + ] + rows.append(raw) + return rows + + +def query_direct(ip: str, qport: int, TIMEOUT=3.0) -> dict | None: + try: + info = a2s.info((ip, qport), TIMEOUT) + + name = info.server_name + mapname = info.map_name + address = ip + ":" + str(qport) + gameport = str(info.port) + players = info.player_count + max_players = info.max_players + keywords = info.keywords + + try: + ping = info.ping + ping = math.floor(info.ping * 1000) + except AttributeError: + ping = 9999 + + res = {} + res["name"] = name + res["map"] = mapname + res["gametype"] = keywords + res["players"] = players + res["max_players"] = max_players + res["addr"] = address + res["gameport"] = gameport + res["ping"] = ping + return res + except TimeoutError: + return None + except KeyError: + return None + + +@dataclass +class Res: + status: int + parsed: bool + json: Union[str, None] + + +@dataclass +class Ping: + addr: str + iteration: int + ping: int + + +@dataclass +class Details: + data: Union[list, None] + description: str + success: bool + + +def details(ip: str, qport: int) -> Details: + default_str = "None provided" + + try: + info = a2s.info((ip, qport)) + except TimeoutError: + return Details(None, default_str, False) + try: + rules = dayzquery.dayz_rules((ip, int(qport))) + except TimeoutError: + return Details(None, default_str, False) + + try: + keywords = info.keywords.split(",") + except AttributeError: + return Details(None, default_str, False) + + battleeye = "Disabled" + if "battleye" in keywords: + battleye = "Enabled" + + day_accel = 0.0 + night_accel = 0.0 + for keyword in keywords: + if "etm" in keyword: + day_accel = float(keyword.lstrip("etm")) + day_accel = f"{day_accel:g}" + if "entm" in keywords: + night_accel = float(keyword.lstrip("entm")) + night_accel = f"{night_accel:g}" + + try: + dlc = rules.dlc_flags + if dlc == 0: + dlc = "None" + if dlc == 2: + dlc = "Frostline" + except AttributeError: + dlc = "not specified" + + try: + platform = rules.platform + if platform == "win": + platform = "Windows" + if platform == "?": + platform = "Linux" + except AttributeError: + platform = "not specified" + + try: + description = rules.description.strip() + if description == "": + description = default_str + except AttributeError: + description = default_str + + rows = [ + ["DLC", dlc], + ["Battleye", battleye], + ["Daytime acceleration", f"{day_accel}x"], + ["Night-time acceleration", f"{night_accel}x"], + ["Platform", platform], + ] + + 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 + + try: + res = query_direct(ip, qport, 0.5) + except Exception as e: + pass + + if res is None: + ping = 9999 + else: + ping = res["ping"] + + return Ping(addr, iteration, ping) + + +def query_api(key: str, param: str) -> Res: + LIMIT = 10000 + url = "https://api.steampowered.com/IGameServersService/GetServerList/v1/?" + + payload: dict[str, Union[int, str]] = { + "filter": r"\appid\221100" + param, + "limit": LIMIT, + "key": key, + } + par = parse.urlencode(payload) + url = f"{url}{par}" + + status = 200 + parsed = True + data = None + + try: + with request.urlopen(url) as response: + if response.status != 200: + status = response.status + try: + parsed = True + data = json.load(response) + except json.decoder.JSONDecodeError: + parsed = False + data = None + except HTTPError: + status = 403 + parsed = False + data = None + finally: + return Res(status, parsed, data) diff --git a/helpers/ui.py b/helpers/ui.py index e167277..a643b32 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -4,403 +4,88 @@ import locale import logging import multiprocessing import os +import re import signal import subprocess import sys import textwrap import threading +import typing # noqa +import warnings + from enum import Enum +from concurrent.futures import wait +from concurrent.futures import ThreadPoolExecutor -locale.setlocale(locale.LC_ALL, '') +from collections.abc import Callable +from typing import Literal, Self, Any + +sys.path.append("servers") +import servers as Servers # noqa E402 + +locale.setlocale(locale.LC_ALL, "") + +import gi # noqa E402 -import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib, Gdk, GObject, Pango +from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa: E402 + +# https://bugzilla.gnome.org/show_bug.cgi?id=708676 +warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) app_name = "DZGUI" - -cache = {} -config_vals = [] -stored_keys = [] -toggled_checks = [] -server_filters = [] +app_name_lower = app_name.lower() +app_name_abbr = "dzg" delimiter = "␞" -selected_map = ["Map=All maps"] -keyword_filter = ["Keyword%s" %(delimiter)] -checks = list() +cache: dict[str, int] = {} +config_vals: list[str] = [] + +_VERSION: str +IS_GAME_MODE: bool +IS_STEAM_DECK: bool + map_store = Gtk.ListStore(str) row_store = Gtk.ListStore(str) modlist_store = Gtk.ListStore(str, str, str) -#cf. mod_cols, last column holds hex color mod_store = Gtk.ListStore(str, str, str, float, str) -#cf. log_cols log_store = Gtk.ListStore(str, str, str, str) -#cf. browser_cols -server_store = Gtk.ListStore(str, str, str, str, int, int, int, str, int, str) -default_tooltip = "Select a row to see its detailed description" -server_tooltip = [None, None] - -user_path = os.path.expanduser('~') -cache_path = '%s/.cache/dzgui' %(user_path) -state_path = '%s/.local/state/dzgui' %(user_path) -helpers_path = '%s/.local/share/dzgui/helpers' %(user_path) -log_path = '%s/logs' %(state_path) -changelog_path = '%s/CHANGELOG.md' %(state_path) -geometry_path = '%s/dzg.cols.json' %(state_path) -res_path = '%s/dzg.res.json' %(state_path) -funcs = '%s/funcs' %(helpers_path) -mods_temp_file = '%s/dzg.mods_temp' %(cache_path) -stale_mods_temp_file = '%s/dzg.stale_mods_temp' %(cache_path) +user_path = os.path.expanduser("~") +cache_path = f"{user_path}/.cache/{app_name_lower}" +state_path = f"{user_path}/.local/state/{app_name_lower}" +helpers_path = f"{user_path}/.local/share/{app_name_lower}/helpers" +log_path = f"{state_path}/logs" +changelog_path = f"{state_path}/CHANGELOG.md" +geometry_path = f"{state_path}/{app_name_abbr}.cols.json" +res_path = f"{state_path}/{app_name_abbr}.res.json" +funcs = f"{helpers_path}/funcs" +mods_temp_file = f"{cache_path}/{app_name_abbr}.mods_temp" +stale_mods_temp_file = f"{cache_path}/{app_name_abbr}.stale_mods_temp" +servers_path = f"{cache_path}/{app_name_abbr}.servers" +config_path = f"{user_path}/.config" +config_file = f"{user_path}/.config/dztuirc" +history_file = f"{state_path}/{app_name_abbr}.history" logger = logging.getLogger(__name__) -log_file = '%s/DZGUI_DEBUG.log' %(log_path) -system_log = '%s/DZGUI_SYSTEM.log' %(log_path) +log_file = f"{log_path}/{app_name}_DEBUG.log" +system_log = f"{log_path}/{app_name}_SYSTEM.log" FORMAT = "%(asctime)s␞%(levelname)s␞%(filename)s::%(funcName)s::%(lineno)s␞%(message)s" -logging.basicConfig(filename=log_file, - format=FORMAT, -level=logging.DEBUG) +logging.basicConfig(filename=log_file, format=FORMAT, level=logging.DEBUG) -browser_cols = [ - "Name", - "Map", - "Perspective", - "Gametime", - "Players", - "Maximum", - "Queue", - "IP", - "Qport", -] -mod_cols = [ - "Mod", - "Symlink", - "Dir", - "Size (MiB)", - "Color" -] -log_cols = [ - "Timestamp", - "Flag", - "Traceback", - "Message" -] -filters = { - "1PP": True, - "Day": True, - "Empty": False, - "3PP": True, - "Night": True, - "Full": False, - "Low pop": True, - "Non-ASCII": False, - "Duplicate": False, - "Official": True, - "Unoffic.": True, -} +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' +on the right-hand side, then click OK below to enable AUTO mod install mode. +""" - -class EnumWithAttrs(Enum): - - def __new__(cls, *args, **kwds): - value = len(cls.__members__) + 1 - obj = object.__new__(cls) - obj._value_ = value - return obj - def __init__(self, a): - self.dict = a - - -class RowType(EnumWithAttrs): - @classmethod - def str2rowtype(cls, str): - for member in cls: - if str == member.dict["label"]: - return member - return RowType.DYNAMIC - - 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" - } - HANDSHAKE = { - "label": "Handshake", - "tooltip": None, - "wait_msg": "Waiting for DayZ" - } - DELETE_SELECTED = { - "label": "Delete selected mods", - "tooltip": None, - "wait_msg": "Deleting mods" - } - SERVER_BROWSER = { - "label": "Server browser", - "tooltip": "Used to browse the global server list", - } - SAVED_SERVERS = { - "label": "My saved servers", - "tooltip": "Browse your saved servers. Unreachable/offline servers will be excluded", - } - QUICK_CONNECT = { - "label": "Quick-connect to favorite server", - "tooltip": "Connect to your favorite server", - "wait_msg": "Working", - "default": "unset", - "alt": None, - "val": "fav_label" - } - RECENT_SERVERS = { - "label": "Recent servers", - "tooltip": "Shows the last 10 servers you connected to (includes attempts)", - } - 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, - } - CONN_BY_ID = { - "label": "Connect by ID", - "tooltip": "Connect to a server by Battlemetrics ID", - "prompt": "Enter server ID", - "link_label": "Open Battlemetrics", - } - SCAN_LAN = { - "label": "Scan LAN servers", - "tooltip": "Search for servers on your local network" - } - 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, - } - ADD_BY_ID = { - "label": "Add server by ID", - "tooltip": "Add a server by Battlemetrics ID", - "prompt": "Enter server ID", - "link_label": "Open Battlemetrics", - } - 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" - } - LIST_MODS = { - "label": "List installed mods", - "tooltip": "Browse a list of locally-installed mods", - "quad_label": "Mods" - } - TGL_BRANCH = { - "label": "Toggle release branch", - "tooltip": "Switch between stable and testing branches", - "default": None, - "val": "branch" - } - TGL_INSTALL = { - "label": "Toggle mod install mode", - "tooltip": "Switch between manual and auto mod installation", - "default": "manual", - "link_label": "Open Steam Workshop", - "alt": "auto", - "val": "auto_install" - } - TGL_STEAM = { - "label": "Toggle Steam/Flatpak", - "tooltip": "Switch the preferred client to use for launching DayZ", - "alt": None, - "default": None, - "val": "preferred_client" - } - TGL_FULLSCREEN = { - "label": "Toggle DZGUI fullscreen boot", - "tooltip": "Whether to start DZGUI as a maximized window (desktop only)", - "alt": "true", - "default": "false", - "val": "fullscreen" - } - 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" - } - 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", - } - 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", - } - FORCE_UPDATE = { - "label": "Force update local mods", - "tooltip": "Synchronize the signatures of all local mods with remote versions (experimental)", - "wait_msg": "Updating mods" - } - DUMP_LOG = { - "label": "Output system info to log file", - "tooltip": "Dump diagnostic data for troubleshooting", - "wait_msg": "Generating log" - } - CHANGELOG = { - "label": "View changelog", - "tooltip": "Opens the DZGUI changelog in a dialog window" - } - SHOW_LOG = { - "label": "Show debug log", - "tooltip": "Read the DZGUI log generated since startup", - "quad_label": "Debug log" - } - DOCS = { - "label": "Documentation/help files (GitHub) ⧉", - "tooltip": "Opens the DZGUI documentation in a browser" - } - DOCS_FALLBACK = { - "label": "Documentation/help files (Codeberg mirror) ⧉", - "tooltip": "Opens the DZGUI documentation in a browser" - } - BUGS = { - "label": "Report a bug (GitHub) ⧉", - "tooltip": "Opens the DZGUI issue tracker in a browser" - } - FORUM = { - "label": "DZGUI Subreddit ⧉", - "tooltip": "Opens the DZGUI discussion forum in a browser" - } - SPONSOR = { - "label": "Sponsor (GitHub) ⧉", - "tooltip": "Sponsor the developer of DZGUI" - } - - -class WindowContext(EnumWithAttrs): - @classmethod - def row2con(cls, row): - m = None - for member in cls: - if row in member.dict["rows"]: - m = member - elif row in member.dict["called_by"]: - m = member - else: - continue - return m - - - MAIN_MENU = { - "label": "", - "rows": [ - RowType.SERVER_BROWSER, - RowType.SAVED_SERVERS, - RowType.QUICK_CONNECT, - RowType.RECENT_SERVERS, - RowType.CONN_BY_IP, - RowType.CONN_BY_ID, - RowType.SCAN_LAN - ], - "called_by": [] - } - MANAGE = { - "label": "Manage", - "rows": [ - RowType.ADD_BY_IP, - RowType.ADD_BY_ID, - RowType.CHNG_FAV - ], - "called_by": [] - } - OPTIONS = { - "label": "Options", - "rows":[ - RowType.LIST_MODS, - RowType.TGL_BRANCH, - RowType.TGL_INSTALL, - RowType.TGL_STEAM, - RowType.TGL_FULLSCREEN, - RowType.CHNG_PLAYER, - RowType.CHNG_STEAM_API, - RowType.CHNG_BM_API, - RowType.FORCE_UPDATE, - RowType.DUMP_LOG - ], - "called_by": [] - } - HELP = { - "label": "Help", - "rows":[ - RowType.CHANGELOG, - RowType.SHOW_LOG, - RowType.DOCS, - RowType.DOCS_FALLBACK, - RowType.BUGS, - RowType.FORUM, - RowType.SPONSOR, - ], - "called_by": [] - } - # inner server contexts - TABLE_API = { - "label": "", - "rows": [], - "called_by": [ - RowType.SERVER_BROWSER - ], - } - TABLE_SERVER = { - "label": "", - "rows": [], - "called_by": [ - RowType.SAVED_SERVERS, - RowType.RECENT_SERVERS, - RowType.SCAN_LAN - ], - } - TABLE_MODS = { - "label": "", - "rows": [], - "called_by": [ - RowType.LIST_MODS, - ], - } - TABLE_LOG = { - "label": "", - "rows": [], - "called_by": [ - RowType.SHOW_LOG - ], - } - - -class WidgetType(Enum): - OUTER_WIN = 1 - TREEVIEW = 2 - GRID = 3 - RIGHT_PANEL = 4 - MOD_PANEL = 5 - FILTER_PANEL = 6 +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. +""" class Port(Enum): @@ -413,978 +98,1836 @@ class Popup(Enum): NOTIFY = 2 CONFIRM = 3 ENTRY = 4 + RETURN = 5 + MODLIST = 6 + DETAILS = 7 + + +class NotebookPage(Enum): + MAIN = 0 + CHANGELOG = 1 + KEYS = 2 + SETTINGS = 3 + + +class Command(Enum): + INTERACTIVE = 1 + ONESHOT = 2 + HELP = 3 + TOGGLE = 4 + + +class VAdjustment(Enum): + UP = 1 + DOWN = 2 + TOP = 3 + BOTTOM = 4 + + +class CursorPosition(Enum): + UP = 1 + DOWN = 2 + TOP = 3 + BOTTOM = 4 + + +class FilterMode(Enum): + KEYWORD = 1 + MAP = 2 + INITIAL = 3 + TOGGLE_OFF = 4 + TOGGLE_ON = 5 + + +class EnumWithAttrs(Enum): + def __new__(cls, *args, **kwargs) -> Self: + value = len(cls.__members__) + 1 + obj = object.__new__(cls) + obj._value_ = value + return obj + + def __init__(self, d: dict): + self.dict = d + + +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 + 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": "List installed mods", + "tooltip": "Browse a list of locally-installed mods", + "quad_label": "Mods", + "type": "mods", + } + CHANGELOG = { + "label": "View changelog", + "tooltip": "Opens the DZGUI changelog in a dialog window", + } + 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, + } + 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, + } + + # settings toggles + TGL_BRANCH = { + "label": "Toggle release branch", + "tooltip": "Switch between stable and testing branches", + "default": None, + "val": "branch", + "type": Command.TOGGLE, + } + TGL_INSTALL = { + "label": "Toggle mod install mode", + "tooltip": "Switch between manual and auto mod installation", + "default": "manual", + "link_label": "Open Steam Workshop", + "alt": "auto", + "val": "auto_install", + "type": Command.TOGGLE, + } + TGL_STEAM = { + "label": "Toggle Steam/Flatpak", + "tooltip": "Switch the preferred client to use for launching DayZ", + "alt": None, + "default": None, + "val": "preferred_client", + "type": Command.TOGGLE, + } + TGL_FULLSCREEN = { + "label": "Toggle DZGUI fullscreen boot", + "tooltip": "Whether to start DZGUI as a maximized window (desktop only)", + "alt": "true", + "default": "false", + "val": "fullscreen", + "type": Command.TOGGLE, + } + + # 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, + } + DELETE_SELECTED = { + "label": "Delete selected mods", + "tooltip": None, + "wait_msg": "Deleting mods", + "type": Command.ONESHOT, + } + + # help pages + DOCS = { + "label": "Documentation/help files (GitHub) ⧉", + "tooltip": "Opens the DZGUI documentation in a browser", + "type": Command.HELP, + } + DOCS_FALLBACK = { + "label": "Documentation/help files (Codeberg mirror) ⧉", + "tooltip": "Opens the DZGUI documentation in a browser", + "type": Command.HELP, + } + BUGS = { + "label": "Report a bug (GitHub) ⧉", + "tooltip": "Opens the DZGUI issue tracker in a browser", + "type": Command.HELP, + } + FORUM = { + "label": "DZGUI Subreddit ⧉", + "tooltip": "Opens the DZGUI discussion forum in a browser", + "type": Command.HELP, + } + SPONSOR = { + "label": "Sponsor (GitHub) ⧉", + "tooltip": "Sponsor the developer of DZGUI", + "type": Command.HELP, + } + + +class ContextMenu(EnumWithAttrs): + """ + Calls methods defined in TreeView + """ + + ADD_SERVER = {"label": "Add to my servers", "action": "add_server"} + REMOVE_SERVER = { + "label": "Remove from my servers", + "action": "remove_server", + } + COPY_NAME = {"label": "Copy name to clipboard", "action": "copy_name"} + COPY_CLIPBOARD = { + "label": "Copy IP to clipboard", + "action": "copy_clipboard", + } + SHOW_MODS = {"label": "Show server-side mods", "action": "show_mods"} + SHOW_DETAILS = {"label": "Server details", "action": "show_details"} + REFRESH_PLAYERS = { + "label": "Refresh player count", + "action": "refresh_player_count", + } + REMOVE_HISTORY = { + "label": "Remove from history", + "action": "remove_from_history", + } + OPEN_WORKSHOP = { + "label": "Open in Steam Workshop", + "action": "open_workshop", + } + DELETE_MOD = {"label": "Delete mod", "action": "delete_mod"} + + +class WindowContext(EnumWithAttrs): + @classmethod + def row2con(cls, row: RowType) -> "WindowContext": + m = WindowContext.MAIN_MENU + for member in cls: + if row in member.dict["rows"]: + m = member + elif row in member.dict["called_by"]: + m = member + else: + continue + return m + + # outer menu pages + MAIN_MENU = { + "label": "Main menu", + "rows": [ + RowType.SERVER_BROWSER, + RowType.SAVED_SERVERS, + RowType.QUICK_CONNECT, + RowType.RECENT_SERVERS, + RowType.CONN_BY_IP, + RowType.CONN_BY_ID, + RowType.SCAN_LAN, + ], + "called_by": [], + } + MANAGE = { + "label": "Manage", + "rows": [RowType.ADD_BY_IP, RowType.ADD_BY_ID, RowType.CHNG_FAV], + "called_by": [], + } + OPTIONS = { + "label": "Options", + "rows": [ + RowType.LIST_MODS, + RowType.TGL_BRANCH, + RowType.TGL_INSTALL, + RowType.TGL_STEAM, + RowType.TGL_FULLSCREEN, + RowType.CHNG_PLAYER, + RowType.CHNG_STEAM_API, + RowType.CHNG_BM_API, + RowType.FORCE_UPDATE, + RowType.DUMP_LOG, + ], + "called_by": [], + } + HELP = { + "label": "Help", + "rows": [ + RowType.CHANGELOG, + RowType.SHOW_LOG, + RowType.DOCS, + RowType.DOCS_FALLBACK, + RowType.BUGS, + RowType.FORUM, + RowType.SPONSOR, + ], + "called_by": [], + } + + # inner server contexts + TABLE_API = { + "label": "", + "rows": [], + "called_by": [RowType.SERVER_BROWSER], + } + TABLE_SERVER = { + "label": "", + "rows": [], + "called_by": [ + RowType.SAVED_SERVERS, + RowType.RECENT_SERVERS, + RowType.SCAN_LAN, + ], + } + TABLE_MODS = { + "label": "", + "rows": [], + "called_by": [ + RowType.LIST_MODS, + ], + } + TABLE_LOG = { + "label": "", + "rows": [], + "called_by": [RowType.SHOW_LOG], + } class ButtonType(EnumWithAttrs): - MAIN_MENU = {"label": "Main menu", - "opens": WindowContext.MAIN_MENU, - "tooltip": "Search for and connect to servers" - } - MANAGE = {"label": "Manage", - "opens": WindowContext.MANAGE, - "tooltip": "Manage/add to saved servers" - } - OPTIONS = {"label": "Options", - "opens": WindowContext.OPTIONS, - "tooltip": "Change settings, list local mods and\nother advanced options" - } - HELP = {"label": "Help", - "opens": WindowContext.HELP, - "tooltip": "Links to documentation" - } - EXIT = {"label": "Exit", - "opens": None, - "tooltip": "Quits the application" - } + MAIN_MENU = { + "label": "Main menu", + "opens": WindowContext.MAIN_MENU, + "tooltip": "Search for and connect to servers", + } + MANAGE = { + "label": "Manage", + "opens": WindowContext.MANAGE, + "tooltip": "Manage/add to saved servers", + } + OPTIONS = { + "label": "Options", + "opens": WindowContext.OPTIONS, + "tooltip": "Change settings, list local mods and\nother advanced options", + } + HELP = { + "label": "Help", + "opens": WindowContext.HELP, + "tooltip": "Links to documentation", + } + EXIT = {"label": "Exit", "opens": None, "tooltip": "Quits the application"} -class EnumeratedButton(Gtk.Button): - @GObject.Property - def button_type(self): - return self._button_type - - @button_type.setter - def button_type(self, value): - self._button_type = value +def is_navkey(key: int) -> bool: + nav_keys = [ + Gdk.KEY_Down, + Gdk.KEY_Up, + Gdk.KEY_Page_Down, + Gdk.KEY_Page_Up, + Gdk.KEY_j, + Gdk.KEY_k, + Gdk.KEY_g, + Gdk.KEY_G, + ] + if key in nav_keys: + return True + return False -def relative_widget(child): - # returns collection of outer widgets relative to source widget - # chiefly used for transient modals and accessing non-adjacent widget methods - # positions are always relative to grid sub-children - # containers and nested buttons should never need to call this function directly - - grid = child.get_parent().get_parent() - treeview = grid.scrollable_treelist.treeview - outer = grid.get_parent() - - widgets = { - 'grid': grid, - 'treeview': treeview, - 'outer': outer - } - - supported = [ - "ModSelectionPanel", # Grid < RightPanel < ModSelectionPanel - "ButtonBox", # Grid < RightPanel < ButtonBox - "TreeView" # Grid < ScrollableTree < TreeView - ] - - if child.__class__.__name__ not in supported: - raise Exception("Unsupported child widget") - - return widgets +def call_bash_func(command: str, arg: str) -> None: + """ + Instantaneous system calls that open something + in the background (xdg-open) or serialize a file quickly + Contrast with call_out(), which should be called on a thread + """ + subprocess.Popen(["/usr/bin/env", "bash", funcs, command, arg]) -def pluralize(plural, count): +def load_css() -> None: + css = """ + .frame { + border: 0px; + } + .frame > border { + border-radius: 5px; + padding: 5px; + } + .page-heading { + font-size: 1.5rem; + font-weight: 800; + margin-bottom: 0.5rem; + } + .left-label { + font-size: 1.3rem; + } + .details-heading { + font-size: 1.2rem + } + """ + prov = Gtk.CssProvider() + prov.load_from_data(css.encode("ascii")) + screen = Gdk.Screen.get_default() + if screen: + Gtk.StyleContext.add_provider_for_screen( + screen, prov, Gtk.STYLE_PROVIDER_PRIORITY_USER + ) + + +def add_class(widget: Gtk.Widget, label: str) -> None: + """ + Sets the classname of a widget, used + to apply CSS styling later + """ + context = widget.get_style_context() + context.add_class(label) + + +def unblock_signals() -> None: + block_signals(False) + + +def block_signals(state: bool = True) -> None: + suppress_signal( + App.grid.right_panel.filters_vbox, + App.grid.right_panel.filters_vbox.maps_combo, + "_on_map_changed", + state, + ) + suppress_signal( + App.treeview, + App.treeview.selected_row, + "_on_tree_selection_changed", + state, + ) + suppress_signal(App.treeview, App.treeview, "_on_keypress", state) + for check in App.grid.right_panel.filters_vbox.checks: + suppress_signal( + App.grid.right_panel.filters_vbox, + check, + "_on_check_toggled", + state, + ) + + +def save_res_and_quit() -> None: + if App.window.props.is_maximized: + Gtk.main_quit() + return + rect = App.window.get_size() + + def write_json(rect): + data = {"res": {"width": rect.width, "height": rect.height}} + j = json.dumps(data, indent=2) + with open(res_path, "w") as outfile: + outfile.write(j) + logger.info(f"Wrote initial window size to '{res_path}'") + + if os.path.isfile(res_path): + with open(res_path, "r") as infile: + try: + data = json.load(infile) + data["res"]["width"] = rect.width + data["res"]["height"] = rect.height + with open(res_path, "w") as outfile: + outfile.write(json.dumps(data, indent=2)) + except json.decoder.JSONDecodeError: + logger.critical(f"JSON decode error in '{res_path}'") + write_json(rect) + else: + write_json(rect) + + Gtk.main_quit() + + +def suppress_signal( + owner: Gtk.Widget, widget: Gtk.Widget, func_name: str, state: bool +) -> None: + func = getattr(owner, func_name) + if state: + logger.debug(f"Unblocking {func_name} for {widget}") + widget.handler_block_by_func(func) + else: + logger.debug(f"Blocking {func_name} for {widget}") + widget.handler_unblock_by_func(func) + + +def pluralize(plural: str, count: int) -> str: suffix = plural[-2:] if suffix == "es": base = plural[:-2] - return f"%s{'es'[:2*count^2]}" %(base) + return f"{base}{'es'[:2*count ^ 2]}" else: base = plural[:-1] - return f"%s{'s'[:count^1]}" %(base) + return f"{base}{'s'[:count ^ 1]}" -def format_ping(ping): - ms = " | Ping: %s" %(ping) - return ms +def format_metadata(row_sel: str) -> str: + row = None + for i in RowType: + if i.dict["label"] == row_sel: + row = i + prefix = i.dict["tooltip"] + break + vals = { + "branch": config_vals[0], + "debug": config_vals[1], + "auto_install": config_vals[2], + "name": config_vals[3], + "fav_label": config_vals[4], + "preferred_client": config_vals[5], + "fullscreen": config_vals[6], + } + if row is None: + return "" + + try: + alt = row.dict["alt"] + default = row.dict["default"] + val = row.dict["val"] + except KeyError: + return prefix + + try: + cur_val = vals[val] + if cur_val == "": + current = default + elif cur_val == "1": + current = alt + else: + current = cur_val + return f"{prefix} | Current: '{current}'" + except KeyError: + return prefix -def format_distance(distance): - if distance == "Unknown": - distance = "| Distance: %s" %(distance) - else: - d = int(distance) - formatted = f'{d:n}' - distance = "| Distance: %s km" %(formatted) - return distance +def signal_emission(func: Callable) -> Callable: + def wrapper(*args, **kwargs): + block_signals() + func(*args, **kwargs) + unblock_signals() + + return wrapper -def set_surrounding_margins(widget, margin): +def update_window_labels(func: Callable) -> Callable: + """ + Decorator that sets metadata on the + current page context and subcontext + """ + + def wrapper(*args, **kwargs): + if not App.ready: + return + func(*args, **kwargs) + page_context = App.treeview.page.dict["label"] + text = page_context + App.window.hb.set_subtitle(page_context) + + if App.treeview.subpage == RowType.KEYBINDINGS: + text = App.treeview.subpage.dict["label"] + elif App.treeview.subpage: + text = page_context + " > " + App.treeview.subpage.dict["label"] + + App.grid.set_breadcrumbs(text) + App.grid.statusbar.refresh() + + logger.info(f"Window context changed to: {App.treeview.view}") + logger.info(f"Page context changed to: {App.treeview.page}") + logger.info(f"Subpage context changed to: {App.treeview.subpage}") + + return wrapper + + +def set_surrounding_margins(widget: Gtk.Widget, margin: int) -> None: + """ + Utility function that sets all margins + on a widget to a uniform integer value + """ widget.set_margin_top(margin) widget.set_margin_start(margin) widget.set_margin_end(margin) -def parse_modlist_rows(data): - lines = data.stdout.splitlines() - hits = len(lines) - reader = csv.reader(lines, delimiter=delimiter) +def query_history() -> list | None: try: - rows = [[row[0], row[1], row[2]] for row in reader if row] - except IndexError: - return 1 - for row in rows: - modlist_store.append(row) - return hits + with open(history_file, "r") as f: + rows = [row for row in f] + except OSError: + rows = None + finally: + return rows -def parse_log_rows(data): - lines = data.stdout.splitlines() - reader = csv.reader(lines, delimiter=delimiter) - try: - rows = [[row[0], row[1], row[2], row[3]] for row in reader if row] - except IndexError: - return 1 - for row in rows: - log_store.append(row) +def query_favorites() -> None | list: + proc = call_out("query_favorites") + if proc.returncode == 1: + return None + rows = proc.stdout.splitlines() + return rows -def parse_mod_rows(data): - # GTK pads trailing zeroes on floats - # https://stackoverflow.com/questions/26827434/gtk-cellrenderertext-with-format - sum = 0 - lines = data.stdout.splitlines() - hits = len(lines) - reader = csv.reader(lines, delimiter=delimiter) - # Nonetype inherits default GTK color - try: - rows = [[row[0], row[1], row[2], locale.atof(row[3], func=float), None] for row in reader if row] - except IndexError: - return 1 - for row in rows: - mod_store.append(row) - size = float(row[3]) - sum += size - return [sum, hits] - - -def parse_server_rows(data): - lines = data.stdout.splitlines() - reader = csv.reader(lines, delimiter=delimiter) - try: - rows = [[row[0], row[1], row[2], row[3], int(row[4]), int(row[5]), int(row[6]), row[7], int(row[8]), row[9]] for row in reader if row] - except IndexError: - return 1 - for row in rows: - server_store.append(row) - - -def query_config(widget, key=""): - proc = call_out(widget, "query_config", key) +def query_config(key: str = "") -> list: + proc = call_out("query_config", key) config = list(proc.stdout.splitlines()) - return (config) + return config -def call_out(widget, command, *args): - if widget is not None: - widget_name = widget.get_name() - try: - widget_name = widget_name.split('+')[1] - match widget_name: - case "TreeView": - context = widget.get_first_col() - case "ScrollableTree": - context = widget.treeview.get_first_col() - case "OuterWindow": - context = widget.grid.scrollable_treelist.treeview.get_first_col() - case "Grid": - context = widget.scrollable_treelist.treeview.get_first_col() - except IndexError: - context = "Generic" +def call_out(command: str, *args: str) -> subprocess.CompletedProcess: + if hasattr(TreeView, "view"): + name = getattr(TreeView, "view") else: - context = "Generic" + name = "Generic" arg_ar = [] for i in args: arg_ar.append(i) - logger.info("Context '%s' calling subprocess '%s' with args '%s'" %(context, command, arg_ar)) - proc = subprocess.run(["/usr/bin/env", "bash", funcs, command] + arg_ar, capture_output=True, text=True) + logger.info( + f"Context '{name}' calling subprocess '{command}' with args '{arg_ar}'" + ) + proc = subprocess.run( + ["/usr/bin/env", "bash", funcs, command] + arg_ar, + capture_output=True, + text=True, + ) return proc -def spawn_dialog(transient_parent, msg, mode): - dialog = GenericDialog(transient_parent, msg, mode) +def spawn_dialog(msg: str, mode: Popup) -> bool: + msg = textwrap.dedent(msg) + dialog = GenericDialog(msg, mode) response = dialog.run() dialog.destroy() + match response: case Gtk.ResponseType.OK: - logger.info("User confirmed dialog with message '%s'" %(msg)) - return 0 + logger.info(f"User confirmed dialog with message '{msg}'") + return False case Gtk.ResponseType.CANCEL | Gtk.ResponseType.DELETE_EVENT: - logger.info("User aborted dialog with message '%s'" %(msg)) - return 1 + logger.info(f"User aborted dialog with message '{msg}'") + return True + return False -def process_shell_return_code(transient_parent, msg, code, original_input): - logger.info("Processing return code '%s' for the input '%s', returned message '%s'" %(code, original_input, msg)) +def process_shell_return_code( + msg: str, code: int, original_input: RowType +) -> None: + logger.info( + f"Processing return code '{code}' for the input " + f"'{original_input}', returned message '{msg}'" + ) match code: - case 0: - # success with notice popup - spawn_dialog(transient_parent, msg, Popup.NOTIFY) - case 1: - # error with notice popup + case 0: # success with notice popup + spawn_dialog(msg, Popup.NOTIFY) + case 1: # error with notice popup if msg == "": msg = "Something went wrong" - spawn_dialog(transient_parent, msg, Popup.NOTIFY) - case 2: - # warn and recurse (e.g. validation failed) - spawn_dialog(transient_parent, msg, Popup.NOTIFY) - treeview = transient_parent.grid.scrollable_treelist.treeview - process_tree_option(original_input, treeview) - case 4: - # for BM only - spawn_dialog(transient_parent, msg, Popup.NOTIFY) - treeview = transient_parent.grid.scrollable_treelist.treeview - process_tree_option([treeview.view, RowType.CHNG_BM_API], treeview) - case 5: - # for steam only - # deprecated, Steam is mandatory now - spawn_dialog(transient_parent, msg, Popup.NOTIFY) - treeview = transient_parent.grid.scrollable_treelist.treeview - process_tree_option([treeview.view, RowType.CHNG_STEAM_API], treeview) - case 6: - # return silently + spawn_dialog(msg, Popup.NOTIFY) + case 2: # warn and recurse (e.g. validation failed) + spawn_dialog(msg, Popup.NOTIFY) + process_tree_option(original_input) + case 4: # for BM only + spawn_dialog(msg, Popup.NOTIFY) + process_tree_option(RowType.CHNG_BM_API) + case 5: # for steam only, deprecated + spawn_dialog(msg, Popup.NOTIFY) + process_tree_option(RowType.CHNG_STEAM_API) + case 6: # return silently pass - case 90: - # used to update configs and metadata in-place - treeview = transient_parent.grid.scrollable_treelist.treeview - col = treeview.get_column_at_index(0) + case 90: # used to update configs and metadata in-place config_vals.clear() - for i in query_config(None): + for i in query_config(): config_vals.append(i) - tooltip = format_metadata(col) - transient_parent.grid.update_statusbar(tooltip) - spawn_dialog(transient_parent, msg, Popup.NOTIFY) + App.grid.statusbar.refresh() + spawn_dialog(msg, Popup.NOTIFY) return - case 95: - # successful mod deletion - spawn_dialog(transient_parent, msg, Popup.NOTIFY) - treeview = transient_parent.grid.scrollable_treelist.treeview - grid = treeview.get_parent().get_parent() - (model, pathlist) = treeview.get_selection().get_selected_rows() - for p in reversed(pathlist): - it = model.get_iter(p) - model.remove(it) - total_size = 0 - total_mods = len(model) - for row in model: - total_size += row[3] - size = locale.format_string('%.3f', total_size, grouping=True) - pretty = pluralize("mods", total_mods) - grid.update_statusbar(f"Found {total_mods:n} {pretty} taking up {size} MiB") - # untoggle selection for visibility of other stale rows - treeview.toggle_selection(False) - case 96: - # unsuccessful mod deletion - spawn_dialog(transient_parent, msg, Popup.NOTIFY) + case 95: # successful mod deletion + spawn_dialog(msg, Popup.NOTIFY) + App.treeview._update_mod_store() + case 96: # unsuccessful mod deletion + spawn_dialog(msg, Popup.NOTIFY) # re-block this signal before redrawing table contents - treeview = transient_parent.grid.scrollable_treelist.treeview - toggle_signal(treeview, treeview, '_on_keypress', False) - treeview.update_quad_column(RowType.LIST_MODS) - case 99: - # highlight stale mods - panel = transient_parent.grid.sel_panel + suppress_signal(App.treeview, App.treeview, "_on_keypress", False) + App.treeview.update_quad_column(RowType.LIST_MODS) + case 99: # highlight stale mods + panel = App.grid.sel_panel panel.colorize_cells(True) - panel.toggle_select_stale_button(True) - case 100: - # final handoff before launch - final_conf = spawn_dialog(transient_parent, msg, Popup.CONFIRM) - treeview = transient_parent.grid.scrollable_treelist.treeview + case 100: # final handshake before launch + final_conf = spawn_dialog(msg, Popup.CONFIRM) if final_conf == 1 or final_conf is None: return - process_tree_option([treeview.view, RowType.HANDSHAKE], treeview) - case 255: - spawn_dialog(transient_parent, "Update complete. Please close DZGUI and restart.", Popup.NOTIFY) - save_res_and_quit(transient_parent) + process_tree_option(RowType.HANDSHAKE) + case 255: # dzgui version update + msg = "Update complete. Please close DZGUI and restart." + spawn_dialog(msg, Popup.NOTIFY) + save_res_and_quit() -def process_tree_option(input, treeview): - context = input[0] - command = input[1] - cmd_string = command.dict["label"] - logger.info("Parsing tree option '%s' for the context '%s'" %(command, context)) - - widgets = relative_widget(treeview) - transient_parent = widgets["outer"] - grid = widgets["grid"] - - def call_on_thread(bool, subproc, msg, args): - def _background(subproc, args, dialog): - def _load(): - wait_dialog.destroy() - out = proc.stdout.splitlines() - try: - msg = out[-1] - except: - msg = '' - rc = proc.returncode - logger.info("Subprocess returned code %s with message '%s'" %(rc, msg)) - process_shell_return_code(transient_parent, msg, rc, input) - proc = call_out(transient_parent, subproc, args) - GLib.idle_add(_load) - if bool is True: - wait_dialog = GenericDialog(transient_parent, msg, Popup.WAIT) - wait_dialog.show_all() - thread = threading.Thread(target=_background, args=(subproc, args, wait_dialog)) - thread.start() - else: - # False is used to bypass wait dialogs - proc = call_out(transient_parent, subproc, args) - rc = proc.returncode +def call_on_thread( + state: bool, subproc: str, msg: str, args: str, choice: RowType = None +) -> None: + def _background(subproc: str, args: str, dialog): + def _load() -> None: + wait_dialog.destroy() out = proc.stdout.splitlines() - msg = out[-1] - process_shell_return_code(transient_parent, msg, rc, input) + try: + msg = out[-1] + except IndexError: + msg = "" + rc = proc.returncode + logger.info(f"Subprocess returned code {rc} with message '{msg}'") + process_shell_return_code(msg, rc, choice) + proc = call_out(subproc, args) + GLib.idle_add(_load) + + if state: + wait_dialog = GenericDialog(msg, Popup.WAIT) + wait_dialog.show_all() + thread = threading.Thread( + target=_background, args=(subproc, args, wait_dialog) + ) + thread.start() + else: + """ + False is used to bypass wait dialogs; + used by fast, one-shot processes + """ + proc = call_out(subproc, args) + rc = proc.returncode + out = proc.stdout.splitlines() + msg = out[-1] + process_shell_return_code(msg, rc, choice) + + +def process_tree_option(choice: RowType) -> None: + context = App.treeview.view + command = choice + cmd_string = command.dict["label"] + logger.info(f"Parsing tree option '{command}' for the context '{context}'") + + # server tables if command == RowType.RESOLVE_IP: - record = "%s:%s" %(treeview.get_column_at_index(7), treeview.get_column_at_index(8)) + record = App.treeview.get_record_string() wait_msg = command.dict["wait_msg"] - call_on_thread(True, cmd_string, wait_msg, record) + show_wait_dialog = True + call_on_thread( + show_wait_dialog, cmd_string, wait_msg, record, choice=choice + ) return - # help pages + + # modlist highlight stale action if context == WindowContext.TABLE_MODS and command == RowType.HIGHLIGHT: wait_msg = command.dict["wait_msg"] - call_on_thread(True, cmd_string, wait_msg, '') - return - if context == WindowContext.HELP: - match command: - case RowType.CHANGELOG: - diag = ChangelogDialog(transient_parent) - diag.run() - diag.destroy() - case _: - base_cmd = "Open link" - arg_string = cmd_string - subprocess.Popen(['/usr/bin/env', 'bash', funcs, base_cmd, arg_string]) - pass + show_wait_dialog = True + call_on_thread( + show_wait_dialog, cmd_string, wait_msg, "", choice=choice + ) return - # config metadata toggles - toggle_commands = [ - RowType.TGL_INSTALL, - RowType.TGL_BRANCH, - RowType.TGL_STEAM, - RowType.TGL_FULLSCREEN - ] - - if command in toggle_commands: - match command: - case RowType.TGL_BRANCH: - wait_msg = "Updating DZGUI branch" - call_on_thread(False, "toggle", wait_msg, cmd_string) - case RowType.TGL_INSTALL: - if query_config(None, "auto_install")[0] == "1": - proc = call_out(transient_parent, "toggle", cmd_string) - grid.update_right_statusbar() - tooltip = format_metadata(command.dict["label"]) - transient_parent.grid.update_statusbar(tooltip) - return - # manual -> auto mode - proc = call_out(transient_parent, "find_id", "") - if proc.returncode == 1: - link=None - uid=None - else: - link=command.dict["link_label"] - uid=proc.stdout - 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' - on the right-hand side, then click OK below to enable AUTO mod install mode.""" - LinkDialog(transient_parent, textwrap.dedent(manual_sub_msg), Popup.NOTIFY, link, command, uid) - case _: - proc = call_out(transient_parent, "toggle", cmd_string) - grid.update_right_statusbar() - tooltip = format_metadata(command.dict["label"]) - transient_parent.grid.update_statusbar(tooltip) + if command == RowType.CHANGELOG: + App.grid.notebook.set_page_enum(NotebookPage.CHANGELOG) return - # entry dialogs - interactive_commands = [ - RowType.CONN_BY_IP, - RowType.CONN_BY_ID, - RowType.ADD_BY_IP, - RowType.ADD_BY_ID, - RowType.CHNG_FAV, - RowType.CHNG_PLAYER, - RowType.CHNG_STEAM_API, - RowType.CHNG_BM_API - ] - - if command in interactive_commands: - prompt = command.dict["prompt"] - flag = True - link_label = command.dict["link_label"] - wait_msg = "Working" - - user_entry = EntryDialog(transient_parent, prompt, Popup.ENTRY, link_label) - res = user_entry.get_input() - - if res is None: - logger.info("User aborted entry dialog") + match command.dict["type"]: + case Command.HELP: + call_bash_func("Open link", cmd_string) + case Command.TOGGLE: + process_toggle(command) + case Command.INTERACTIVE: + process_user_input(command) + case Command.ONESHOT: + wait_msg = command.dict["wait_msg"] + show_wait_dialog = True + call_on_thread(show_wait_dialog, cmd_string, wait_msg, "") + case _: return - logger.info("User entered: '%s'" %(res)) - - if command == RowType.CHNG_PLAYER: flag = False - call_on_thread(flag, cmd_string, wait_msg, res) - return - - # standalone commands - misc_commands = [ - RowType.DELETE_SELECTED, - RowType.HANDSHAKE, - RowType.DUMP_LOG, - RowType.FORCE_UPDATE, - RowType.QUICK_CONNECT - ] - if command in misc_commands: - wait_msg = command.dict["wait_msg"] - call_on_thread(True, cmd_string, wait_msg, '') return -def reinit_checks(): - toggled_checks.clear() - for check in checks: - label = check.get_label() - if filters[label] is True: - check.set_active(True) - toggled_checks.append(label) - else: - check.set_active(False) + +def process_toggle(command: RowType) -> None: + cmd_string = command.dict["label"] + match command: + case RowType.TGL_BRANCH: + wait_msg = "Updating DZGUI branch" + show_wait_dialog = False + call_on_thread(show_wait_dialog, "toggle", wait_msg, cmd_string) + case RowType.TGL_INSTALL: + if query_config("auto_install")[0] == "1": + proc = call_out("toggle", cmd_string) + App.grid.statusbar.refresh() + return + # manual -> auto mode + proc = call_out("find_id", "") + if proc.returncode == 1: + link = None + user_id = "" + else: + link = command.dict["link_label"] + user_id = proc.stdout + LinkDialog(manual_sub_msg, link, command, user_id) + case _: + proc = call_out("toggle", cmd_string) + App.grid.statusbar.refresh() + + +def process_user_input(enum: RowType) -> None: + prompt = enum.dict["prompt"] + link_label = enum.dict["link_label"] + cmd_string = enum.dict["label"] + + user_entry = EntryDialog(prompt, Popup.ENTRY, link_label) + response = user_entry.get_input() + + if response is None: + logger.info("User aborted entry dialog") + return + logger.info(f"User entered: '{response}'") + + show_wait_dialog = True + # this is the only instantaneous toggle, no wait dialog necessary + if enum == RowType.CHNG_PLAYER: + show_wait_dialog = False + wait_msg = "Working" + call_on_thread( + show_wait_dialog, cmd_string, wait_msg, response, choice=enum + ) + return class OuterWindow(Gtk.Window): - @GObject.Property - def widget_type(self): - return self._widget_type - - @widget_type.setter - def widget_type(self, value): - self._widget_type = value - - def __init__(self, is_steam_deck, is_game_mode): + def __init__(self): super().__init__(title=app_name) + App.ready = False self.hb = AppHeaderBar() + # steam deck taskbar may occlude elements - if is_steam_deck is False: + if not IS_STEAM_DECK: self.set_titlebar(self.hb) - self.set_property("widget_type", WidgetType.OUTER_WIN) - self.connect("delete-event", self.halt_proc_and_quit) + self.connect("key-press-event", self._on_keypress) + self.set_border_width(10) + self._set_resolution() - #app > win > grid > scrollable > treeview [row/server/mod store] - #app > win > grid > vbox > buttonbox > filterpanel > combo [map store] - - self.grid = Grid(is_steam_deck) + self.grid = Grid() self.add(self.grid) - if is_game_mode is True: + self.show_all() + + self.grid.right_panel.filters_vbox.set_visible(False) + self.grid.right_panel.enable_ping_button(False) + self.grid.sel_panel.set_visible(False) + + # convenience to avoid deep calls + App.window = self + App.grid = self.grid + App.notebook = self.grid.notebook + App.treeview = self.grid.scrollable_treelist.treeview + App.right_panel = self.grid.right_panel + + load_css() + App.ready = True + App.grid.notebook.set_page_enum(NotebookPage.MAIN) + App.treeview.grab_focus() + + def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: + if event.keyval is not Gdk.KEY_d: + return + if App.right_panel.filters_vbox.keyword_entry.is_focus(): + return + if App.right_panel.filters_vbox.maps_entry.is_focus(): + return + App.right_panel.toggle_debug() + + def _set_resolution(self) -> None: + if IS_GAME_MODE is True: self.fullscreen() - elif query_config(None, "fullscreen")[0] == "true": + return + elif query_config("fullscreen")[0] == "true": logger.info("User preference for 'fullscreen' is 'true'") self.maximize() - else: - if os.path.isfile(res_path): - with open(res_path, "r") as infile: - try: - data = json.load(infile) - valid_json = True - except json.decoder.JSONDecodeError: - logger.critical("JSON decode error in '%s'" %(res_path)) - valid_json = False - else: - valid_json = False - if valid_json: - res = data["res"] - w = res["width"] - h = res["height"] - logger.info("Restoring window size to %s,%s" %(w,h)) - self.set_default_size(w, h) + return - self.show_all() - # Hide FilterPanel on main menu - self.grid.right_panel.set_filter_visibility(False) - self.grid.sel_panel.set_visible(False) - self.grid.scrollable_treelist.treeview.grab_focus() + try: + with open(res_path, "r") as infile: + try: + data = json.load(infile) + valid_json = True + except json.decoder.JSONDecodeError: + logger.critical(f"JSON decode error in '{res_path}'") + valid_json = False + except OSError: + valid_json = False - def halt_proc_and_quit(self, window, event): - self.grid.terminate_treeview_process() - save_res_and_quit(window) + if valid_json: + res = data["res"] + w, h = res["width"], res["height"] + logger.info(f"Restoring window size to {w},{h}") + self.set_default_size(w, h) + + def halt_proc_and_quit(self) -> None: + App.grid.terminate_treeview_process() + save_res_and_quit() class ScrollableTree(Gtk.ScrolledWindow): - def __init__(self, is_steam_deck): + def __init__(self): super().__init__() - self.treeview = TreeView(is_steam_deck) + self.treeview = TreeView() self.add(self.treeview) -class RightPanel(Gtk.Box): - def __init__(self, is_steam_deck): - super().__init__(spacing=6) - self.set_orientation(Gtk.Orientation.VERTICAL) - self.button_vbox = ButtonBox(is_steam_deck) +class RightPanel(Gtk.Box): + def __init__(self): + super().__init__(spacing=6, orientation=Gtk.Orientation.VERTICAL) + + self.button_vbox = ButtonBox() self.filters_vbox = FilterPanel() - toggle_signal(self.filters_vbox, self.filters_vbox.maps_combo, '_on_map_changed', False) self.pack_start(self.button_vbox, False, False, 0) self.pack_start(self.filters_vbox, False, False, 0) - self.debug_toggle = Gtk.ToggleButton(label="Debug mode") - self.debug_toggle.set_tooltip_text("Used to perform a dry run without\nactually connecting to a server") + 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" + ) - if query_config(None, "debug")[0] == '1': + self.ping = Gtk.Button( + label="Ping servers", + margin_top=10, + margin_start=80, + margin_end=80, + tooltip_text=ping_tooltip, + ) + self.ping.connect("clicked", self._on_ping_clicked) + + self.debug_toggle = Gtk.ToggleButton( + label="Debug mode", + margin_top=10, + margin_start=80, + margin_end=80, + tooltip_text=debug_tooltip, + ) + + if query_config("debug")[0] == "1": self.debug_toggle.set_active(True) - self.debug_toggle.connect("toggled", self._on_button_toggled, "Toggle debug mode") - set_surrounding_margins(self.debug_toggle, 10) + self.debug_toggle.connect("toggled", self._on_debug_toggled) - self.question_button = Gtk.Button(label="?") - self.question_button.set_tooltip_text("Opens the keybindings dialog") - self.question_button.set_margin_top(10) - self.question_button.set_margin_start(50) - self.question_button.set_margin_end(50) - self.question_button.connect("clicked", self._on_button_clicked) + self.question = Gtk.Button( + label="?", + margin_top=10, + margin_start=80, + margin_end=80, + tooltip_text="Opens the keybindings dialog", + ) + self.question.connect("clicked", self._on_question_clicked) + self.pack_start(self.ping, False, True, 0) self.pack_start(self.debug_toggle, False, True, 0) - self.pack_start(self.question_button, False, True, 0) + self.pack_start(self.question, False, True, 0) - def _on_button_toggled(self, button, command): - grid = self.get_parent() - transient_parent = grid.get_parent() - call_out(transient_parent, "toggle", command) - grid.update_right_statusbar() - grid.scrollable_treelist.treeview.grab_focus() + def enable_ping_button(self, state: bool) -> None: + self.ping.set_visible(state) - def _on_button_clicked(self, button): - grid = self.get_parent() - grid.scrollable_treelist.treeview.spawn_keys_dialog(button) + def reinit_maps(self, rows: list) -> None: + map_store.clear() + map_store.append(["All maps"]) + self.selected = "All maps" + self.filters_vbox.set_unique_maps(rows) - def set_filter_visibility(self, bool): - self.filters_vbox.set_visible(bool) + def toggle_debug(self) -> None: + state = self.debug_toggle.get_active() + self.debug_toggle.set_active(not state) - def focus_button_box(self): - self.button_vbox.focus_button(0) + def _on_debug_toggled(self, button: Gtk.Button) -> None: + grid = App.grid + call_out("toggle", "Toggle debug mode") + grid.statusbar.refresh() + App.grid.notebook.focus_current() - def set_active_combo(self): - self.filters_vbox.set_active_combo() + def _on_ping_clicked(self, button: Gtk.Button) -> None: + block_signals() + + def _update_pings(): + 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 + App.treeview.set_model(temp_model) + App.treeview.wait_dialog.destroy() + App.treeview.enable_ping_column(True) + App.treeview.grab_focus() + App.right_panel.ping.set_sensitive(False) + + unblock_signals() + + temp_model = App.treeview.get_model() + App.treeview.set_model(None) + App.treeview.wait_dialog = GenericDialog("Pinging servers", Popup.WAIT) + App.treeview.wait_dialog.show_all() + thread = threading.Thread(target=_update_pings, args=()) + thread.start() + + def _on_question_clicked(self, button: Gtk.Button) -> None: + App.grid.notebook.toggle_keybindings() + + def focus_button_box(self) -> None: + self.button_vbox.buttons[0].grab_focus() class ButtonBox(Gtk.Box): - def __init__(self, is_steam_deck): - super().__init__(spacing=6) - self.set_orientation(Gtk.Orientation.VERTICAL) - set_surrounding_margins(self, 10) + def __init__(self): + super().__init__( + spacing=6, + margin_top=0, + margin_start=10, + margin_end=10, + orientation=Gtk.Orientation.VERTICAL, + ) self.buttons = list() - self.is_steam_deck = is_steam_deck + self.connect("key-press-event", self._on_keypress) for side_button in ButtonType: - button = EnumeratedButton(label=side_button.dict["label"]) - button.set_property("button_type", side_button) + button = Gtk.Button(label=side_button.dict["label"]) + button.type = side_button button.set_tooltip_text(side_button.dict["tooltip"]) - if is_steam_deck is True: + if IS_STEAM_DECK: button.set_size_request(10, 10) else: - button.set_size_request(50,50) - #TODO: explore a more intuitive way of highlighting the active context - button.set_opacity(0.6) + button.set_size_request(50, 50) self.buttons.append(button) button.connect("clicked", self._on_selection_button_clicked) self.pack_start(button, False, False, 0) - self.buttons[0].set_opacity(1.0) - - def _update_single_column(self, context): - logger.info("Returning from multi-column view to monocolumn view for the context '%s'" %(context)) - widgets = relative_widget(self) - - # only applicable when returning from mod list - grid = widgets["grid"] - grid_last_child = grid.right_panel.get_children()[-1] - if isinstance(grid_last_child, ModSelectionPanel): - grid.sel_panel.set_visible(False) - right_panel = self.get_parent() - right_panel.set_filter_visibility(False) - - treeview = widgets["treeview"] - treeview.set_selection_mode(Gtk.SelectionMode.SINGLE) - - # Block maps combo when returning to main menu - toggle_signal(right_panel.filters_vbox, right_panel.filters_vbox.maps_combo, '_on_map_changed', False) - right_panel.filters_vbox.keyword_entry.set_text("") - keyword_filter.clear() - keyword_filter.append("Keyword␞") - server_store.clear() - - for column in treeview.get_columns(): - treeview.remove_column(column) - # used as a convenience for Steam Deck if it has no titlebar - for i, column_title in enumerate([context.dict["label"]]): - renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn(column_title, renderer, text=i) - treeview.append_column(column) - - if self.is_steam_deck is False: - treeview.set_headers_visible(False) - - self._populate(context.dict["opens"]) - toggle_signal(treeview, treeview, '_on_keypress', False) - treeview.set_model(row_store) - treeview.grab_focus() - - def _populate(self, context): - widgets = relative_widget(self) - treeview = widgets["treeview"] - grid = widgets["grid"] - window = widgets["outer"] - - # set global window context - treeview.view = context - - row_store.clear() - array = context.dict["rows"] - - window.hb.set_subtitle(context.dict["label"]) - - for item in array: - label = item.dict["label"] - tooltip = item.dict["tooltip"] - t = (label, ) - row_store.append(t) - grid.update_statusbar(tooltip) - treeview.grab_focus() - - def _on_selection_button_clicked(self, button): - treeview = self.get_treeview() - toggle_signal(treeview, treeview.selected_row, '_on_tree_selection_changed', False) - context = button.get_property("button_type") - logger.info("User clicked '%s'" %(context)) + @signal_emission + def _on_selection_button_clicked(self, button: Gtk.Button) -> None: + context = button.type + logger.info(f"User clicked '{context}'") if context == ButtonType.EXIT: logger.info("Normal user exit") - widgets = relative_widget(self) - window = widgets["outer"] - save_res_and_quit(window) + save_res_and_quit() return - cols = treeview.get_columns() + cols = App.treeview.get_columns() if len(cols) > 1: - self._update_single_column(context) + # restores tree from multi column view to main menu + App.treeview.update_single_column(context) + return - # Highlight the active widget - for inactive_button in self.buttons: - inactive_button.set_opacity(0.6) - button.set_opacity(1.0) + App.treeview._populate(context.dict["opens"]) + cols[0].set_title(context.dict["label"]) - for col in cols: - col.set_title(context.dict["label"]) + def _walk_buttons(self, increment: int) -> None: + for i, button in enumerate(self.buttons): + if button.is_focus(): + n = i + increment + if n == len(self.buttons): + return + if n == -1: + return + n = self.buttons[n] + n.grab_focus() + return - # get destination WindowContext enum from button - self._populate(context.dict["opens"]) - - toggle_signal(treeview, treeview.selected_row, '_on_tree_selection_changed', True) - - def focus_button(self, index): - self.buttons[index].grab_focus() - - def get_treeview(self): - grid = self.get_parent().get_parent() - treeview = grid.scrollable_treelist.treeview - return treeview + def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: + match event.keyval: + case Gdk.KEY_h: + App.notebook.focus_current() + case Gdk.KEY_j: + self._walk_buttons(1) + case Gdk.KEY_k: + self._walk_buttons(-1) + case Gdk.KEY_question: + App.grid.notebook.toggle_keybindings() class CalcDist(multiprocessing.Process): - def __init__(self, widget, addr, qport, result_queue, cache): + def __init__( + self, + widget: Gtk.Widget, + addr: str, + result_queue: multiprocessing.Queue, + cache: dict, + ): super().__init__() self.widget = widget self.result_queue = result_queue self.addr = addr - self.qport = str(qport) - self.ip = addr.split(':')[0] + self.ip = addr.split(":")[0] - def run(self): + def run(self) -> None: if self.addr in cache: - logger.info("Address '%s' already in cache" %(self.addr)) - self.result_queue.put([self.addr, cache[self.addr][0], cache[self.addr][1]]) + logger.info(f"Address '{self.addr}' already in cache") + self.result_queue.put([self.addr, cache[self.addr]]) return - proc = call_out(self.widget, "get_dist", self.ip) - proc2 = call_out(self.widget, "test_ping", self.ip, self.qport) + proc = call_out("get_dist", self.ip) km = proc.stdout - ping = proc2.stdout - self.result_queue.put([self.addr, km, ping]) + self.result_queue.put([self.addr, km]) + + +class ModelManagerSingleton: + """ + Manages access to ListStore cache resources and + performs filtering on behalf of TreeView. + + Filtration to and from ListStore format is + delegated to this singleston. + + Not thread-safe. + """ + + def __init__(self): + # packed ListStores + self.filter_cache = {} + self.ping_cache = {} + # stringwise (list) representation of the model + self.control_model = None + self.filtered = None + self.success = True + + def __new__(cls): + if not hasattr(cls, "instance"): + cls.instance = super(ModelManagerSingleton, cls).__new__(cls) + return cls.instance + + 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 = App.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(App.treeview._filter_cleanup) + return + + match mode: + case FilterMode.INITIAL: + rows = self.filter_initial(filters) + + case FilterMode.MAP: + panel = App.right_panel.filters_vbox + prior_map = panel.get_prior_map() + + if prior_map == "All maps": + rows = self.filter_map(filters) + else: + App.right_panel.ping.set_sensitive(True) + rows = self.filter_toggle_on(filters, *args) + + case FilterMode.KEYWORD: + App.right_panel.ping.set_sensitive(True) + 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: + App.right_panel.ping.set_sensitive(True) + 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 = ModelManager.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(App.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 = App.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 = App.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 = {"3PP": "1PP", "Day": "Night", "Official": "Unoffic."} + 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 "3PP": + rows = [row for row in rows if row[2] != "3PP"] + case "1PP": + rows = [row for row in rows if row[2] != "1PP"] + case "Official": + rows = [row for row in rows if row[10] != "Official"] + case "Unoffic.": + rows = [row for row in rows if row[10] != "Unoffic."] + case "Empty": + rows = [row for row in rows if row[4] != 0] + case "Full": + rows = [row for row in rows if row[4] != row[5]] + case "Duplicate": + seen = [] + final = [] + for row in rows: + if row[0] in seen: + continue + seen.append(row[0]) + final.append(row) + rows = final + case "Day": + reg = r"([0][0-9]|[1][0-6])" + rows = [row for row in rows if not re.match(reg, row[3])] + case "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 "Non-ASCII": + rows = [row for row in rows if row[0].isascii()] + case "Low pop": + rows = [row for row in rows if (row[4] / row[5] * 100) > 30] + case "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: Gtk.ListStore | None, rows: list + ) -> None: + self.filter_cache[filters] = (model, rows) + + def new_model(self) -> Gtk.ListStore: + return Gtk.ListStore( + str, str, str, str, int, int, int, str, int, int, str, bool + ) + + 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 = App.right_panel.filters_vbox.get_filters() + refiltered = self.filter_toggle_on(filters) + self.set_filtered(refiltered) + self.set_success(True) + GLib.idle_add(App.treeview._filter_cleanup) + + def convert_model_to_list(self, model: Gtk.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: Gtk.ListStore | None) -> None: + self.store = model + + def get_store(self) -> Gtk.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 class TreeView(Gtk.TreeView): - __gsignals__ = {"on_distcalc_started": (GObject.SignalFlags.RUN_FIRST, None, ())} - @GObject.Property - def widget_type(self): - return self._widget_type + __gsignals__ = { + "on_distcalc_started": (GObject.SignalFlags.RUN_FIRST, None, ()) + } - @widget_type.setter - def widget_type(self, value): - self._widget_type = value - - def __init__(self, is_steam_deck): + def __init__(self): super().__init__() - self.set_property("widget_type", WidgetType.TREEVIEW) + """ + Since some views like TABLE_SERVER recycle + the same model type, self.view corresponds + to an enumeration of the current model in the tree. + + By contrast, self.page and self.subpage + correspond to user-facing navigation contexts, e.g., + Main menu (page) > Server browser (subpage) + + This is used by: + - logs + - labels like breadcrumbs and the headerbar subtitle + - methods passing contextual "you are here" info to helpers + + When on a top-level menu (WindowContext.MAIN_MENU), + the subpage is None. + """ + self.view = WindowContext.MAIN_MENU + self.page = WindowContext.MAIN_MENU + self.subpage = None + + self.set_fixed_height_mode(True) self.queue = multiprocessing.Queue() self.current_proc = None - # Disables typeahead search + # disables typeahead search self.set_enable_search(False) self.set_search_column(-1) - # Populate model with initial context + # populate model with initial context for row in WindowContext.MAIN_MENU.dict["rows"]: label = row.dict["label"] t = (label,) row_store.append(t) self.set_model(row_store) - for i, column_title in enumerate( - ["Main menu"] - ): - renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn(column_title, renderer, text=i) - self.append_column(column) + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn("Main menu", renderer, text=0) + column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + self.append_column(column) - if is_steam_deck is False: - self.set_headers_visible(False) - self.connect("row-activated", self._on_row_activated) - self.connect("key-press-event", self._on_keypress) - self.connect("key-press-event", self._on_keypress_main_menu) - toggle_signal(self, self, '_on_keypress', False) + self.set_headers_visible(False) self.selected_row = self.get_selection() self.selected_row.connect("changed", self._on_tree_selection_changed) self.connect("button-release-event", self._on_button_release) + self.connect("row-activated", self._on_row_activated) - def terminate_process(self): + self.connect("key-press-event", self._on_keypress) + self.connect("key-release-event", self._on_key_release) + + def _update_mod_store(self) -> None: + (model, pathlist) = self.get_selection().get_selected_rows() + for p in reversed(pathlist): + it = model.get_iter(p) + model.remove(it) + total_size = 0 + total_mods = len(model) + for row in model: + total_size += row[3] + size = locale.format_string("%.3f", total_size, grouping=True) + pretty = pluralize("mods", total_mods) + App.grid.statusbar.set_text( + f"Found {total_mods:n} {pretty} taking up {size} MiB" + ) + # untoggle selection for visibility of other stale rows + self.toggle_selection(False) + + def get_subpage_label(self) -> None: + if self.subpage: + return self.subpage.dict["label"] + return None + + def get_subpage(self) -> RowType: + return self.subpage + + def terminate_process(self) -> None: if self.current_proc and self.current_proc.is_alive(): self.current_proc.terminate() - def _on_menu_click(self, menu_item): - #TODO: context menus use old stringwise parsing - # use enumerated contexts - parent = self.get_outer_window() - context = self.get_first_col() - value = self.get_column_at_index(0) - context_menu_label = menu_item.get_label() - logger.info("User clicked context menu '%s'" %(context_menu_label)) + def copy_name(self) -> None: + self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + record = self.get_name() + self.clipboard.set_text(record, -1) - match context_menu_label: - case "Add to my servers" | "Remove from my servers": - record = "%s:%s" %(self.get_column_at_index(7), self.get_column_at_index(8)) - process_tree_option([self.view, RowType.RESOLVE_IP], self) - if context == "Name (My saved servers)": - iter = self.get_current_iter() - server_store.remove(iter) - case "Remove from history": - record = "%s:%s" %(self.get_column_at_index(7), self.get_column_at_index(8)) - call_out(parent, context_menu_label, record) - iter = self.get_current_iter() - server_store.remove(iter) - case "Copy IP to clipboard": - self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) - addr = self.get_column_at_index(7) - qport = self.get_column_at_index(8) - ip = addr.split(':')[0] - record = "%s:%s" %(ip, qport) - self.clipboard.set_text(record, -1) - case "Refresh player count": - self.refresh_player_count() - case "Show server-side mods": - record = "%s:%s" %(self.get_column_at_index(7), self.get_column_at_index(8)) - dialog = ModDialog(parent, "Enter/double click a row to open in Steam Workshop. ESC exits this dialog", "Modlist", record) - modlist_store.clear() - case "Delete mod": - conf_msg = "Really delete the mod '%s'?" %(value) - success_msg = "Successfully deleted the mod '%s'." %(value) - fail_msg = "An error occurred during deletion. Aborting." - res = spawn_dialog(parent, conf_msg, Popup.CONFIRM) - if res != 0: - return - mods = [] - symlink = self.get_column_at_index(1) - dir = self.get_column_at_index(2) - concat = symlink + " " + dir + "\n" - mods.append(concat) - with open(mods_temp_file, "w") as outfile: - outfile.writelines(mods) - process_tree_option([self.view, RowType.DELETE_SELECTED], self) - case "Open in Steam Workshop": - record = self.get_column_at_index(2) - base_cmd = "open_workshop_page" - subprocess.Popen(['/usr/bin/env', 'bash', funcs, base_cmd, record]) + def copy_clipboard(self) -> None: + self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + record = self.get_record_string() + self.clipboard.set_text(record, -1) - def toggle_selection(self, bool): - l = len(mod_store) - match bool: - case True: - for i in range (0, l): - path = Gtk.TreePath(i) - self.get_selection().select_path(path) - case False: - for i in range (0, l): - path = Gtk.TreePath(i) - self.get_selection().unselect_path(path) + def delete_mod(self) -> None: + modname = self.get_value_at_index(0) + conf_msg = f"Really delete the mod '{modname}'?" + res = spawn_dialog(conf_msg, Popup.CONFIRM) + if res: + return + mods = [] + symlink = self.get_value_at_index(1) + path = self.get_value_at_index(2) + concat = symlink + " " + path + "\n" + mods.append(concat) + with open(mods_temp_file, "w") as outfile: + outfile.writelines(mods) + process_tree_option(RowType.DELETE_SELECTED) - def _on_button_release(self, widget, event): + def open_workshop(self) -> None: + record = self.get_value_at_index(2) + base_cmd = "open_workshop_page" + call_bash_func(base_cmd, record) + + def add_server(self) -> None: + process_tree_option(RowType.RESOLVE_IP) + + def remove_server(self) -> None: + """ + Both add and remove server functionally + call the same logic in helpers/funcs; + if the record exists, it is removed, and vice versa. + When removing, the model needs to be updated in situ + if the current context is RowType.SAVED_SERVERS + """ + self.add_server() + + if self.subpage != RowType.SAVED_SERVERS: + return + + self.resync_with_manager() + + def resync_with_manager(self): + model = self.get_model() + it = self.get_current_iter() + if it: + addr = model.get_value(it, 7) + qport = model.get_value(it, 8) + model.remove(it) + + block_signals() + thread = threading.Thread( + target=ModelManager.resync_model, args=(addr, qport) + ) + thread.start() + + def remove_from_history(self) -> None: + record = self.get_record_string() + call_out("Remove from history", record) + self.resync_with_manager() + + def show_details(self) -> None: + model = self.get_model() + it = self.get_current_iter() + name = model.get_value(it, 0) + record = self.get_record_dict() + DetailsDialog(name, record["ip"], record["qport"]) + + def show_mods(self) -> None: + record = self.get_record_string() + ModDialog(record) + modlist_store.clear() + + def _on_menu_click(self, menu_item: Gtk.MenuItem) -> None: + if hasattr(TreeView, menu_item.action): + func = getattr(TreeView, menu_item.action) + msg = ( + f"User clicked context menu '{menu_item.get_label()}', " + f"calls {func}" + ) + logger.info(msg) + func(self) + else: + msg = ( + f"Context menu function for '{menu_item.action}' " + f"does not exist" + ) + u_msg = ( + f"Something went wrong when accessing the method " + f"'{menu_item.action}'" + ) + logger.critical(msg) + spawn_dialog(u_msg, Popup.NOTIFY) + return + + def toggle_selection(self, state: bool) -> None: + for i, rows in enumerate(mod_store): # type: ignore + path = Gtk.TreePath.new_from_indices([i]) + if state: + self.get_selection().select_path(path) + else: + self.get_selection().unselect_path(path) + + def has_mods(self) -> bool: + select = self.get_selection() + sels = select.get_selected_rows() + (model, pathlist) = sels + path = pathlist[0] + tree_iter = model.get_iter(path) + mods = model.get_value(tree_iter, 11) + return mods + + def is_in_favs(self) -> bool: + record = self.get_record_string() + proc = call_out("is_in_favs", record) + if proc.returncode == 0: + return True + return False + + def is_selection_empty(self) -> bool: + sel = self.get_selection() + sels = sel.get_selected_rows() + (model, pathlist) = sels + if len(pathlist) < 1: + return True + return False + + def _on_button_release( + self, widget: Gtk.Widget, event: Gdk.EventButton + ) -> None: if event.type is Gdk.EventType.BUTTON_RELEASE and event.button != 3: return + try: - pathinfo = self.get_path_at_pos(event.x, event.y) + pathinfo = self.get_path_at_pos(int(event.x), int(event.y)) if pathinfo is None: return (path, col, cellx, celly) = pathinfo - self.set_cursor(path,col,0) + if path is None: + return + self.set_cursor(path, col, False) except AttributeError: pass - context = self.get_first_col() self.menu = Gtk.Menu() + mod_context_items = [ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD] + server_context_items = { + RowType.SERVER_BROWSER: [ + ContextMenu.ADD_SERVER, + ContextMenu.COPY_NAME, + ContextMenu.COPY_CLIPBOARD, + ContextMenu.SHOW_MODS, + ContextMenu.SHOW_DETAILS, + ContextMenu.REFRESH_PLAYERS, + ], + RowType.SCAN_LAN: [ + ContextMenu.COPY_NAME, + ContextMenu.COPY_CLIPBOARD, + ContextMenu.SHOW_MODS, + ContextMenu.SHOW_DETAILS, + ContextMenu.REFRESH_PLAYERS, + ], + RowType.SAVED_SERVERS: [ + ContextMenu.REMOVE_SERVER, + ContextMenu.COPY_NAME, + ContextMenu.COPY_CLIPBOARD, + ContextMenu.SHOW_MODS, + ContextMenu.SHOW_DETAILS, + ContextMenu.REFRESH_PLAYERS, + ], + RowType.RECENT_SERVERS: [ + ContextMenu.ADD_SERVER, + ContextMenu.REMOVE_HISTORY, + ContextMenu.COPY_NAME, + ContextMenu.COPY_CLIPBOARD, + ContextMenu.SHOW_MODS, + ContextMenu.SHOW_DETAILS, + ContextMenu.REFRESH_PLAYERS, + ], + } - mod_context_items = ["Open in Steam Workshop", "Delete mod"] - subcontext_items = { - "Server browser": - ["Add to my servers", "Copy IP to clipboard", "Show server-side mods", "Refresh player count"], - "My saved servers": - ["Remove from my servers", "Copy IP to clipboard", "Show server-side mods", "Refresh player count"], - "Recent servers": - ["Add to my servers", "Remove from history", "Copy IP to clipboard", "Show server-side mods", "Refresh player count"], - } - # submenu hierarchy https://stackoverflow.com/questions/52847909/how-to-add-a-sub-menu-to-a-gtk-menu - - if self.view == WindowContext.TABLE_LOG: - return if self.view == WindowContext.TABLE_MODS: items = mod_context_items - subcontext = "List installed mods" - elif "Name" in context: - subcontext = context.split('(')[1].split(')')[0] - items = subcontext_items[subcontext] + elif self.subpage in server_context_items: + items = server_context_items[self.subpage] else: return - for item in items: - if subcontext == "Server browser" or "Recent servers": - if item == "Add to my servers": - record = "%s:%s" %(self.get_column_at_index(7), self.get_column_at_index(8)) - proc = call_out(widget, "is_in_favs", record) - if proc.returncode == 0: - item = "Remove from my servers" - item = Gtk.MenuItem(label=item) + 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"] item.connect("activate", self._on_menu_click) self.menu.append(item) - + if row == ContextMenu.SHOW_MODS: + if not self.has_mods(): + item.set_sensitive(False) self.menu.show_all() if event.type is Gdk.EventType.KEY_PRESS and event.keyval is Gdk.KEY_l: - sel = self.get_selection() - sels = sel.get_selected_rows() - (model, pathlist) = sels - if len(pathlist) < 1: + if self.is_selection_empty(): return - self.menu.popup_at_widget(widget, Gdk.Gravity.CENTER, Gdk.Gravity.WEST) + 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 refresh_player_count(self): - parent = self.get_outer_window() - - cooldown = call_out(self, "test_cooldown", "", "") + def refresh_player_count(self) -> None: + if not self.is_server_context(self.view): + return + cooldown = call_out("test_cooldown", "", "") if cooldown.returncode == 1: - spawn_dialog(self.get_outer_window(), cooldown.stdout, Popup.NOTIFY) - return 1 - call_out(self, "start_cooldown", "", "") + spawn_dialog(cooldown.stdout, Popup.NOTIFY) + return None + call_out("start_cooldown", "", "") - thread = threading.Thread(target=self._background_player_count, args=()) + thread = threading.Thread( + target=self._background_player_count, args=() + ) thread.start() - def get_outer_window(self): - win = self.get_parent().get_parent().get_parent() - return win + def get_current_iter(self) -> Gtk.TreeIter | None: + it = self.get_selection().get_selected()[1] + return it - def get_outer_grid(self): - grid = self.get_parent().get_parent() - return grid - - def get_current_iter(self): - iter = self.get_selection().get_selected()[1] - return iter - - def get_current_index(self): - index = treeview.get_selection().get_selected_rows()[1][0][0] - return index - - def _on_tree_selection_changed(self, selection): - # no statusbar queue on quad tables - - grid = self.get_outer_grid() - context = self.get_first_col() - row_sel = self.get_column_at_index(0) - logger.info("Tree selection for context '%s' changed to '%s'" %(context, row_sel)) - if self.view == WindowContext.TABLE_MODS or context == "Timestamp": + def _on_tree_selection_changed(self, selection: Gtk.TreeSelection) -> None: + # bail out on early init + if not hasattr(App, "grid"): return + grid = App.grid + + context = App.treeview.get_subpage_label() + row_sel = self.get_value_at_index(0) + logger.info( + f"Tree selection for context '{context}' changed to '{row_sel}'" + ) if self.current_proc and self.current_proc.is_alive(): self.current_proc.terminate() - if self.view == WindowContext.TABLE_API or self.view == WindowContext.TABLE_SERVER: - addr = self.get_column_at_index(7) - qport = self.get_column_at_index(8) - if addr is None: - server_tooltip[0] = format_tooltip() - grid.update_statusbar(server_tooltip[0]) + if ( + self.view == WindowContext.TABLE_API + or self.view == WindowContext.TABLE_SERVER + ): + record = self.get_record_dict() + if not record: + grid.statusbar.update_server_meta() return - if addr in cache: - server_tooltip[0] = format_tooltip() - dist = format_distance(cache[addr][0]) - ping = format_ping(cache[addr][1]) - - tooltip = server_tooltip[0] + dist + ping - grid.update_statusbar(tooltip) + ip = record["ip"] + if ip in cache: + km = cache[ip] + grid.statusbar.append_distance(km) return self.emit("on_distcalc_started") - self.current_proc = CalcDist(self, addr, qport, self.queue, cache) + self.current_proc = CalcDist(self, ip, self.queue, cache) self.current_proc.start() else: - tooltip = format_metadata(row_sel) - grid.update_statusbar(tooltip) + grid.statusbar.refresh() - def spawn_keys_dialog(self, widget): - diag = KeysDialog(self.get_outer_window(), '', "Keybindings") - diag.run() - diag.destroy() - self.grab_focus() + def get_selected_row_index(self) -> int: + sel = self.get_selection() + rows = sel.get_selected_rows() + cur_row = rows[1][0][0] + return cur_row - def _on_keypress_main_menu(self, treeview, event): - window = self.get_outer_window() - grid = self.get_outer_grid() - match event.keyval: - case Gdk.KEY_d: - debug = grid.right_panel.debug_toggle - if debug.get_active(): - debug.set_active(False) - else: - debug.set_active(True) - case Gdk.KEY_Right: - grid.right_panel.focus_button_box() - case Gdk.KEY_question: - if event.state is Gdk.ModifierType.SHIFT_MASK: - self.spawn_keys_dialog(None) - case Gdk.KEY_f: - if event.state is Gdk.ModifierType.CONTROL_MASK: - return True - case _: - return False + def _move_cursor(self, position: CursorPosition) -> bool | None: + cur_row = self.get_selected_row_index() + model = self.get_model() + if model: + end = len(model) - 1 + else: + return None - def _on_keypress(self, treeview, event): + if position == CursorPosition.DOWN: + if cur_row == end: + return True + dest = cur_row + 1 + if position == CursorPosition.UP: + if cur_row == 0: + return True + dest = cur_row - 1 + if position == CursorPosition.TOP: + if cur_row == 0: + return True + dest = 0 + if position == CursorPosition.BOTTOM: + if cur_row == end: + return True + dest = end + path = Gtk.TreePath.new_from_indices([dest]) + self.set_cursor(path) + return None + + def _on_keypress( + self, treeview: Gtk.TreeView, event: Gdk.EventKey + ) -> bool | None: keyname = Gdk.keyval_name(event.keyval) - grid = self.get_outer_grid() - cur_proc = grid.scrollable_treelist.treeview.current_proc + grid = App.grid if event.state is Gdk.ModifierType.CONTROL_MASK: match event.keyval: case Gdk.KEY_l: @@ -1392,171 +1935,412 @@ class TreeView(Gtk.TreeView): case Gdk.KEY_r: self.refresh_player_count() case Gdk.KEY_f: - if self.get_first_col() == "Mod": - return - grid.right_panel.filters_vbox.grab_keyword_focus() + if not App.treeview.is_server_context(App.treeview.view): + return True + App.right_panel.filters_vbox.keyword_entry.grab_focus() case Gdk.KEY_m: - if self.get_first_col() == "Mod": - return - grid.right_panel.filters_vbox.maps_entry.grab_focus() + if App.treeview.view == WindowContext.TABLE_MODS: + return True + App.right_panel.filters_vbox.maps_entry.grab_focus() case _: return False - elif keyname.isnumeric() and int(keyname) > 0: - if self.get_first_col() == "Mod": - return - digit = (int(keyname) - 1) - grid.right_panel.filters_vbox.toggle_check(checks[digit]) else: - return False + if is_navkey(event.keyval): + suppress_signal( + App.treeview, + App.treeview.selected_row, + "_on_tree_selection_changed", + True, + ) + if keyname.isnumeric() and int(keyname) > 0: + digit = int(keyname) - 1 + grid.right_panel.filters_vbox.toggle_check(digit) + return False + if event.keyval == Gdk.KEY_G: + self._move_cursor(CursorPosition.BOTTOM) + match event.keyval: + case Gdk.KEY_g: + self._move_cursor(CursorPosition.TOP) + case Gdk.KEY_j: + self._move_cursor(CursorPosition.DOWN) + case Gdk.KEY_k: + self._move_cursor(CursorPosition.UP) + case Gdk.KEY_l | Gdk.KEY_Right: + if event.state is Gdk.ModifierType.CONTROL_MASK: + return + App.right_panel.focus_button_box() + case Gdk.KEY_0: + grid.right_panel.filters_vbox.toggle_check(9) + case Gdk.KEY_minus: + grid.right_panel.filters_vbox.toggle_check(10) + case Gdk.KEY_backslash: + grid.right_panel.filters_vbox.toggle_check(11) + case _: + return False + return None - def _focus_first_row(self): - path = Gtk.TreePath(0) + def _on_key_release( + self, treeview: Gtk.TreeView, event: Gdk.EventKey + ) -> None: + """ + Suppresses spamming on keydown + """ + if is_navkey(event.keyval): + suppress_signal( + App.treeview, + App.treeview.selected_row, + "_on_tree_selection_changed", + False, + ) + selection = self.get_selection() + self._on_tree_selection_changed(selection) + + def focus_first_row(self) -> None: + path = Gtk.TreePath.new_from_indices([0]) try: - it = mod_store.get_iter(path) self.get_selection().select_path(path) except ValueError: pass - def get_column_at_index(self, index): + def get_value_at_index(self, index: int) -> str: select = self.get_selection() sels = select.get_selected_rows() (model, pathlist) = sels if len(pathlist) < 1: - return + return "" path = pathlist[0] tree_iter = model.get_iter(path) value = model.get_value(tree_iter, index) return value - def _background_player_count(self): - def _load(): - lines = data.stdout.splitlines() - #update players - server_store[path][4] = int(lines[0]) - #update queue - server_store[path][6] = int(lines[1]) - wait_dialog.destroy() + def get_name(self) -> str: + name = self.get_value_at_index(0) + return name - parent = self.get_outer_window() - wait_dialog = GenericDialog(parent, "Refreshing player count", Popup.WAIT) - wait_dialog.show_all() + 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_dict(self) -> dict | None: select = self.get_selection() sels = select.get_selected_rows() (model, pathlist) = sels if len(pathlist) < 1: - return + return None path = pathlist[0] - tree_iter = model.get_iter(path) - addr = server_store[path][7] - qport = server_store[path][8] - ip = addr.split(':')[0] + model = self.get_model() + if not model: + return None + addr = model[path][7] + qport = model[path][8] + ip = addr.split(":")[0] qport = str(qport) + return {"ip": ip, "qport": qport} - data = call_out(self, "get_player_count", ip, qport) + def update_players(self, players: int) -> None: + model = self.get_model() + path = self.get_mpath() + if not model: + return + if not path: + return + model[path][4] = players + + def update_queue(self, players: int) -> None: + model = self.get_model() + path = self.get_mpath() + model[path][6] = players + + def enable_ping_column(self, state: bool) -> None: + columns = self.get_columns() + for column in columns: + if column.get_title() == "Ping": + column.set_visible(state) + + def select_first_row(self): + sel = self.get_selection() + self._on_tree_selection_changed(sel) + + def get_mpath(self) -> Gtk.TreePath | None: + select = self.get_selection() + sels = select.get_selected_rows() + (model, pathlist) = sels + if len(pathlist) < 1: + return None + path = pathlist[0] + return path + + def _background_player_count(self) -> None: + def _load(): + lines = data.stdout.splitlines() + self.update_players(int(lines[0])) + self.update_queue(int(lines[1])) + wait_dialog.destroy() + + wait_dialog = GenericDialog("Refreshing player count", Popup.WAIT) + wait_dialog.show_all() + record = self.get_record_dict() + if not record: + return + ip = record["ip"] + qport = record["qport"] + data = call_out("get_player_count", ip, qport) if data.returncode == 1: wait_dialog.destroy() return GLib.idle_add(_load) - def _background(self, dialog, mode): - def loadTable(): - for map in maps: - map_store.append([map]) - toggle_signal(self, self.selected_row, '_on_tree_selection_changed', True) - right_panel.set_filter_visibility(True) - dialog.destroy() - self.grab_focus() - for column in self.get_columns(): - column.connect("notify::width", self._on_col_width_changed) - if len(server_store) == 0: - call_out(self, "start_cooldown", "", "") - api_warn_msg = """\ - No servers returned. Possible network issue or API key on cooldown? - Return to the main menu, wait 60s, and try again. - If this issue persists, your API key may be defunct.""" - spawn_dialog(self.get_outer_window(), textwrap.dedent(api_warn_msg), Popup.NOTIFY) + def _dump_api(self): + key = query_config("steam_api")[0] + job = Servers.query_api + params = Servers.params + with ThreadPoolExecutor() as executor: + futures = [executor.submit(job, key, param) for param in params] + wait(futures) + serv = [] + for future in futures: + res = future.result() + if res.status != 200 or not res.parsed: + ModelManager.set_store(None) + ModelManager.set_success(False) + GLib.idle_add(self._filter_cleanup) + return + j = res.json + serv += j["response"]["servers"] + parsed = Servers.parse_json(serv) + return parsed - grid = self.get_outer_grid() - right_panel = grid.right_panel + def _dump_lan(self, port: int) -> list | None: + with ThreadPoolExecutor() as executor: + futures = [ + executor.submit(Servers.test_ip, i, port) + for i in range(1, 256) + ] + wait(futures) + servers = [] + for future in futures: + res = future.result() + if res is None: + continue + servers.append(res) + if len(servers) == 0: + ModelManager.set_store(None) + ModelManager.set_success(False) + GLib.idle_add(self._filter_cleanup) + return None + parsed = Servers.parse_json(servers) + return parsed - filters = toggled_checks + keyword_filter + selected_map - data = call_out(self, "dump_servers", mode, *filters) + def _dump_servers(self, ips: list) -> list | None: + if len(ips) == 0: + return [] + with ThreadPoolExecutor() as executor: + futures = [ + executor.submit( + Servers.query_direct, + ip.split(":")[0], + int(ip.split(":")[2]), + ) + for ip in ips + ] + wait(futures) + serv = [] + for future in futures: + res = future.result() + if res is None: + continue + serv.append(res) + if len(serv) == 0: + ModelManager.set_store(None) + ModelManager.set_success(False) + GLib.idle_add(self._filter_cleanup) + return None + parsed = Servers.parse_json(serv) + return parsed - toggle_signal(self, self.selected_row, '_on_tree_selection_changed', False) - parse_server_rows(data) - server_tooltip[0] = format_tooltip() - grid.update_statusbar(server_tooltip[0]) + def _query_servers(self, mode: RowType, port: int = 27016) -> None: + block_signals() - map_data = call_out(self, "get_unique_maps", mode) - maps = map_data.stdout.splitlines() - self.set_model(server_store) - GLib.idle_add(loadTable) + match mode: + case RowType.SCAN_LAN: + parsed = self._dump_lan(port) + case RowType.SERVER_BROWSER: + App.treeview.enable_ping_column(False) + App.right_panel.enable_ping_button(True) + parsed = self._dump_api() + case RowType.SAVED_SERVERS: + App.treeview.enable_ping_column(True) + favs = query_favorites() + if not favs: + ModelManager.set_success(False) + ModelManager.set_store(None) + GLib.idle_add(self._filter_cleanup) + return + parsed = self._dump_servers(favs) + case RowType.RECENT_SERVERS: + App.treeview.enable_ping_column(True) + history = query_history() + if not history: + ModelManager.set_success(False) + ModelManager.set_store(None) + GLib.idle_add(self._filter_cleanup) + return + parsed = self._dump_servers(history) - def _background_quad(self, dialog, mode): + if parsed is None: + return + App.right_panel.reinit_maps(parsed) + + # intialize to empty + ModelManager.set_store(None) + ModelManager.set_control(parsed) + ModelManager.filter(FilterMode.INITIAL) + + def _filter_cleanup(self, empty: bool = False) -> None: + model = ModelManager.get_store() + self.set_model(model) + + if App.treeview.subpage == RowType.SERVER_BROWSER: + call_out("start_cooldown", "", "") + + """ + There may be scenarios where opposed filter results + deterministically yield 0 hits. The model needs to be + emptied without triggering a fetch error. This method is + reserved for cases where the query actually failed. + """ + if not ModelManager.get_success(): + if self.wait_dialog: + self.wait_dialog.destroy() + spawn_dialog(api_warn_msg, Popup.RETURN) + unblock_signals() + return + + if App.right_panel.filters_vbox.get_active_combo() < 0: + App.right_panel.filters_vbox.set_active_combo(0) + App.grid.right_panel.filters_vbox.set_visible(True) + for column in self.get_columns(): + column.connect("notify::width", self._on_col_width_changed) + + App.grid.statusbar.update_server_meta() + + if self.wait_dialog: + self.wait_dialog.destroy() + unblock_signals() + self.grab_focus() + App.treeview.select_first_row() + + def filter(self, mode: FilterMode, *args) -> None: + block_signals() + self.wait_dialog = GenericDialog("Filtering servers", Popup.WAIT) + self.wait_dialog.show_all() + + ModelManager.set_store(App.treeview.get_model()) + App.treeview.set_model(None) + + thread = threading.Thread( + target=ModelManager.filter, args=(mode, *args) + ) + thread.start() + + def _background_quad(self, dialog: "GenericDialog", mode: RowType) -> None: # currently only used by list mods method def load(): dialog.destroy() # suppress button panel if store is empty - if isinstance(panel_last_child, ModSelectionPanel): - if total_mods == 0: - # do not forcibly remove previously added widgets when reloading in-place - grid.sel_panel.set_visible(False) - right_panel.set_filter_visibility(False) - else: - grid.sel_panel.set_visible(True) - grid.sel_panel.initialize() + if total_mods == 0: + grid.sel_panel.set_visible(False) + right_panel.filters_vbox.set_visible(False) + logger.info("Nothing to do, spawning notice dialog") + spawn_dialog(data.stdout, Popup.RETURN) + return + else: + grid.sel_panel.set_visible(True) + grid.sel_panel.initialize() self.set_model(mod_store) self.grab_focus() - size = locale.format_string('%.3f', total_size, grouping=True) + size = locale.format_string("%.3f", total_size, grouping=True) pretty = pluralize("mods", total_mods) - grid.update_statusbar(f"Found {total_mods:n} {pretty} taking up {size} MiB") + grid.statusbar.set_text( + f"Found {total_mods:n} {pretty} taking up {size} MiB" + ) + self.focus_first_row() - toggle_signal(self, self.selected_row, '_on_tree_selection_changed', True) - toggle_signal(self, self, '_on_keypress', True) - self._focus_first_row() - if total_mods == 0: - logger.info("Nothing to do, spawning notice dialog") - spawn_dialog(self.get_outer_window(), data.stdout, Popup.NOTIFY) - - widgets = relative_widget(self) - grid = widgets["grid"] + grid = App.grid right_panel = grid.right_panel - data = call_out(self, mode.dict["label"], '') - panel_last_child = right_panel.get_children()[-1] + data = call_out(mode.dict["label"], "") # suppress errors if no mods available on system if data.returncode == 1: logger.info("Failed to find mods on local system") total_mods = 0 total_size = 0 - GLib.idle_add(load) else: - # show button panel missing (prevents duplication when reloading in-place) - if not isinstance(panel_last_child, ModSelectionPanel): + if App.treeview.view == WindowContext.TABLE_MODS: grid.sel_panel.set_visible(True) - result = parse_mod_rows(data) - total_size = result[0] - total_mods = result[1] - logger.info("Found mods on local system") - logger.info("Total mod size: %s" %(total_size)) - logger.info("Total mod count: %s" %(total_mods)) - GLib.idle_add(load) + result = self._parse_mod_rows(data) + try: + total_size = result[0] + total_mods = result[1] + info = ( + f"Found mods on local system: " + f"{total_mods} total, occupies {total_size}" + ) + except IndexError: + total_size = 0 + total_mods = 0 + info = "Found mods on system, but was unable to parse results." + finally: + logger.info(info) + GLib.idle_add(load) - def _on_col_width_changed(self, col, width): + def _parse_log_rows(self, data: subprocess.CompletedProcess) -> bool: + lines = data.stdout.splitlines() + reader = csv.reader(lines, delimiter=delimiter) + try: + rows = [[row[0], row[1], row[2], row[3]] for row in reader if row] + except IndexError: + return False + for row in rows: + log_store.append(row) + return True + def _parse_mod_rows(self, data: subprocess.CompletedProcess) -> list: + # GTK pads trailing zeroes on floats + # https://stackoverflow.com/questions/26827434/gtk-cellrenderertext-with-format + total = float(0) + lines = data.stdout.splitlines() + hits = len(lines) + reader = csv.reader(lines, delimiter=delimiter) + + # Nonetype inherits default GTK color + try: + rows = [ + [row[0], row[1], row[2], locale.atof(row[3], func=float), None] + for row in reader + if row + ] + except IndexError: + return [] + for row in rows: + mod_store.append(row) + total += float(row[3]) + return [total, hits] + + def _on_col_width_changed( + self, col: Gtk.TreeViewColumn, width: GObject.ParamSpecInt + ) -> None: def write_json(title, size): - data = {"cols": { title: size } } + data = {"cols": {title: size}} j = json.dumps(data, indent=2) with open(geometry_path, "w") as outfile: outfile.write(j) - logger.info("Wrote initial column widths to '%s'" %(geometry_path)) + logger.info(f"Wrote initial column widths to '{geometry_path}'") title = col.get_title() size = col.get_width() - # steam deck column title workaround - if "Name" in title: - title = "Name" if os.path.isfile(geometry_path): with open(geometry_path, "r") as infile: @@ -1566,356 +2350,329 @@ class TreeView(Gtk.TreeView): with open(geometry_path, "w") as outfile: outfile.write(json.dumps(data, indent=2)) except json.decoder.JSONDecodeError: - logger.critical("JSON decode error in '%s'" %(geometry_path)) + logger.critical(f"JSON decode error in '{geometry_path}'") write_json(title, size) else: write_json(title, size) - def _update_multi_column(self, mode): - # Local server lists may have different filter toggles from remote list - # FIXME: tree selection updates twice here. attach signal later - self.set_headers_visible(True) - - toggle_signal(self, self.selected_row, '_on_tree_selection_changed', False) - for column in self.get_columns(): - self.remove_column(column) - row_store.clear() - + def initialize_columns(self) -> None: if os.path.isfile(geometry_path): with open(geometry_path, "r") as infile: try: data = json.load(infile) valid_json = True except json.decoder.JSONDecodeError: - logger.critical("JSON decode error in '%s'" %(geometry_path)) + logger.critical(f"JSON decode error in '{geometry_path}'") valid_json = False else: valid_json = False + browser_cols = [ + "Name", + "Map", + "Perspective", + "Gametime", + "Players", + "Maximum", + "Queue", + "IP", + "Qport", + "Ping", + ] for i, column_title in enumerate(browser_cols): renderer = Gtk.CellRendererText() 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 valid_json: - if "Name" in column_title: - column_title = "Name" - saved_size = data["cols"][column_title] + try: + saved_size = data["cols"][column_title] + except KeyError: + saved_size = 100 column.set_fixed_width(saved_size) column.set_expand(True) else: - if ("Name" in column_title): + if column_title == "Name": column.set_fixed_width(800) - if (column_title == "Map"): + if column_title == "Map": column.set_fixed_width(300) - if i != 10: - self.append_column(column) + self.append_column(column) - self.update_first_col(mode.dict["label"]) - - widgets = relative_widget(self) - grid = widgets["grid"] - window = widgets["outer"] - window.hb.set_subtitle(mode.dict["label"]) - - transient_parent = window - - # Reset map selection - selected_map.clear() - selected_map.append("Map=All maps") + @update_window_labels + def _update_multi_column(self, mode: RowType, port: int = 27016) -> None: + self.subpage = mode + self.set_headers_visible(True) + for column in self.get_columns(): + self.remove_column(column) + row_store.clear() + self.initialize_columns() self.set_selection_mode(Gtk.SelectionMode.SINGLE) - for check in checks: - toggle_signal(self.get_outer_grid().right_panel.filters_vbox, check, '_on_check_toggle', True) - toggle_signal(self, self, '_on_keypress', True) - - string = mode.dict["label"] - if mode == RowType.SCAN_LAN: - lan_dialog = LanButtonDialog(self.get_outer_window()) - port = lan_dialog.get_selected_port() - if port is None: - grid = self.get_outer_grid() - right_panel = grid.right_panel - vbox = right_panel.button_vbox - vbox._update_single_column(ButtonType.MAIN_MENU) - return - string = string + ":" + port - - wait_dialog = GenericDialog(transient_parent, "Fetching server metadata", Popup.WAIT) - wait_dialog.show_all() - thread = threading.Thread(target=self._background, args=(wait_dialog, string)) + self.wait_dialog = GenericDialog( + "Fetching server metadata", Popup.WAIT + ) + self.wait_dialog.show_all() + thread = threading.Thread( + target=self._query_servers, args=(mode, port) + ) thread.start() - def update_first_col(self, title): - for col in self.get_columns(): - old_title = col.get_title() - col.set_title("%s (%s)" %(old_title, title)) - break - - def get_first_col(self): - for col in self.get_columns(): - cur_col = col.get_title() - break - return cur_col - - def _format_float(self, column, cell, model, iter, data): + def _format_float( + self, + column: Gtk.TreeViewColumn, + cell: Gtk.CellRendererText, + model: Gtk.TreeModel, + it: Gtk.TreeIter, + data: Any, + ) -> Any: # https://docs.huihoo.com/pygtk/2.0-tutorial/sec-CellRenderers.html - val = model[iter][3] - formatted = locale.format_string('%.3f', val, grouping=True) - cell.set_property('text', formatted) + val = model[it][3] + formatted = locale.format_string("%.3f", val, grouping=True) + cell.set_property("text", formatted) return - def set_selection_mode(self, mode): + def set_selection_mode(self, mode: Gtk.SelectionMode) -> None: sel = self.get_selection() sel.set_mode(mode) - def update_quad_column(self, mode): - toggle_signal(self, self.selected_row, '_on_tree_selection_changed', False) - for column in self.get_columns(): - self.remove_column(column) - - self.set_headers_visible(True) - mod_store.clear() - log_store.clear() - - if mode == RowType.LIST_MODS: - cols = mod_cols - self.set_model(mod_store) - else: - cols = log_cols - self.set_model(log_store) + def _set_quad_col_mode(self, mode: RowType) -> None: + mod_cols = ["Mod", "Symlink", "Dir", "Size (MiB)", "Color"] + log_cols = ["Timestamp", "Flag", "Traceback", "Message"] + match mode: + case RowType.LIST_MODS: + cols = mod_cols + model = mod_store + self.set_selection_mode(Gtk.SelectionMode.MULTIPLE) + case RowType.SHOW_LOG: + cols = log_cols + model = log_store + self.set_model(model) for i, column_title in enumerate(cols): renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn(column_title, renderer, text=i, foreground=4) if mode == RowType.LIST_MODS: + column = Gtk.TreeViewColumn( + column_title, renderer, text=i, foreground=4 + ) 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 + ) + else: + # WindowContext.TABLE_LOG uses undecorated columns + column = Gtk.TreeViewColumn(column_title, renderer, text=i) + column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) column.set_sort_column_id(i) # hidden color property column if i != 4: self.append_column(column) - widgets = relative_widget(self) - grid = widgets["grid"] - window = widgets["outer"] - try: - window.hb.set_subtitle(mode.dict["quad_label"]) - except KeyError: - window.hb.set_subtitle(mode.dict["label"]) + @update_window_labels + def _populate(self, context: WindowContext) -> None: + self.view = context + self.page = context + self.subpage = None - if mode == RowType.LIST_MODS: - self.set_selection_mode(Gtk.SelectionMode.MULTIPLE) - else: - # short circuit and jump to debug log - data = call_out(self, "show_log") - res = parse_log_rows(data) - toggle_signal(self, self, '_on_keypress', True) - if res == 1: - spawn_dialog(self.get_outer_window(), "Failed to load log file, possibly corrupted", Popup.NOTIFY) + row_store.clear() + array = context.dict["rows"] + + for item in array: + label = item.dict["label"] + row = (label,) + row_store.append(row) + App.grid.statusbar.refresh() + App.grid.notebook.set_page_enum(NotebookPage.MAIN) + self.grab_focus() + + @signal_emission + def update_single_column(self, button_context: ButtonType) -> None: + msg = ( + f"Returning from multi-column view to monocolumn view " + f"for the context '{button_context}'" + ) + logger.info(msg) + model = self.get_model() + if model: + model.clear() + ModelManager.wipe_cache(full=True) + + App.right_panel.enable_ping_button(False) + App.right_panel.filters_vbox.reinit_panel() + self.set_selection_mode(Gtk.SelectionMode.SINGLE) + + for column in self.get_columns(): + self.remove_column(column) + for i, column_title in enumerate([button_context.dict["label"]]): + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn(column_title, renderer, text=i) + column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + self.append_column(column) + + self.set_headers_visible(False) + self.set_model(row_store) + self._populate(button_context.dict["opens"]) + self.grab_focus() + + def update_quad_column(self, mode: RowType) -> None: + for column in self.get_columns(): + self.remove_column(column) + + self.subpage = mode + self.set_headers_visible(True) + self._set_quad_col_mode(mode) + + mod_store.clear() + log_store.clear() + + if mode == RowType.SHOW_LOG: + data = call_out("show_log") + res = self._parse_log_rows(data) + App.treeview.focus_first_row() + if not res: + spawn_dialog( + "Failed to load log file, possibly corrupted", Popup.NOTIFY + ) return + else: + wait_dialog = GenericDialog("Checking mods", Popup.WAIT) + wait_dialog.show_all() + thread = threading.Thread( + target=self._background_quad, args=(wait_dialog, mode) + ) + thread.start() - - wait_dialog = GenericDialog(window, "Checking mods", Popup.WAIT) - wait_dialog.show_all() - thread = threading.Thread(target=self._background_quad, args=(wait_dialog, mode)) - thread.start() - - def _background_connection(self, dialog, record): + def _background_connection( + self, dialog: "GenericDialog", record: str + ) -> None: def load(): dialog.destroy() - transient = self.get_outer_window() out = proc.stdout.splitlines() msg = out[-1] - process_shell_return_code(transient, msg, proc.returncode, record) + process_shell_return_code(msg, proc.returncode, record) - proc = call_out(self, "Connect from table", record) + proc = call_out("Connect from table", record) GLib.idle_add(load) - - def _attempt_connection(self): - transient_parent = self.get_outer_window() - addr = self.get_column_at_index(7) - qport = self.get_column_at_index(8) - record = "%s:%s" %(addr, str(qport)) - - wait_dialog = GenericDialog(transient_parent, "Querying server and aligning mods", Popup.WAIT) + def _attempt_connection(self) -> None: + record = self.get_record_string() + msg = "Querying server and aligning mods" + wait_dialog = GenericDialog(msg, Popup.WAIT) wait_dialog.show_all() - thread = threading.Thread(target=self._background_connection, args=(wait_dialog, record)) + thread = threading.Thread( + target=self._background_connection, args=(wait_dialog, record) + ) thread.start() - def _on_row_activated(self, treeview, tree_iter, col): - context = self.get_first_col() - chosen_row = self.get_column_at_index(0) + def is_row_to_server_context(self, view: RowType) -> bool: + """Row activation that jumps into a server table""" + row_contexts = [ + RowType.SERVER_BROWSER, + RowType.RECENT_SERVERS, + RowType.SAVED_SERVERS, + ] + if view in row_contexts: + return True + return False + + def is_server_context(self, view: WindowContext) -> bool: + """Server tables""" + server_contexts = [ + WindowContext.TABLE_API, + WindowContext.TABLE_SERVER, + ] + if view in server_contexts: + return True + return False + + def is_dynamic_context(self, view: WindowContext) -> bool: + """Tables populated with arbitrary content""" + dynamic_contexts = [ + WindowContext.TABLE_LOG, + WindowContext.TABLE_SERVER, + WindowContext.TABLE_API, + ] + if view in dynamic_contexts: + return True + return False + + def set_view(self, view): + self.view = view + + def get_view(self): + return self.view + + @signal_emission + @update_window_labels + def _on_row_activated( + self, + treeview: Gtk.TreeView, + tree_iter: Gtk.TreePath, + col: Gtk.TreeViewColumn, + ) -> None: + + context = self.page + chosen_row = self.get_value_at_index(0) - # recycled from ModDialog if self.view == WindowContext.TABLE_MODS: - 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, 2) - base_cmd = "open_workshop_page" - subprocess.Popen(['/usr/bin/env', 'bash', funcs, base_cmd, mod_id]) + self.open_workshop() return - dynamic_contexts = [ - WindowContext.TABLE_LOG, - WindowContext.TABLE_SERVER, - WindowContext.TABLE_API - ] - - # if already in table, the row selection is arbitrary - if self.view in dynamic_contexts: - cr = RowType.DYNAMIC + if self.is_dynamic_context(self.view): + cr = RowType.DYNAMIC else: cr = RowType.str2rowtype(chosen_row) wc = WindowContext.row2con(cr) - self.view = wc + self.set_view(wc) - output = self.view, cr - logger.info("User selected '%s' for the context '%s'" %(chosen_row, context)) + output = cr + logger.info(f"User selected '{cr}' for the context '{context}'") - if self.view == WindowContext.TABLE_LOG and cr == RowType.DYNAMIC: + if cr == RowType.SCAN_LAN: + lan_dialog = LanDialog() + lan_dialog.run() + lan_dialog.destroy() + port = lan_dialog.get_selected_port() + if port is None: + return + App.right_panel.filters_vbox.enable_all_filters() + self._update_multi_column(cr, port=port) return - outer = self.get_outer_window() - right_panel = outer.grid.right_panel - filters_vbox = right_panel.filters_vbox - - server_contexts = [ - RowType.SCAN_LAN, - RowType.SERVER_BROWSER, - RowType.RECENT_SERVERS, - RowType.SAVED_SERVERS - ] - - # server contexts share the same model type - if cr in server_contexts: + if self.is_row_to_server_context(cr): if cr == RowType.SERVER_BROWSER: - cooldown = call_out(self, "test_cooldown", "", "") + cooldown = call_out("test_cooldown", "", "") if cooldown.returncode == 1: - spawn_dialog(outer, cooldown.stdout, Popup.NOTIFY) - # reset context to main menu if navigation was blocked - self.view = WindowContext.MAIN_MENU - return 1 - for check in checks: - toggle_signal(filters_vbox, check, '_on_check_toggle', False) - reinit_checks() + spawn_dialog(cooldown.stdout, Popup.NOTIFY) + self.set_view(WindowContext.MAIN_MENU) + return + try: + key = query_config("steam_api")[0] + except IndexError: + spawn_dialog("No Steam API key is set.", Popup.NOTIFY) + self.set_view(WindowContext.MAIN_MENU) + return + if len(key) < 1: + spawn_dialog("No Steam API key is set.", Popup.NOTIFY) + self.set_view(WindowContext.MAIN_MENU) + return + App.grid.right_panel.filters_vbox.reinit_filters() else: - for check in checks: - toggle_signal(filters_vbox, check, '_on_check_toggle', False) - if check.get_label() not in toggled_checks: - toggled_checks.append(check.get_label()) - check.set_active(True) + # local server lists need not be restricted + App.right_panel.filters_vbox.enable_all_filters() self._update_multi_column(cr) - - map_store.clear() - map_store.append(["All maps"]) - right_panel.set_active_combo() - - toggle_signal(filters_vbox, filters_vbox.maps_combo, '_on_map_changed', True) - toggle_signal(self, self.selected_row, '_on_tree_selection_changed', True) - self.grab_focus() return - if self.view == WindowContext.TABLE_MODS or self.view == WindowContext.TABLE_LOG: - toggle_signal(self, self.selected_row, '_on_tree_selection_changed', False) - self.update_quad_column(cr) - toggle_signal(self, self.selected_row, '_on_tree_selection_changed', True) - elif self.view == WindowContext.TABLE_SERVER or self.view == WindowContext.TABLE_API: - self._attempt_connection() - else: - # implies any other non-server option selected from main menu - process_tree_option(output, self) - - -def format_metadata(row_sel): - # this function is recycled for the add by ip/id methods + - # the right-click context menu (add/remove servers) - # in the latter case, there is no metadata to update - # see grid.update_statusbar(), so the returned row is None - row = None - for i in RowType: - if i.dict["label"] == row_sel: - row = i - prefix = i.dict["tooltip"] - break - vals = { - "branch": config_vals[0], - "debug": config_vals[1], - "auto_install": config_vals[2], - "name": config_vals[3], - "fav_label": config_vals[4], - "preferred_client": config_vals[5], - "fullscreen": config_vals[6] - } - if row is None: - return None - try: - alt = row.dict["alt"] - default = row.dict["default"] - val = row.dict["val"] - except KeyError: - return prefix - try: - cur_val = vals[val] - if cur_val == "": - return "%s | Current: '%s'" %(prefix, default) - # TODO: migrate to human readable config values - elif cur_val == "1": - return "%s | Current: '%s'" %(prefix, alt) - else: - return "%s | Current: '%s'" %(prefix, cur_val) - except KeyError: - return prefix - - -def format_tooltip(): - hits = len(server_store) - players = 0 - for row in server_store: - players+= row[4] - hits_pretty = pluralize("matches", hits) - players_pretty = pluralize("players", players) - tooltip = f"Found {hits:n} {hits_pretty} with {players:n} {players_pretty}" - return tooltip - - -def filter_servers(transient_parent, filters_vbox, treeview, context): - def filter(dialog): - def clear_and_destroy(): - parse_server_rows(data) - server_tooltip[0] = format_tooltip() - transient_parent.grid.update_statusbar(server_tooltip[0]) - - toggle_signal(treeview, treeview.selected_row, '_on_tree_selection_changed', True) - toggle_signal(filters_vbox, filters_vbox, '_on_button_release', True) - toggle_signal(filters_vbox, filters_vbox.maps_combo, '_on_map_changed', True) - dialog.destroy() - treeview.grab_focus() - - server_filters = toggled_checks + keyword_filter + selected_map - data = call_out(transient_parent, "filter", context, *server_filters) - GLib.idle_add(clear_and_destroy) - - # block additional input on FilterPanel while filters are running - toggle_signal(treeview, treeview.selected_row, '_on_tree_selection_changed', False) - toggle_signal(filters_vbox, filters_vbox, '_on_button_release', False) - toggle_signal(filters_vbox, filters_vbox.maps_combo, '_on_map_changed', False) - - dialog = GenericDialog(transient_parent, "Filtering results", Popup.WAIT) - dialog.show_all() - server_store.clear() - - thread = threading.Thread(target=filter, args=(dialog,)) - thread.start() + match self.view: + case WindowContext.TABLE_MODS | WindowContext.TABLE_LOG: + self.update_quad_column(cr) + case WindowContext.TABLE_SERVER | WindowContext.TABLE_API: + self._attempt_connection() + case _: # any other non-server option from the main menu + process_tree_option(output) class AppHeaderBar(Gtk.HeaderBar): @@ -1927,12 +2684,7 @@ class AppHeaderBar(Gtk.HeaderBar): class GenericDialog(Gtk.MessageDialog): - def __init__(self, parent, text, mode): - - def _on_dialog_delete(self, response_id): - """Passively ignore user-input""" - return True - + def __init__(self, text: str, mode: Popup): match mode: case Popup.WAIT: dialog_type = Gtk.MessageType.INFO @@ -1942,6 +2694,10 @@ class GenericDialog(Gtk.MessageDialog): dialog_type = Gtk.MessageType.INFO button_type = Gtk.ButtonsType.OK header_text = "Notice" + case Popup.RETURN: + dialog_type = Gtk.MessageType.INFO + button_type = Gtk.ButtonsType.OK + header_text = "Notice" case Popup.CONFIRM: dialog_type = Gtk.MessageType.QUESTION button_type = Gtk.ButtonsType.OK_CANCEL @@ -1950,20 +2706,24 @@ class GenericDialog(Gtk.MessageDialog): dialog_type = Gtk.MessageType.QUESTION button_type = Gtk.ButtonsType.OK_CANCEL header_text = "User input required" - case _: + case Popup.MODLIST: dialog_type = Gtk.MessageType.INFO button_type = Gtk.ButtonsType.OK - header_text = mode + header_text = "Modlist" + case Popup.DETAILS: + dialog_type = Gtk.MessageType.INFO + button_type = Gtk.ButtonsType.OK + header_text = "Server details" + # steam deck prints <2> if dialog title is duplicated Gtk.MessageDialog.__init__( self, - transient_for=parent, - flags=0, + transient_for=App.window, message_type=dialog_type, text=header_text, secondary_text=textwrap.fill(text, 50), buttons=button_type, - title="DZGUI - Dialog", + title=f"{app_name} - Dialog", modal=True, ) @@ -1972,292 +2732,373 @@ class GenericDialog(Gtk.MessageDialog): spinner = Gtk.Spinner() dialogBox.pack_end(spinner, False, False, 0) spinner.start() - self.connect("delete-event", _on_dialog_delete) + self.connect("delete-event", self._on_dialog_delete) + + if mode == Popup.RETURN: + button_label = "Return to 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) self.set_default_response(Gtk.ResponseType.OK) self.set_size_request(500, 0) self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) - def update_label(self, text): + self.action_area.set_layout(Gtk.ButtonBoxStyle.CENTER) + 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) + + def _on_dialog_delete( + self, response_id: Gtk.ResponseType + ) -> Literal[True]: + return True + + def _return_to_main_menu(self, widget: Gtk.Widget) -> None: + App.treeview.update_single_column(ButtonType.MAIN_MENU) + + def update_label(self, text: str) -> None: self.format_secondary_text(text) -class LanButtonDialog(Gtk.Window): - def __init__(self, parent): - super().__init__() +class LanDialog(Gtk.MessageDialog): + """ + Performs integer validation on the provided port + and blocks if out of range. Returns None if user cancels + """ - self.buttonBox = Gtk.Box() + def __init__(self): + super().__init__( + transient_for=App.window, + flags=0, + message_type=Gtk.MessageType.INFO, + buttons=Gtk.ButtonsType.OK_CANCEL, + text="Scan LAN servers", + secondary_text="Select the query port", + title="{appname}", + modal=True, + ) + + self.set_size_request(500, 0) + self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) - header_label = "Scan LAN servers" buttons = [ - ( "Use default query port (27016)", Port.DEFAULT ), - ( "Enter custom query port", Port.CUSTOM ), - ] + ("Use default query port (27016)", Port.DEFAULT), + ("Enter custom query port", Port.CUSTOM), + ] - self.buttonBox.set_orientation(Gtk.Orientation.VERTICAL) - self.buttonBox.active_button = None + self.button_box = Gtk.Box() + self.button_box.set_orientation(Gtk.Orientation.VERTICAL) + self.button_box.active_button = None - for i in enumerate(buttons): - - string = i[1][0] - enum = i[1][1] - - button = Gtk.RadioButton(label=string) - button.port = enum + for k, v in buttons: + button = Gtk.RadioButton(label=k) + button.port = v button.connect("toggled", self._on_button_toggled) - - if i[0] == 0: - self.buttonBox.active_button = button + self.button_box.add(button) + if v == Port.DEFAULT: + self.button_box.active_button = button else: - button.join_group(self.buttonBox.active_button) - - self.buttonBox.add(button) + button.join_group(self.button_box.active_button) self.entry = Gtk.Entry() - self.buttonBox.add(self.entry) + self.button_box.add(self.entry) self.entry.set_no_show_all(True) - self.label = Gtk.Label() - self.label.set_text("Invalid port") - self.label.set_no_show_all(True) - self.buttonBox.add(self.label) + self.warn_label = Gtk.Label(label="Invalid port") + self.warn_label.set_no_show_all(True) + self.button_box.add(self.warn_label) - self.dialog = LanDialog(parent, header_label, self.buttonBox, self.entry, self.label) - self.dialog.run() - self.dialog.destroy() + content = self.get_content_area() + content.pack_start(self.button_box, False, False, 0) + content.set_margin_start(30) + content.set_margin_end(30) + content.show_all() - def get_selected_port(self): - return self.dialog.p + self.action_area.set_layout(Gtk.ButtonBoxStyle.CENTER) + self.action_area.set_margin_bottom(20) - def _on_button_toggled(self, button): + 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: + if self._is_invalid(text): + state = False + self.warn_label.set_visible(True) + else: + state = True + self.warn_label.set_visible(False) + + self.ok.set_sensitive(state) + 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) -> None: + context = WindowContext.MAIN_MENU + App.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: "LanDialog", 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 = 27016 + case Port.CUSTOM: + if self._is_invalid(string): + self.stop_emission_by_name("response") + else: + self.port = int(string) + + def _is_invalid(self, string: str) -> bool: + if ( + not string.isdigit() + or int(string) == 0 + or int(string[0]) == 0 + or int(string) > 65535 + ): + return True + return False + + def get_selected_port(self) -> int: + return self.port + + def _on_button_toggled(self, button: Gtk.Button) -> None: if button.get_active(): - self.buttonBox.active_button = button - + 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() - - def get_active_button(): - return self.buttonBox.active_button + self.ok.set_sensitive(False) -class LanDialog(Gtk.MessageDialog): - # Custom dialog class that performs integer validation and blocks input if invalid port - # Returns None if user cancels the dialog - def __init__(self, parent, text, child, entry, label): - super().__init__(transient_for=parent, - flags=0, - message_type=Gtk.MessageType.INFO, - buttons=Gtk.ButtonsType.OK_CANCEL, - text=text, - secondary_text="Select the query port", - title="DZGUI - Dialog", - modal=True, - ) +class DetailsDialog(GenericDialog): + def __init__(self, server_name: str, ip: str, qport: str): + super().__init__(server_name, Popup.DETAILS) - self.outer = self.get_content_area() - self.outer.pack_start(child, False, False, 0) - self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) - self.set_size_request(500, 0) - self.outer.set_margin_start(30) - self.outer.set_margin_end(30) - self.outer.show_all() - - self.connect("response", self._on_dialog_response, child, entry) - self.connect("key-press-event", self._on_keypress, entry) - self.connect("key-release-event", self._on_key_release, entry, label) - - self.child = child - - self.p = None - - def _on_key_release(self, dialog, event, entry, label): - label.set_visible(False) - if entry.is_visible() == False or entry.get_text() == "": - return - if self._is_invalid(entry.get_text()): - label.set_visible(True) - else: - label.set_visible(False) - - def _on_keypress(self, a, event, entry): - if event.keyval == Gdk.KEY_Return: - self.response(Gtk.ResponseType.OK) - if event.keyval == Gdk.KEY_Up: - entry.set_text("") - self.child.get_children()[0].grab_focus() - - def _on_dialog_response(self, dialog, resp, child, entry): - match resp: - case Gtk.ResponseType.CANCEL: - return - case Gtk.ResponseType.DELETE_EVENT: - return - - string = entry.get_text() - port = child.active_button.port - - match port: - case Port.DEFAULT: - self.p = "27016" - case Port.CUSTOM: - if self._is_invalid(string): - self.stop_emission_by_name("response") - else: - self.p = string - - def _is_invalid(self, string): - if string.isdigit() == False \ - or int(string) == 0 \ - or int(string[0]) == 0 \ - or int(string) > 65535: - return True - return False - - -def ChangelogDialog(parent): - - text = '' - mode = "Changelog -- content can be scrolled" - dialog = GenericDialog(parent, text, mode) - dialogBox = dialog.get_content_area() - dialog.set_default_response(Gtk.ResponseType.OK) - dialog.set_size_request(1000, 600) - - with open(changelog_path, 'r') as f: - changelog = f.read() - - scrollable = Gtk.ScrolledWindow() - label = Gtk.Label() - label.set_markup(changelog) - scrollable.add(label) - dialogBox.pack_end(scrollable, True, True, 0) - set_surrounding_margins(dialogBox, 30) - - dialog.show_all() - return dialog - - -def KeysDialog(parent, text, mode): - - dialog = GenericDialog(parent, text, mode) - dialogBox = dialog.get_content_area() - dialog.set_default_response(Gtk.ResponseType.OK) - dialog.set_size_request(700, 0) - - keybindings = """ - Basic navigation - Ctrl-q: quit - Enter/Space/Double click: select row item - Up, Down: navigate through row items - ?: open this dialog - - Button navigation - Right: jump from main view to side buttons - Left: jump from side buttons to main view - Up, Down: navigate up and down through side buttons - Tab, Shift-Tab: navigate forward/back through menu elements - - Any server browsing context - Enter/Space/Double click: connect to server - Right-click on row/Ctrl-l: displays additional context menus - Ctrl-f: jump to keyword field - Ctrl-m: jump to maps field - Ctrl-d: toggle dry run (debug) mode - Ctrl-r: refresh player count for active row - 1-9: toggle filter ON/OFF - ESC: jump back to main view from keyword/maps - """ - - label = Gtk.Label() - label.set_markup(keybindings) - dialogBox.pack_end(label, False, False, 0) - dialog.show_all() - return dialog - - -class PingDialog(GenericDialog): - def __init__(self, parent, text, mode, record): - super().__init__(parent, text, mode) - dialogBox = self.get_content_area() + dialog_box = self.get_content_area() self.set_default_response(Gtk.ResponseType.OK) - self.set_size_request(500, 200) - wait_dialog = GenericDialog(parent, "Checking ping", Popup.WAIT) - wait_dialog.show_all() - thread = threading.Thread(target=self._background, args=(wait_dialog, parent, record)) + self.set_size_request(800, 700) + + self.ip = ip.split(":")[0] + self.qport = int(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_message = Gtk.ScrolledWindow() + desc = Gtk.Label(label="Server message", valign=Gtk.Align.START) + add_class(desc, "details-heading") + box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER + ) + self.description = Gtk.Label(justify=Gtk.Justification.CENTER) + 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, True, True, 0) + dialog_box.pack_start(scrollable_message, True, True, 0) + + self.wait_dialog = GenericDialog("Fetching details", Popup.WAIT) + self.wait_dialog.show_all() + thread = threading.Thread( + target=self._background, args=(self.wait_dialog, ip, qport) + ) thread.start() - def _background(self, dialog, parent, record): - def _load(): - dialog.destroy() - self.show_all() - ping = data.stdout - self.format_secondary_text("Ping to remote server: %s" %(ping)) - res = self.run() - self.destroy() + def _on_row_activated( + self, + treeview: Gtk.TreeView, + tree_iter: Gtk.TreeIter, + col: Gtk.TreeViewColumn, + ) -> None: + self.destroy() - addr = record.split(':') - ip = addr[0] - qport = addr[2] - data = call_out(parent, "test_ping", ip, qport) - GLib.idle_add(_load) + def _load(self) -> None: + if self.wait_dialog: + self.wait_dialog.destroy() + if self.success is False: + msg = """Error while contacting server, possibly timed out. + Please wait and try again. + """ + spawn_dialog(msg, 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) + self.description.set_text(response.description) + self.success = response.success + GLib.idle_add(self._load) class ModDialog(GenericDialog): - def __init__(self, parent, text, mode, record): - super().__init__(parent, text, mode) + def __init__(self, record: str): + msg = "Enter/double click a row to open in Steam 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() + 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( - ["Mod", "ID", "Installed"] - ): - renderer = Gtk.CellRendererText() + for i, column_title in enumerate(["Mod", "ID", "Installed"]): + 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) + column.set_fixed_width(350) dialogBox.pack_end(self.scrollable, True, True, 0) - wait_dialog = GenericDialog(parent, "Fetching modlist", Popup.WAIT) + wait_dialog = GenericDialog("Fetching modlist", Popup.WAIT) wait_dialog.show_all() - thread = threading.Thread(target=self._background, args=(wait_dialog, parent, record)) + thread = threading.Thread( + target=self._background, args=(wait_dialog, record) + ) thread.start() - def _background(self, dialog, parent, record): + def _background(self, dialog: "GenericDialog", record: str) -> None: def _load(): dialog.destroy() if data.returncode == 1: - spawn_dialog(parent, "Server has no mods installed or is unsupported in this mode", Popup.NOTIFY) + msg = """Error while contacting server, possibly timed out. + Please wait and try again. + """ + spawn_dialog(msg, Popup.NOTIFY) return self.show_all() - self.set_markup("Modlist (%s mods)" %(mod_count)) - res = self.run() + self.set_markup(f"Modlist ({mod_count} mods)") + self.run() self.destroy() - addr = record.split(':') - ip = addr[0] - qport = addr[2] - data = call_out(parent, "show_server_modlist", ip, qport) - mod_count = parse_modlist_rows(data) + addr = App.treeview.get_record_dict() + if not addr: + return + ip = addr["ip"] + qport = addr["qport"] + data = call_out("show_server_modlist", ip, qport) + mod_count = self._parse_modlist_rows(data) self.view.set_model(modlist_store) GLib.idle_add(_load) - def popup(self): - pass + def _parse_modlist_rows( + self, data: subprocess.CompletedProcess + ) -> bool | int: + lines = data.stdout.splitlines() + hits = len(lines) + reader = csv.reader(lines, delimiter=delimiter) + try: + rows = [[row[0], row[1], row[2]] for row in reader if row] + except IndexError: + return 1 + for row in rows: + modlist_store.append(row) + return hits - def _on_row_activated(self, treeview, tree_iter, col): + 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 @@ -2266,51 +3107,54 @@ class ModDialog(GenericDialog): path = pathlist[0] tree_iter = model.get_iter(path) mod_id = model.get_value(tree_iter, 1) - subprocess.Popen(['/usr/bin/env', 'bash', funcs, "open_workshop_page", mod_id]) + call_bash_func("open_workshop_page", mod_id) class LinkDialog(GenericDialog): - def __init__(self, parent, text, mode, link, command, uid=None): - super().__init__(parent, text, mode) + def __init__( + self, text: str, link: str | None, command: RowType, uid: str = "" + ): + super().__init__(text, Popup.NOTIFY) - self.dialog = GenericDialog(parent, text, mode) - self.dialogBox = self.dialog.get_content_area() - self.dialog.set_default_response(Gtk.ResponseType.OK) - self.dialog.set_size_request(500, 0) + text = textwrap.dedent(text) + self.dialogBox = self.get_content_area() + self.set_default_response(Gtk.ResponseType.OK) + self.set_size_request(500, 0) - if link is not None: + if link: button = Gtk.Button(label=link) button.set_margin_start(60) button.set_margin_end(60) button.connect("clicked", self._on_button_clicked, uid) self.dialogBox.pack_end(button, False, False, 0) - self.dialog.show_all() - self.dialog.connect("response", self._on_dialog_response, parent, command) + self.show_all() + self.connect("response", self._on_dialog_response, command) - def _on_button_clicked(self, button, uid): - subprocess.Popen(['/usr/bin/env', 'bash', funcs, "open_user_workshop", uid]) + def _on_button_clicked(self, button: Gtk.Button, uid: str) -> None: + call_bash_func("open_user_workshop", uid) - def _on_dialog_response(self, dialog, resp, parent, command): + def _on_dialog_response( + self, dialog: "LinkDialog", resp: Gtk.ResponseType, command: RowType + ) -> None: match resp: case Gtk.ResponseType.DELETE_EVENT: return case Gtk.ResponseType.OK: - self.dialog.destroy() - proc = call_out(parent, "toggle", command.dict["label"]) - parent.grid.update_right_statusbar() - tooltip = format_metadata(command.dict["label"]) - parent.grid.update_statusbar(tooltip) + self.destroy() + call_out("toggle", command.dict["label"]) + App.grid.statusbar.refresh() class EntryDialog(GenericDialog): - def __init__(self, parent, text, mode, link): - super().__init__(parent, text, mode) + def __init__(self, text: str, mode: Popup, link: str): + super().__init__(text, mode) - """ Returns user input as a string or None """ - """ If user does not input text it returns None, NOT AN EMPTY STRING. """ + """ + Returns user input as a string or None + """ - self.dialog = GenericDialog(parent, text, mode) + 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) @@ -2322,62 +3166,412 @@ class EntryDialog(GenericDialog): self.userEntry.set_activates_default(True) self.dialogBox.pack_start(self.userEntry, False, False, 0) - if link is not None: + if link: button = Gtk.Button(label=link) button.set_margin_start(60) button.set_margin_end(60) button.connect("clicked", self._on_button_clicked) self.dialogBox.pack_end(button, False, False, 0) - def _on_button_clicked(self, button): - label = button.get_label() - subprocess.Popen(['/usr/bin/env', 'bash', funcs, "Open link", label]) + self.ok = self.dialog.action_area.get_children()[1] + self.ok.set_sensitive(False) + self.userEntry.connect("insert-text", self._on_text_typed) + self.userEntry.get_property("buffer").connect( + "deleted-text", self._on_text_deleted + ) - def get_input(self): + 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) -> None: + label = button.get_label() + call_bash_func("Open link", label) + + def get_input(self) -> str | None: self.dialog.show_all() + response = self.dialog.run() text = self.userEntry.get_text() self.dialog.destroy() - if (response == Gtk.ResponseType.OK) and (text != ''): + if (response == Gtk.ResponseType.OK) and (text != ""): return text else: return None -class Grid(Gtk.Grid): - def __init__(self, is_steam_deck): +class ScrollableNote(Gtk.Box): + def __init__(self, content_box: Gtk.Box): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + + self.scrollable = Gtk.ScrolledWindow() + self.scrollable.set_vexpand(True) + + self.back_button = Gtk.Button( + label="Back", hexpand=True, halign=Gtk.Align.CENTER + ) + self.back_button.connect("clicked", self._on_back_clicked) + + self.gutter = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, valign=Gtk.Align.END + ) + self.gutter.add(self.back_button) + + self.scrollable.add(content_box) + self.add(self.scrollable) + self.add(self.gutter) + + @update_window_labels + def _on_back_clicked(self, button: Gtk.Button) -> None: + if App.notebook.get_page() == NotebookPage.KEYS.value: + App.notebook.toggle_keybindings() + return + App.notebook.set_page_enum(NotebookPage.MAIN) + + +class KeybindingsDialog(Gtk.Box): + """ + Notebook page holding a prearranged grid + of keybindings and their descriptions + """ + + def __init__(self): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + + navigation = { + "Enter/space/double click": "select row item", + "Down arrow": "move down a row/scroll down", + "Up arrow": "move up a row/scroll up", + "Right arrow": "jump to sidebar from main area", + "Left arrow": "jump to main area from sidebar", + "Tab": "cycle forward through elements", + "Shift-tab": "cycle backward through elements", + "ESC/Enter": "close dialogs", + "?": "show/hide this dialog", + "Ctrl-q": "Quit", + } + servers = { + "Enter/space/double-click": "connect to server", + "Right-click/Ctrl-l": "additional context menus", + "Ctrl-r": "refresh players", + "Ctrl-p": "refresh ping", + "Ctrl-f": "jump to keyword search field", + "Ctrl-m": "jump to maps field", + "Ctrl-d": "toggle dry run (debug) mode", + "ESC": "return to table", + "1-9": "toggle filter 1-9 on/off", + "0": "toggle filter 10", + "Minus": "toggle filter 11", + "Backslash": "toggle filter 12", + } + vim = { + "j": "Move down a row/scroll up", + "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 last row/bottom of page", + } + + label = Gtk.Label(label="Keybindings") + add_class(label, "page-heading") + self.add(label) + + grid = self.build_grid([servers, navigation, vim]) + self.add(grid) + + def build_keys(self, items: list) -> Gtk.Grid: + grid = Gtk.Grid(row_spacing=10, column_homogeneous=True) + grid.set_halign(Gtk.Align.START) + row = 1 + col = 0 + w = 1 + h = 1 + sep = None + for item in items: + for k, v in item.items(): + desc = Gtk.Label(label=k) + desc.set_halign(Gtk.Align.START) + + key = Gtk.Label(label=v) + key.set_halign(Gtk.Align.CENTER) + + frame = Gtk.Frame() + frame.add(key) + add_class(frame, "frame") + + col = col + 1 + if col > 1: + row += 1 + col = 1 + if not sep: + grid.attach(desc, col, row, w, h) + else: + 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) + + 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 + ) + row += 1 + return grid + + def build_sidebar(self, categories: list) -> Gtk.Grid: + row = 0 + col = 0 + w = 1 + h = 1 + sidebar = Gtk.Grid( + row_homogeneous=True, orientation=Gtk.Orientation.VERTICAL + ) + for cat in categories: + label = Gtk.Label(label=cat) + add_class(label, "left-label") + row += 1 + sidebar.attach(label, col, row, w, h) + return sidebar + + def build_grid(self, items: list) -> Gtk.Grid: + grid = Gtk.Grid( + row_spacing=20, + halign=Gtk.Align.CENTER, + margin_top=20, + column_spacing=50, + ) + + row = 1 + column = 1 + w = 1 + h = 1 + sidebar = self.build_sidebar( + ["Servers", "Navigation", "Vim-style keys"] + ) + separator = Gtk.Separator() + keys_box = self.build_keys(items) + + grid.attach(sidebar, column, row, w, h) + 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 + + +class Changelog(Gtk.Box): + def __init__(self): super().__init__() - self.set_column_homogeneous(True) - #self.set_row_homogeneous(True) + try: + with open(changelog_path, "r") as f: + changelog = f.read() + changelog_label = Gtk.Label() + changelog = self.format_pango(changelog) + changelog_label.set_markup(changelog) + self.add(changelog_label) + except FileNotFoundError: + msg = f"Failed to find CHANGELOG.md at {changelog_path}" + logger.critical(msg) + spawn_dialog(msg, Popup.WARN) + return + except OSError as e: + spawn_dialog(f"Something went wrong: {e}", Popup.WARN) + logger.critical(e) + return - self._version = "%s %s" %(app_name, sys.argv[2]) + def format_pango(self, text: str) -> str: + medium = '' + large = '' + xlarge = '' + text = re.sub("^# ", xlarge, text, flags=re.M) + text = re.sub("^## ", large, text, flags=re.M) + text = re.sub("^### ", medium, text, flags=re.M) + text = re.sub(r"(", text) + return text - self.scrollable_treelist = ScrollableTree(is_steam_deck) - self.scrollable_treelist.set_hexpand(False) - self.scrollable_treelist.set_vexpand(True) - self.right_panel = RightPanel(is_steam_deck) - self.sel_panel = ModSelectionPanel() - self.right_panel.pack_start(self.sel_panel, False, False, 0) - self.show_all() +class Notebook(Gtk.Notebook): + def __init__(self): + super().__init__(show_tabs=False, show_border=False) - self.bar = Gtk.Statusbar() - self.scrollable_treelist.treeview.connect("on_distcalc_started", self._on_calclat_started) + clog = Changelog() + self.clog = ScrollableNote(clog) + self.clog.type = RowType.CHANGELOG + self.clog.show_all() + self.append_page(self.clog) + self.prior_page: int + self.prior_subpage = None - GLib.timeout_add(200, self._check_result_queue) + self.keys = ScrollableNote(KeybindingsDialog()) + self.keys.type = RowType.KEYBINDINGS + self.keys.show_all() + self.append_page(self.keys) - self.update_statusbar(default_tooltip) + self.connect("switch-page", self._on_page_changed) + self.connect("key-press-event", self._on_keypress) + + def _set_adjustment(self, adjustment: VAdjustment) -> None: + INCREMENT = 50 + page = self.get_page() + if not page: + return + allowed_contexts = [RowType.CHANGELOG, RowType.KEYBINDINGS] + if page.type not in allowed_contexts: + return + vadj = page.scrollable.get_vadjustment() + match adjustment: + case VAdjustment.TOP: + adj = vadj.get_lower() + case VAdjustment.BOTTOM: + adj = vadj.get_upper() + case VAdjustment.UP: + adj = vadj.get_value() - INCREMENT + case VAdjustment.DOWN: + adj = vadj.get_value() + INCREMENT + vadj.set_value(adj) + + def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: + match event.keyval: + case Gdk.KEY_Return: + page = self.get_page() + if page: + page.back_button.clicked() + case Gdk.KEY_Right | Gdk.KEY_l: + if event.state is Gdk.ModifierType.CONTROL_MASK: + return + App.right_panel.focus_button_box() + case Gdk.KEY_question: + self.toggle_keybindings() + case Gdk.KEY_k | Gdk.KEY_Up: + self._set_adjustment(VAdjustment.UP) + case Gdk.KEY_Down | Gdk.KEY_j: + self._set_adjustment(VAdjustment.DOWN) + case Gdk.KEY_g: + self._set_adjustment(VAdjustment.TOP) + case Gdk.KEY_G: + self._set_adjustment(VAdjustment.BOTTOM) + + def toggle_keybindings(self) -> None: + if self.get_current_page() == NotebookPage.KEYS.value: + self.set_current_page(self.prior_page) + else: + self.prior_page = self.get_current_page() + self.set_page_enum(NotebookPage.KEYS) + + def focus_current(self) -> None: + widget = self.get_page() + if widget: + w = widget.get_children()[0] + w.grab_focus() + + def get_page(self) -> Gtk.Widget | None: + ind = self.get_current_page() + widget = self.get_nth_page(ind) + if not widget: + return None + return widget + + def set_page_enum(self, enum: NotebookPage) -> None: + self.prior_page = self.get_current_page() + self.set_current_page(enum.value) + self.focus_current() + self._set_adjustment(VAdjustment.TOP) + + @update_window_labels + def _on_page_changed( + self, notebook: "Notebook", page: Gtk.Widget, page_num: int + ) -> None: + pass + if page.type is None: + App.treeview.subpage = self.prior_subpage + else: + self.prior_subpage = App.treeview.subpage + App.treeview.subpage = page.type + + +class Statusbar(Gtk.Statusbar): + def __init__(self): + super().__init__() + + help_text = "Select a row to see its detailed description" + self.set_text(help_text) self.status_right_label = Gtk.Label(label="") - self.bar.add(self.status_right_label) - self.update_right_statusbar() + self.add(self.status_right_label) + self.update_app_meta() - self.attach(self.scrollable_treelist, 0, 0, 3, 1) - self.attach_next_to(self.bar, self.scrollable_treelist, Gtk.PositionType.BOTTOM, 3, 1) - self.attach_next_to(self.right_panel, self.scrollable_treelist, Gtk.PositionType.RIGHT, 1, 1) + self.players = "" - def update_right_statusbar(self): + def get_text(self) -> str: + area = self.get_message_area() + label = area.get_children()[0] + return label.get_text() + + def set_text(self, string: str) -> None: + if string is None: + return + meta = self.get_context_id("Statusbar") + self.push(meta, string) + + def refresh(self) -> None: + self.update_app_meta() + command = App.treeview.get_value_at_index(0) + formatted = format_metadata(command) + if len(formatted) > 0: + self.set_text(formatted) + + def append_distance(self, dist: str) -> None: + if dist == "Unknown": + dist = f"| Distance: {dist}" + else: + d = int(dist) + dist = f"| Distance: {d:n} km" + self.set_text(self.players + dist) + + def update_server_meta(self) -> None: + model = App.treeview.get_model() + if model is None: + players = 0 + hits = 0 + else: + hits = len(model) + players = 0 + for row in model: + players += row[4] + + players_pretty = pluralize("players", players) + hits_pretty = pluralize("matches", hits) + formatted = ( + f"Found {hits:n} {hits_pretty} with {players:n} {players_pretty}" + ) + suffix = "| Distance: calculating..." + + if players == 0: + suffix = "" + self.set_text(formatted + suffix) + self.players = formatted + + def update_app_meta(self) -> None: config_vals.clear() - for i in query_config(self): + for i in query_config(): config_vals.append(i) _branch = config_vals[0] _branch = _branch.upper() @@ -2386,143 +3580,158 @@ class Grid(Gtk.Grid): _debug = "NORMAL" else: _debug = "DEBUG" - concat_label = "%s | %s | %s" %(_branch, _debug, self._version) + concat_label = f"{_branch} | {_debug} | {_VERSION}" self.status_right_label.set_text(concat_label) - def terminate_treeview_process(self): + +class Grid(Gtk.Grid): + def __init__(self): + super().__init__() + self.set_column_homogeneous(True) + + self._version = f"{app_name} {_VERSION}" + + self.scrollable_treelist = ScrollableTree() + self.scrollable_treelist.set_hexpand(False) + self.scrollable_treelist.set_vexpand(True) + self.scrollable_treelist.treeview.connect( + "on_distcalc_started", self._on_calclat_started + ) + + self.right_panel = RightPanel() + self.sel_panel = ModSelectionPanel() + self.right_panel.pack_start(self.sel_panel, False, False, 0) + + """ + Note that due to historical reasons, Gtk.Notebook refuses to + switch to a page unless the child widget is visible. + Therefore, it is recommended to show child widgets + before adding them to a notebook. + + """ + self.show_all() + self.scrollable_treelist.type = None + self.notebook = Notebook() + self.notebook.insert_page(self.scrollable_treelist, None, 0) + + self.statusbar = Statusbar() + GLib.timeout_add(200, self._check_result_queue) + + self.breadcrumbs = Gtk.Label(label="Main menu", halign=Gtk.Align.START) + + self.attach(self.notebook, 0, 0, 3, 1) + self.attach_next_to( + self.breadcrumbs, self.notebook, Gtk.PositionType.TOP, 3, 1 + ) + self.attach_next_to( + self.statusbar, self.notebook, Gtk.PositionType.BOTTOM, 3, 1 + ) + self.attach_next_to( + self.right_panel, self.notebook, Gtk.PositionType.RIGHT, 1, 1 + ) + + def get_breadcrumbs(self) -> str: + return self.breadcrumbs.get_text() + + def set_breadcrumbs(self, text: str) -> None: + self.breadcrumbs.set_text(text) + + def terminate_treeview_process(self) -> None: self.scrollable_treelist.treeview.terminate_process() - 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]) + def _on_calclat_started(self, treeview: Gtk.TreeView) -> None: + App.grid.statusbar.update_server_meta() - def _check_result_queue(self): + def _check_result_queue(self) -> Literal[True]: latest_result = None result_queue = self.scrollable_treelist.treeview.queue while not result_queue.empty(): latest_result = result_queue.get() - if latest_result is not None: + if latest_result: addr = latest_result[0] km = latest_result[1] - ping = latest_result[2] - - cache[addr] = km, ping - - ping = format_ping(ping) - dist = format_distance(km) - tooltip = server_tooltip[1] = server_tooltip[0] + dist + ping - self.update_statusbar(tooltip) - + cache[addr] = km + self.statusbar.append_distance(km) return True - def update_statusbar(self, string): - if string is None: - return - meta = self.bar.get_context_id("Statusbar") - self.bar.push(meta, string) - - -def toggle_signal(owner, widget, func_name, bool): - func = getattr(owner, func_name) - if (bool): - logger.debug("Unblocking %s for %s" %(func_name, widget)) - widget.handler_unblock_by_func(func) - else: - logger.debug("Blocking %s for %s" %(func_name, widget)) - widget.handler_block_by_func(func) - class App(Gtk.Application): def __init__(self): - _isd = int(sys.argv[3]) + global IS_STEAM_DECK + global IS_GAME_MODE if _isd == 1: - is_steam_deck = True - is_game_mode = False + IS_STEAM_DECK = True + IS_GAME_MODE = False elif _isd == 2: - is_steam_deck = True - is_game_mode = True + IS_STEAM_DECK = True + IS_GAME_MODE = True else: - is_steam_deck = False - is_game_mode = False + IS_STEAM_DECK = False + IS_GAME_MODE = False GLib.set_prgname(app_name) - self.win = OuterWindow(is_steam_deck, is_game_mode) - self.win.set_icon_name("dzgui") - + self.win = OuterWindow() + self.win.set_icon_name("{app_name_lower}") accel = Gtk.AccelGroup() - accel.connect(Gdk.KEY_q, Gdk.ModifierType.CONTROL_MASK, Gtk.AccelFlags.VISIBLE, self._halt_window_subprocess) + accel.connect( + Gdk.KEY_q, + Gdk.ModifierType.CONTROL_MASK, + Gtk.AccelFlags.VISIBLE, + self._halt_window_subprocess, + ) self.win.add_accel_group(accel) - - GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, self._catch_sigint) + GLib.unix_signal_add( + GLib.PRIORITY_DEFAULT, signal.SIGINT, self._catch_sigint + ) Gtk.main() - def _catch_sigint(self): - self.win.halt_proc_and_quit(self.win, None) + def _catch_sigint(self) -> None: + self.win.halt_proc_and_quit() - def _halt_window_subprocess(self, accel_group, window, code, flag): - self.win.halt_proc_and_quit(self.win, None) + def _halt_window_subprocess( + self, + accel_group: Gtk.AccelGroup, + window: "OuterWindow", + code: Gdk.EventKey, + flag: Gdk.ModifierType, + ) -> None: + self.win.halt_proc_and_quit() -def save_res_and_quit(window): - if window.props.is_maximized: - Gtk.main_quit() - return - rect = window.get_size() - - def write_json(rect): - data = {"res": { "width": rect.width, "height": rect.height } } - j = json.dumps(data, indent=2) - with open(res_path, "w") as outfile: - outfile.write(j) - logger.info("Wrote initial window size to '%s'" %(res_path)) - - if os.path.isfile(res_path): - with open(res_path, "r") as infile: - try: - data = json.load(infile) - data["res"]["width"] = rect.width - data["res"]["height"] = rect.height - with open(res_path, "w") as outfile: - outfile.write(json.dumps(data, indent=2)) - except json.decoder.JSONDecodeError: - logger.critical("JSON decode error in '%s'" %(res_path)) - write_json(rect) - else: - write_json(rect) - - Gtk.main_quit() - class ModSelectionPanel(Gtk.Box): def __init__(self): super().__init__(spacing=6) self.set_orientation(Gtk.Orientation.VERTICAL) - labels = [ - {"label": "Select all", "tooltip": "Bulk selects all mods"}, - {"label": "Unselect all", "tooltip": "Bulk unselects all mods"}, - {"label": "Delete selected", "tooltip": "Deletes selected mods from the system"}, - {"label": "Highlight stale", "tooltip": "Shows locally-installed mods\nwhich are not used by any server\nin your Saved Servers"} - ] + {"label": "Select all", "tooltip": "Bulk selects all mods"}, + {"label": "Unselect all", "tooltip": "Bulk unselects all mods"}, + { + "label": "Delete selected", + "tooltip": "Deletes selected mods from the system", + }, + { + "label": "Highlight stale", + "tooltip": """Shows locally-installed mods + which are not used by any server + in your Saved Servers""", + }, + ] self.active_button = None - for l in labels: - button = Gtk.Button(label=l["label"]) - button.set_tooltip_text(l["tooltip"]) + for label in labels: + button = Gtk.Button(label=label["label"]) + button.set_tooltip_text(label["tooltip"]) button.set_margin_start(10) button.set_margin_end(10) button.connect("clicked", self._on_button_clicked) self.pack_start(button, False, True, 0) - def initialize(self): - l = len(self.get_children()) - last = self.get_children()[l-1] - last_label = last.get_label() + def initialize(self) -> None: for i in self.get_children(): match i.get_label(): case "Select stale": @@ -2530,12 +3739,10 @@ class ModSelectionPanel(Gtk.Box): case "Unhighlight stale": i.set_label("Highlight stale") - def _on_button_clicked(self, button): + def _on_button_clicked(self, button: Gtk.Button) -> None: self.active_button = button label = button.get_label() - widgets = relative_widget(self) - parent = widgets["outer"] - treeview = widgets["treeview"] + treeview = App.treeview (model, pathlist) = treeview.get_selection().get_selected_rows() match label: case "Select all": @@ -2548,77 +3755,73 @@ class ModSelectionPanel(Gtk.Box): return self._iterate_mod_deletion(model, pathlist, ct) case "Highlight stale": - process_tree_option([treeview.view, RowType.HIGHLIGHT], treeview) + process_tree_option(RowType.HIGHLIGHT) case "Unhighlight stale": self.colorize_cells(False) self._remove_last_button() case "Select stale": - for i in range (0, len(mod_store)): + for i in range(0, len(mod_store)): if mod_store[i][4] == "#FF0000": - path = Gtk.TreePath(i) + path = Gtk.TreePath.new_from_indices([i]) treeview.get_selection().select_path(path) - def _remove_last_button(self): + def _remove_last_button(self) -> None: children = self.get_children() - l = len(children) - tip = children[l-1] + tip = children[-1] label = tip.get_label() if label == "Select stale": tip.destroy() - - def toggle_select_stale_button(self, bool): - if bool is True: - button = Gtk.Button(label="Select stale") - button.set_tooltip_text("Bulk selects all currently highlighted mods") - button.set_margin_start(10) - button.set_margin_end(10) + def toggle_select_stale_button(self, state: bool) -> None: + if state: + button = Gtk.Button( + label="Select stale", margin_start=10, margin_end=10 + ) + text = "Bulk selects all currently highlighted mods" + button.set_tooltip_text(text) button.connect("clicked", self._on_button_clicked) self.pack_start(button, False, True, 0) self.show_all() - def colorize_cells(self, bool): + def colorize_cells(self, state: bool) -> None: def _colorize(path, color): mod_store[path][4] = color - - widgets = relative_widget(self) - parent = widgets["outer"] - treeview = widgets["treeview"] + + treeview = App.treeview (model, pathlist) = treeview.get_selection().get_selected_rows() - if bool is False: - for i in range (0, len(mod_store)): - path = Gtk.TreePath(i) + if not state: + for i in range(0, len(mod_store)): + path = Gtk.TreePath.new_from_indices([i]) it = mod_store.get_iter(path) _colorize(path, None) self.active_button.set_label("Highlight stale") return with open(stale_mods_temp_file, "r") as infile: - lines = [line.rstrip('\n') for line in infile] + lines = [line.rstrip("\n") for line in infile] - for i in range (0, len(mod_store)): + hits = 0 + for i, row in enumerate(mod_store): # type: ignore red = "#FF0000" - path = Gtk.TreePath(i) + path = Gtk.TreePath.new_from_indices([i]) it = mod_store.get_iter(path) if model.get_value(it, 2) not in lines: + hits += 1 _colorize(path, red) treeview.toggle_selection(False) + if hits > 0: self.active_button.set_label("Unhighlight stale") - self.active_button.set_tooltip_text("Clears highlights and reverts\nthe table to a default state") - - - def _iterate_mod_deletion(self, model, pathlist, ct): - widgets = relative_widget(self) - parent = widgets["outer"] - treeview = widgets["treeview"] + text = "Clears highlights and reverts the table to a default state" + self.active_button.set_tooltip_text(text) + self.toggle_select_stale_button(True) + def _iterate_mod_deletion( + self, model: Gtk.ListStore, pathlist: list, ct: int + ) -> None: pretty = pluralize("mods", ct) conf_msg = f"You are going to delete {ct} {pretty}. Proceed?" - success_msg = f"Successfully deleted {ct} {pretty}." - fail_msg = "An error occurred during deletion. Aborting." - - res = spawn_dialog(parent, conf_msg, Popup.CONFIRM) + res = spawn_dialog(conf_msg, Popup.CONFIRM) if res != 0: return @@ -2629,26 +3832,59 @@ class ModSelectionPanel(Gtk.Box): path = model.get_value(it, 2) concat = symlink + " " + path + "\n" mods.append(concat) - # hedge against large number of arguments passed to shell + # use a temp file to avoid passing too many args to shell with open(mods_temp_file, "w") as outfile: outfile.writelines(mods) - process_tree_option([treeview.view, RowType.DELETE_SELECTED], treeview) + process_tree_option(RowType.DELETE_SELECTED) class FilterPanel(Gtk.Box): def __init__(self): super().__init__(spacing=6) - for check in filters.keys(): - checkbutton = Gtk.CheckButton(label=check) - label = checkbutton.get_children() + self.default_filters = { + "1PP": True, + "Day": True, + "Empty": False, + "3PP": True, + "Night": True, + "Full": False, + "Low pop": True, + "Non-ASCII": False, + "Duplicate": False, + "Official": True, + "Unoffic.": True, + "Modded": True, + } + self.checks = [] + self.maps_hr = [] + self.enabled_filters = dict(self.default_filters) + self.keyword_filter = "" + self.selected_map = "All maps" + self.prior_map = "All maps" + + button_grid = Gtk.Grid( + 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 filters[check] is True: - checkbutton.set_active(True) - toggled_checks.append(check) - checkbutton.connect("toggled", self._on_check_toggle) - checks.append(checkbutton) + + if self.default_filters[check]: + checkbox.set_active(True) + + col = col + 1 + if col > 3: + row += 1 + col = 1 + button_grid.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) @@ -2660,7 +3896,9 @@ class FilterPanel(Gtk.Box): 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_esc_pressed) + self.keyword_entry.connect( + "key-press-event", self._on_keyword_keypress + ) completion = Gtk.EntryCompletion(inline_completion=True) completion.set_text_column(0) @@ -2671,7 +3909,6 @@ class FilterPanel(Gtk.Box): self.maps_combo = Gtk.ComboBox.new_with_model_and_entry(map_store) self.maps_combo.set_entry_text_column(0) - # instantiate maps completer entry self.maps_entry = self.maps_combo.get_child() self.maps_entry.set_completion(completion) self.maps_entry.set_placeholder_text("Filter by map") @@ -2680,157 +3917,195 @@ class FilterPanel(Gtk.Box): 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_esc_pressed) + self.maps_combo.connect("key-press-event", self._on_combo_keypress) self.pack_start(self.filters_label, False, False, 0) self.pack_start(self.keyword_entry, False, False, 0) self.pack_start(self.maps_combo, False, False, 0) - button_grid = Gtk.Grid() - row = 1 - col = 0 - for i, check in enumerate(checks[0:]): - col = col + 1 - if (col > 3): - row = row + 1 - col = 1 - button_grid.attach(checks[i], col, row, 1, 1) self.pack_start(button_grid, False, False, 0) - def _on_map_entry_keypress(self, entry, event): + def set_unique_maps(self, maps: list) -> None: + if len(maps) < 1: + return + u_maps = set([row[1] for row in maps]) # type: ignore + u_maps = sorted(u_maps) # type: ignore + for m in u_maps: + map_store.append([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) + + # used on personal/local server lists + def enable_all_filters(self) -> None: + for check in self.checks: + check.set_active(True) + for k in self.enabled_filters: + self.enabled_filters[k] = True + + def reinit_panel(self) -> None: + self.keyword_entry.set_text("") + self.keyword_filter = "" + self.reinit_filters() + self.set_visible(False) + sel_panel = App.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) + 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 + ) -> 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): + """ + 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) - # TODO: this is a workaround for widget.grab_remove() - # set cursor position to SOL when unfocusing + """ + 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_completer_match(self, completion, model, iter): - self.maps_combo.set_active_iter(iter) + 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, editable): text = entry.get_text() completion = entry.get_completion() - if len(text) >= completion.get_minimum_key_length(): completion.set_model(map_store) - self._on_map_changed(self.maps_combo) - def grab_keyword_focus(self): - self.keyword_entry.grab_focus() - - def restore_focus_to_treeview(self): - grid = self.get_outer_grid() - grid.scrollable_treelist.treeview.grab_focus() + def restore_focus_to_treeview(self) -> Literal[False]: + App.treeview.grab_focus() return False - def _on_esc_pressed(self, entry, event): + def _on_keyword_keypress( + self, entry: Gtk.Entry, event: Gdk.EventKey + ) -> bool: match event.keyval: - case Gdk.KEY_Escape: - GLib.idle_add(self.restore_focus_to_treeview) case Gdk.KEY_Up: return True case Gdk.KEY_Down: return True + case Gdk.KEY_Escape: + GLib.idle_add(self.restore_focus_to_treeview) + return True + return False + + def _on_combo_keypress( + self, combo: Gtk.ComboBox, event: Gdk.EventKey + ) -> bool: + match event.keyval: + case Gdk.KEY_Down: + self.maps_combo.popup() + return True case _: return False - def get_outer_grid(self): - panel = self.get_parent() - grid = panel.get_parent() - return grid + def set_prior_map(self, mapname: str) -> None: + self.prior_map = mapname - def get_outer_window(self): - grid = self.get_outer_grid() - outer_window = grid.get_parent() - return outer_window + def get_prior_map(self) -> str: + return self.prior_map - def _on_keyword_enter(self, keyword_entry): - win = self.get_outer_window() - win.set_keep_below(False) - keyword = keyword_entry.get_text() - old_keyword = keyword_filter[0].split(delimiter)[1] - if keyword == old_keyword: + 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: + App.window.set_keep_below(False) + keyword = entry.get_text().lower() + if keyword == self.keyword_filter: return - logger.info("User filtered by keyword '%s'" %(keyword)) - keyword_filter.clear() - keyword_filter.append("Keyword␞" + keyword) - transient_parent = self.get_outer_window() - grid = self.get_outer_grid() - treeview = grid.scrollable_treelist.treeview - context = grid.scrollable_treelist.treeview.get_first_col() - filter_servers(transient_parent, self, treeview, context) + if keyword.isspace(): + return + logger.info(f"User filtered by keyword '{keyword}'") + self.keyword_filter = keyword + App.treeview.filter(FilterMode.KEYWORD, keyword) - def _on_button_release(self, window, button): + def _on_button_release(self, window, button) -> Literal[True]: return True - def set_active_combo(self): - self.maps_combo.set_active(0) + def get_active_combo(self) -> int: + return self.maps_combo.get_active() - def toggle_check(self, button): - if button.get_active(): - button.set_active(False) - else: - button.set_active(True) + def set_active_combo(self, row: int) -> None: + self.maps_combo.set_active(row) - def _on_check_toggle(self, button): - grid = self.get_outer_grid() - treeview = grid.scrollable_treelist.treeview - context = grid.scrollable_treelist.treeview.get_first_col() + def toggle_check(self, digit: int) -> None: + check = self.checks[digit] + state = check.get_active() + check.set_active(not state) + + def _on_check_toggled(self, button: Gtk.CheckButton) -> None: + if not App.treeview.is_server_context(App.treeview.view): + return label = button.get_label() state = button.get_active() - - if context == "Mod": - return - if state is True: - toggled_checks.append(label) + logger.info(f"User toggled button '{label}' to {state}") + if state: + mode = FilterMode.TOGGLE_ON else: - toggled_checks.remove(label) + mode = FilterMode.TOGGLE_OFF - logger.info("User toggled button '%s' to %s" %(label, state)) - transient_parent = self.get_outer_window() - filter_servers(transient_parent, self, treeview, context) - - def _on_map_changed(self, combo): - grid = self.get_outer_grid() - transient_parent = self.get_outer_window() - treeview = grid.scrollable_treelist.treeview - context = grid.scrollable_treelist.treeview.get_first_col() + self.enabled_filters[label] = state + App.treeview.filter(mode, label) + def _on_map_changed(self, combo: Gtk.ComboBox) -> None: + old_sel = self.selected_map + model = combo.get_model() tree_iter = combo.get_active_iter() - if tree_iter is not None: - # take no action if completer query is same as current map sel - old_sel = selected_map[0].split("Map=")[1] - model = combo.get_model() - selection = model[tree_iter][0] - if selection == old_sel: - return - - selected_map.clear() - if selection is not None: - selected_map.append("Map=" + selection) - logger.info("User selected map '%s'" %(selection)) - filter_servers(transient_parent, self, treeview, context) - self.maps_entry.set_text(selection) + if tree_iter is None: + return + selection = model[tree_iter][0] + if selection == old_sel: + 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) + App.treeview.filter(FilterMode.MAP) def main(): - def usage(): - text = "UI constructor must be run via DZGUI" + text = "UI helper must be run via DZGUI" logger.critical(text) print(text) sys.exit(1) @@ -2842,8 +4117,11 @@ def main(): usage() logger.info("Spawned UI from DZGUI setup process") + global _VERSION + _VERSION = sys.argv[2] App() -if __name__ == '__main__': +ModelManager = ModelManagerSingleton() +if __name__ == "__main__": main()