Compare commits

...
Author SHA1 Message Date
aclist 63316a01a9 fix: extraneous newlines in log 2026-11-11 03:57:51 +09:00
aclist 0729273503 change: drop Union 2026-11-11 03:51:09 +09:00
aclist 91d164d666 chore: drop unused method 2026-11-11 03:49:43 +09:00
aclist 0d7f022c43 Merge branch 'dzgui7' into change/server-row-dataclass 2026-11-11 03:49:05 +09:00
aclist f76c400697 Merge pull request #535 from aclist/change/enum-dataclasses
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
change: enum dataclasses
2026-11-11 03:44:58 +09:00
aclist 7ae5880890 chore: update union
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-11-11 03:36:14 +09:00
aclist afbced228d Merge branch 'dzgui7' into change/enum-dataclasses 2026-11-11 03:33:44 +09:00
aclist f5d7bf713d Merge pull request #538 from aclist/fix/clipboard-button-controller
fix: clipboard button controller
2026-11-11 03:32:27 +09:00
aclist 9b948271e1 chore: add test 2026-11-11 00:57:12 +09:00
aclist 7322970c54 fix: define constants outside of FiltrationManager 2026-11-11 00:56:51 +09:00
aclist 504a2a724c fix: typo 2026-11-11 00:48:37 +09:00
aclist c66511b005 change: marshal server row data 2026-11-11 00:41:40 +09:00
aclist c94e4e73db chore: add strings file 2026-11-10 04:34:38 +09:00
aclist 8da264686a chore: clear typehinting errors 2026-11-10 04:33:54 +09:00
aclist 598973104c change: reference dataclass values in enum 2026-11-10 04:30:40 +09:00
20 changed files with 500 additions and 349 deletions

No files matched your search

+3 -2
View File
@@ -13,6 +13,7 @@ from typing import Any, Optional, TYPE_CHECKING, Union
from dzgui.const.constants import APP_NAME, REQUEST_TIMEOUT
from dzgui.const.endpoints import STEAM_SERVERS
from dzgui.model.proxy_model import ServerRow
from dzgui.util.format import format_exception
from dzgui.util import strings
@@ -141,7 +142,7 @@ def sanitize(name: str) -> str:
def parse_json(
json: list[dict[str, Any]],
) -> list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]]:
) -> list[ServerRow]:
"""
Server metadata is underspecified and server operators
tend to insert random garbage in the headers. In case
@@ -226,7 +227,7 @@ def parse_json(
mapname = row["map"].lower()
players = row["players"]
max_players = row["max_players"]
raw = (
raw = ServerRow(
name,
mapname,
view,
+2 -1
View File
@@ -5,6 +5,7 @@ from dzgui.util._json import read_json
from dzgui.const.enum import Preferences
class ConfigFileError(Exception):
pass
@@ -39,4 +40,4 @@ def get_favorites(path: Path) -> list[str]:
def enum_to_key(enum: Preferences) -> str:
return str(enum.dict["key"])
return str(enum.key)
+224 -206
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Any, Self
from dzgui.util import strings
from dzgui.strings import rows
class Port(Enum):
@@ -46,125 +48,61 @@ class FilterMode(Enum):
TOGGLE_ON = 3
class EnumWithAttrs(Enum):
def __new__(cls, *args: Any, **kwargs: Any) -> Self:
value = len(cls.__members__) + 1
obj = object.__new__(cls)
obj._value_ = value
return obj
def __init__(self, d: dict):
self.dict = d
@dataclass(slots=True, frozen=True)
class ModButtonMeta:
label: str
tooltip: str
class Preferences(EnumWithAttrs):
STEAM = {
"key": "steam_api",
}
CLIENT = {
"key": "client",
}
WINDOW = {
"key": "fullscreen",
}
DIST = {
"key": "use_miles",
}
NAME = {
"key": "name",
}
DEFAULT = {
"key": "default_steam_path",
}
FAV_LBL = {
"key": "fav_label",
}
FAV_SRV = {
"key": "fav_server",
}
IP_LIST = {
"key": "ip_list",
}
START_TAB = {
"key": "start_tab",
}
@dataclass(slots=True, frozen=True)
class ButtonTypeMeta:
label: str
tooltip: str
opens: NotebookPage | None
class NotebookPage(EnumWithAttrs):
OFFLINE = {"crumbs": strings.crumbs.offline, "statusbar": True}
CHANGELOG = {"crumbs": strings.crumbs.changelog, "statusbar": True}
DEVELOPERS = {"crumbs": strings.crumbs.developers, "statusbar": False}
HELP = {"crumbs": strings.crumbs._help, "statusbar": True}
KEYS = {"crumbs": strings.crumbs.keys, "statusbar": True}
LOG = {"crumbs": strings.crumbs.log, "statusbar": True}
MODS = {"crumbs": strings.crumbs.mods, "statusbar": True}
OPTIONS = {"crumbs": strings.crumbs.options, "statusbar": False}
SERVERS = {"crumbs": strings.crumbs.servers, "statusbar": True}
THANKS = {"crumbs": strings.crumbs.thanks, "statusbar": True}
CONNECTION = {"crumbs": "Connect", "statusbar": True}
@dataclass(slots=True, frozen=True)
class ConfigKey:
key: str
class RowType(EnumWithAttrs):
# TODO: strings
CHANGELOG = {
"label": "View changelog",
"tooltip": "Opens the DZGUI changelog",
}
SHOW_LOG = {
"label": "Show debug log",
"tooltip": "Read the DZGUI log generated since startup",
}
SEPARATOR = {
"label": "SEPARATOR",
"tooltip": "",
}
DUMP_LOG = {
"label": "Output system info to log file",
"tooltip": "Dump diagnostic data for troubleshooting",
}
DOCS = {
"label": "Documentation/help files (GitHub) ⧉",
"tooltip": "Open the DZGUI documentation in a browser",
}
DOCS_FALLBACK = {
"label": "Documentation/help files (Codeberg mirror) ⧉",
"tooltip": "Open the DZGUI documentation in a browser",
}
BUGS = {
"label": "Report a bug (GitHub) ⧉",
"tooltip": "Open the DZGUI issue tracker in a browser",
}
FORUM = {
"label": "DZGUI Subreddit ⧉",
"tooltip": "Open the DZGUI discussion forum in a browser",
}
SPONSOR = {
"label": "Sponsor (GitHub) ⧉",
"tooltip": "Sponsor development of the DZGUI project",
}
THANKS = {
"label": "Special thanks",
"tooltip": "A list of contributors, testers, and sponsors",
}
@dataclass(slots=True, frozen=True)
class NotebookMeta:
crumbs: str
has_statusbar: bool
# TODO: rename to ContextMenuItem
class ContextMenu(EnumWithAttrs):
ADD_NOTE = {"label": strings.add_note}
ADD_SERVER = {"label": strings.add}
CONNECT = {"label": strings.connect}
COPY_LOG_CLIPBOARD = {"label": strings.copy_log}
COPY_SERVER_IP = {"label": strings.copy_ip}
COPY_SERVER_NAME = {"label": strings.copy_name}
UNSUB_MOD = {"label": strings.unsub_mod}
VALIDATE_MOD = {"label": strings.validate_mod}
OPEN_WORKSHOP = {"label": strings.open_workshop}
REFRESH_PLAYERS = {"label": strings.refresh_players}
REMOVE_HISTORY = {"label": strings.remove_history}
REMOVE_SERVER = {"label": strings.remove}
SET_FAV = {"label": strings.add_fav}
SHOW_DETAILS = {"label": strings.show_details}
SHOW_MODS = {"label": strings.show_mods}
@dataclass(slots=True, frozen=True)
class RowMeta:
label: str
tooltip: str
@dataclass(slots=True, frozen=True)
class ContextMenuMeta:
label: str
class ContextMenuItem(Enum):
ADD_NOTE = ContextMenuMeta(strings.add_note)
ADD_SERVER = ContextMenuMeta(strings.add)
CONNECT = ContextMenuMeta(strings.connect)
COPY_LOG_CLIPBOARD = ContextMenuMeta(strings.copy_log)
COPY_SERVER_IP = ContextMenuMeta(strings.copy_ip)
COPY_SERVER_NAME = ContextMenuMeta(strings.copy_name)
UNSUB_MOD = ContextMenuMeta(strings.unsub_mod)
VALIDATE_MOD = ContextMenuMeta(strings.validate_mod)
OPEN_WORKSHOP = ContextMenuMeta(strings.open_workshop)
REFRESH_PLAYERS = ContextMenuMeta(strings.refresh_players)
REMOVE_HISTORY = ContextMenuMeta(strings.remove_history)
REMOVE_SERVER = ContextMenuMeta(strings.remove)
SET_FAV = ContextMenuMeta(strings.add_fav)
SHOW_DETAILS = ContextMenuMeta(strings.show_details)
SHOW_MODS = ContextMenuMeta(strings.show_mods)
@property
def label(self) -> str:
return self.value.label
class ContextMenuGroup(Enum):
@@ -172,113 +110,193 @@ class ContextMenuGroup(Enum):
Groupings of context menu items
"""
SERVER_MOD = (ContextMenu.OPEN_WORKSHOP,)
MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.UNSUB_MOD, ContextMenu.VALIDATE_MOD)
SERVER_MOD = (ContextMenuItem.OPEN_WORKSHOP,)
MOD = (
ContextMenuItem.OPEN_WORKSHOP,
ContextMenuItem.UNSUB_MOD,
ContextMenuItem.VALIDATE_MOD,
)
MOD_OFFLINE = (None,)
LOG = (ContextMenu.COPY_LOG_CLIPBOARD,)
LOG = (ContextMenuItem.COPY_LOG_CLIPBOARD,)
SERVER_BROWSER = (
ContextMenu.CONNECT,
ContextMenu.ADD_SERVER,
ContextMenu.SET_FAV,
ContextMenu.COPY_SERVER_NAME,
ContextMenu.COPY_SERVER_IP,
ContextMenu.ADD_NOTE,
ContextMenu.SHOW_MODS,
ContextMenu.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS,
ContextMenuItem.CONNECT,
ContextMenuItem.ADD_SERVER,
ContextMenuItem.SET_FAV,
ContextMenuItem.COPY_SERVER_NAME,
ContextMenuItem.COPY_SERVER_IP,
ContextMenuItem.ADD_NOTE,
ContextMenuItem.SHOW_MODS,
ContextMenuItem.SHOW_DETAILS,
ContextMenuItem.REFRESH_PLAYERS,
)
SCAN_LAN = (
ContextMenu.CONNECT,
ContextMenu.SET_FAV,
ContextMenu.COPY_SERVER_NAME,
ContextMenu.COPY_SERVER_IP,
ContextMenu.ADD_NOTE,
ContextMenu.SHOW_MODS,
ContextMenu.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS,
ContextMenuItem.CONNECT,
ContextMenuItem.SET_FAV,
ContextMenuItem.COPY_SERVER_NAME,
ContextMenuItem.COPY_SERVER_IP,
ContextMenuItem.ADD_NOTE,
ContextMenuItem.SHOW_MODS,
ContextMenuItem.SHOW_DETAILS,
ContextMenuItem.REFRESH_PLAYERS,
)
SAVED = (
ContextMenu.CONNECT,
ContextMenu.SET_FAV,
ContextMenu.REMOVE_SERVER,
ContextMenu.COPY_SERVER_NAME,
ContextMenu.COPY_SERVER_IP,
ContextMenu.ADD_NOTE,
ContextMenu.SHOW_MODS,
ContextMenu.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS,
ContextMenuItem.CONNECT,
ContextMenuItem.SET_FAV,
ContextMenuItem.REMOVE_SERVER,
ContextMenuItem.COPY_SERVER_NAME,
ContextMenuItem.COPY_SERVER_IP,
ContextMenuItem.ADD_NOTE,
ContextMenuItem.SHOW_MODS,
ContextMenuItem.SHOW_DETAILS,
ContextMenuItem.REFRESH_PLAYERS,
)
RECENT = (
ContextMenu.CONNECT,
ContextMenu.ADD_SERVER,
ContextMenu.SET_FAV,
ContextMenu.REMOVE_HISTORY,
ContextMenu.COPY_SERVER_NAME,
ContextMenu.COPY_SERVER_IP,
ContextMenu.ADD_NOTE,
ContextMenu.SHOW_MODS,
ContextMenu.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS,
ContextMenuItem.CONNECT,
ContextMenuItem.ADD_SERVER,
ContextMenuItem.SET_FAV,
ContextMenuItem.REMOVE_HISTORY,
ContextMenuItem.COPY_SERVER_NAME,
ContextMenuItem.COPY_SERVER_IP,
ContextMenuItem.ADD_NOTE,
ContextMenuItem.SHOW_MODS,
ContextMenuItem.SHOW_DETAILS,
ContextMenuItem.REFRESH_PLAYERS,
)
class ModButton(EnumWithAttrs):
SELECT_ALL = {
"label": strings.mod_panel.select_all,
"tooltip": strings.mod_panel.select_all_tooltip,
}
UNSELECT_ALL = {
"label": strings.mod_panel.unselect_all,
"tooltip": strings.mod_panel.unselect_all_tooltip,
}
HIGHLIGHT_STALE = {
"label": strings.mod_panel.highlight_stale,
"tooltip": strings.mod_panel.highlight_stale_tooltip,
}
UNHIGHLIGHT_STALE = {
"label": strings.mod_panel.unhighlight_stale,
"tooltip": strings.mod_panel.unhighlight_stale_tooltip,
}
UNSUB_SELECTED = {
"label": strings.mod_panel.unsub_selected,
"tooltip": strings.mod_panel.unsub_selected_tooltip,
}
SELECT_STALE = {
"label": strings.mod_panel.select_stale,
"tooltip": strings.mod_panel.select_stale_tooltip,
}
VALIDATE = {
"label": strings.mod_panel.validate,
"tooltip": strings.mod_panel.validate_tooltip,
}
class Preferences(Enum):
STEAM = ConfigKey("steam_api")
CLIENT = ConfigKey("client")
WINDOW = ConfigKey("fullscreen")
DIST = ConfigKey("use_miles")
NAME = ConfigKey("name")
DEFAULT = ConfigKey("default_steam_path")
FAV_LBL = ConfigKey("fav_label")
FAV_SRV = ConfigKey("fav_server")
IP_LIST = ConfigKey("ip_list")
START_TAB = ConfigKey("start_tab")
@property
def key(self) -> str:
return self.value.key
class ButtonType(EnumWithAttrs):
SERVERS = {
"label": strings.buttons.servers_label,
"tooltip": strings.buttons.servers_tooltip,
"opens": NotebookPage.SERVERS,
}
MODS = {
"label": strings.buttons.mods_label,
"tooltip": strings.buttons.mods_tooltip,
"opens": NotebookPage.MODS,
}
OPTIONS = {
"label": strings.buttons.options_label,
"tooltip": strings.buttons.options_tooltip,
"opens": NotebookPage.OPTIONS,
}
HELP = {
"label": strings.buttons.help_label,
"tooltip": strings.buttons.help_tooltip,
"opens": NotebookPage.HELP,
}
EXIT = {
"label": strings.buttons.exit_label,
"tooltip": strings.buttons.exit_tooltip,
"opens": None,
}
class NotebookPage(Enum):
OFFLINE = NotebookMeta(strings.crumbs.offline, True)
CHANGELOG = NotebookMeta(strings.crumbs.changelog, True)
DEVELOPERS = NotebookMeta(strings.crumbs.developers, False)
HELP = NotebookMeta(strings.crumbs._help, True)
KEYS = NotebookMeta(strings.crumbs.keys, True)
LOG = NotebookMeta(strings.crumbs.log, True)
MODS = NotebookMeta(strings.crumbs.mods, True)
OPTIONS = NotebookMeta(strings.crumbs.options, False)
SERVERS = NotebookMeta(strings.crumbs.servers, True)
THANKS = NotebookMeta(strings.crumbs.thanks, True)
CONNECTION = NotebookMeta(strings.crumbs.connect, True)
@property
def crumbs(self) -> str:
return self.value.crumbs
@property
def has_statusbar(self) -> bool:
return self.value.has_statusbar
class RowType(Enum):
CHANGELOG = RowMeta(rows.changelog_label, rows.changelog_status)
SHOW_LOG = RowMeta(rows.log_label, rows.log_status)
SEPARATOR = RowMeta(rows.separator_label, rows.separator_status)
DUMP_LOG = RowMeta(rows.system_log_label, rows.system_log_status)
DOCS = RowMeta(rows.docs_label, rows.docs_status)
DOCS_FALLBACK = RowMeta(rows.docs_fallback_label, rows.docs_fallback_status)
BUGS = RowMeta(rows.bugs_label, rows.bugs_status)
FORUM = RowMeta(rows.forum_label, rows.forum_status)
SPONSOR = RowMeta(rows.sponsor_label, rows.sponsor_status)
THANKS = RowMeta(rows.thanks_label, rows.thanks_status)
@property
def label(self) -> str:
return self.value.label
@property
def tooltip(self) -> str:
return self.value.tooltip
class ModButton(Enum):
SELECT_ALL = ModButtonMeta(
strings.mod_panel.select_all, strings.mod_panel.select_all_tooltip
)
UNSELECT_ALL = ModButtonMeta(
strings.mod_panel.unselect_all, strings.mod_panel.unselect_all_tooltip
)
HIGHLIGHT_STALE = ModButtonMeta(
strings.mod_panel.highlight_stale, strings.mod_panel.highlight_stale_tooltip
)
UNHIGHLIGHT_STALE = ModButtonMeta(
strings.mod_panel.unhighlight_stale, strings.mod_panel.unhighlight_stale_tooltip
)
UNSUB_SELECTED = ModButtonMeta(
strings.mod_panel.unsub_selected,
strings.mod_panel.unsub_selected_tooltip,
)
SELECT_STALE = ModButtonMeta(
strings.mod_panel.select_stale,
strings.mod_panel.select_stale_tooltip,
)
VALIDATE = ModButtonMeta(
strings.mod_panel.validate,
strings.mod_panel.validate_tooltip,
)
@property
def label(self) -> str:
return self.value.label
@property
def tooltip(self) -> str:
return self.value.tooltip
class ButtonType(Enum):
SERVERS = ButtonTypeMeta(
strings.buttons.servers_label,
strings.buttons.servers_tooltip,
NotebookPage.SERVERS,
)
MODS = ButtonTypeMeta(
strings.buttons.mods_label,
strings.buttons.mods_tooltip,
NotebookPage.MODS,
)
OPTIONS = ButtonTypeMeta(
strings.buttons.options_label,
strings.buttons.options_tooltip,
NotebookPage.OPTIONS,
)
HELP = ButtonTypeMeta(
strings.buttons.help_label,
strings.buttons.help_tooltip,
NotebookPage.HELP,
)
EXIT = ButtonTypeMeta(
strings.buttons.exit_label,
strings.buttons.exit_tooltip,
None,
)
@property
def label(self) -> str:
return self.value.label
@property
def tooltip(self) -> str:
return self.value.tooltip
@property
def opens(self) -> NotebookPage | None:
return self.value.opens
HELP_MENU_ROWS = (
+5 -4
View File
@@ -8,7 +8,7 @@ import dzgui.util._json as JSON # noqa
from dzgui.const.enum import (
ButtonType,
ContextMenu,
ContextMenuItem,
FilterMode,
NotebookPage,
Preferences,
@@ -196,7 +196,9 @@ class Controller(GObject.GObject):
def show_developers_page(self) -> None:
self.open_page(NotebookPage.DEVELOPERS)
def open_page(self, page: NotebookPage) -> None:
def open_page(self, page: NotebookPage | None) -> None:
if page is None:
return
self.mediator.grid.notebook.set_page_by_enum(page)
def open_page_by_button(self, button: "ContextualButton") -> None:
@@ -262,7 +264,7 @@ class Controller(GObject.GObject):
logger.critical(e)
return
def menu_action(self, action: ContextMenu, tree: "ServerTreeView") -> None:
def menu_action(self, action: ContextMenuItem, tree: "ServerTreeView") -> None:
context_man = ContextMenuManager(tree, self)
context_man.process(action)
@@ -370,7 +372,6 @@ class Controller(GObject.GObject):
return filter_man.get_filters()
# FIXME: wipe maps store when changing tabs if model is none
def has_server_model(self) -> bool:
treeview = self.get_active_treeview()
proxy_man = treeview.get_proxy_man()
+1 -1
View File
@@ -124,7 +124,7 @@ class ConfigManager:
return False
def enum_to_key(self, enum: Preferences) -> str:
return str(enum.dict["key"])
return str(enum.key)
def toggle_config(self, key: Preferences) -> None:
try:
+18 -18
View File
@@ -2,7 +2,7 @@ import logging
from typing import TYPE_CHECKING
from dzgui.const.constants import APP_NAME
from dzgui.const.enum import ContextMenu, Preferences
from dzgui.const.enum import ContextMenuItem, Preferences
from dzgui.util.clip import copy_clipboard
from dzgui.util.open_links import open_workshop_page
@@ -34,7 +34,7 @@ class ContextMenuManager:
self.controller = controller
self.treeview = treeview
def process(self, action: ContextMenu) -> None:
def process(self, action: ContextMenuItem) -> None:
if isinstance(self.treeview, ServerTreeView):
record = self.treeview.get_record()
if record is None:
@@ -42,14 +42,14 @@ class ContextMenuManager:
match action:
# UNTHREADED
case ContextMenu.ADD_NOTE:
case ContextMenuItem.ADD_NOTE:
dialog = NoteDialog(self.controller)
dialog.run() # type: ignore[no-untyped-call]
case ContextMenu.COPY_SERVER_IP:
case ContextMenuItem.COPY_SERVER_IP:
self.copy_server_ip()
case ContextMenu.COPY_SERVER_NAME:
case ContextMenuItem.COPY_SERVER_NAME:
self.copy_server_name()
case ContextMenu.SET_FAV:
case ContextMenuItem.SET_FAV:
name = self.treeview.get_value_at_index(0)
# NOTE: fully qualified ip
fqip = self.treeview.get_record_string()
@@ -58,40 +58,40 @@ class ContextMenuManager:
self.controller.set_fav(name, fqip, simple)
# THREADED
case ContextMenu.ADD_SERVER:
case ContextMenuItem.ADD_SERVER:
self.controller.add_by_record(record)
case ContextMenu.CONNECT:
case ContextMenuItem.CONNECT:
self.controller.connect_by_record(record)
case ContextMenu.REFRESH_PLAYERS:
case ContextMenuItem.REFRESH_PLAYERS:
self.controller.refresh_players(record)
case ContextMenu.REMOVE_HISTORY:
case ContextMenuItem.REMOVE_HISTORY:
self.controller.remove_from_history(record)
case ContextMenu.REMOVE_SERVER:
case ContextMenuItem.REMOVE_SERVER:
self.controller.remove_by_record(record)
case ContextMenu.SHOW_DETAILS:
case ContextMenuItem.SHOW_DETAILS:
self.controller.get_details(record)
case ContextMenu.SHOW_MODS:
case ContextMenuItem.SHOW_MODS:
self.controller.get_modlist(record)
if isinstance(self.treeview, (ModTreeView, OfflineModTreeView)):
match action:
case ContextMenu.OPEN_WORKSHOP:
case ContextMenuItem.OPEN_WORKSHOP:
self.open_mod_page()
case ContextMenu.UNSUB_MOD:
case ContextMenuItem.UNSUB_MOD:
self.controller.unsub_mods(self.treeview)
case ContextMenu.VALIDATE_MOD:
case ContextMenuItem.VALIDATE_MOD:
self.controller.validate_mods(self.treeview)
case _:
pass
if isinstance(self.treeview, LogTreeView):
match action:
case ContextMenu.COPY_LOG_CLIPBOARD:
case ContextMenuItem.COPY_LOG_CLIPBOARD:
self.copy_log()
if isinstance(self.treeview, ServerModTreeView):
match action:
case ContextMenu.OPEN_WORKSHOP:
case ContextMenuItem.OPEN_WORKSHOP:
self.open_mod_page()
def copy_server_ip(self) -> None:
+1 -1
View File
@@ -147,7 +147,7 @@ class ModelFactory:
def make_help_store(self) -> FastInsertListStore:
store = self.new_model_from_class(MenuCols)
rows = [[row.dict["label"], row] for row in HELP_MENU_ROWS]
rows = [[row.label, row] for row in HELP_MENU_ROWS]
store.extend(rows)
return store
+149 -75
View File
@@ -1,7 +1,8 @@
import re
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING, Union
from dataclasses import dataclass, fields
from operator import attrgetter
from typing import TYPE_CHECKING
from dzgui.const.enum import FilterMode
from dzgui.model.model_factory import ModelFactory
@@ -33,6 +34,101 @@ class ServerRow:
provider: str
modded: bool
# NOTE: construct getter once for fast lookup in lieu of field introspection
_to_tuple = attrgetter(
"name",
"mapname",
"view",
"time",
"players",
"max_players",
"queue",
"ip",
"qport",
"ping",
"provider",
"modded",
)
# TODO: tests for marshalled row insertion
@classmethod
def marshal_rows(
cls, rows: list["ServerRow"]
) -> list[tuple[str, str, str, str, int, int, int, str, int, str, str, bool]]:
"""Marshals data before passing into FastInsertListStore's extend() method"""
getter = cls._to_tuple
return [getter(row) for row in rows]
class FiltrationManager:
def __init__(self) -> None:
self.filter_map = {
strings.filter_3pp: self.filter_3pp,
strings.filter_1pp: self.filter_1pp,
strings.filter_official: self.filter_official,
strings.filter_unofficial: self.filter_unofficial,
strings.filter_empty: self.filter_empty,
strings.filter_full: self.filter_full,
strings.filter_duplicate: self.filter_duplicate,
strings.filter_day: self.filter_day,
strings.filter_night: self.filter_night,
strings.filter_nonascii: self.filter_nonascii,
strings.filter_lowpop: self.filter_lowpop,
strings.filter_modded: self.filter_modded,
strings.filter_unmodded: self.filter_unmodded,
}
def filter_3pp(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if row.view != strings.filter_3pp]
def filter_1pp(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if row.view != strings.filter_1pp]
def filter_official(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if row.provider != strings.filter_official]
def filter_unofficial(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if row.provider != strings.filter_unofficial]
def filter_empty(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if row.players != 0]
def filter_full(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if row.players != row.max_players]
def filter_duplicate(self, rows: list[ServerRow]) -> list[ServerRow]:
seen = set()
final = []
for row in rows:
if row.name not in seen:
seen.add(row.name)
final.append(row)
rows = final
return rows
def filter_day(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if not re.match(DAY_REG, row.time)]
def filter_night(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if not re.match(NIGHT_REG, row.time)]
def filter_nonascii(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if row.name.isascii()]
def filter_lowpop(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if (row.players / row.max_players * 100) > 30]
def filter_modded(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if not row.modded]
def filter_unmodded(self, rows: list[ServerRow]) -> list[ServerRow]:
return [row for row in rows if row.modded]
def filter(self, filter_type: str, rows: list[ServerRow]) -> list[ServerRow]:
func = self.filter_map[filter_type]
return func(rows)
class ProxyModelManager:
"""
@@ -49,19 +145,15 @@ class ProxyModelManager:
def __init__(self, filter_man: "FilterManager") -> None:
self.filter_cache: dict[
tuple[str, ...],
list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]],
list[ServerRow],
] = {}
self.proxy_model: "FastInsertListStore"
self.filter_man = filter_man
self.filtration_manager = FiltrationManager()
self.control_model: (
list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]]
| None
) = None
self.filtered: list[
tuple[str, str, str, str, int, int, int, str, int, int, str, bool]
] = []
self.control_model: list[ServerRow] | None = None
self.filtered: list[ServerRow] = []
self.success = True
def has_control_model(self) -> bool:
@@ -69,9 +161,7 @@ class ProxyModelManager:
return False
return True
def append_row_to_control(
self, row: tuple[str, str, str, str, int, int, int, str, int, int, str, bool]
) -> None:
def append_row_to_control(self, row: ServerRow) -> None:
if self.control_model is None:
raise AttributeError("Trying to add rows to a non-existent model")
self.control_model.append(row)
@@ -79,21 +169,24 @@ class ProxyModelManager:
def update_playercount(self, playercount: "NewPlayerCount") -> None:
treeiter = playercount.treeiter
self.proxy_model[treeiter][4] = playercount.players
self.proxy_model[treeiter][6] = playercount.queue
field = [f.name for f in fields(ServerRow)]
index_players = field.index("players")
index_queue = field.index("queue")
self.proxy_model[treeiter][index_players] = playercount.players
self.proxy_model[treeiter][index_queue] = playercount.queue
def append_row_to_history(
self,
history: tuple[str, str, str, str, int, int, int, str, int, int, str, bool],
history: ServerRow,
) -> None:
addr = history[7]
qport = history[8]
addr = history.ip
qport = history.qport
if self.control_model is None:
raise AttributeError("Trying to append row to empty model")
found = False
for i, row in enumerate(self.control_model):
if addr == row[7] and qport == row[8]:
if addr == row.ip and qport == row.qport:
item = self.control_model.pop(i)
self.control_model.append(item)
found = True
@@ -102,6 +195,7 @@ class ProxyModelManager:
if found is False:
self.control_model.append(history)
# NOTE: truncate history to 10 records max
if len(self.control_model) == 11:
del self.control_model[0]
@@ -113,7 +207,7 @@ class ProxyModelManager:
addr = f"{record.ip}:{record.gameport}"
qport = record.qport
for row in self.control_model:
if addr == row[7] and qport == row[8]:
if addr == row.ip and qport == row.qport:
self.control_model.remove(row)
break
self.wipe_cache()
@@ -122,19 +216,22 @@ class ProxyModelManager:
def clear_proxy_model(self) -> None:
self.proxy_model.clear()
def get_proxy_model(self) -> Union["FastInsertListStore", None]:
def get_proxy_model(self) -> "FastInsertListStore | None":
if hasattr(self, "proxy_model"):
return self.proxy_model
return None
def filter(
self, mode: FilterMode, skip_cache: bool = False
) -> Union["FastInsertListStore", None]:
) -> "FastInsertListStore | None":
"""
Native Gtk.TreeView.refilter() method was not performant enough
when running in the main loop with 40k+ records
skip_cache: used when updating a record on Saved Servers/History and forcing a refilter
NOTE: data must be marshalled from ServerRow format before constructing list stores
The "clone" is the proxy model (list of tuples) actually fed into Gtk.TreeView
"""
# TODO: filter cache: return a dataclass object with clearly enumerated map, keyword, and filter values
# instead of just a serial list of strings
@@ -143,8 +240,9 @@ class ProxyModelManager:
if skip_cache is False:
if filters in self.filter_cache:
cached_rows = self.filter_cache[filters]
marshalled_rows = ServerRow.marshal_rows(cached_rows)
clone = ModelFactory().make_server_store()
clone.extend(cached_rows)
clone.extend(marshalled_rows)
self.set_proxy_model(clone)
self.set_filtered(cached_rows)
return None
@@ -163,17 +261,18 @@ class ProxyModelManager:
clone = ModelFactory().make_server_store()
if len(rows) > 0:
rows = self.sort_rows(rows)
clone.extend(rows)
marshalled_rows = ServerRow.marshal_rows(rows)
clone.extend(marshalled_rows)
self.set_cache(filters, rows)
self.set_proxy_model(clone)
return clone
def sort_rows(self, rows: list) -> list:
rows.sort(key=lambda x: re.sub(r"[^A-Za-z0-9]+", "", x[0].lower()))
def sort_rows(self, rows: list[ServerRow]) -> list[ServerRow]:
rows.sort(key=lambda x: re.sub(r"[^A-Za-z0-9]+", "", x.name.lower()))
return rows
def filter_initial(self, filters: tuple) -> list:
def filter_initial(self, filters: tuple[str, ...]) -> list[ServerRow]:
"""
Simply culls the control model of any disabled filters
"""
@@ -182,7 +281,7 @@ class ProxyModelManager:
self.set_filtered(self.filter_toggle_off(filters, f))
return self.filtered
def filter_map(self, filters: tuple) -> list:
def filter_map(self, filters: tuple[str, ...]) -> list[ServerRow]:
"""
Multi-filtration for any context starts by narrowing by map
"""
@@ -192,10 +291,10 @@ class ProxyModelManager:
if sel_map == str_filters.all_maps:
return rows
rows = [row for row in rows if row[1] == sel_map]
rows = [row for row in rows if row.mapname == sel_map]
return rows
def filter_keyword(self, filters: tuple) -> list:
def filter_keyword(self, filters: tuple[str, ...]) -> list[ServerRow]:
keyword = self.filter_man.get_active_keyword()
rows = self.filtered
@@ -205,13 +304,15 @@ class ProxyModelManager:
filtered = [
row
for row in rows
if keyword in row[0].lower()
or keyword in row[1].lower()
or keyword in row[7].lower()
if keyword in row.name.lower()
or keyword in row.mapname.lower()
or keyword in row.ip.lower()
]
return filtered
def filter_toggle_off(self, filters: tuple, filter_type: str) -> list:
def filter_toggle_off(
self, filters: tuple[str, ...], filter_type: str
) -> list[ServerRow]:
"""
Sub-filtration of the current model
"""
@@ -225,43 +326,11 @@ class ProxyModelManager:
self.set_filtered(None)
return []
rows = self.filtered
match filter_type:
case strings.filter_3pp:
rows = [row for row in rows if row[2] != strings.filter_3pp]
case strings.filter_1pp:
rows = [row for row in rows if row[2] != strings.filter_1pp]
case strings.filter_official:
rows = [row for row in rows if row[10] != strings.filter_official]
case strings.filter_unofficial:
rows = [row for row in rows if row[10] != strings.filter_unofficial]
case strings.filter_empty:
rows = [row for row in rows if row[4] != 0]
case strings.filter_full:
rows = [row for row in rows if row[4] != row[5]]
case strings.filter_duplicate:
seen = set()
final = []
for row in rows:
if row[0] not in seen:
seen.add(row[0])
final.append(row)
rows = final
case strings.filter_day:
rows = [row for row in rows if not re.match(DAY_REG, row[3])]
case strings.filter_night:
rows = [row for row in rows if not re.match(NIGHT_REG, row[3])]
case strings.filter_nonascii:
rows = [row for row in rows if row[0].isascii()]
case strings.filter_lowpop:
rows = [row for row in rows if (row[4] / row[5] * 100) > 30]
case strings.filter_modded:
rows = [row for row in rows if not row[11]]
case strings.filter_unmodded:
rows = [row for row in rows if row[11]]
# TODO: pack filter type enum in filter checkbox, rather than stringwise comparison
rows = self.filtration_manager.filter(filter_type, self.filtered)
return rows
def filter_toggle_on(self, filters: tuple[str, ...]) -> list:
def filter_toggle_on(self, filters: tuple[str, ...]) -> list[ServerRow]:
"""Effectively applies all filters"""
self.set_filtered(self.control_model)
self.set_filtered(self.filter_map(filters))
@@ -272,31 +341,36 @@ class ProxyModelManager:
self.set_filtered(self.filter_toggle_off(filters, f))
return self.filtered
def set_cache(self, filters: tuple[str, ...], filtered_rows: list[tuple]) -> None:
def set_cache(
self, filters: tuple[str, ...], filtered_rows: list[ServerRow]
) -> None:
self.filter_cache[filters] = filtered_rows
def set_filtered(self, rows: list | None) -> None:
def set_filtered(self, rows: list[ServerRow] | None) -> None:
if rows is None:
self.filtered = []
else:
self.filtered = rows
def get_filtered(self) -> list:
def get_filtered(self) -> list[ServerRow]:
return self.filtered
def set_proxy_model(self, model: "FastInsertListStore") -> None:
"""
FastInsertListStore representation of the raw model after filtration
This attribute is saved from within a thread so that it later be retrieved
and safely packed into the TreeView in the main loop, cf. get_proxy_model()
"""
self.proxy_model = model
def set_control(self, rows: list) -> None:
def set_control(self, rows: list[ServerRow]) -> None:
"""
Raw data model prior to filtration
"""
self.control_model = rows
def get_control(self) -> list[Any]:
def get_control(self) -> list[ServerRow]:
if self.control_model is None:
raise AttributeError(
"Expected a populated control model, but it is Nonetype"
@@ -307,7 +381,7 @@ class ProxyModelManager:
self.filtered = []
self.filter_cache = {}
def push(self, data: list[Any]) -> None:
def push(self, data: list[ServerRow]) -> None:
self.wipe_cache()
self.set_control(data)
cur_keyword = self.filter_man.get_active_keyword()
+4 -8
View File
@@ -26,7 +26,7 @@ from gi.repository import Gtk, GLib
if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo, Record
from dzgui.controllers.mc import Controller
from dzgui.model.proxy_model import ProxyModelManager
from dzgui.model.proxy_model import ProxyModelManager, ServerRow
from dzgui.views.trees.tree_servers import ServerTreeView
logger = logging.getLogger(APP_NAME)
@@ -241,10 +241,7 @@ class ServerModelManager:
def update_history(
self,
rows: (
list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]]
| None
) = None,
rows: list["ServerRow"] | None = None,
) -> None:
proxy_man = self._get_proxy_man()
if rows is not None:
@@ -267,7 +264,6 @@ class ServerModelManager:
self.first_iteration = False
self.emitter.emit("load_maps", store)
# TODO: dataclass for record rows; check for other dict annotations
def add_to_history(self, data: tuple[dict[str, Any], "Record"]) -> None:
row, record = data
proxy_man = self._get_proxy_man()
@@ -453,13 +449,13 @@ class ServerModelManager:
dialog.show_all()
dialog.run() # type: ignore[no-untyped-call]
def _push_data(self, data: list[Any]) -> None:
def _push_data(self, data: list["ServerRow"]) -> None:
self._get_proxy_man().push(data)
self._sort_unique_maps(data)
self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success))
def _sort_unique_maps(self, data: list[Any]) -> None:
u_maps = set([row[1] for row in data])
u_maps = set([row.mapname for row in data])
self._set_new_maps(sorted(u_maps))
def _set_new_maps(self, maps: list[str]) -> None:
+29
View File
@@ -0,0 +1,29 @@
changelog_label = "View changelog"
changelog_status = "Opens the DZGUI changelog"
log_label = "Show debug log"
log_status = "Read the DZGUI log generated since startup"
separator_label = "SEPARATOR"
separator_status = ""
system_log_label = "Output system info to log file"
system_log_status = "Dump diagnostic data for troubleshooting"
docs_label = "Documentation/help files (GitHub) ⧉"
docs_status = "Open the DZGUI documentation in a browser"
docs_fallback_label = "Documentation/help files (Codeberg mirror) ⧉"
docs_fallback_status = "Open the DZGUI documentation in a browser"
bugs_label = "Report a bug (GitHub) ⧉"
bugs_status = "Open the DZGUI issue tracker in a browser"
forum_label = "DZGUI Subreddit ⧉"
forum_status = "Open the DZGUI discussion forum in a browser"
sponsor_label = "Sponsor (GitHub) ⧉"
sponsor_status = "Sponsor development of the DZGUI project"
thanks_label = "Special thanks"
thanks_status = "A list of contributors, testers, and sponsors"
+6 -11
View File
@@ -11,6 +11,7 @@ from dzgui.const.enum import Preferences
from dzgui.config.query import lookup
from dzgui.init.prefix import get_version
from dzgui.util.redact import redact_home
from dzgui.util.strings import unknown
logger = logging.getLogger(APP_NAME)
@@ -27,28 +28,22 @@ def get_cpu_model() -> str:
def print_servers(servers: list[str]) -> str:
s = ""
for server in servers:
s += server + "\n"
return s
return "\n".join(servers)
def print_mods(mods: list[int]) -> str:
s = ""
for mod in mods:
s += str(mod) + "\n"
return s
return "\n".join(map(str, mods))
def write_diagnostic(config: Path, outfile: Path) -> None:
date = datetime.now().isoformat()
try:
# FIXME: ID_LIKE not available on CachyOS
# NOTE: ID_LIKE not available on CachyOS
distro = platform.freedesktop_os_release()["ID_LIKE"]
except Exception as e:
logger.warn(e)
distro = "Unknown"
distro = unknown
kernel = os.uname().release
cpu = get_cpu_model()
@@ -68,7 +63,6 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
servers = lookup(config, Preferences.IP_LIST)
servers_pretty = print_servers(servers)
# FIXME: extraneous newlines in lists of mods
template = f"""\
{APP_NAME} version {version}
Date: {date}
@@ -82,6 +76,7 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
Mods:
{mods_pretty}
Servers:
{servers_pretty}
"""
+2
View File
@@ -250,6 +250,7 @@ class Crumbs:
developers: str
default: str
offline: str
connect: str
@dataclass(slots=True, frozen=True)
@@ -437,6 +438,7 @@ crumbs = Crumbs(
developers="Options > Developers",
default="Servers > ",
offline="Mods > Play offline",
connect="Connect",
)
no_mods = "No local mods found."
+8 -4
View File
@@ -19,7 +19,11 @@ if TYPE_CHECKING:
class ContextualButton(Gtk.Button):
def __init__(
self, label: str, opens: "NotebookPage", tooltip: str, context: ButtonType
self,
label: str,
opens: "NotebookPage | None",
tooltip: str,
context: ButtonType,
) -> None:
super().__init__(
label=label,
@@ -51,9 +55,9 @@ class ButtonBox(Gtk.Box):
for side_button in ButtonType:
button = ContextualButton(
label=side_button.dict["label"],
opens=side_button.dict["opens"],
tooltip=side_button.dict["tooltip"],
label=side_button.label,
opens=side_button.opens,
tooltip=side_button.tooltip,
context=side_button,
)
+1 -1
View File
@@ -43,7 +43,7 @@ class Breadcrumbs(Gtk.Label):
label = page.get_current_tab_text()
self.set_server_crumbs(label)
else:
crumbs = enum.dict["crumbs"]
crumbs = enum.crumbs
self.set_crumbs(crumbs)
def set_server_crumbs(self, suffix: str) -> None:
+2 -2
View File
@@ -19,8 +19,8 @@ class EnumeratedModButton(Gtk.Button):
super().__init__(margin_start=10, margin_end=10, focus_on_click=False)
self.enum = enum
self.set_label(enum.dict["label"])
self.set_tooltip_text(enum.dict["tooltip"])
self.set_label(enum.label)
self.set_tooltip_text(enum.tooltip)
class ModPanelButton(EnumeratedModButton):
+2 -2
View File
@@ -56,7 +56,7 @@ class Statusbar(Gtk.Grid):
return
enum = notebook.get_page_by_enum()
show_statusbar = enum.dict["statusbar"]
show_statusbar = enum.has_statusbar
if show_statusbar is False:
self.set_by_context(enum, "")
@@ -148,5 +148,5 @@ class Statusbar(Gtk.Grid):
row = tree.get_value_at_index(1)
if self.controller.loaded is False:
return
tooltip = row.dict["tooltip"]
tooltip = row.tooltip
self.set_by_context(NotebookPage.HELP, tooltip)
+8 -8
View File
@@ -1,7 +1,7 @@
import logging
from dzgui.const.constants import APP_NAME
from dzgui.const.enum import ContextMenu
from dzgui.const.enum import ContextMenuItem
from dzgui.util.keys import is_ctrl_mask, is_navkey
from dzgui.util.strings import edit_note
from dzgui.views.trees.tree_base import TreeView
@@ -58,17 +58,17 @@ class ContextMixin(TreeView):
self.context_menu.select_first(False)
return True
def _process_dynamic_row(self, row: ContextMenu) -> Gtk.MenuItem:
if row == ContextMenu.ADD_SERVER and self.is_in_favs(): # type: ignore
row = ContextMenu.REMOVE_SERVER
def _process_dynamic_row(self, row: ContextMenuItem) -> Gtk.MenuItem:
if row == ContextMenuItem.ADD_SERVER and self.is_in_favs(): # type: ignore
row = ContextMenuItem.REMOVE_SERVER
item = Gtk.MenuItem(label=row.dict["label"])
item = Gtk.MenuItem(label=row.label)
item.connect("activate", self._on_menu_click, row)
if row == ContextMenu.SHOW_MODS:
if row == ContextMenuItem.SHOW_MODS:
item.set_sensitive(self.is_modded()) # type: ignore
if row == ContextMenu.ADD_NOTE:
if row == ContextMenuItem.ADD_NOTE:
if self.controller.has_note():
item.set_label(edit_note)
@@ -92,7 +92,7 @@ class ContextMixin(TreeView):
except AttributeError:
return False
def _on_menu_click(self, widget: Gtk.MenuItem, enum: ContextMenu) -> None:
def _on_menu_click(self, widget: Gtk.MenuItem, enum: ContextMenuItem) -> None:
self.controller.menu_action(enum, self) # type: ignore
def _on_key(self, menu: Gtk.Menu, event: Gdk.EventKey) -> bool | None:
+1 -1
View File
@@ -77,4 +77,4 @@ class MenuTreeView(TreeView):
raise ValueError("Trying to call a method on non-existent model")
_iter = self.get_focused_row_iter()
rowtype = model.get_value(_iter, 1)
return str(rowtype.dict["tooltip"])
return str(rowtype.tooltip)
+8 -4
View File
@@ -7,7 +7,7 @@ from warnings import deprecated
from dzgui.api.servers import ping, Record
from dzgui.const.constants import APP_NAME
from dzgui.const.enum import ContextMenu, ContextMenuGroup, ServerTab
from dzgui.const.enum import ContextMenuItem, ContextMenuGroup, ServerTab
from dzgui.managers.filter import FilterManager
from dzgui.model.proxy_model import ProxyModelManager
from dzgui.util import strings
@@ -251,9 +251,9 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
if self.enum is ServerTab.LAN:
self.emitter.emit("request_custom_port_focus")
case Gdk.KEY_c:
self.controller.menu_action(ContextMenu.COPY_SERVER_IP, self)
self.controller.menu_action(ContextMenuItem.COPY_SERVER_IP, self)
case Gdk.KEY_r:
self.controller.menu_action(ContextMenu.REFRESH_PLAYERS, self)
self.controller.menu_action(ContextMenuItem.REFRESH_PLAYERS, self)
else:
match event.keyval:
case Gdk.KEY_l | Gdk.KEY_Right:
@@ -413,5 +413,9 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
if row_index >= start[0] <= end[0]:
# NOTE: fetch raw data rows
real_model = self.proxy_man.get_control()
value = real_model[row_index][col_index]
from dataclasses import fields
from dzgui.model.proxy_model import ServerRow
key = fields(ServerRow)[col_index].name
value = getattr(real_model[row_index], key)
cell.set_property("text", str(value))
+26
View File
@@ -0,0 +1,26 @@
from dzgui.model.model_factory import ModelFactory
from dzgui.model.proxy_model import ServerRow
def test_row_conversion() -> None:
"""Converted FastInsertListStore is expected to have identical elements"""
data = [
"Test Server",
"chernarusplus",
"1PP",
"05:00",
10,
10,
0,
"127.0.0.0:27016",
27017,
99,
"Unofficial",
False,
]
rows = [ServerRow(*data)]
marshalled = ServerRow.marshal_rows(rows)
store = ModelFactory().make_server_store()
store.extend(marshalled)
converted = [[el for el in row] for row in store]
assert data == converted[0]