diff --git a/CHANGELOG.md b/CHANGELOG.md index 4804309..4d2c86e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,19 +1,20 @@ # Changelog -## [6.0.0-beta.1] 2025-07-25 +## [6.0.0-beta.1] 2025-07-28 ## Added -- 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 -- Speed up load time of server tables -- More robust threading and cache system when filtering servers +- Ping column to server browsing contexts - Filter by modded servers -- Refresh server ping context menu and hotkey -- Disable "show mods" context menu if server has no mods - Breadcrumbs showing current menu context to top of window -- 0, minus, and backslash keybindings for filter toggles +- Speed up load time and navigation of server tables - 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 +- More robust threading and cache system when filtering servers +- Refresh server ping: context menu and hotkey +- Disable "show mods" context menu if server has no mods +- 0, minus, and backslash keybindings for filter toggles ## Fixed - Entry dialog sensitivity when validating API keys diff --git a/dzgui.sh b/dzgui.sh index b80ca0c..ea2e75d 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -585,11 +585,11 @@ fetch_helpers_by_sum(){ [[ -f "$config_file" ]] && source "$config_file" declare -A sums sums=( - ["ui.py"]="c05cb7eaf40347b49cdd71030ca9cc39" + ["ui.py"]="168865d5a0e7936e0f72da59dde6b6f4" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" ["funcs"]="5d244114c501754a630988354306e470" - ["servers.py"]="e5eae96d11cb1e551aaae74a469c4f5f" + ["servers.py"]="dc23a9f15e5014be171054792cf3fc16" ) local author="aclist" local repo="dztui" diff --git a/helpers/servers.py b/helpers/servers.py index cb5cb9b..c07c0ac 100644 --- a/helpers/servers.py +++ b/helpers/servers.py @@ -1,4 +1,5 @@ import json +import math import os import re import socket @@ -123,6 +124,11 @@ def parse_json(json: list) -> list: except IndexError: continue + try: + ping = row["ping"] + except KeyError: + ping = 9999 + mapname = row["map"].lower() players = row["players"] max_players = row["max_players"] @@ -136,6 +142,7 @@ def parse_json(json: list) -> list: int(queue), ip, int(qport), + ping, provider, modded, ] @@ -143,9 +150,9 @@ def parse_json(json: list) -> list: return rows -def query_direct(ip: str, qport: int) -> dict | None: +def query_direct(ip: str, qport: int, TIMEOUT=3.0) -> dict | None: try: - info = a2s.info((ip, qport)) + info = a2s.info((ip, qport), TIMEOUT) name = info.server_name mapname = info.map_name @@ -155,6 +162,12 @@ def query_direct(ip: str, qport: int) -> dict | None: 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 @@ -163,6 +176,7 @@ def query_direct(ip: str, qport: int) -> dict | None: res["max_players"] = max_players res["addr"] = address res["gameport"] = gameport + res["ping"] = ping return res except TimeoutError: return None @@ -177,6 +191,40 @@ class Res: json: Union[str, None] +@dataclass +class Ping: + addr: str + iteration: int + ping: int + + +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/?" diff --git a/helpers/ui.py b/helpers/ui.py index c215f7a..3294845 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -16,11 +16,12 @@ import warnings from enum import Enum from concurrent.futures import wait from concurrent.futures import ThreadPoolExecutor + from collections.abc import Callable from typing import Literal, Self, Any sys.path.append("servers") -import servers as Servers +import servers as Servers # noqa E402 locale.setlocale(locale.LC_ALL, "") @@ -1013,6 +1014,7 @@ class OuterWindow(Gtk.Window): 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 @@ -1085,16 +1087,30 @@ class RightPanel(Gtk.Box): self.pack_start(self.button_vbox, False, False, 0) self.pack_start(self.filters_vbox, False, False, 0) - tooltip = ( + 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" + ) + + 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=tooltip, + tooltip_text=debug_tooltip, ) if query_config("debug")[0] == "1": @@ -1110,9 +1126,13 @@ class RightPanel(Gtk.Box): ) 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, False, True, 0) + def enable_ping_button(self, state: bool) -> None: + self.ping.set_visible(state) + def reinit_maps(self, rows: list) -> None: map_store.clear() map_store.append(["All maps"]) @@ -1129,6 +1149,37 @@ class RightPanel(Gtk.Box): grid.statusbar.refresh() App.grid.notebook.focus_current() + 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() @@ -1250,6 +1301,7 @@ class ModelManagerSingleton: def __init__(self): # packed ListStores self.filter_cache = {} + self.ping_cache = {} # stringwise (list) representation of the model self.control_model = None self.filtered = None @@ -1285,9 +1337,11 @@ class ModelManagerSingleton: 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: @@ -1296,8 +1350,14 @@ class ModelManagerSingleton: 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) @@ -1370,9 +1430,9 @@ class ModelManagerSingleton: case "1PP": rows = [row for row in rows if row[2] != "1PP"] case "Official": - rows = [row for row in rows if row[9] != "Official"] + rows = [row for row in rows if row[10] != "Official"] case "Unoffic.": - rows = [row for row in rows if row[9] != "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": @@ -1397,7 +1457,7 @@ class ModelManagerSingleton: 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[10]] + rows = [row for row in rows if not row[11]] return rows def filter_toggle_on(self, filters: tuple, *args: str) -> list: @@ -1417,7 +1477,7 @@ class ModelManagerSingleton: def new_model(self) -> Gtk.ListStore: return Gtk.ListStore( - str, str, str, str, int, int, int, str, int, str, bool + str, str, str, str, int, int, int, str, int, int, str, bool ) def resync_model(self, addr: str, qport: int) -> None: @@ -1433,6 +1493,8 @@ class ModelManagerSingleton: 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] @@ -1442,6 +1504,9 @@ class ModelManagerSingleton: rows = [] self.filtered = rows + def get_filtered(self) -> list: + return self.filtered + def set_store(self, model: Gtk.ListStore | None) -> None: self.store = model @@ -1461,6 +1526,7 @@ class ModelManagerSingleton: self.success = True self.filtered = None self.filter_cache = {} + self.ping_cache = {} if full: self.control_model = None @@ -1610,9 +1676,12 @@ class TreeView(Gtk.TreeView): addr = model.get_value(it, 7) qport = model.get_value(it, 8) model.remove(it) - print(addr) - ModelManager.resync_model(addr, qport) + 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() @@ -1660,7 +1729,7 @@ class TreeView(Gtk.TreeView): (model, pathlist) = sels path = pathlist[0] tree_iter = model.get_iter(path) - mods = model.get_value(tree_iter, 10) + mods = model.get_value(tree_iter, 11) return mods def is_in_favs(self) -> bool: @@ -1973,7 +2042,7 @@ class TreeView(Gtk.TreeView): path = pathlist[0] model = self.get_model() if not model: - return + return None addr = model[path][7] qport = model[path][8] ip = addr.split(":")[0] @@ -1994,6 +2063,12 @@ class TreeView(Gtk.TreeView): 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) @@ -2102,8 +2177,11 @@ class TreeView(Gtk.TreeView): 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) @@ -2112,6 +2190,7 @@ class TreeView(Gtk.TreeView): 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) @@ -2309,6 +2388,7 @@ class TreeView(Gtk.TreeView): "Queue", "IP", "Qport", + "Ping", ] for i, column_title in enumerate(browser_cols): renderer = Gtk.CellRendererText() @@ -2318,7 +2398,10 @@ class TreeView(Gtk.TreeView): column.set_sort_column_id(i) if valid_json: - 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: @@ -2428,6 +2511,7 @@ class TreeView(Gtk.TreeView): 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)