diff --git a/CHANGELOG.md b/CHANGELOG.md index ef0913c..d1b5ca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## [7.0.0] Unreleased ### Added - Setup wizard +- Uninstall wizard +- Descriptive details panel to certain error dialogs - Changelog text wrapping and formatting - Changelog ships with source - Documentation ships with source @@ -24,6 +26,7 @@ - Set favorite server from tables - Detailed/copyable trace in critical error dialogs - Visual icons +- Unset favorite server button - Integrated server notebook - Propagate width changes to all tables - Remember tree position in menus @@ -45,11 +48,17 @@ - Generate Steam shortcuts and cover art - Warn user if background downloads are disabled - Dynamic copy button +- Update map count (-u flag) +- Filter button tooltips +- Expand/collapse changelog versions ### Changed - Conform to PEP 440 versioning for beta versions +- Enforce fullscreen mode on Steam Deck - Reduce padding on keys button - Boldface breadcrumbs +- Changed some dialog error messages from popups to popovers +- Changed how filter buttons are packed in sidebar - Bold labels inside frames - Sidebar buttons do not steal focus - Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method @@ -62,16 +71,20 @@ - Reduce size of geolocation DB on disk (~100MB) - Enable LAN page Empty/Full filters on first run of app - Propagate subscribed mods to Steam client +- Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow ### Dropped - Battlemetrics API support -- Debug mode +- Unused metadata from system log files - Branch switching - Manual mod install mode (describe rationale) - Force update mods ### Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input +- In-game time field missing certain ranges of time at night +- Wrap long entries in changelog +- Certain server descriptions with special characters not rendering correctly - Center server title text on server dialogs - Rare segfaults when changing maps (threading) - Moved dialogs out of threads diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5ba222..77d627a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,3 +67,7 @@ It is recommended to follow integrates well with tooling and helps the project review your code. Please see DEVELOPERS.md for further details. + +## License applicability to contributions + +Code contributed to this repository becomes subject to the terms of the GPL license defined in the LICENSE file at the project root. diff --git a/docs/source/dzgui7.rst b/docs/source/dzgui7.rst index 5793579..b6a0e30 100644 --- a/docs/source/dzgui7.rst +++ b/docs/source/dzgui7.rst @@ -8,7 +8,7 @@ runtime environment are built-in. Turnkey installer ------------------------ -- Visit the project's `Releases page `_. +- Visit the project's `Releases page `_. - Extract the DZGUI tarball from the top of the **Assets** list. - From a terminal, run the command ``./dzgui`` or double click on the extracted file in a file explorer. diff --git a/docs/source/kb.rst b/docs/source/kb.rst index 638f189..7ab8bb2 100644 --- a/docs/source/kb.rst +++ b/docs/source/kb.rst @@ -3,17 +3,22 @@ Knowledge Base .. _DZG-001: -DZG-001: Periodically getting dropped from servers, or servers time out in DZGUI +DZG-001: Timeouts occur while trying to query a specific server ------------------------------------------------------------------------------------ -DayZ opens a large number of connections while querying/connected to servers. +The leading cause of specific servers periodically timing out is local network configuration. -In addition, packets sent from server responses are expected to be a standard size (see warning below). +Many third-party DayZ servers use a server rental/hosting provider with DDoS protection. +This can cause responses from servers to originate from a server other than the one originally queried. -If your network does not have enough headroom or has settings departing from defaults, this may lead to getting dropped from servers, -unresponsiveness, or a timeout. +Consumer-grade routers are likely to drop this traffic as invalid due to how they handle NAT (network address translation). +By contrast, wireless routers and enterprise-grade routers may be less likely to have this issue. + +If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for +the server's query port. + +In addition, a uniform packet size is expected. If your router's MTU setting differs from the default of 1,500, +it could lead to incoming responses being discarded (see warning below). -If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. Consumer Wi-Fi routers -tend to have less headroom than their wired counterparts. .. important:: Ensure that MTU (maximum tranmission unit) on your network is set to the standard size of 1,500 bytes. @@ -80,3 +85,28 @@ There is some misconception that a Steam Web API key could be used to gain infor A Steam Web API key is the most strict way of getting authentic, reliable, and consistent server information in a zero-trust model. You are responsible for the creation, storage, management, and revocation of your Web API key. + +DZG-008: Periodically getting dropped from servers while connected +------------------------------------------------------------------- +In some cases, DayZ opens a large number of connections while connected to servers. + +If your network does not have enough headroom or has settings departing from defaults, this may lead to getting dropped from servers, +unresponsiveness, or a timeout. + +If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. Consumer Wi-Fi routers +tend to have less headroom for simultaneous connections than their wired counterparts. + +DZG-009: Floating dialogs appear maximized on tiling window managers +------------------------------------------------------------------- + +The main DZGUI window and its child dialogs are expected to be rendered as floating by your window manager. +DZGUI sends window manager hints to this effect, but tiling window managers designed to bisect the screen into quadrants (e.g., i3 window manager) +may try to always launch applications in fullscreen. + +To resolve this, set specific exclusions or window hints in your WM's configuration file. +For example, for i3, add the following to your `XDG_CONFIG_HOME/i3/config` file (defaults to `$HOME/.config/i3/config`): + +.. code:: console + + for_window [instance="DZGUI"] floating enable, move position center + for_window [instance="DZGUI - Dialog"] floating enable, move position center diff --git a/docs/source/setup.rst b/docs/source/setup.rst index b7204d8..ec1fa07 100644 --- a/docs/source/setup.rst +++ b/docs/source/setup.rst @@ -4,7 +4,7 @@ Setup DayZ license ----------------- -Prepare a Steam account with a DayZ license (i.e., own the game). From Steam's right-click menu for the game options, under ``Compatibility``, enable a Proton version ≥ ``6.8`` (or use Proton +Prepare a Steam account with a DayZ license (i.e., own the game). From Steam's right-click menu for the game options, under ``Compatibility``, enable a Proton version ≥ ``6.8`` (or use Proton Experimental for the latest version). As of this writing, any recent version of Proton should work, and it is encouraged to use the most recent one. @@ -33,20 +33,10 @@ Once configured, you can insert this key in the app when launching it for the fi Battlemetrics API key ^^^^^^^^^^^^^^^^^^^^^^ -**This step is optional, but recommended**. Using this key lets you also connect to and query servers by their shorthand ID instead of by IP. +.. warning:: -Register for an API key at `BattleMetrics `_ (free). - -From the ``Personal Access Tokens area``, select ``New Token``. - -Give the token any name in the field at the top. - -Leave **all options unchecked** and scroll to the bottom. Select ``Create Token``. - -Once configured, you can insert this key in the app when launching it for the first time (optional), or later on when using the connect/query by ID methods in the app. - -.. tip:: - Each server has a unique ID. This is the string of numbers at the end of the URL. For example, in the URL ``https://www.battlemetrics.com/servers/dayz/8039514``, the ID is ``8039514``. + This feature is no longer supported in DZGUI. Battlemetrics has switched to a paid service. + Battlemetrics queries are expected to fail in DZGUI 6. In DZGUI 7, this feature has been removed entirely. Next steps -------------- diff --git a/docs/source/steam.rst b/docs/source/steam.rst index c709725..687c75d 100644 --- a/docs/source/steam.rst +++ b/docs/source/steam.rst @@ -1,6 +1,10 @@ Steam integration ==================== +.. note:: + + In :doc:`DZGUI 7 `, Steam shortcut creation and cover art setup are handled automatically during setup. You can skip this step unless you are using DZGUI 6. + DZGUI can be added to Steam as a "non-Steam game" in order to facilitate integration with Steam Deck or desktop environments. First, launch Steam in the **Large** (default) view. @@ -74,7 +78,7 @@ The final result will create box art looking like the above. :scale: 100% :alt: Adding a non-Steam game -Next, right-click the DZGUI entry in your Library and select **Properties** to open the properties dialog. Next to the **Shortcut** field, you will see a small square box which represents the game’s +Next, right-click the DZGUI entry in your Library and select **Properties** to open the properties dialog. Next to the **Shortcut** field, you will see a small square box which represents the game’s icon. Click this to open a file explorer, navigate to ``$HOME/.local/share/dzgui``, and select ``icon.png``. This will add a small icon to the list view. Finally, after you launch DZGUI for the first time, you should quit the application and return to the Library view. Select the **Recent Games** dropdown on the right-hand side. diff --git a/dzgui/api/shortcuts.py b/dzgui/api/shortcuts.py index 154645e..594d1a1 100644 --- a/dzgui/api/shortcuts.py +++ b/dzgui/api/shortcuts.py @@ -160,6 +160,14 @@ class Shortcuts: NEW_ENTRY["tags"] = {} return NEW_ENTRY + def delete_shortcut(self, start_path: Path) -> None: + shortcuts = self.shortcuts["shortcuts"] + for s in shortcuts: + if shortcuts[s]["StartDir"] == str(start_path): + del shortcuts[s] + break + self.save_shortcuts() + def save_shortcuts(self) -> None: try: backup = self.shortcuts_path.with_suffix(".vdf.bak") diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index c5b0074..668def6 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -16,6 +16,7 @@ from dzgui.const.constants import ( APPID_DAYZ_EXP, APP_NAME, DAYZ_BINARY, + DAYZ_LAUNCHER, DEBIAN_STEAM_PATH, DEFAULT_STEAM_PATH, FLATPAK_STEAM_PATH, @@ -150,6 +151,22 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]: return hashes +def connect_debug(client: str, addr: str, appid: int, name: str, mods: list[str]) -> str: + concat = concat_mods(mods) + client_args = concat_bash_args(client) + params = [ + "-applaunch", + str(appid), + f"-connect={addr}", + "-nolauncher", + "-nosplash", + "-skipintro", + f"-name={name}", + f"-mod={concat}", + ] + client_args.extend(params) + return " ".join(client_args) + # TODO: set config to name=user, use official server and no mods, # ensure that formatted string is identical to fixture with same hash def connect(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int: @@ -382,14 +399,14 @@ def get_client_allows_downloads(path: Path) -> bool: def is_dayz_running() -> bool: """Subprocesses spawned from Steam will not show up in regular process tree""" procs = [] - substring = DAYZ_BINARY + substring = [DAYZ_BINARY, DAYZ_LAUNCHER] for proc in psutil.process_iter(): try: procs.append(proc.cmdline()) except Exception as e: logger.warning(e) continue - return any(substring in item for sublist in procs for item in sublist) + return any(s in item for sublist in procs for item in sublist for s in substring) def get_app_path(folders_path: Path, appid: int) -> Path: diff --git a/dzgui/app_init.py b/dzgui/app_init.py index 8a74466..8a3999f 100644 --- a/dzgui/app_init.py +++ b/dzgui/app_init.py @@ -18,10 +18,10 @@ from dzgui.init.flock import lock_acquire from dzgui.init.prereqs import has_steam_client from dzgui.strings import boot -# from dzgui.util.map_count import get_map_count from dzgui.util.deck import is_steam_deck, is_game_mode from dzgui.util.dirs import make_parents from dzgui.util.localize import set_locale +from dzgui.util.map_count import test_map_count from dzgui.util.redact import RedactionFilter, REDACTION_PATTERNS from dzgui.util.strings import init @@ -119,6 +119,7 @@ def load_gui(version: str, is_debug: bool) -> None: del os.environ["GTK_IM_MODULE"] if has_new_config(XDG.config) is False: + test_map_count() migrate_cols_file(XDG.columns) copy_state_files(xdg_paths["XDG_STATE_HOME"]) # TODO: add logging inside wizard diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index d0843fc..d75fc31 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -12,6 +12,7 @@ APPNAME_DAYZ = "DayZ" APPNAME_DAYZ_EXP = "DayZ Exp" APPNAME_DAYZ_EXP_HUMAN = "DayZ Experimental" DAYZ_BINARY = "DayZ_x64.exe" +DAYZ_LAUNCHER = "DayZLauncher.exe" LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf" WORKSHOP_PATH = "steamapps/workshop/content/" + str(APPID_DAYZ) @@ -38,6 +39,7 @@ HEX_ORANGE = "#FFAC1C" CARET_DOWN = "go-down-symbolic" CARET_UP = "go-up-symbolic" CLIPBOARD = "edit-copy-symbolic" +CLOSE = "window-close-symbolic" FOLDER = "folder-symbolic" EDIT_DELETE = "edit-delete-symbolic" ERROR = "dialog-error-symbolic" diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index 28a4cdf..05a9763 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -73,12 +73,6 @@ class Preferences(EnumWithAttrs): NAME = { "key": "name", } - INSTALL = { - "key": "auto_install", - } - DEBUG = { - "key": "debug", - } DEFAULT = { "key": "default_steam_path", } @@ -91,9 +85,6 @@ class Preferences(EnumWithAttrs): IP_LIST = { "key": "ip_list", } - BRANCH = { - "key": "branch", - } START_TAB = { "key": "start_tab", } diff --git a/dzgui/controllers/emitter.py b/dzgui/controllers/emitter.py index ba7ef10..28016ca 100644 --- a/dzgui/controllers/emitter.py +++ b/dzgui/controllers/emitter.py @@ -145,6 +145,10 @@ class Emitter(GObject.GObject): def api_change_failed(self) -> None: pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def api_change_successful(self) -> None: + pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) def already_saved_server(self) -> None: pass diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index e167ba9..9effd3c 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -88,7 +88,6 @@ class Controller(GObject.GObject): # NOTE: suppress requests until entire UI is loaded self.loaded = False - self.pending_jobs = 1 self.exit_event = threading.Event() self.cancel_event = threading.Event() @@ -115,9 +114,6 @@ class Controller(GObject.GObject): def query_config(self, key: Preferences) -> Any: return self.config_man.lookup(key) - def is_auto_install(self) -> bool: - return bool(self.query_config(Preferences.INSTALL)) - def suppress_signal( self, owner: Any, child: Any, func_name: str, state: bool ) -> None: @@ -267,6 +263,7 @@ class Controller(GObject.GObject): self.open_page(NotebookPage.LOG) except Exception as e: dialog = ExceptionDialog(self, str(e)) + dialog.show_all() dialog.run() def select_colorized(self) -> None: @@ -300,10 +297,11 @@ class Controller(GObject.GObject): write_diagnostic(self.prefs.paths.config, file) except Exception as e: dialog = ExceptionDialog(self, str(e)) + dialog.show_all() dialog.run() - def update_api_key(self, key: Preferences, text: str) -> None: - self.config_man.update_api_key(key, text) + def update_steam_api_key(self, text: str) -> None: + self.config_man.update_steam_api_key(text) def set_resolution(self, window: "OuterWindow") -> None: self.config_man.set_resolution(window) @@ -402,6 +400,9 @@ class Controller(GObject.GObject): def get_menu(self) -> "MenuTreeView": return self.mediator.menu + def unset_fav(self) -> None: + self.config_man.unset_fav() + def has_favorites(self) -> bool: favs = self.config_man.get_favorites() if len(favs) < 1: @@ -459,6 +460,9 @@ class Controller(GObject.GObject): open_workshop_page(mod, cmd) def has_note(self) -> bool: + # TODO: get record string, store in memory + # get note by record, get is in favs, etc. store as a block + # this is only used by context mixin note = self.get_note() if len(note) > 0: return True @@ -473,11 +477,13 @@ class Controller(GObject.GObject): return self.notes_man.get_note(record) def add_note(self, note: str) -> None: + # TODO: record should be cached upon creation of dialog tv = self.get_active_treeview() record = tv.get_record_string() self.notes_man.add_note(record, note) def delete_note(self) -> None: + # TODO: record should be cached upon creation of dialog tv = self.get_active_treeview() record = tv.get_record_string() self.notes_man.delete_note(record) @@ -519,6 +525,9 @@ class Controller(GObject.GObject): ind = self.config_man.get_start_tab() self.get_servers().notebook.set_current_page(ind) + def get_debug_args(self) -> str: + return self.connection_man.get_debug_args() + def update_and_load_to_menu(self) -> None: self.connection_man.update_and_connect(menu_only=True) diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 85bd68d..d1b5ca0 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -3,6 +3,8 @@ ## [7.0.0] Unreleased ### Added - Setup wizard +- Uninstall wizard +- Descriptive details panel to certain error dialogs - Changelog text wrapping and formatting - Changelog ships with source - Documentation ships with source @@ -24,6 +26,7 @@ - Set favorite server from tables - Detailed/copyable trace in critical error dialogs - Visual icons +- Unset favorite server button - Integrated server notebook - Propagate width changes to all tables - Remember tree position in menus @@ -44,11 +47,18 @@ - Play offline (load mods directly) - Generate Steam shortcuts and cover art - Warn user if background downloads are disabled +- Dynamic copy button +- Update map count (-u flag) +- Filter button tooltips +- Expand/collapse changelog versions ### Changed - Conform to PEP 440 versioning for beta versions +- Enforce fullscreen mode on Steam Deck - Reduce padding on keys button - Boldface breadcrumbs +- Changed some dialog error messages from popups to popovers +- Changed how filter buttons are packed in sidebar - Bold labels inside frames - Sidebar buttons do not steal focus - Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method @@ -61,18 +71,24 @@ - Reduce size of geolocation DB on disk (~100MB) - Enable LAN page Empty/Full filters on first run of app - Propagate subscribed mods to Steam client +- Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow ### Dropped -- Debug mode +- Battlemetrics API support +- Unused metadata from system log files - Branch switching - Manual mod install mode (describe rationale) - Force update mods ### Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input +- In-game time field missing certain ranges of time at night +- Wrap long entries in changelog +- Certain server descriptions with special characters not rendering correctly - Center server title text on server dialogs - Rare segfaults when changing maps (threading) - Moved dialogs out of threads +- History table not being in lock-step with state file ### Unreleased - Load offline mods diff --git a/dzgui/main.py b/dzgui/main.py index 2deeba5..6abe684 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -5,33 +5,34 @@ import warnings from dzgui.const.constants import APP_NAME from dzgui.init.libgi import test_libgi_missing from dzgui.init.prefix import get_version +from dzgui.util.map_count import set_map_count from dzgui.util.strings import flags +from dzgui.views.dialogs.uninstall import UninstallWizard +from dzgui.config.xdg import get_xdg_paths + parser = argparse.ArgumentParser(description=flags.description) -parser.add_argument("-v", "--version", action="store_true", help=flags.version) -parser.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall) -parser.add_argument("-d", "--debug", action="store_true", help=flags.debug) +group = parser.add_mutually_exclusive_group() +group.add_argument("-d", "--debug", action="store_true", help=flags.debug) +group.add_argument("-m", "--map", action="store_true", help=flags.map_count) +group.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall) +group.add_argument("-v", "--version", action="store_true", help=flags.version) args = parser.parse_args() -def uninstall() -> None: - # TODO: uninstall data files (-u) - # -u removes state, log, freedesktop - # XDG_SHARE_HOME/dzgui - # XDG_STATE_HOME/dzgui - # XDG_DATA_HOME/dzgui - pass - - def main() -> None: if args.version is True: print(get_version()) sys.exit(0) if args.uninstall is True: - uninstall() + paths = get_xdg_paths() + UninstallWizard(False, paths) sys.exit(0) if args.debug is True: warnings.filterwarnings("default", category=DeprecationWarning) + if args.map is True: + set_map_count() + sys.exit(0) version = get_version() diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index 1395dd5..576f809 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -98,12 +98,13 @@ class ConfigManager: logger.critical(e) raise e - @call_on_thread(dialogs.checking_api) - def update_api_key(self, key: Preferences, text: str) -> None: - if key is Preferences.STEAM: - res = test_steam_api(text) + @call_on_thread(dialogs.checking_api, show_dialog=False) + def update_steam_api_key(self, text: str) -> None: + res = test_steam_api(text) if res is True: - self.update_config(key, text) + self.update_config(Preferences.STEAM, text) + func = StoredFunc(lambda: self.emitter.emit("api_change_successful")) + self.thread_man.set_cleanup_func(func) else: self.thread_man.set_cleanup_func( StoredFunc(lambda: self.emitter.emit("api_change_failed")) @@ -160,6 +161,13 @@ class ConfigManager: except Exception: return + def unset_fav(self) -> None: + try: + self.write_config(Preferences.FAV_LBL, "") + self.write_config(Preferences.FAV_SRV, "") + except Exception: + return + def write_config(self, key: Preferences, value: str) -> None: try: real_key = self.enum_to_key(key) @@ -170,8 +178,8 @@ class ConfigManager: logger.critical(e) trace = traceback.format_exc() dialog = ExceptionDialog(self.controller, trace) + dialog.show_all() dialog.run() - raise e def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None: columns = tv.get_columns() diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index c96abc2..7d2047e 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -13,6 +13,7 @@ from dzgui.api.shortcuts import Shortcuts from dzgui.api.steam import ( connect, + connect_debug, get_app_allows_downloads, get_app_name, get_needs_update, @@ -45,6 +46,7 @@ from dzgui.strings.dialogs import ( waiting_for_mods, waiting_for_directories, ) +from dzgui.strings import kb from dzgui.strings.server_mods import checkmark, resync from dzgui.util.format import format_mib from dzgui.util.strings import dialog, server_timeout @@ -193,7 +195,9 @@ class ConnectionManager: is_last = self.is_last_server() # TODO: strings - invalid_mods = [(mod[0], mod[1]) for mod in remote_mods if mod[2] == "Invalid mod"] + invalid_mods = [ + (mod[0], mod[1]) for mod in remote_mods if mod[2] == "Invalid mod" + ] prereqs = Prerequisites( name=info.server_name, @@ -298,8 +302,17 @@ class ConnectionManager: def _server_timeout(self) -> None: dialog = ExceptionDialog(self.controller, server_timeout) + dialog.set_details_buffer(kb.DZG_006) + dialog.show_all() dialog.run() + def get_debug_args(self) -> str: + addr = f"{self.record.ip}:{self.record.gameport}" + playername = self.controller.query_config(Preferences.NAME) + return connect_debug( + self.client, addr, self.appid, playername, self.remote_mod_ids + ) + def _connect_steam(self, menu_only: bool) -> None: addr = f"{self.record.ip}:{self.record.gameport}" playername = self.controller.query_config(Preferences.NAME) diff --git a/dzgui/managers/filter.py b/dzgui/managers/filter.py index b4c02e5..febb861 100644 --- a/dzgui/managers/filter.py +++ b/dzgui/managers/filter.py @@ -21,14 +21,30 @@ class FilterManager: strings.filter_3pp: True, strings.filter_night: True, strings.filter_full: show_full, - strings.filter_lowpop: True, - strings.filter_nonascii: False, - strings.filter_duplicate: False, strings.filter_official: True, + strings.filter_nonascii: False, + strings.filter_lowpop: True, strings.filter_unofficial: True, + strings.filter_duplicate: False, strings.filter_modded: True, } + # TODO: strings + self.tooltips = { + strings.filter_1pp: "First-person perspective", + strings.filter_day: "In-game time between 0700 and 1659", + strings.filter_empty: "Servers with no players", + strings.filter_3pp: "Third-person perspective", + strings.filter_night: "In-game time between 1700 and 0659", + strings.filter_full: "Servers with no open slots", + strings.filter_official: "Bohemia official servers", + strings.filter_nonascii: "Server names using non-standard, complex glyphs", + strings.filter_lowpop: "Current population is under 30% of total", + strings.filter_unofficial: "Third-party servers", + strings.filter_duplicate: "Duplicates of existing servers (usually spoofed)", + strings.filter_modded: "Servers with one or more mods", + } + self.active_keyword = "" self.active_map = (0, all_maps) self.prior_map = all_maps @@ -36,6 +52,9 @@ class FilterManager: self.filters: list self.enabled_filters = dict(self.default_filters) + def get_tooltips(self) -> dict[str, str]: + return self.tooltips + def set_prior_map(self, name: str) -> None: self.prior_map = name @@ -86,6 +105,7 @@ class FilterManager: self.reinit_map_store() maps.sort() for m in maps: + # TODO: strings if m == "All maps": continue self.append_map([m]) diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index da31385..c04d231 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -22,9 +22,7 @@ from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManage from dzgui.model.model_factory import FastInsertListStore, ModelFactory from dzgui.strings import dialogs from dzgui.util.format import format_mods -from dzgui.util.strings import server_timeout from dzgui.util.symlink import rebuild_symlinks -from dzgui.views.dialogs.generic import ExceptionDialog import dzgui.api.pefile as PeFile @@ -213,10 +211,6 @@ class ModManager: func = StoredFunc(self._on_stale_mods_found, stale) self.thread_man.set_cleanup_func(func) - def _server_timeout(self) -> None: - dialog = ExceptionDialog(self.controller, server_timeout) - dialog.run() - def select_colorized(self) -> None: model = self.treeview.get_model() if model is None: diff --git a/dzgui/managers/update.py b/dzgui/managers/update.py index 3c0f928..ff64fb4 100644 --- a/dzgui/managers/update.py +++ b/dzgui/managers/update.py @@ -64,4 +64,5 @@ class UpdateManager: def _on_update_failure(self, msg: str) -> None: dialog = ExceptionDialog(self.controller, msg) + dialog.show_all() dialog.run() diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index c07a711..dec8338 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -12,6 +12,9 @@ if TYPE_CHECKING: from dzgui.api.servers import Record from dzgui.model.servers import NewPlayerCount +DAY_REG = r"([0][7-9]|[1][0-6])" +NIGHT_REG = r"([0][0-6]|[1][7-9]|[2][0-3])" + class ProxyModelManager: """ @@ -231,11 +234,9 @@ class ProxyModelManager: final.append(row) rows = final case strings.filter_day: - reg = r"([0][0-9]|[1][0-6])" - rows = [row for row in rows if not re.match(reg, row[3])] + rows = [row for row in rows if not re.match(DAY_REG, row[3])] case strings.filter_night: - reg = r"([0][0-4]|[1][8]|[2][0-3])" - rows = [row for row in rows if not re.match(reg, row[3])] + 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: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index bc5d616..a429ffb 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -14,7 +14,7 @@ from dzgui.const.constants import ( ) from dzgui.const.enum import FilterMode, Preferences, ServerTab from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager -from dzgui.strings import dialogs +from dzgui.strings import dialogs, kb from dzgui.util.strings import server_timeout, dialog from dzgui.views.dialogs.generic import ExceptionDialog @@ -405,6 +405,7 @@ class ServerModelManager: if show_dialog: dialog = ExceptionDialog(self.controller, dialogs.load_error_lan) + dialog.show_all() dialog.run() def _cleanup_on_failure(self, show_dialog: bool = True) -> None: @@ -420,6 +421,8 @@ class ServerModelManager: # customize statusbar and dialog accordingly if show_dialog: dialog = ExceptionDialog(self.controller, server_timeout) + dialog.set_details_buffer(kb.DZG_006) + dialog.show_all() dialog.run() def _push_data(self, data: list[Any]) -> None: diff --git a/dzgui/strings/changelog.py b/dzgui/strings/changelog.py new file mode 100644 index 0000000..cc637ea --- /dev/null +++ b/dzgui/strings/changelog.py @@ -0,0 +1,2 @@ +collapse_all = "Collapse all" +expand_all = "Expand all" diff --git a/dzgui/strings/connect_panel.py b/dzgui/strings/connect_panel.py index 870624f..94d9e7d 100644 --- a/dzgui/strings/connect_panel.py +++ b/dzgui/strings/connect_panel.py @@ -4,10 +4,7 @@ add_popover = "This address is already in your Saved Servers" add_tooltip="Add to Saved Servers" connect_button="Connect" connect_tooltip="Connect to this server" -connect_entry_tooltip=( - "- IP: format as IP:Query port\ne.g. 192.168.1.1:27016" -) -connect_entry_placeholder="Enter IP (IP:Query port)" +connect_entry_placeholder="Enter IP:Query port (e.g., 192.168.1.1:27016)" fav_heading="Favorite server" favs_empty="None set. Right click a server and select 'Set favorite' to set." @@ -18,6 +15,6 @@ lan_custom_button="Custom port" lan_scan_button="Scan" lan_abort_tooltip = "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster" lan_checkbox = "Stop scanning on first hit" -lan_placeholder="Enter the query port (1-65535)" +lan_entry_placeholder="Enter the query port (1-65535)" lan_entry_tooltip="Specify the port to search for DayZ servers on the local network" lan_scan_tooltip="Scan for servers" diff --git a/dzgui/strings/dialogs.py b/dzgui/strings/dialogs.py index 460250d..3ab9a04 100644 --- a/dzgui/strings/dialogs.py +++ b/dzgui/strings/dialogs.py @@ -20,3 +20,6 @@ ok = "OK" mission_dialog = "Set a mission folder location" custom_mod_dialog = "Set location to a custom mods folder" + +debug_heading = "Debug" +debug_secondary = "Launch arguments" diff --git a/dzgui/strings/kb.py b/dzgui/strings/kb.py new file mode 100644 index 0000000..68ad2e5 --- /dev/null +++ b/dzgui/strings/kb.py @@ -0,0 +1,13 @@ +DZG_006 = """The leading cause of specific servers periodically timing out is local network configuration. + +Many third-party DayZ servers use a server rental/hosting provider with DDoS protection. + +This can cause responses from servers to originate from a server other than the one originally queried. + +Consumer-grade routers are likely to drop this traffic as invalid due to how they handle NAT (network address translation). + +By contrast, wireless routers and enterprise-grade routers may be less likely to have this issue. + +If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for the server's query port. + +In addition, a uniform packet size is expected. If your router's MTU (maximum transmission unit) setting differs from the default of 1500, it could lead to incoming responses being discarded.""" diff --git a/dzgui/strings/map_count.py b/dzgui/strings/map_count.py new file mode 100644 index 0000000..b1c255d --- /dev/null +++ b/dzgui/strings/map_count.py @@ -0,0 +1,16 @@ +exit_msg = ( + "System map count is not high enough to run DayZ. " + "Please exit and run 'dzgui -m' to update map count." +) +failed_to_parse = ( + "Failed to parse system map count.\n" + "This usually indicates that systemd is not installed." +) +failed_to_update = ( + "Failed to update system map count.\n" + "Please report the issue upstream and provide the following traceback.\n\n" + "{0}" +) +meets_minimum = "System map count of {0} already meets the minimum." +prompt = "Updated map count will be written to the file {0}." +user_exit = "User exit" diff --git a/dzgui/strings/options.py b/dzgui/strings/options.py index ec28776..fd2aaaf 100644 --- a/dzgui/strings/options.py +++ b/dzgui/strings/options.py @@ -1,12 +1,19 @@ developers = "Developers" start_tab = "Start tab" workshop_label = "Subscribed mods" -workshop_eventbox = "If you manually subscribed to mods on Steam prior to using DZGUI, you can unsubscribe via this link." steam_combo = "Steam" flatpak_combo = "Flatpak" flatpak_container_combo = "Flatpak (container)" +steam_placeholder = "Enter your Steam API key" +name_placeholder = "Identifies you to other players" + server_combo = "Server browser" saved_combo = "Saved servers" recent_combo = "Recent" lan_combo = "LAN" + +fullscreen_eventbox = "This option is not available on Steam Deck." +save_button = "Save" + +api_failed = "API validation failed" diff --git a/dzgui/strings/preconnect.py b/dzgui/strings/preconnect.py index 07e5e57..274c4b0 100644 --- a/dzgui/strings/preconnect.py +++ b/dzgui/strings/preconnect.py @@ -1,9 +1,8 @@ update_mods = "Update mods and connect" connect = "Connect" connect_last = "Load to menu screen" -connect_last_tooltip = ( - "Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting." -) +connect_last_tooltip = "Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting." +debug = "Debug" back = "Back" cancel = "Cancel" warnings = "Warnings" @@ -14,3 +13,38 @@ up_to_date = "All mods are up to date." all_updated = "All mods updated." "If you recently installed {build} or moved it to a different drive, " "restart Steam to allow these changes to synchronize, then try again." +required_space = " Need to download {0} MiB of mod updates." + +placeholder_no_mods = "This server has no mods." +placeholder_no_warnings = "No warnings." + + +# Errors +invalid_mods = ( + "Server has invalid mods that are not recognized by Steam.\n" + "Contact the server owner and include these mod IDs in your report:\n" + "{0}" +) +not_enough_space = ( + "Need to update {0} MiB of mods, but installation path only has {1} MiB." +) +resync = ( + "If you recently installed {0} or moved it to a different drive,\n" + "restart Steam to allow these changes to synchronize, then try again." +) +steam_not_running = "'{0}' is set as the default Steam client, but it is either not installed or not running." +use_desktop_mode = "Use Desktop Mode to download mods on Steam Deck." +version_mismatch = "Local client version '{0}' does not match remote version '{1}'." +version_missing = "Remote server is running the build '{0}', but it is not installed." + + +# Warnings +protected_server = ( + "Protected: you will be prompted for a password when connecting to this server." +) +dayz_running = "It looks like DayZ is already running in the background. Exit DayZ before connecting." +running_app = ( + "The app '{0}' is currently running in Steam, but background downloads are not enabled.\n" + "Either stop the game first, or update your global Steam settings or the game's local settings.\n" + "Otherwise, mods may be queued for download but never update." +) diff --git a/dzgui/strings/uninstall.py b/dzgui/strings/uninstall.py new file mode 100644 index 0000000..a609899 --- /dev/null +++ b/dzgui/strings/uninstall.py @@ -0,0 +1,38 @@ +pg1_title = "Choose uninstall targets" +pg1_heading = "Choose uninstall targets" +pg1_blurb = "Here you can choose which DZGUI configuration/state files to remove. " + +pg2_title = "Uninstall complete" +pg2_heading = "Uninstall complete" +pg2_blurb = "Finished removing the selected files. See the console for any errors." + +config_label = "Configuration files" +config_details = ( + "User preferences and API key. " + "Keep this file if you want to import it again later." +) +desktop_label = "Desktop shortcut" +desktop_details = "Shortcut set on the desktop, if applicable." + +start_menu_label = "Start menu shortcut" +start_menu_details = ( + "Shortcut set under the Games category of the start menu, if applicable." +) +state_label = "Long-term state files" +state_details = ( + "UI size, filter preferences, column size, server notes, and mod signatures." +) +steam_shortcut = "Steam shortcut" +steam_details = "Shortcut to DZGUI installed within Steam, if applicable. Requires Steam to be restarted to reflect the change." + + +path_remove_prefix = "The following path will be removed:" +not_installed = "It looks like DZGUI was not previously set up; nothing to uninstall." +standalone_uninstall = ( + "It looks like you are running the standalone version of DZGUI. " + "To finalize the uninstall process, you can remove the 'dzgui' file." +) +system_uninstall = ( + "It looks like DZGUI was installed via your system package manager or directly from source code.\n" + "To finalize the uninstall process, follow the standard removal procedure for that context." +) diff --git a/dzgui/util/diag.py b/dzgui/util/diag.py index 354234f..97423da 100644 --- a/dzgui/util/diag.py +++ b/dzgui/util/diag.py @@ -54,8 +54,6 @@ def write_diagnostic(config: Path, outfile: Path) -> None: cpu = get_cpu_model() version = get_version() - debug = lookup(config, Preferences.DEBUG) - install = lookup(config, Preferences.INSTALL) default = lookup(config, Preferences.DEFAULT) steam_path = Path(default) @@ -79,8 +77,6 @@ def write_diagnostic(config: Path, outfile: Path) -> None: Kernel: {kernel} CPU: {cpu} - Debug: {debug} - Auto-install: {install} Steam path: {steam_redacted} Workshop path: {workshop_redacted} diff --git a/dzgui/util/map_count.py b/dzgui/util/map_count.py index 80fff84..9adc00e 100644 --- a/dzgui/util/map_count.py +++ b/dzgui/util/map_count.py @@ -1,28 +1,71 @@ +import logging import subprocess +import sys +import tempfile +import traceback from pathlib import Path -from dzgui.const.constants import VM_FILE, MIN_COUNT +from dzgui.const.constants import APP_NAME, VM_FILE, MIN_COUNT +from dzgui.util.bash import concat_bash_args +from dzgui.strings import map_count +from dzgui.views.dialogs.early_alert import EarlyIgnoreDialog + +logger = logging.getLogger(APP_NAME) + + +def is_map_count_valid(count: int | None) -> bool: + if count is None: + # NOTE: permit if count was unreadable + return True + return count >= MIN_COUNT + def get_map_count() -> int | None: path = Path(VM_FILE) - if path.is_file() is False: + try: + count = int(path.read_text()) + except Exception as e: + logger.debug(e) return None - count = int(path.read_text()) return count - # TODO: unfinished - #if count < MIN_COUNT: - # print("needs sudo escalation") - # # pop prompt - # # get response - # set_map_count() - #return count -# TODO: unfinished +def test_map_count() -> None: + count = get_map_count() + if is_map_count_valid(count): + return + msg = map_count.exit_msg + EarlyIgnoreDialog(msg) + + def set_map_count() -> None: + count = get_map_count() + if count is None: + print(map_count.failed_to_parse) + return + if is_map_count_valid(count): + msg = map_count.meets_minimum.format(count) + print(msg) + return + conf = "/etc/sysctl.d/dayz.conf" - count = f"vm.max_map_count={MIN_COUNT}" - with open(conf, "w") as f: - f.write(count) - # TODO: use concat_bash_args() - subprocess.run(["/usr/bin/sudo", "sysctl", "-p", conf]) + value = f"vm.max_map_count={MIN_COUNT}" + try: + msg = map_count.prompt.format(conf) + print(msg) + with tempfile.NamedTemporaryFile(delete=False) as f: + tmp = f.name + Path(tmp).write_text(value) + + mv_cmd = f"sudo mv {tmp} {conf}" + reload_cmd = f"sudo sysctl -p {conf}" + for cmd in mv_cmd, reload_cmd: + args = concat_bash_args(cmd) + subprocess.run([*args]) + except Exception as e: + logger.debug(e) + trace = traceback.format_exc() + print(map_count.failed_to_update.format(trace)) + except KeyboardInterrupt: + print(map_count.user_exit) + sys.exit(0) diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index d87394a..9056200 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -1,27 +1,6 @@ from dataclasses import dataclass from dzgui.const.constants import SYSTEM_LOG -# TODO: move to util.format.py - - -def build_missing(build: str) -> str: - msg = ( - f"This server is running {build}. You can install " - f"{build} by searching for it in your Steam library. " - f"If you recently installed {build} or moved it to a different drive, " - "restart Steam to allow these changes to synchronize, then try again." - ) - return msg - - -def build_path_invalid(build: str) -> str: - msg = ( - f"Steam is reporting that {build} is installed at a non-existent location. " - f"If you recently installed {build} or moved it to a different drive, " - "restart Steam to allow these changes to synchronize, then try again." - ) - return msg - # General dialog_header = "DZGUI - Dialog" @@ -467,13 +446,15 @@ class Flags: version: str uninstall: str debug: str + map_count: str flags = Flags( description="DayZ server browser and mod manager", - version="Print version information", - uninstall="Uninstall data files (use prior to 'pip uninstall')", - debug="Enables developer debugging features", + version="print version information", + uninstall="clean up state/config files", + debug="enable developer debugging features", + map_count="check and update system map count value", ) @@ -525,63 +506,6 @@ server_labels = ServerLabels( ) -@dataclass(slots=True, frozen=True) -class ConnectPanel: - connect: str - add: str - add_con: str - placeholder: str - entry_tooltip: str - add_tooltip: str - connect_tooltip: str - - -connect_panel = ConnectPanel( - connect="Connect", - add="Add", - add_con="Add/connect", - placeholder="Enter IP (IP:Query port)", - entry_tooltip=( - "- IP: format as IP:Query port\ne.g. 192.168.1.1:27016" - ), - add_tooltip="Add to Saved Servers", - connect_tooltip="Connect to this server", -) - - -@dataclass(slots=True, frozen=True) -class FavPanel: - heading: str - no_fav: str - - -fav_panel = FavPanel( - heading="Favorite server", - no_fav="None set. Right click a server and select 'Set favorite' to set.", -) - - -@dataclass(slots=True, frozen=True) -class LanPanel: - heading: str - default_button: str - custom_button: str - scan_button: str - placeholder: str - entry_tooltip: str - scan_tooltip: str - - -lan_panel = LanPanel( - heading="LAN query port", - default_button="Default port (27016)", - custom_button="Custom port", - scan_button="Scan", - placeholder="Enter the query port (1-65535)", - entry_tooltip="Specify the port to search for DayZ servers on the local network", - scan_tooltip="Scan for servers", -) - distance_suffix = "Distance: calculating..." dialog_error = "ERROR" diff --git a/dzgui/views/base.py b/dzgui/views/base.py index 0cba916..7e0faea 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -69,8 +69,8 @@ class OuterWindow(Gtk.Window): self.show_all() css.load_css() - MainController.open_page(NotebookPage.SERVERS) MainController.set_start_tab() + MainController.open_page(NotebookPage.SERVERS) self.grid.hide_widgets_on_init() MainController.loaded = True diff --git a/dzgui/views/components/box.py b/dzgui/views/components/box.py index be64f82..4e1772f 100644 --- a/dzgui/views/components/box.py +++ b/dzgui/views/components/box.py @@ -23,3 +23,10 @@ class HBox(GenericBox): class VBox(GenericBox): def __init__(self, spacing: int = 0) -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing) + +class ShortHBox(HBox): + def __init__(self, widget: Gtk.Widget) -> None: + super().__init__(spacing=5) + + self.set_halign(Gtk.Align.START) + self.pack_start(widget, expand=False, fill=False, padding=0) diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index 8674820..45df27d 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -6,10 +6,10 @@ from dzgui.strings import buttons from dzgui.util.strings import ( alert_button_tooltip, atomic_buttons, - connect_panel, ) from dzgui.const.constants import ( CLIPBOARD, + CLOSE, INPUT_KEYBOARD, LIST_ADD, REFRESH_ICON, @@ -17,6 +17,7 @@ from dzgui.const.constants import ( WARNING, WEB_BROWSER, ) +from dzgui.strings import connect_panel import gi @@ -63,7 +64,6 @@ class IconButton(Gtk.Button): self.icon = Icon(icon, margin_start=margin_start, margin_end=margin_end) self.set_image(self.icon) self.set_image_position(position) - # self.set_image_position(Gtk.PositionType.RIGHT) self.set_focus_on_click(False) def swap_icon(self, icon: str) -> None: @@ -128,6 +128,14 @@ class CopyIpButton(ClipboardButton): self.set_tooltip_text("Copy IP to clipboard") +class CloseButton(IconTextButton): + def __init__(self, label: str) -> None: + super().__init__(icon=CLOSE, label=label) + + # TODO: strings + self.set_tooltip_text("Unset this server as favorite") + + class WebButton(IconTextButton): def __init__(self, label: str) -> None: super().__init__(icon=WEB_BROWSER, label=label) @@ -213,7 +221,7 @@ class KeysButton(IconTextButton): class SteamConnectButton(LargeIconTextButton): def __init__(self) -> None: - super().__init__(icon=STEAM_ICON, label=connect_panel.connect) + super().__init__(icon=STEAM_ICON, label=connect_panel.connect_button) self.set_tooltip_text(connect_panel.connect_tooltip) @@ -233,7 +241,7 @@ class SteamWorkshopButton(SteamTextButton): class AddButton(IconTextButton): def __init__(self) -> None: - super().__init__(icon=LIST_ADD, label=connect_panel.add) + super().__init__(icon=LIST_ADD, label=connect_panel.add_button) self.set_tooltip_text(connect_panel.add_tooltip) @@ -258,3 +266,38 @@ class LoggerAlertsButton(IconTextButton): self.set_halign(Gtk.Align.END) self.set_hexpand(True) self.set_tooltip_text(alert_button_tooltip) + + +class SpinnerButton(Gtk.Button): + def __init__(self, label: str) -> None: + super().__init__() + + self.text = label + self.label = Gtk.Label(label=label, halign=Gtk.Align.CENTER) + + self.spinner = Gtk.Spinner() + self.spinner.set_halign(Gtk.Align.END) + self.spinner.set_sensitive(False) + + grid = Gtk.Grid(column_spacing=10) + for el in self.label, self.spinner: + grid.add(el) + + self.add(grid) + + self.connect("clicked", self._on_button_clicked) + self.connect("map", self._on_map) + + def _on_map(self, button: Self) -> None: + self.spinner.set_visible(False) + + def _on_button_clicked(self, button: Self) -> None: + self.start_spinner() + + def stop_spinner(self) -> None: + self.spinner.set_visible(False) + self.spinner.stop() + + def start_spinner(self) -> None: + self.spinner.set_visible(True) + self.spinner.start() diff --git a/dzgui/views/components/connect_box.py b/dzgui/views/components/connect_box.py deleted file mode 100644 index 3f1200b..0000000 --- a/dzgui/views/components/connect_box.py +++ /dev/null @@ -1,68 +0,0 @@ -from dzgui.strings import preconnect -import gi - -gi.require_version("Gtk", "3.0") -from gi.repository import Gdk, Gtk # noqa E402 - - -class ConnectBox(Gtk.Box): - def __init__(self) -> None: - super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - - self.back = Gtk.Button( - label=preconnect.back, halign=Gtk.Align.END, hexpand=True - ) - - # TODO: dynamically update this - self.ok = Gtk.Button(label="Launch", halign=Gtk.Align.END) - - for button in self.back, self.ok: - self.add(button) - - def get_back_button(self) -> Gtk.Button: - return self.back - - def get_ok_button(self) -> Gtk.Button: - return self.ok - - -class PreconnectBox(Gtk.Box): - def __init__(self) -> None: - super().__init__( - orientation=Gtk.Orientation.VERTICAL, - valign=Gtk.Align.END, - vexpand=True, - spacing=5, - ) - - self.button_hbox = ConnectBox() - self.ok = self.button_hbox.get_ok_button() - self.ok.set_label(preconnect.update_mods) - - self.connect_last = Gtk.Button( - label=preconnect.connect_last, - halign=Gtk.Align.END, - tooltip_text=preconnect.connect_last_tooltip, - ) - self.button_hbox.add(self.connect_last) - - self.raise_window = Gtk.CheckButton( - # TODO: strings - label="Foreground DZGUI while downloading", - halign=Gtk.Align.END, - hexpand=True, - valign=Gtk.Align.END, - visible=False, - has_tooltip=True, - sensitive=False, - tooltip_text="Foreground the DZGUI window after mod downloads are queued", - active=True, - ) - self.add(self.button_hbox) - self.add(self.raise_window) - - def get_back_button(self) -> Gtk.Button: - return self.button_hbox.get_back_button() - - def get_ok_button(self) -> Gtk.Button: - return self.ok diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 0e4e6f4..17d0a71 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -6,6 +6,7 @@ from dzgui.strings import connect_panel from dzgui.util.keys import is_ctrl_mask from dzgui.views.components.buttons import ( AddButton, + CloseButton, CopyIpButton, Icon, IconButton, @@ -187,17 +188,27 @@ class FavPanel(Gtk.Frame): self.fav_button = SteamConnectButton() self.fav_button.connect("clicked", self._on_connect_clicked) self.copy_button = CopyIpButton(self.controller, self.get_fav_ip) + + # TODO: strings + self.unset_button = CloseButton("Unset") + self.unset_button.connect("clicked", self._on_unset_clicked) + if favorite is None: self.toggle_buttons(False) # NOTE: disable vscrollbar to prevent layout jumping behavior - scrollable_label = Gtk.ScrolledWindow(vscrollbar_policy=Gtk.PolicyType.NEVER) - scrollable_label.add(self.fav_label) + self.scrollable_label = Gtk.ScrolledWindow( + vscrollbar_policy=Gtk.PolicyType.NEVER, overlay_scrolling=False + ) + self.scrollable_label.add(self.fav_label) grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) - grid.attach(scrollable_label, 0, 0, 3, ROWS) + grid.attach(self.scrollable_label, 0, 0, 3, ROWS) grid.attach_next_to( - self.copy_button, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS + self.unset_button, self.scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS + ) + grid.attach_next_to( + self.copy_button, self.unset_button, Gtk.PositionType.RIGHT, COLS, ROWS ) grid.attach_next_to( self.fav_button, self.copy_button, Gtk.PositionType.RIGHT, COLS, ROWS @@ -205,8 +216,16 @@ class FavPanel(Gtk.Frame): self.add(grid) + def _on_unset_clicked(self, button: Gtk.Button) -> None: + try: + self.controller.unset_fav() + except Exception: + return + self.fav_label.set_text(connect_panel.favs_empty) + self.toggle_buttons(False) + def toggle_buttons(self, state: bool) -> None: - for button in self.fav_button, self.copy_button: + for button in self.fav_button, self.copy_button, self.unset_button: button.set_sensitive(state) def _on_connect_clicked(self, button: Gtk.Button) -> None: diff --git a/dzgui/views/components/entry.py b/dzgui/views/components/entry.py index 131b457..e1b3e7e 100644 --- a/dzgui/views/components/entry.py +++ b/dzgui/views/components/entry.py @@ -3,7 +3,7 @@ from typing import Any, Callable, TYPE_CHECKING from dzgui.api.servers import validate_ip from dzgui.const.constants import VIEW_CONCEAL, VIEW_REVEAL from dzgui.util.css import add_class, remove_class -from dzgui.util.strings import connect_panel, lan_panel +from dzgui.strings import connect_panel from dzgui.strings.errors import api_popover import gi @@ -122,11 +122,8 @@ class IpEntry(ValidatedEntry): super().__init__( controller, func=validate_ip_truthy, - placeholder_text=connect_panel.placeholder, - tooltip_text=connect_panel.entry_tooltip, + placeholder_text=connect_panel.connect_entry_placeholder, ) - self.set_placeholder_text(connect_panel.placeholder) - self.set_tooltip_text(connect_panel.entry_tooltip) class PortEntry(ValidatedEntry): @@ -134,11 +131,9 @@ class PortEntry(ValidatedEntry): super().__init__( controller, func=validate_port, - placeholder_text=connect_panel.placeholder, - tooltip_text=connect_panel.entry_tooltip, + placeholder_text=connect_panel.lan_entry_placeholder, + tooltip_text=connect_panel.lan_entry_tooltip, ) - self.set_placeholder_text(lan_panel.placeholder) - self.set_tooltip_text(lan_panel.entry_tooltip) # TODO: backport to Options page diff --git a/dzgui/views/components/filter_panel.py b/dzgui/views/components/filter_panel.py index c79ee0c..35a6ea4 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -29,21 +29,33 @@ if TYPE_CHECKING: class ButtonGrid(Gtk.Grid): - def __init__(self, controller: "Controller", defaults: dict) -> None: + def __init__( + self, + controller: "Controller", + defaults: dict[str, str], + tooltips: dict[str, str], + ) -> None: super().__init__( halign=Gtk.Align.CENTER, column_spacing=5, column_homogeneous=True ) - row = 1 - col = 0 - self.controller = controller self.emitter = controller.get_emitter() self.checks: list[Gtk.CheckButton] = [] - # TODO: use enumerated checks + flowbox = Gtk.FlowBox( + halign=Gtk.Align.CENTER, min_children_per_line=3, max_children_per_line=3, selection_mode=Gtk.SelectionMode.NONE + ) + for check in defaults.keys(): checkbox = Gtk.CheckButton(label=check) + try: + tt = tooltips[check] + except Exception as e: + logger.debug(e) + tt = "" + + checkbox.set_tooltip_text(tt) label = checkbox.get_child() if label is not None: label.set_ellipsize(Pango.EllipsizeMode.END) # type: ignore @@ -51,14 +63,12 @@ class ButtonGrid(Gtk.Grid): if defaults[check]: checkbox.set_active(True) - col = col + 1 - if col > 3: - row += 1 - col = 1 - self.attach(checkbox, col, row, 1, 1) checkbox.connect("toggled", self._on_check_toggled) + flowbox.add(checkbox) self.checks.append(checkbox) + self.add(flowbox) + def block_toggles(self, state: bool) -> None: for check in self.checks: self.controller.suppress_signal( @@ -163,11 +173,12 @@ class FilterPanel(Gtk.Box): filter_man = self.controller.get_filter_man() defaults = filter_man.get_default_filters() + tooltips = filter_man.get_tooltips() self.map_store = filter_man.get_map_store() self.enabled_filters = defaults self.keyword_entry = KeywordEntry(self.controller) - self.button_grid = ButtonGrid(self.controller, defaults) + self.button_grid = ButtonGrid(self.controller, defaults, tooltips) # TODO: strings self.filters_label = BoldLabel("Filters") diff --git a/dzgui/views/components/misc.py b/dzgui/views/components/misc.py index 7b9fc0f..fa8e29c 100644 --- a/dzgui/views/components/misc.py +++ b/dzgui/views/components/misc.py @@ -1,5 +1,8 @@ +from dzgui.const.constants import ERROR, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD from dzgui.strings import options -from dzgui.const.constants import FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD +from dzgui.views.components.box import HBox +from dzgui.views.components.buttons import Icon + import gi @@ -33,3 +36,18 @@ class ClientCombo(Gtk.ComboBox): self.pack_start(renderer_text, True) self.add_attribute(renderer_text, "text", 0) self.set_active(0) + +class ErrorPopover(Gtk.Popover): + def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None: + super().__init__(relative_to=relative_to, position=position) + + self.hbox = HBox() + self.label = Gtk.Label(label="", margin_start=10, margin_end=10) + error_icon = Icon(ERROR, margin_start=10) + self.hbox.extend([error_icon, self.label]) + self.add(self.hbox) + self.show_all() + self.popdown() + + def set_label(self, label: str) -> None: + self.label.set_label(label) diff --git a/dzgui/views/dialogs/early_alert.py b/dzgui/views/dialogs/early_alert.py index 1f911c7..e55c76b 100644 --- a/dzgui/views/dialogs/early_alert.py +++ b/dzgui/views/dialogs/early_alert.py @@ -10,21 +10,26 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 -class EarlyAlertDialog(Gtk.MessageDialog): - def __init__(self, string: str) -> None: +class AbortDialog(Gtk.MessageDialog): + def __init__(self, string: str, buttons: Gtk.ButtonsType) -> None: super().__init__( title=dialog_header, text=dialog_error, transient_for=None, - buttons=Gtk.ButtonsType.OK, + buttons=buttons, ) msg = textwrap.fill(string, 50) self.format_secondary_text(msg) + ma = self.get_message_area() + label = ma.get_children()[1] # type: ignore + label.set_justify(Gtk.Justification.CENTER) + aa = self.get_action_area() aa.set_margin_bottom(20) - # self.action_area.set_margin_bottom(20) + aa.set_layout(Gtk.ButtonBoxStyle.CENTER) # type: ignore + self.outer = self.get_content_area() self.outer.set_margin_start(30) self.outer.set_margin_end(30) @@ -32,26 +37,30 @@ class EarlyAlertDialog(Gtk.MessageDialog): self.set_default_size(250, 100) abort = self.get_widget_for_response(Gtk.ResponseType.OK) + ignore = self.get_widget_for_response(Gtk.ResponseType.CANCEL) if abort is not None and hasattr(abort, "set_label"): abort.set_label("Exit") + if ignore is not None and hasattr(ignore, "set_label"): + ignore.set_label("Ignore") self.connect("response", self._on_response) + self.run() self.destroy() def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: match response: - case Gtk.ResponseType.OK: + case Gtk.ResponseType.OK | Gtk.ResponseType.DELETE_EVENT: sys.exit(1) case Gtk.ResponseType.CANCEL: return -class EarlyIgnoreDialog(EarlyAlertDialog): +class EarlyAlertDialog(AbortDialog): def __init__(self, string: str) -> None: - super().__init__(string=string) + super().__init__(string=string, buttons=Gtk.ButtonsType.OK) - # TODO: reverse order - self.add_button("Ignore", Gtk.ResponseType.CANCEL) - # TODO: if exit, sys.exit(1), else pass +class EarlyIgnoreDialog(AbortDialog): + def __init__(self, string: str) -> None: + super().__init__(string=string, buttons=Gtk.ButtonsType.OK_CANCEL) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index 73da729..b504955 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -1,6 +1,7 @@ from typing import Literal, Self, TYPE_CHECKING from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING, EXPAND, FILL +from dzgui.strings import dialogs from dzgui.util import strings from dzgui.views.components.buttons import ClipboardButton @@ -107,7 +108,7 @@ class WaitDialog(GenericDialog): self.cur_job = 1 self.cancel = Gtk.Button(label="Cancel", halign=Gtk.Align.CENTER) - self.cancel.connect("clicked", lambda _: self.controller.set_cancel_event()) + self.cancel.connect("clicked", self._on_cancel_clicked) self.connect("delete-event", lambda widget, event: True) content = self.get_content_area() @@ -138,6 +139,11 @@ class WaitDialog(GenericDialog): self.prog.set_fraction(fraction) self.cur_job += 1 + def _on_cancel_clicked(self, button: Gtk.Button) -> None: + # TODO: strings + GLib.idle_add(self.update_text, "Caught cancel signal, cleaning up") + self.controller.set_cancel_event() + def show_cancel(self, state: bool) -> None: self.cancel.set_visible(state) @@ -156,6 +162,7 @@ class QuitDialog(GenericDialog): self.add_button(strings.exit_app, Gtk.ResponseType.OK) self.connect("response", self._on_response) + # TODO: superfluous with the above self.connect("delete-event", self._on_response) def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: @@ -163,58 +170,30 @@ class QuitDialog(GenericDialog): self.controller.save_res_and_quit() -class ExceptionDialog(GenericDialog): - """ - Error dialog with rich traceback. - Usage: - try: - foo() - except Exception: - trace = traceback.format_exc() - dialog = ExceptionDialog(Controller, trace) - dialog.run() - """ - - def __init__(self, controller: "Controller", trace: str): +class TextBufferDialog(GenericDialog): + def __init__( + self, + controller: "Controller", + mtype: Gtk.MessageType, + heading: str, + secondary: str, + text: str, + ): super().__init__( controller=controller, - text=strings.error_heading, - mtype=Gtk.MessageType.ERROR, + text=heading, + mtype=mtype, buttons=Gtk.ButtonsType.NONE, - secondary=strings.something_wrong, + secondary=secondary, ) - - self.trace = trace - # NOTE: box expands to end of content area - scrollable = Gtk.ScrolledWindow( - propagate_natural_height=True, max_content_height=500 - ) - box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL) - # TODO: wrap/truncate long messages - textview = Gtk.TextView( - wrap_mode=Gtk.WrapMode.WORD, editable=False, left_margin=10, right_margin=10 - ) - textview.set_buffer(Gtk.TextBuffer(text=self.trace)) - box.pack_start(textview, EXPAND, FILL, 10) - scrollable.add(box) - - content = self.get_content_area() - content.set_spacing(0) - # TODO: padding around top of content area when traceback is long - content.add(scrollable) - - copy_button = ClipboardButton(controller, self.get_trace) + self.text = text + copy_button = ClipboardButton(controller, self.get_text) self.add_action_widget(copy_button, Gtk.ResponseType.NONE) self.add_button("OK", Gtk.ResponseType.OK) - - self.show_all() - self.ok = self.get_widget_for_response(Gtk.ResponseType.OK) - if self.ok is not None: - self.ok.grab_focus() self.connect("response", self._on_response) - def get_trace(self) -> str: - return self.trace + def get_text(self) -> str: + return self.text def _on_response( self, dialog: Self, response: Gtk.ResponseType @@ -230,3 +209,114 @@ class ExceptionDialog(GenericDialog): return None case _: return None + + +class DebugDialog(TextBufferDialog): + def __init__(self, controller: "Controller", debug: str): + super().__init__( + controller=controller, + mtype=Gtk.MessageType.INFO, + heading=dialogs.debug_heading, + secondary=dialogs.debug_secondary, + text=debug, + ) + scrollable = Gtk.ScrolledWindow( + propagate_natural_height=False, + min_content_height=200, + max_content_height=200, + margin_bottom=10, + ) + textview = Gtk.TextView( + wrap_mode=Gtk.WrapMode.CHAR, editable=False, left_margin=10, right_margin=10 + ) + textview.set_buffer(Gtk.TextBuffer(text=debug)) + scrollable.add(textview) + content = self.get_content_area() + content.set_spacing(0) + content.add(scrollable) + + +class ExceptionDialog(TextBufferDialog): + """ + Error dialog with rich traceback. + Usage: + try: + foo() + except Exception: + trace = traceback.format_exc() + dialog = ExceptionDialog(Controller, trace) + dialog.show_all() + dialog.run() + """ + + def __init__(self, controller: "Controller", trace: str): + super().__init__( + controller=controller, + heading=strings.error_heading, + secondary=strings.something_wrong, + text=trace, + mtype=Gtk.MessageType.ERROR, + ) + + # NOTE: box expands to end of content area + scrollable = Gtk.ScrolledWindow( + propagate_natural_height=True, max_content_height=500 + ) + box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL) + textview = Gtk.TextView( + wrap_mode=Gtk.WrapMode.WORD, + editable=False, + left_margin=10, + right_margin=10, + top_margin=15, + bottom_margin=10, + ) + textview.set_buffer(Gtk.TextBuffer(text=trace)) + box.pack_start(textview, EXPAND, FILL, 0) + + self.error_details = Gtk.ScrolledWindow( + overlay_scrolling=False, + max_content_height=150, + propagate_natural_height=False, + ) + details_box = Gtk.Box( + hexpand=True, + vexpand=True, + margin_right=5, + orientation=Gtk.Orientation.VERTICAL, + ) + + self.details_buffer = Gtk.TextBuffer() + details_textview = Gtk.TextView( + wrap_mode=Gtk.WrapMode.WORD_CHAR, + editable=False, + left_margin=10, + right_margin=10, + top_margin=15, + ) + + details_textview.set_buffer(self.details_buffer) + details_box.pack_start(details_textview, EXPAND, FILL, 10) + self.error_details.add(details_box) + + self.error_notebook = Gtk.Notebook(show_tabs=False, margin_bottom=15) + self.error_notebook.append_page(box, Gtk.Label(label="Error")) + self.error_notebook.append_page(self.error_details, Gtk.Label(label="Details")) + self.error_notebook.connect("switch-page", self._on_page_changed) + scrollable.add(self.error_notebook) + + content = self.get_content_area() + content.set_spacing(0) + content.add(scrollable) + + def _on_page_changed( + self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int + ) -> None: + if child == self.error_details: + GLib.idle_add(self.error_details.set_propagate_natural_height, True) + else: + GLib.idle_add(self.error_details.set_propagate_natural_height, False) + + def set_details_buffer(self, text: str) -> None: + self.details_buffer.set_text(text) + self.error_notebook.set_show_tabs(True) diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index 14cb719..d957158 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -1,3 +1,4 @@ +import html from typing import Self, TYPE_CHECKING from dzgui.const.constants import EXPAND, FILL @@ -65,6 +66,7 @@ class ServerDialog(GenericDialog): class ServerDetailsDialog(ServerDialog): def __init__(self, controller: "Controller", details: "Details"): + # TODO: server name should also be packed in details struct name = controller.get_server_name() super().__init__(controller, strings.server_details, name, menu=None) @@ -109,7 +111,7 @@ class ServerDetailsDialog(ServerDialog): self.view.set_model(self.store) text = details.description text = format_hyperlinks(text) - self.description.set_markup(text) + self.description.set_markup(html.escape(text)) self.show_all() diff --git a/dzgui/views/dialogs/uninstall.py b/dzgui/views/dialogs/uninstall.py new file mode 100644 index 0000000..1e50129 --- /dev/null +++ b/dzgui/views/dialogs/uninstall.py @@ -0,0 +1,233 @@ +import os +import shutil +import subprocess + +from pathlib import Path +from typing import Self + +from dzgui.api.shortcuts import Shortcuts +from dzgui.const.constants import APP_NAME +from dzgui.strings import uninstall +from dzgui.util._json import read_json +from dzgui.util.css import load_css +from dzgui.util.format import format_exception +from dzgui.views.components.box import HBox +from dzgui.views.dialogs.wizard import ScrolledWizardPage, CheckboxWithLabel + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib, Pango # noqa E402 + + +class Assistant(Gtk.Assistant): + def __init__(self, is_deck: bool, paths: dict[str, str]): + super().__init__() + if is_deck: + # NOTE: deemed to be "safe" dimensions that exclude taskbar size + self.set_default_size(1085, 670) + else: + self.set_default_size(1500, 900) + + self.set_forward_page_func(self._advance_page) + + pyapp = os.getenv("PYAPP") + + self.page1 = UninstallPage(paths, pyapp) + self.page2 = CompletionPage(pyapp) + + self.append_page(self.page1) + self.set_page_type(self.page1, Gtk.AssistantPageType.INTRO) + self.set_page_title(self.page1, uninstall.pg1_title) + self.set_page_complete(self.page1, True) + + self.append_page(self.page2) + self.set_page_title(self.page2, uninstall.pg2_title) + self.set_page_type(self.page2, Gtk.AssistantPageType.SUMMARY) + + self.connect("cancel", self.destroy_and_quit) + self.connect("close", self.destroy_and_quit) + self.show_all() + load_css() + + def _advance_page(self, index: int) -> int: + cur_page = self.get_nth_page(index) + if cur_page == self.page1: + self.page1.uninstall() + return index + 1 + + def destroy_and_quit(self, widget: Self) -> None: + self.destroy() + Gtk.main_quit() + + +class Window(Gtk.Window): + def __init__(self, is_deck: bool, paths: dict[str, str]) -> None: + super().__init__(title=APP_NAME, icon_name=APP_NAME) + self.assistant = Assistant(is_deck, paths) + + +class CompletionPage(ScrolledWizardPage): + def __init__(self, pyapp: str | None): + super().__init__(heading=uninstall.pg2_heading, description=uninstall.pg2_blurb) + + text = ( + uninstall.standalone_uninstall + if pyapp is not None + else uninstall.system_uninstall + ) + + label = Gtk.Label( + label=text, + wrap_mode=Pango.WrapMode.WORD, + margin=10, + max_width_chars=80, + justify=Gtk.Justification.CENTER, + ) + frame = Gtk.Frame() + frame.add(label) + self.add_start(frame) + + +class FileLabel(HBox): + def __init__(self, path: Path) -> None: + super().__init__(spacing=10) + + label = Gtk.Label(label=uninstall.path_remove_prefix, halign=Gtk.Align.START) + textview = Gtk.TextView( + editable=False, + halign=Gtk.Align.START, + left_margin=10, + right_margin=10, + ) + textview.set_buffer(Gtk.TextBuffer(text=str(path))) + self.extend([label, textview]) + + +class CheckboxWithPath(CheckboxWithLabel): + def __init__( + self, label: str, details: str, path: Path, active: bool = True + ) -> None: + super().__init__(text=label, blurb_text=details) + + self.conf_path = path + self.set_active(active) + fl = FileLabel(path) + self.indent_below(fl) + + def get_conf_path(self) -> Path: + return self.conf_path + + +class UninstallPage(ScrolledWizardPage): + def __init__(self, paths: dict[str, str], pyapp: str | None): + super().__init__(heading=uninstall.pg1_heading, description=uninstall.pg1_blurb) + + self.pyapp = pyapp + + config = Path(paths["XDG_CONFIG_HOME"]) + state = Path(paths["XDG_STATE_HOME"]) + self.share = Path(paths["XDG_DATA_HOME"]) + shortcut = self.share.parent.joinpath("applications/dzgui.desktop") + desktop = config.parent.parent.joinpath("Desktop/dzgui.desktop") + self.steam_path = self.parse_steam_path(config, self.share) + + self.boxes: list[CheckboxWithPath] = [] + self.checks_area = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + halign=Gtk.Align.CENTER, + spacing=20, + margin_top=20, + margin_bottom=20, + ) + self.config_box = CheckboxWithPath( + uninstall.config_label, uninstall.config_details, config, active=False + ) + self.desktop_box = CheckboxWithPath( + uninstall.desktop_label, uninstall.desktop_details, desktop + ) + self.start_menu_box = CheckboxWithPath( + uninstall.start_menu_label, uninstall.start_menu_details, shortcut + ) + self.share_box = CheckboxWithPath( + uninstall.desktop_label, uninstall.desktop_details, desktop + ) + self.state_box = CheckboxWithPath( + uninstall.state_label, uninstall.state_details, state + ) + self.steam_box = CheckboxWithLabel( + uninstall.steam_shortcut, uninstall.steam_details + ) + + for box in ( + self.config_box, + self.state_box, + self.desktop_box, + self.start_menu_box, + ): + self.checks_area.add(box) + self.boxes.append(box) + + self.checks_area.add(self.steam_box) + + self.add_start(self.checks_area) + self.show_all() + + def parse_steam_path(self, config: Path, steam: Path) -> Path | None: + try: + file = config.joinpath("config.json") + conf = read_json(file) + steam = conf["default_steam_path"] + self.steam_path = Path(steam) + return Path(steam) + except Exception as e: + print(format_exception(e)) + return None + + def wipe_conf_file(self, box: CheckboxWithPath) -> None: + if box.get_active(): + path = box.get_conf_path() + try: + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() + print(f"Deleted '{path}'") + except Exception as e: + print(format_exception(e)) + + def wipe_steam_shortcut(self) -> None: + if self.steam_path is None: + return + try: + shortcuts = Shortcuts(self.steam_path) + shortcuts.delete_shortcut(self.share) + except Exception as e: + print(format_exception(e)) + + def wipe_pyapp(self) -> None: + if self.pyapp is None: + return + res = subprocess.run([self.pyapp, "self", "remove"]) + print(res) + + def uninstall(self) -> None: + for box in self.boxes: + self.wipe_conf_file(box) + if self.steam_box.get_active(): + self.wipe_steam_shortcut() + self.wipe_pyapp() + pass + + +class UninstallWizard(Gtk.Application): + def __init__(self, is_deck: bool, paths: dict[str, str]) -> None: + super().__init__() + config = Path(paths["XDG_CONFIG_HOME"]) + # TODO: tests + if not config.is_dir() or not config.joinpath("config.json").is_file(): + print(uninstall.not_installed) + return + GLib.set_prgname(APP_NAME) + self.win = Window(is_deck, paths) + Gtk.main() diff --git a/dzgui/views/dialogs/wizard.py b/dzgui/views/dialogs/wizard.py index 1df4ece..d0f0aa0 100644 --- a/dzgui/views/dialogs/wizard.py +++ b/dzgui/views/dialogs/wizard.py @@ -52,7 +52,7 @@ class PageNum(Enum): class OptionalPageMixin: """Marks optional pages as advanceable""" - def _on_map(self, page: "ScrolledWizardPage") -> None: + def _on_map(self, page: "EnumeratedWizardPage") -> None: EMITTER.emit("step_complete") @@ -72,10 +72,9 @@ class Progress(Gtk.ProgressBar): class ScrolledWizardPage(Gtk.ScrolledWindow): - def __init__(self, enum: PageNum, heading: str, description: str): + def __init__(self, heading: str, description: str): super().__init__(overlay_scrolling=False) - self.enum = enum self.page_type: Gtk.AssistantPageType self.title = heading self.heading = Heading(heading) @@ -88,7 +87,6 @@ class ScrolledWizardPage(Gtk.ScrolledWindow): height=600, preserve_aspect_ratio=True, ) - image = Gtk.Image.new_from_pixbuf(pixbuf) self.box = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, @@ -97,22 +95,13 @@ class ScrolledWizardPage(Gtk.ScrolledWindow): margin_top=50, spacing=20, ) + image = Gtk.Image.new_from_pixbuf(pixbuf) + self.box.pack_start(image, expand=False, fill=True, padding=0) self.add(self.box) - self.prog = Progress() - self.box.pack_end(self.prog, expand=False, fill=False, padding=0) - self.box.pack_start(image, expand=False, fill=True, padding=0) self.box.pack_start(self.heading, expand=False, fill=True, padding=0) self.box.pack_start(self.description, expand=False, fill=True, padding=0) - self.connect("map", self._on_map) - - def get_enum(self) -> PageNum: - return self.enum - - def get_progress_bar(self) -> Progress: - return self.prog - def get_page_type(self) -> Gtk.AssistantPageType: return self.page_type @@ -131,8 +120,20 @@ class ScrolledWizardPage(Gtk.ScrolledWindow): def get_box(self) -> Gtk.Box: return self.box - def _on_map(self, page: "ScrolledWizardPage") -> None: - pass + +class EnumeratedWizardPage(ScrolledWizardPage): + def __init__(self, enum: PageNum, heading: str, description: str) -> None: + super().__init__(heading=heading, description=description) + + self.enum = enum + self.prog = Progress() + self.box.pack_end(self.prog, expand=False, fill=False, padding=0) + + def get_enum(self) -> PageNum: + return self.enum + + def get_progress_bar(self) -> Progress: + return self.prog class NotificationFrame(Gtk.Frame): @@ -162,7 +163,7 @@ class NotificationFrame(Gtk.Frame): self.label.set_markup(wrapped) -class APIValidationPage(ScrolledWizardPage): +class APIValidationPage(EnumeratedWizardPage): def __init__( self, enum: PageNum, heading: str, description: str, link: str, func: Callable ) -> None: @@ -239,7 +240,7 @@ class SteamValidationPage(APIValidationPage): self.thread_man.set_cleanup_func(cleanup) -class IntroductionPage(ScrolledWizardPage): +class IntroductionPage(EnumeratedWizardPage): def __init__(self) -> None: super().__init__( enum=PageNum.INTRO, @@ -294,7 +295,7 @@ class RadioFrame(Gtk.Frame): return self.button -class ConfigMigrationPage(ScrolledWizardPage): +class ConfigMigrationPage(EnumeratedWizardPage): def __init__(self, config: Path) -> None: super().__init__( enum=PageNum.HAS_CONFIG, @@ -354,7 +355,7 @@ class ConfigMigrationPage(ScrolledWizardPage): EMITTER.emit("config", True) -class PreferencesPage(ScrolledWizardPage): +class PreferencesPage(EnumeratedWizardPage): def __init__(self) -> None: super().__init__( enum=PageNum.USER_PREFS, @@ -414,7 +415,7 @@ class PreferencesPage(ScrolledWizardPage): EMITTER.emit("step_complete") -class CompletionPage(ScrolledWizardPage): +class CompletionPage(EnumeratedWizardPage): def __init__(self) -> None: super().__init__( enum=PageNum.FINAL, @@ -539,7 +540,9 @@ class Assistant(Gtk.Assistant): return self.set_page_complete(page, True) - def _add_page(self, page: ScrolledWizardPage, ptype: Gtk.AssistantPageType) -> None: + def _add_page( + self, page: EnumeratedWizardPage, ptype: Gtk.AssistantPageType + ) -> None: self.append_page(page) self.set_page_type(page, ptype) self.set_page_title(page, page.get_title()) @@ -548,7 +551,7 @@ class Assistant(Gtk.Assistant): def _set_config_state(self, emitter: "Emitter", state: bool) -> None: self.config = state - def _on_page_prepare(self: Self, wizard: Self, page: ScrolledWizardPage) -> None: + def _on_page_prepare(self: Self, wizard: Self, page: EnumeratedWizardPage) -> None: page_num = self.get_current_page() + 1 total = self.get_n_pages() fraction = page_num / total @@ -565,15 +568,21 @@ class CheckboxWithLabel(Gtk.Box): def __init__(self, text: str, blurb_text: str) -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=5) + self.indent = 20 self.button = Gtk.CheckButton(label=text) self.button.set_active(True) - label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=20) + label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=self.indent) + wrapped = textwrap.fill(blurb_text, width=100) label.set_markup(f"- {wrapped}") for el in self.button, label: self.add(el) + def indent_below(self, widget: Gtk.Widget) -> None: + widget.set_margin_start(self.indent) + self.add(widget) + def get_checkbox(self) -> Gtk.CheckButton: return self.button @@ -584,7 +593,7 @@ class CheckboxWithLabel(Gtk.Box): self.button.set_active(state) -class ShortcutCreationPage(OptionalPageMixin, ScrolledWizardPage): # type: ignore +class ShortcutCreationPage(OptionalPageMixin, EnumeratedWizardPage): # type: ignore def __init__(self, shortcut: Path) -> None: super().__init__( enum=PageNum.SHORTCUTS, @@ -642,7 +651,7 @@ class ShortcutCreationPage(OptionalPageMixin, ScrolledWizardPage): # type: igno freedesktop.write_desktop_shortcut(desktop_file) -class SteamPathPage(ScrolledWizardPage): +class SteamPathPage(EnumeratedWizardPage): def __init__(self) -> None: super().__init__( enum=PageNum.USER_PREFS, diff --git a/dzgui/views/mixins/context_mixin.py b/dzgui/views/mixins/context_mixin.py index 19c0f42..358c65a 100644 --- a/dzgui/views/mixins/context_mixin.py +++ b/dzgui/views/mixins/context_mixin.py @@ -104,20 +104,11 @@ class ContextMixin(TreeView): return False menu = self.context_menu children = menu.get_children() - sel = menu.get_selected_item() - for i, child in enumerate(children): - if sel is child: - ind = i - break match event.keyval: case Gdk.KEY_j: - if ind + 1 > len(children) - 1: - return False menu.emit("move-current", Gtk.MenuDirectionType.NEXT) case Gdk.KEY_k: - if ind - 1 < 0: - return False menu.emit("move-current", Gtk.MenuDirectionType.PREV) case Gdk.KEY_g: menu.select_item(children[0]) diff --git a/dzgui/views/pages/changelog.py b/dzgui/views/pages/changelog.py index 4d36637..108912c 100644 --- a/dzgui/views/pages/changelog.py +++ b/dzgui/views/pages/changelog.py @@ -1,10 +1,12 @@ import logging import re +import textwrap from typing import TYPE_CHECKING from importlib import resources from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, CHANGELOG_PATH +from dzgui.strings import changelog as clog from dzgui.util.strings import missing_changelog from dzgui.util.format import format_pango from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin @@ -31,20 +33,39 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig logger.critical(e) changelog = missing_changelog - # TODO: should long text be wrapped? self.controller = controller self.box = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, spacing=5, margin_top=10 ) self.add(self.box) + expand_all = Gtk.Button( + label=clog.expand_all, halign=Gtk.Align.START, margin_start=20 + ) + expand_all.connect("clicked", self._on_expand_all_clicked) + self.box.add(expand_all) + + self.expanded = False + self.expanders: list[Gtk.Expander] = [] self.connect("key-press-event", self._on_keypress) self.connect("key-press-event", self._on_esc_keypress) changes = self._parse(changelog) self._generate_nodes(changes) + self.show_all() + def _on_expand_all_clicked(self, button: Gtk.Button) -> None: + self.expanded = not self.expanded + for expander in self.expanders: + # NOTE: simply setting set_expanded() does not trigger activate() signal, + # so margins are not applied + if expander.get_expanded() == self.expanded: + continue + expander.activate() + label = clog.collapse_all if self.expanded else clog.expand_all + button.set_label(label) + def grab_content_area(self) -> None: self.grab_focus() @@ -65,9 +86,7 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig text = "\n".join(changes) formatted = format_pango(text) - container = Gtk.Box( - valign=Gtk.Align.START, halign=Gtk.Align.START - ) + container = Gtk.Box(valign=Gtk.Align.START, halign=Gtk.Align.START) label = Gtk.Label() label.set_markup(formatted) container.add(label) @@ -81,6 +100,7 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig expander.add(container) expander.connect("activate", self._on_expand, container) self.box.add(expander) + self.expanders.append(expander) def _on_expand(self, expander: Gtk.Box, container: Gtk.Box) -> None: """ @@ -94,6 +114,7 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig container.set_margin_bottom(15) def _parse(self, changelog: str) -> list[tuple[str, list[str]]]: + release = "" releases: list[tuple[str, list[str]]] = [] release_notes: list[str] = [] @@ -107,5 +128,5 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig release = "" release = line continue - release_notes.append(line.rstrip()) + release_notes.append(textwrap.fill(line.rstrip(), width=120)) return releases diff --git a/dzgui/views/pages/offline.py b/dzgui/views/pages/offline.py index f5baa9d..b0200b0 100644 --- a/dzgui/views/pages/offline.py +++ b/dzgui/views/pages/offline.py @@ -9,7 +9,6 @@ from dzgui.const.constants import ( APPNAME_DAYZ, APPNAME_DAYZ_EXP_HUMAN, EDIT_DELETE, - ERROR, FOLDER, WARNING, ) @@ -21,6 +20,7 @@ from dzgui.views.components.buttons import Icon, IconTextButton from dzgui.views.components.eventbox import InfoEventBox from dzgui.views.components.frame import HeadingFrame from dzgui.views.components.scrollable import NoOverlayScrolledWindow +from dzgui.views.components.misc import ErrorPopover from dzgui.views.trees.tree_mods import OfflineModTreeView @@ -57,19 +57,11 @@ class PageHeading(Gtk.Box): css.add_class(self, "page-heading") -class ErrorPopover(Gtk.Popover): - def __init__(self) -> None: - super().__init__(position=Gtk.PositionType.RIGHT) +class OfflineErrorPopover(ErrorPopover): + def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None: + super().__init__(relative_to=relative_to, position=position) - self.hbox = HBox() - self.label = Gtk.Label(label="", margin_start=10, margin_end=10) - error_icon = Icon(ERROR, margin_start=10) - self.hbox.extend([error_icon, self.label]) - self.add(self.hbox) - self.show_all() - self.popdown() - - def set_label(self, error: FolderError, msg: str) -> None: + def set_error_label(self, error: FolderError, msg: str) -> None: match error: case FolderError.NO_VALID_MODS: prefix = offline.no_mods @@ -115,13 +107,10 @@ class FolderHBox(HBox): [self.eb, self.button, self.spinner, self.scrolled_label, self.unset_button] ) - self.pop = ErrorPopover() - self.pop.set_relative_to(self.button) + self.pop = OfflineErrorPopover(self.button, Gtk.PositionType.RIGHT) self.pop.connect("unmap", lambda _: self.grab_focus()) - self.sidepop = ErrorPopover() - self.sidepop.set_position(Gtk.PositionType.BOTTOM) - self.sidepop.set_relative_to(self.scrolled_label) + self.sidepop = OfflineErrorPopover(self.scrolled_label, Gtk.PositionType.BOTTOM) self.sidepop.connect("unmap", lambda _: self.grab_focus()) self.connect("map", self._on_map) @@ -170,13 +159,13 @@ class FolderHBox(HBox): def present_error(self, error: FolderError, msg: str) -> None: if error == FolderError.FOLDER_CHANGED: - self.sidepop.set_label(error, msg) + self.sidepop.set_error_label(error, msg) self.sidepop.popup() return self.folder = "" self.label.set_text("") self.unset_button.hide() - self.pop.set_label(error, msg) + self.pop.set_error_label(error, msg) self.pop.popup() @@ -467,7 +456,6 @@ class OfflineLoader(Gtk.Box): ] ) - # TODO: share ConnectBox class with preconnect dialog? self.button_box = HBox(spacing=5) self.button_box.set_halign(Gtk.Align.END) self.button_box.set_margin_top(15) diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index c8f482a..93341ec 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import TYPE_CHECKING +from typing import Callable, TYPE_CHECKING from dzgui.api import pefile as PeFile @@ -17,14 +17,15 @@ from dzgui.const.constants import ( ) from dzgui.const.endpoints import STEAM_API_SETUP from dzgui.const.enum import Preferences, ServerTab -from dzgui.strings import errors, options +from dzgui.strings import options from dzgui.util import strings, css, open_links -from dzgui.views.components.box import VBox +from dzgui.views.components.box import ShortHBox, VBox +from dzgui.views.components.eventbox import InfoEventBox from dzgui.views.components.labels import LeftLabel -from dzgui.views.components.buttons import WebButton +from dzgui.views.components.buttons import SpinnerButton, WebButton from dzgui.views.components.frame import HeadingFrame -from dzgui.views.components.misc import ClientCombo +from dzgui.views.components.misc import ClientCombo, ErrorPopover from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.mixins.scrollable_mixin import ScrollableMixin @@ -39,11 +40,214 @@ if TYPE_CHECKING: from dzgui.controllers.emitter import Emitter -class ShortHBox(Gtk.Box): - def __init__(self, widget: Gtk.Widget) -> None: +class SubmitField(Gtk.Box): + def __init__( + self, + controller: "Controller", + placeholder: str, + context: Preferences, + slow: bool = False, + private: bool = True, + ) -> None: + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + + self.callback: Callable + self.controller = controller + self.context = context + + self.old_text: str + self.placeholder = placeholder + + self.entry = Gtk.Entry(placeholder_text=placeholder, hexpand=True) + + # TODO: audit for all applicable callbacks + if private: + self.entry.set_icon_from_icon_name( + Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL + ) + self.entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) + self.entry.connect("icon-release", self._on_icon_release) + self.entry.set_visibility(False) + + self.button: SpinnerButton | Gtk.Button + if slow: + self.button = SpinnerButton(label=options.save_button) + else: + self.button = Gtk.Button(label=options.save_button) + + self.button.connect("clicked", self._on_save_clicked) + self.entry.connect("insert-text", self._on_text_typed) + self.entry.connect("activate", self._on_field_activated) + self.entry.get_property("buffer").connect("deleted-text", self._on_text_deleted) + + self.add(self.entry) + self.add(self.button) + + def set_text(self, text: str) -> None: + self.old_text = text + self.entry.set_text(text) + if len(text) == 0: + self.button.set_sensitive(False) + + def _is_valid_text(self, text: str) -> bool: + if text.isspace(): + return False + if len(text) == 0: + return False + + if text == self.old_text: + 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.button.set_sensitive(state) + + def _on_text_typed( + self, + entry: Gtk.Entry, + text: str, + length: int, + pos: int, + ) -> None: + + buffer = entry.get_property("buffer") + text = buffer.get_text() + text + state = self._is_valid_text(text) + self.button.set_sensitive(state) + + def _on_icon_release( + self, + widget: Gtk.Entry, + icon_pos: Gtk.EntryIconPosition, + event: Gdk.Event, + ) -> None: + visible = widget.get_visibility() + if visible: + icon, state = VIEW_REVEAL, False + else: + icon, state = VIEW_CONCEAL, True + widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + widget.set_visibility(state) + + def _on_field_activated(self, entry: Gtk.Entry) -> None: + text = entry.get_text() + if not self._is_valid_text(text): + return + self.save_option() + + def set_callback(self, callback: Callable) -> None: + self.callback = callback + + def save_option(self) -> None: + self.callback() + self.button.set_sensitive(False) + + def _on_save_clicked(self, button: Gtk.Button) -> None: + self.save_option() + + +class SteamSubmitField(SubmitField): + def __init__(self, controller: "Controller") -> None: + super().__init__( + controller, + options.steam_placeholder, + Preferences.STEAM, + slow=True, + private=True, + ) + + self.pop = ErrorPopover( + position=Gtk.PositionType.BOTTOM, relative_to=self.entry + ) + self.pop.set_label(options.api_failed) + self.pop.show_all() + self.pop.popdown() + + emitter = controller.get_emitter() + emitter.connect("api_change_failed", self._on_api_failure) + emitter.connect("api_change_successful", self._on_api_success) + + self.set_callback(self.save_setting) + + def save_setting(self) -> None: + self.entry.set_sensitive(False) + text = "".join(self.entry.get_text().split()) + self.controller.update_steam_api_key(text) + + def _on_api_success(self, emitter: "Emitter") -> None: + self.old_text = self.entry.get_text() + if isinstance(self.button, SpinnerButton): + self.button.stop_spinner() + self.entry.set_sensitive(True) + + def _on_api_failure(self, emitter: "Emitter") -> None: + self.pop.popup() + if isinstance(self.button, SpinnerButton): + self.button.stop_spinner() + self.entry.set_sensitive(True) + + def block_text_entry(self) -> None: + self.entry.set_position(-1) + self.entry.set_can_focus(False) + + def unblock_text_entry(self) -> None: + self.entry.set_can_focus(True) + + +class NameSubmitField(SubmitField): + def __init__(self, controller: "Controller") -> None: + super().__init__( + controller, + options.name_placeholder, + Preferences.NAME, + private=False, + ) + self.entry.set_width_chars(30) # type: ignore + self.set_callback(self.save_player_name) + + def save_player_name(self) -> None: + value = self.entry.get_text().strip() + self.old_text = value + self.controller.update_config(self.context, value) + + +class ToggleField(Gtk.Box): + def __init__( + self, + controller: "Controller", + first_option: str, + second_option: str, + context: Preferences, + ) -> None: super().__init__(spacing=5, halign=Gtk.Align.START) - self.pack_start(widget, NO_EXPAND, NO_FILL, NO_PADDING) + self.controller = controller + self.context = context + self.radio1 = Gtk.RadioButton.new_with_label(None, first_option) + self.radio2 = Gtk.RadioButton.new_from_widget(self.radio1) + self.radio2.set_label(second_option) + self.pack_start(self.radio1, NO_EXPAND, NO_FILL, NO_PADDING) + self.pack_start(self.radio2, NO_EXPAND, NO_FILL, NO_PADDING) + + def set_suboption_active(self, state: bool) -> None: + # NOTE: defer connection of signal until after state is set + self.radio2.set_active(state) + self.radio1.connect("toggled", self._on_radio_toggled, self.context) + + def _on_radio_toggled(self, button: Gtk.RadioButton, context: Preferences) -> None: + self.controller.toggle_config(context) + + def set_sensitive(self, state: bool) -> None: + for el in self.radio1, self.radio2: + el.set_sensitive(state) class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore @@ -55,32 +259,27 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore self.controller = controller self.controller.register_widget("options", self) - emitter = controller.get_emitter() - emitter.connect("api_change_failed", self._on_api_change_failed) self.DEFAULT_WIDTH = 1 self.DEFAULT_HEIGHT = 1 self.steam_entry: Gtk.Entry + self.pop: Gtk.Popover self.steam = WebButton(label=strings.options.steam_web) self.steam.connect("clicked", self._on_link_button_clicked, STEAM_API_SETUP) - self.steam_box = self._make_submit_field( - strings.options.enter_steam, Preferences.STEAM, True - ) + self.steam_box = SteamSubmitField(controller) api_rows = [ [LeftLabel(strings.options.steam_placeholder), self.steam_box, self.steam], ] - self.player_box = self._make_submit_field( - strings.options.name_placeholder, Preferences.NAME - ) + self.player_box = NameSubmitField(controller) self.player_box.set_halign(Gtk.Align.START) - # TODO: make submit field a standalone class self.player_box.get_children()[0].set_width_chars(30) # type: ignore - self.fullscreen_toggle = self.make_binary_radio( + self.fullscreen_toggle = ToggleField( + self.controller, strings.options.last_used, strings.options.always_fs, Preferences.WINDOW, @@ -91,8 +290,8 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore client_hbox = ShortHBox(self.client_combo) - self.distance_toggle = self.make_binary_radio( - strings.options.km, strings.options.mi, Preferences.DIST + self.distance_toggle = ToggleField( + self.controller, strings.options.km, strings.options.mi, Preferences.DIST ) combo_store = Gtk.ListStore(str, object) @@ -178,11 +377,13 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore return str(model[ind][0]) def block_text_entry(self) -> None: - self.steam_entry.set_position(-1) - self.steam_entry.set_can_focus(False) + self.steam_box.block_text_entry() + # self.steam_entry.set_position(-1) + # self.steam_entry.set_can_focus(False) def unblock_text_entry(self) -> None: - self.steam_entry.set_can_focus(True) + self.steam_box.unblock_text_entry() + # self.steam_entry.set_can_focus(True) def _on_developers_clicked(self, button: Gtk.Button) -> None: self.controller.show_developers_page() @@ -190,46 +391,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore def _on_link_button_clicked(self, button: Gtk.Button, url: str) -> None: open_links.open_link_by_url(url) - def _make_submit_field( - self, - placeholder: str, - context: Preferences, - private: bool = False, - ) -> Gtk.Box: - - entry = Gtk.Entry(placeholder_text=placeholder, hexpand=True) - button = Gtk.Button(label="Save") - - button.connect("clicked", self._on_save_clicked, entry, context) - entry.connect("insert-text", self._on_text_typed, context, button) - entry.connect("activate", self._on_field_activated, context, button) - entry.get_property("buffer").connect( - "deleted-text", self._on_text_deleted, context, button - ) - - if private: - entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL) - entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) - entry.connect("icon-release", self._on_icon_release) - entry.set_visibility(False) - - if context == Preferences.STEAM: - self.steam_entry = entry - - box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - box.add(entry) - box.add(button) - - return box - - def _on_field_activated( - self, entry: Gtk.Entry, context: Preferences, button: Gtk.Button - ) -> None: - text = entry.get_text() - if not self._is_valid_text(text, context): - return - self._on_save_clicked(button, entry, context) - def _make_grid(self, rows: list) -> Gtk.Grid: grid = Gtk.Grid( orientation=Gtk.Orientation.VERTICAL, @@ -249,35 +410,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore row += 1 return grid - def _on_save_clicked( - self, button: Gtk.Button, entry: Gtk.Entry, enum: Preferences - ) -> None: - old_text = self.controller.query_config(enum) - self.old_text = old_text - self.old_entry = entry - - button.set_sensitive(False) - match enum: - case Preferences.NAME: - value = entry.get_text().strip() - self.controller.update_config(enum, value) - case Preferences.STEAM: - text = "".join(entry.get_text().split()) - self.controller.update_api_key(enum, text) - - def _on_api_change_failed(self, emitter: "Emitter") -> None: - self.old_entry.set_text(self.old_text) - # TODO: use popover - dialog = ExceptionDialog(self.controller, errors.api_validation_error) - dialog.run() - - def restore_api_text(self, text: str, entry: Gtk.Entry) -> None: - entry.set_text(text) - - def revert(self, mode: Preferences) -> None: - if mode == Preferences.STEAM: - self.steam_entry.set_text(self.old_steam) - def _on_start_tab_changed(self, combo: Gtk.ComboBoxText) -> None: _iter = combo.get_active_iter() if _iter is None: @@ -293,79 +425,12 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore real_cmd = combo.get_model()[_iter][1] self.controller.update_config(Preferences.CLIENT, real_cmd) - def _on_radio_toggled(self, button: Gtk.RadioButton, context: Preferences) -> None: - try: - self.controller.toggle_config(context) - except Exception: - button.handler_block_by_func(self._on_radio_toggled) - self.populate_settings() - button.handler_unblock_by_func(self._on_radio_toggled) - - def _is_valid_text(self, text: str, context: Preferences) -> bool: - if text.isspace(): - return False - if len(text) == 0: - return False - - match context: - case Preferences.NAME: - old = self.old_name - case Preferences.STEAM: - old = self.old_steam - if text == old: - return False - return True - - def _on_text_deleted( - self, - buffer: Gtk.EntryBuffer, - position: int, - chars: int, - context: Preferences, - button: Gtk.Button, - ) -> None: - - text = buffer.get_text() - state = self._is_valid_text(text, context) - button.set_sensitive(state) - - def _on_text_typed( - self, - entry: Gtk.Entry, - text: str, - length: int, - pos: int, - context: Preferences, - button: Gtk.Button, - ) -> None: - - buffer = entry.get_property("buffer") - text = buffer.get_text() + text - state = self._is_valid_text(text, context) - button.set_sensitive(state) - - def make_binary_radio( - self, - first_option: str, - second_option: str, - context: Preferences, - ) -> Gtk.Box: - - hbox = Gtk.Box(spacing=5, halign=Gtk.Align.START) - radio1 = Gtk.RadioButton.new_with_label(None, first_option) - radio2 = Gtk.RadioButton.new_from_widget(radio1) - radio2.set_label(second_option) - radio1.connect("toggled", self._on_radio_toggled, context) - hbox.pack_start(radio1, NO_EXPAND, NO_FILL, NO_PADDING) - hbox.pack_start(radio2, NO_EXPAND, NO_FILL, NO_PADDING) - - return hbox - def populate_settings(self) -> None: prefs = self.controller.get_prefs() # NOTE: re-check in case file was removed by user between runs if prefs.paths.config.is_file() is False: dialog = ExceptionDialog(self.controller, strings.config_not_found) + dialog.show_all() dialog.run() raise OSError(f"Config file '{prefs.paths.config}' not found") @@ -378,30 +443,21 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore steam_path = Path(default_steam_path) - self.old_steam = steam - self.old_name = name + self.steam_box.set_text(steam) + self.player_box.set_text(name) - self.steam_entry.set_text(steam) - p = self.player_box.get_children()[0] - if hasattr(p, "set_text"): - p.set_text(name) + fs = config["fullscreen"] + miles = config["use_miles"] - # NOTE: suppress toggle signal until radios are built - self._suppress_toggles(True) - for el, conf_state in [ - (self.fullscreen_toggle, config["fullscreen"]), - (self.distance_toggle, config["use_miles"]), - ]: - el.get_children()[conf_state].set_active(True) - self._suppress_toggles(False) + if prefs.is_steam_deck: + self.fullscreen_toggle.set_sensitive(False) + eb = InfoEventBox(options.fullscreen_eventbox, self.controller) + self.fullscreen_toggle.add(eb) - # NOTE: disable buttons if no text is set - for field in ( - [name, self.player_box], - [steam, self.steam_box], - ): - if field[0] == "": - field[1].get_children()[1].set_sensitive(False) + fs = True + + self.fullscreen_toggle.set_suboption_active(fs) + self.distance_toggle.set_suboption_active(miles) dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ) if dayz_version is None: @@ -430,19 +486,5 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore self, toggle.get_children()[0], "_on_radio_toggled", state ) - def _on_icon_release( - self, - widget: Gtk.Entry, - icon_pos: Gtk.EntryIconPosition, - event: Gdk.Event, - ) -> None: - visible = widget.get_visibility() - if visible: - icon, state = VIEW_REVEAL, False - else: - icon, state = VIEW_CONCEAL, True - widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) - widget.set_visibility(state) - def grab_content_area(self) -> None: self.grab_focus() diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 47adfb6..93b0fb6 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -11,6 +11,7 @@ from dzgui.util.keys import is_ctrl_mask from dzgui.util.localize import number from dzgui.strings.server_mods import checkmark from dzgui.strings import preconnect +from dzgui.views.dialogs.generic import DebugDialog from dzgui.views.components.frame import HeadingFrame from dzgui.views.trees.tree_server_mods import ServerModTreeView @@ -120,6 +121,12 @@ class PreConnectionAssistant(Gtk.Box): spacing=5, ) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + + # NOTE: -d flag + if self.controller.get_prefs().is_debug: + debug = Gtk.Button(label=preconnect.debug, halign=Gtk.Align.START) + debug.connect("clicked", self._on_debug_clicked) + box.add(debug) for button in self.back, self.ok, self.connect_last: box.add(button) self.button_box.add(box) @@ -150,8 +157,7 @@ class PreConnectionAssistant(Gtk.Box): self.tree_box.add(self.scrolled) self.tree_box.add(self.progress_box) - # TODO: strings - self.mods_placeholder = Placeholder("This server has no mods.") + self.mods_placeholder = Placeholder(preconnect.placeholder_no_mods) self.tree_box.add(self.mods_placeholder) self.tree_frame = HeadingFrame.new_with_widget_and_label( @@ -161,8 +167,7 @@ class PreConnectionAssistant(Gtk.Box): # TODO: abstract into components self.warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.warning_tree = MaskedTree(WARNING) - # TODO: strings - self.warning_placeholder = Placeholder("No warnings.") + self.warning_placeholder = Placeholder(preconnect.placeholder_no_warnings) self.warning_box.add(self.warning_tree) self.warning_box.add(self.warning_placeholder) self.warning_frame = HeadingFrame.new_with_widget_and_label( @@ -223,6 +228,12 @@ class PreConnectionAssistant(Gtk.Box): def _on_connect_last_clicked(self, button: Gtk.Button) -> None: self.controller.update_and_load_to_menu() + def _on_debug_clicked(self, button: Gtk.Button) -> None: + args = self.controller.get_debug_args() + d = DebugDialog(self.controller, args) + d.show_all() + d.run() + def _on_ok_clicked(self, button: Gtk.Button) -> None: self.controller.update_and_connect() @@ -233,61 +244,46 @@ class PreConnectionAssistant(Gtk.Box): warnings: list[str] = [] errors: list[str] = [] - resync_msg = ( - f"If you recently installed {prereqs.build} or moved it to a different drive,\n" - "restart Steam to allow these changes to synchronize, then try again." - ) + resync_msg = preconnect.resync.format(prereqs.build) """Errors""" if len(prereqs.invalid_mods) > 0: pairs = [": ".join(sub) for sub in prereqs.invalid_mods] lines = "\n".join(pairs) - msg = ( - "Server has invalid mods that are not recognized by Steam.\n" - "Contact the server owner and include these mod IDs in your report:\n" - f"{lines}" - ) + msg = preconnect.invalid_mods.format(lines) errors.append(msg) if prereqs.binary_missing: - errors.append( - f"Remote server is running the build '{prereqs.build}', but it is not installed.\n{resync_msg}" - ) + msg = preconnect.version_missing.format(prereqs.build) + msg += f"\n{resync_msg}" + errors.append(msg) elif prereqs.local_version != prereqs.remote_version: - errors.append( - f"Local client version '{prereqs.local_version}' does not match remote version '{prereqs.remote_version}'.\n{resync_msg}" + msg = preconnect.version_mismatch.format( + prereqs.local_version, prereqs.remote_version ) + msg += f"\n{resync_msg}" + errors.append(msg) if prereqs.required_space > prereqs.available_space: required_pretty = number(prereqs.required_space) available_pretty = number(prereqs.available_space) - errors.append( - f"Need to update {required_pretty} MiB of mods, but installation path only has {available_pretty} MiB." - ) + msg = preconnect.not_enough_space.format(required_pretty, available_pretty) + errors.append(msg) if len(prereqs.mods) > 0 and prereqs.game_mode: - errors.append("Use Desktop Mode to download mods on Steam Deck") + errors.append(preconnect.use_desktop_mode) if prereqs.steam_proc.is_running is False: client = prereqs.steam_proc.name - errors.append( - f"'{client}' is set as the default Steam client, but it is either not installed or not running." - ) + msg = preconnect.steam_not_running.format(client) + errors.append(msg) """Warnings""" if prereqs.passworded: - warnings.append( - "Protected: you will be prompted for a password when connecting to this server." - ) + warnings.append(preconnect.protected_server) if prereqs.dayz_running is True: - warnings.append( - "It looks like DayZ is already running in the background. Exit DayZ before connecting." - ) + warnings.append(preconnect.dayz_running) allows_dl, running_app = prereqs.allows_downloads if len(prereqs.mods) > 0 and allows_dl is False: - msg = ( - f"The app '{running_app}' is currently running in Steam, but background downloads are not enabled.\n" - "Either stop the game first, or update your global Steam settings or the game's local settings.\n" - "Otherwise, mods may be queued for download but never update." - ) + msg = preconnect.running_app.format(running_app) warnings.append(msg) self.add_warnings(warnings) @@ -326,7 +322,7 @@ class PreConnectionAssistant(Gtk.Box): if prereqs.required_space != 0: pretty = number(prereqs.required_space) - suffix = f" Need to download {pretty} MiB of mod updates." + suffix = preconnect.required_space.format(pretty) prefix = preconnect.total_mods self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}") else: diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 12eaf8c..2ec2f32 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -295,6 +295,12 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore ) -> None: if self.loaded is False: return + # NOTE: signal triggers twice on right-click events + # due to deselect-then-select behavior in GTK + sel = self.get_selection() + model, row = sel.get_selected() + if row is None: + return self.start_distcalc() def get_name(self) -> str: diff --git a/pyproject.toml b/pyproject.toml index 5c6c791..a349a4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux" authors = [ {name = "aclist"} ] -version = "7.0.0b21" +version = "7.0.0b22" license = "GPL-3.0-or-later" license-files = ["LICENSE"] readme = "README.md" diff --git a/scripts/unused_imports.py b/scripts/unused_imports.py new file mode 100644 index 0000000..ad6d8c7 --- /dev/null +++ b/scripts/unused_imports.py @@ -0,0 +1,27 @@ +from pathlib import Path + +def iterate(search_str: str) -> None: + # TODO: exclude files from .gitignore + print() + print(f"Searching for '{search_str}'") + print() + files = Path(".").rglob("*") + ignore = [".git", ".mypy", "build/", "test.py"] + for file in files: + if file.is_dir(): + continue + if any(s in str(file) for s in ignore): + continue + try: + text = file.read_text() + if search_str in text: + c = text.count(search_str) + if 0 < c < 2: + print(file, c) + except Exception: + continue + + +imports = ["Pango", "GObject", "GLib", "Gdk", "Gtk"] +for i in imports: + iterate(i) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 3eae186..e897571 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1,20 +1,33 @@ +import re import pytest -from importlib import resources -from dzgui.const.constants import APP_NAME_LOWER, CHANGELOG_PATH +from pathlib import Path @pytest.fixture -def changelog(): - path = resources.files(APP_NAME_LOWER).joinpath(CHANGELOG_PATH) - return path +def changelog(request) -> None: + root = request.config.rootpath + changelog = Path(root).joinpath("CHANGELOG.md").read_text() + return changelog -def test_headings(changelog): - with open(changelog, "r") as f: - lines = f.readlines() - for line in lines: - if line.startswith("#"): - pass - # TODO: use regex - pass +def count_hash(line: str) -> int: + cnt = 0 + for c in line: + if c == "#": + cnt += 1 + return cnt + + +def test_changelog_prefix(changelog) -> None: + r = r".*(\[.*\]).*" + lines = changelog.splitlines() + sort = sorted(lines) + match = [line for line in sort if line.startswith("#")] + for m in match: + if "Changelog" in m: + assert count_hash(m) == 1 + elif re.match(r, m) is not None: + assert count_hash(m) == 2 + else: + assert count_hash(m) == 3 diff --git a/tests/test_html_escaping.py b/tests/test_html_escaping.py new file mode 100644 index 0000000..f5c0805 --- /dev/null +++ b/tests/test_html_escaping.py @@ -0,0 +1,30 @@ +from dzgui.api.servers import Details +from dzgui.views.dialogs.servers import ServerDetailsDialog + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk # noqa E402 + + +class MockController: + def __init__(self) -> None: + self.window = Gtk.Window() + + def get_window(self) -> Gtk.Window: + return self.window + + def get_server_name(self) -> str: + return "My server" + + def get_emitter(self) -> None: + return None + + +def test_html_escaping() -> None: + text = " Game & mods" + details = Details([["0", "1"]], text, True) + controller = MockController() + dialog = ServerDetailsDialog(controller, details) + + assert dialog.description.get_text() == text diff --git a/tests/test_time.py b/tests/test_time.py new file mode 100644 index 0000000..210068a --- /dev/null +++ b/tests/test_time.py @@ -0,0 +1,23 @@ +import re + +from dzgui.model.proxy_model import DAY_REG, NIGHT_REG + + +def iterate(h: str, r: str) -> None: + for m in range(60): + time = f"{h:02}:{m:02}" + assert re.match(r, time) is not None + + +def test_day() -> None: + for h in range(17): + if h < 7: + continue + iterate(h, DAY_REG) + + +def test_night() -> None: + for h in range(24): + if 6 < h < 17: + continue + iterate(h, NIGHT_REG)