diff --git a/CHANGELOG.md b/CHANGELOG.md index a698357..30817c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [6.0.0-beta.3] 2025-08-08 +## Added +- Additional server metadata to details dialog +- Prompt user when connecting if a server is locked +- Dedicated settings page ("Options") + +## Fixed +- Statusbar contents not updating on certain pages +- Breadcrumbs not updating when returning from keybindings page +- Wrap long descriptions in server details +- Prevent debug button from activating when in certain input fields +- Window size not updating correctly when unmaximizing window after changing "fullscreen at boot" setting + ## [6.0.0-beta.2] 2025-07-29 ## Fixed - Missing parameters when closing application via window decorations diff --git a/dzgui.sh b/dzgui.sh index b090543..3c4de36 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -o pipefail -version=6.0.0.beta-1 +version=6.0.0.beta-3 #CONSTANTS aid=221100 @@ -513,7 +513,7 @@ lock(){ ps -p $pid -o pid= >/dev/null 2>&1 res=$? if [[ $res -eq 0 ]]; then - local msg="DZGUI already running ($pid)" + local msg="DZGUI is already running ($pid)" raise_error_and_quit "$msg" elif [[ $pid == $$ ]]; then : @@ -585,11 +585,11 @@ fetch_helpers_by_sum(){ [[ -f "$config_file" ]] && source "$config_file" declare -A sums sums=( - ["ui.py"]="52183c5d29d10ebd0bf91693e41f197b" + ["funcs"]="c2aec0a2fc153a2535e6493b3751ca81" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067" + ["servers.py"]="4c5013aaf694806cfecd4f1c905bdc9a" + ["ui.py"]="294a7daf544a96e2a108d252df5e0676" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" - ["funcs"]="19440a79345b66154a49882b25cfa7c5" - ["servers.py"]="51839211af7f65bdc04bab5557bf9466" ) local author="aclist" local repo="dztui" diff --git a/helpers/funcs b/helpers/funcs index f9d0686..b4a6d03 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -o pipefail -version="6.0.0-beta.2" +version="6.0.0-beta.3" #CONSTANTS aid=221100 @@ -1128,14 +1128,14 @@ update_config_val(){ key="steam_api" if [[ ${#value} -lt 32 ]] || [[ $(test_steam_api "$value") -eq 1 ]]; then printf "Invalid API key" - return 2 + return 78 fi ;; "Change Battlemetrics API key") key="api_key" if [[ $(test_bm_api "$value") -eq 1 ]]; then printf "Invalid API key" - return 2 + return 79 fi ;; esac @@ -1143,7 +1143,7 @@ update_config_val(){ nr="$value" update_config echo "Updated the key '$key' to '$value'" - return 90 + return 80 } show_log(){ < "$debug_log" sed 's/Keyword␞/Keyword/' diff --git a/helpers/servers.py b/helpers/servers.py index 12f65ce..5565b28 100644 --- a/helpers/servers.py +++ b/helpers/servers.py @@ -14,10 +14,10 @@ from typing import Union sys.path.append("a2s") import a2s # noqa -from a2s import dayzquery +from a2s import dayzquery # noqa params = [ - r"\nor\1\map\chernarusplus\nor\1\map\sakhal\nor\1\map\enoch\empty\1\nor\1\map\namalsk", + r"\nor\1\map\chernarusplus\nor\1\map\sakhal\nor\1\map\enoch\empty\1\nor\1\map\namalsk", # noqa r"\map\namalsk\empty\1", r"\map\namalsk\noplayers\1", r"\map\chernarusplus\empty\1", @@ -206,6 +206,19 @@ class Details: success: bool +def is_passworded(ip: str, qport: int) -> bool: + try: + info = a2s.info((ip, qport)) + except TimeoutError: + return False + + try: + password = info.password_protected + except AttributeError: + return False + return password + + def details(ip: str, qport: int) -> Details: default_str = "None provided" @@ -223,7 +236,7 @@ def details(ip: str, qport: int) -> Details: except AttributeError: return Details(None, default_str, False) - battleeye = "Disabled" + battleye = "Disabled" if "battleye" in keywords: battleye = "Enabled" @@ -237,6 +250,29 @@ def details(ip: str, qport: int) -> Details: night_accel = float(keyword.lstrip("entm")) night_accel = f"{night_accel:g}" + try: + password = info.password_protected + if password is False: + password = "Disabled" + else: + password = "Enabled" + except AttributeError: + password = "-" + + try: + vac = info.vac_enabled + if vac is False: + vac = "Disabled" + else: + vac = "Enabled" + except AttributeError: + vac = "-" + + try: + version = info.version + except AttributeError: + version = "-" + try: dlc = rules.dlc_flags if dlc == 0: @@ -263,11 +299,14 @@ def details(ip: str, qport: int) -> Details: description = default_str rows = [ - ["DLC", dlc], ["Battleye", battleye], ["Daytime acceleration", f"{day_accel}x"], + ["DLC", dlc], ["Night-time acceleration", f"{night_accel}x"], + ["Password", password], ["Platform", platform], + ["Valve Anti-Cheat", vac], + ["Version", version], ] return Details(rows, description, True) @@ -289,7 +328,7 @@ def ping(iteration: int, row: list) -> Ping: try: res = query_direct(ip, qport, 0.5) - except Exception as e: + except Exception: pass if res is None: diff --git a/helpers/ui.py b/helpers/ui.py index 4c441e0..42e326e 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -63,8 +63,8 @@ funcs = f"{helpers_path}/funcs" mods_temp_file = f"{cache_path}/{app_name_abbr}.mods_temp" stale_mods_temp_file = f"{cache_path}/{app_name_abbr}.stale_mods_temp" servers_path = f"{cache_path}/{app_name_abbr}.servers" -config_path = f"{user_path}/.config" -config_file = f"{user_path}/.config/dztuirc" +config_path = f"{user_path}/.config/dztui" +config_file = f"{config_path}/dztuirc" history_file = f"{state_path}/{app_name_abbr}.history" logger = logging.getLogger(__name__) @@ -88,6 +88,15 @@ If this issue persists, your API key may be defunct. """ +class Preferences(Enum): + STEAM = 1 + BM = 2 + WINDOW = 3 + CLIENT = 4 + NAME = 5 + INSTALL = 6 + + class Port(Enum): DEFAULT = 1 CUSTOM = 2 @@ -101,13 +110,15 @@ class Popup(Enum): RETURN = 5 MODLIST = 6 DETAILS = 7 + QUIT = 8 class NotebookPage(Enum): + # enums correspond to the page in linear order MAIN = 0 CHANGELOG = 1 KEYS = 2 - SETTINGS = 3 + OPTIONS = 3 class Command(Enum): @@ -205,6 +216,7 @@ class RowType(EnumWithAttrs): "label": "View changelog", "tooltip": "Opens the DZGUI changelog in a dialog window", } + OPTIONS = {"label": "Options", "tooltip": None} KEYBINDINGS = {"label": "Keybindings", "tooltip": None} SHOW_LOG = { "label": "Show debug log", @@ -434,13 +446,17 @@ class WindowContext(EnumWithAttrs): } MANAGE = { "label": "Manage", - "rows": [RowType.ADD_BY_IP, RowType.ADD_BY_ID, RowType.CHNG_FAV], + "rows": [ + RowType.ADD_BY_IP, + RowType.ADD_BY_ID, + RowType.CHNG_FAV, + RowType.LIST_MODS, + ], "called_by": [], } OPTIONS = { "label": "Options", "rows": [ - RowType.LIST_MODS, RowType.TGL_BRANCH, RowType.TGL_INSTALL, RowType.TGL_STEAM, @@ -449,7 +465,6 @@ class WindowContext(EnumWithAttrs): RowType.CHNG_STEAM_API, RowType.CHNG_BM_API, RowType.FORCE_UPDATE, - RowType.DUMP_LOG, ], "called_by": [], } @@ -458,6 +473,7 @@ class WindowContext(EnumWithAttrs): "rows": [ RowType.CHANGELOG, RowType.SHOW_LOG, + RowType.DUMP_LOG, RowType.DOCS, RowType.DOCS_FALLBACK, RowType.BUGS, @@ -550,6 +566,9 @@ def load_css() -> None: .frame { border: 0px; } + .toast-label { + padding: 10px; + } .frame > border { border-radius: 5px; padding: 5px; @@ -559,6 +578,10 @@ def load_css() -> None: font-weight: 800; margin-bottom: 0.5rem; } + .settings-subheading { + font-size: 1.2rem; + font-weight: 700; + } .left-label { font-size: 1.3rem; } @@ -611,7 +634,7 @@ def block_signals(state: bool = True) -> None: ) -def save_res_and_quit() -> None: +def save_res_and_quit(*args) -> None: if App.window.props.is_maximized: Gtk.main_quit() return @@ -622,7 +645,7 @@ def save_res_and_quit() -> None: j = json.dumps(data, indent=2) with open(res_path, "w") as outfile: outfile.write(j) - logger.info(f"Wrote initial window size to '{res_path}'") + logger.info(f"Wrote window size to '{res_path}'") if os.path.isfile(res_path): with open(res_path, "r") as infile: @@ -664,6 +687,10 @@ def pluralize(plural: str, count: int) -> str: def format_metadata(row_sel: str) -> str: + """ + Currently only being used for legacy + favorite server tooltip, cf Statusbar.update_app_meta() + """ row = None for i in RowType: if i.dict["label"] == row_sel: @@ -835,6 +862,20 @@ def process_shell_return_code( process_tree_option(RowType.CHNG_STEAM_API) case 6: # return silently pass + case 78: # failed settings update (steam) + spawn_dialog("Invalid Steam API key, reverting", Popup.NOTIFY) + App.notebook.settings.revert(Preferences.STEAM) + case 79: # failed settings update (bm) + spawn_dialog( + "Invalid Battlemetrics API key, reverting", Popup.NOTIFY + ) + App.notebook.settings.revert(Preferences.BM) + case 80: # pop toast after successful settings change + config_vals.clear() + for i in query_config(): + config_vals.append(i) + App.window.toast.set_text_and_fade("Settings updated!") + App.notebook.settings.populate_settings() case 90: # used to update configs and metadata in-place config_vals.clear() for i in query_config(): @@ -860,8 +901,7 @@ def process_shell_return_code( process_tree_option(RowType.HANDSHAKE) case 255: # dzgui version update msg = "Update complete. Please close DZGUI and restart." - spawn_dialog(msg, Popup.NOTIFY) - save_res_and_quit() + spawn_dialog(msg, Popup.QUIT) def call_on_thread( @@ -927,7 +967,7 @@ def process_tree_option(choice: RowType) -> None: return if command == RowType.CHANGELOG: - App.grid.notebook.set_page_enum(NotebookPage.CHANGELOG) + App.grid.notebook.set_page_by_enum(NotebookPage.CHANGELOG) return match command.dict["type"]: @@ -956,7 +996,6 @@ def process_toggle(command: RowType) -> None: case RowType.TGL_INSTALL: if query_config("auto_install")[0] == "1": proc = call_out("toggle", cmd_string) - App.grid.statusbar.refresh() return # manual -> auto mode proc = call_out("find_id", "") @@ -969,7 +1008,6 @@ def process_toggle(command: RowType) -> None: LinkDialog(manual_sub_msg, link, command, user_id) case _: proc = call_out("toggle", cmd_string) - App.grid.statusbar.refresh() def process_user_input(enum: RowType) -> None: @@ -986,9 +1024,6 @@ def process_user_input(enum: RowType) -> None: logger.info(f"User entered: '{response}'") show_wait_dialog = True - # this is the only instantaneous toggle, no wait dialog necessary - if enum == RowType.CHNG_PLAYER: - show_wait_dialog = False wait_msg = "Working" call_on_thread( show_wait_dialog, cmd_string, wait_msg, response, choice=enum @@ -1014,8 +1049,14 @@ class OuterWindow(Gtk.Window): self._set_resolution() self.grid = Grid() - self.add(self.grid) + self.toast = Toast() + self.overlay = Gtk.Overlay() + self.overlay.add_overlay(self.grid) + self.overlay.add_overlay(self.toast) + self.add(self.overlay) + self.show_all() + self.toast.set_visible(False) self.grid.right_panel.filters_vbox.set_visible(False) self.grid.right_panel.enable_ping_button(False) @@ -1030,7 +1071,7 @@ class OuterWindow(Gtk.Window): load_css() App.ready = True - App.grid.notebook.set_page_enum(NotebookPage.MAIN) + App.grid.notebook.set_page_by_enum(NotebookPage.MAIN) App.treeview.grab_focus() def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: @@ -1048,8 +1089,7 @@ class OuterWindow(Gtk.Window): return elif query_config("fullscreen")[0] == "true": logger.info("User preference for 'fullscreen' is 'true'") - self.maximize() - return + self.fullscreen() try: with open(res_path, "r") as infile: @@ -1069,9 +1109,7 @@ class OuterWindow(Gtk.Window): self.set_default_size(w, h) def _on_delete_event( - self, - window: "OuterWindow", - event: Gdk.EventKey + self, window: "OuterWindow", event: Gdk.EventKey ) -> None: self.halt_proc_and_quit() @@ -1080,6 +1118,46 @@ class OuterWindow(Gtk.Window): save_res_and_quit() +class Toast(Gtk.EventBox): + def __init__(self): + super().__init__() + + self.label = Gtk.Label() + self.box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.CENTER, + valign=Gtk.Align.CENTER, + ) + self.box.add(self.label) + self.add(self.box) + self.box.set_size_request(200, 100) + + add_class(self.box, "toast-label") + + def set_text(self, text: str) -> None: + self.label.set_text(text) + + def set_text_and_fade(self, text: str) -> None: + self.set_text(text) + self.pop() + self._defer_fade() + + def fade_out(self) -> bool: + if self.get_opacity() == 0: + self.set_visible(False) + self.set_opacity(1) + return False + self.set_opacity(self.get_opacity() - 0.03) + return True + + def pop(self) -> None: + self.set_visible(True) + + def _defer_fade(self) -> Literal[False]: + GLib.timeout_add(30, self.fade_out) + return False + + class ScrollableTree(Gtk.ScrolledWindow): def __init__(self): super().__init__() @@ -1151,6 +1229,8 @@ class RightPanel(Gtk.Box): self.filters_vbox.set_unique_maps(rows) def toggle_debug(self) -> None: + if type(App.window.get_focus()) is Gtk.Entry: + return state = self.debug_toggle.get_active() self.debug_toggle.set_active(not state) @@ -1234,6 +1314,11 @@ class ButtonBox(Gtk.Box): save_res_and_quit() return + if context == ButtonType.OPTIONS: + App.notebook.settings.populate_settings() + App.notebook.set_page_by_enum(NotebookPage.OPTIONS) + return + cols = App.treeview.get_columns() if len(cols) > 1: # restores tree from multi column view to main menu @@ -2492,7 +2577,7 @@ class TreeView(Gtk.TreeView): row = (label,) row_store.append(row) App.grid.statusbar.refresh() - App.grid.notebook.set_page_enum(NotebookPage.MAIN) + App.grid.notebook.set_page_by_enum(NotebookPage.MAIN) self.grab_focus() @signal_emission @@ -2668,7 +2753,7 @@ class TreeView(Gtk.TreeView): return App.grid.right_panel.filters_vbox.reinit_filters() else: - # local server lists need not be restricted + # local server lists need not be filter-restricted App.right_panel.filters_vbox.enable_all_filters() self._update_multi_column(cr) return @@ -2677,6 +2762,17 @@ class TreeView(Gtk.TreeView): case WindowContext.TABLE_MODS | WindowContext.TABLE_LOG: self.update_quad_column(cr) case WindowContext.TABLE_SERVER | WindowContext.TABLE_API: + record = self.get_record_dict() + if record is None: + return + if Servers.is_passworded(record["ip"], int(record["qport"])): + msg = ( + "This server is password-protected and you will be " + "prompted when connecting. Do you want to proceed?" + ) + res = spawn_dialog(msg, Popup.CONFIRM) + if res is True: + return self._attempt_connection() case _: # any other non-server option from the main menu process_tree_option(output) @@ -2697,11 +2793,7 @@ class GenericDialog(Gtk.MessageDialog): dialog_type = Gtk.MessageType.INFO button_type = Gtk.ButtonsType.NONE header_text = "Please wait" - case Popup.NOTIFY: - dialog_type = Gtk.MessageType.INFO - button_type = Gtk.ButtonsType.OK - header_text = "Notice" - case Popup.RETURN: + case Popup.NOTIFY | Popup.RETURN | Popup.QUIT: dialog_type = Gtk.MessageType.INFO button_type = Gtk.ButtonsType.OK header_text = "Notice" @@ -2748,6 +2840,13 @@ class GenericDialog(Gtk.MessageDialog): ok.connect("clicked", self._return_to_main_menu) self.connect("delete-event", self._return_to_main_menu) + if mode == Popup.QUIT: + button_label = "Exit" + ok = self.action_area.get_children()[0] + ok.set_label(button_label) + ok.connect("clicked", save_res_and_quit) + self.connect("delete-event", save_res_and_quit) + self.set_default_response(Gtk.ResponseType.OK) self.set_size_request(500, 0) self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) @@ -2946,7 +3045,7 @@ class DetailsDialog(GenericDialog): enable_search=False, search_column=-1, headers_visible=False, - fixed_height_mode=True + fixed_height_mode=True, ) self.view.connect("row-activated", self._on_row_activated) @@ -2966,6 +3065,7 @@ class DetailsDialog(GenericDialog): scrollable_tree = Gtk.ScrolledWindow() scrollable_tree.add(self.view) + scrollable_tree.set_size_request(700, 200) scrollable_message = Gtk.ScrolledWindow() desc = Gtk.Label(label="Server message", valign=Gtk.Align.START) @@ -2973,7 +3073,9 @@ class DetailsDialog(GenericDialog): box = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER ) - self.description = Gtk.Label(justify=Gtk.Justification.CENTER) + self.description = Gtk.Label( + justify=Gtk.Justification.CENTER, wrap=True + ) sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) sep.set_margin_bottom(10) for el in desc, sep, self.description: @@ -3035,19 +3137,15 @@ class ModDialog(GenericDialog): self.scrollable = Gtk.ScrolledWindow() self.view = Gtk.TreeView( - enable_search=False, - search_column=-1, - fixed_height_mode=True - ) + enable_search=False, search_column=-1, fixed_height_mode=True + ) self.scrollable.add(self.view) set_surrounding_margins(self.scrollable, 20) self.view.connect("row-activated", self._on_row_activated) for i, column_title in enumerate(["Mod", "ID", "Installed"]): - renderer = Gtk.CellRendererText( - ellipsize=Pango.EllipsizeMode.END - ) + renderer = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END) column = Gtk.TreeViewColumn(column_title, renderer, text=i) column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) self.view.append_column(column) @@ -3224,7 +3322,7 @@ class EntryDialog(GenericDialog): class ScrollableNote(Gtk.Box): - def __init__(self, content_box: Gtk.Box): + def __init__(self, content_box: Gtk.Box, back_button=True): super().__init__(orientation=Gtk.Orientation.VERTICAL) self.scrollable = Gtk.ScrolledWindow() @@ -3233,12 +3331,13 @@ class ScrollableNote(Gtk.Box): self.back_button = Gtk.Button( label="Back", hexpand=True, halign=Gtk.Align.CENTER ) - self.back_button.connect("clicked", self._on_back_clicked) self.gutter = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, valign=Gtk.Align.END ) - self.gutter.add(self.back_button) + if back_button: + self.gutter.add(self.back_button) + self.back_button.connect("clicked", self._on_back_clicked) self.scrollable.add(content_box) self.add(self.scrollable) @@ -3246,10 +3345,437 @@ class ScrollableNote(Gtk.Box): @update_window_labels def _on_back_clicked(self, button: Gtk.Button) -> None: - if App.notebook.get_page() == NotebookPage.KEYS.value: - App.notebook.toggle_keybindings() + App.notebook.return_prior() + + +class InfoEventBox(Gtk.EventBox): + def __init__(self, text: str): + super().__init__(margin_start=10) + + self.text = text + + self.icon = Gtk.Image.new_from_icon_name( + "help-about-symbolic", Gtk.IconSize.LARGE_TOOLBAR + ) + self.icon.set_opacity(0.8) + box = Gtk.Box() + box.add(self.icon) + + self.connect("enter-notify-event", self._on_enter_tooltip) + self.connect("leave-notify-event", self._on_leave_tooltip) + self.add(box) + + def _on_enter_tooltip( + self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing + ) -> None: + self.icon.set_opacity(1) + App.grid.statusbar.set_text(self.text) + + def _on_leave_tooltip( + self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing + ) -> None: + self.icon.set_opacity(0.8) + App.grid.statusbar.set_text("") + + +class LeftLabel(Gtk.Label): + def __init__(self, text: str, tooltip: str = ""): + super().__init__( + label=text, + halign=Gtk.Align.START, + ) + self.set_tooltip_text(tooltip) + pass + + +class Options(Gtk.Box): + def __init__(self): + super().__init__( + orientation=Gtk.Orientation.VERTICAL, + margin_start=10, + margin_end=10, + ) + + self.DEFAULT_WIDTH = 1 + self.DEFAULT_HEIGHT = 1 + + label = Gtk.Label(label="Options") + label.set_halign(Gtk.Align.CENTER) + add_class(label, "page-heading") + self.add(label) + + self.steam_entry = None + self.bm_entry = None + + self.steam_box = self._make_submit_field( + "Enter your Steam API key", Preferences.STEAM, True + ) + self.bm_box = self._make_submit_field( + "Enter your Battlemetrics API key", Preferences.BM, True + ) + api_rows = [ + [LeftLabel("Steam API key"), self.steam_box], + [LeftLabel("Battlemetrics API key"), self.bm_box], + ] + + self.player_box = self._make_submit_field( + "Identifies you to other players in-game", Preferences.NAME + ) + self.fullscreen_toggle = self.make_binary_radio( + "Last used dimensions", + "Always fullscreen", + Preferences.WINDOW, + ) + self.steam_toggle = self.make_binary_radio( + "Steam", "Flatpak (experimental)", Preferences.CLIENT + ) + + pref_rows = [ + [LeftLabel("Steam client"), self.steam_toggle], + [LeftLabel("Window size at boot"), self.fullscreen_toggle], + [LeftLabel("Player name"), self.player_box], + ] + + self.mod_install_toggle = self.make_binary_radio( + "Manual", "Auto", Preferences.INSTALL + ) + self.force_button = Gtk.Button(label="Update") + self.force_button.connect("clicked", self._on_force_update_clicked) + # sensitivity state is set after config file is loaded + self.force_button.set_sensitive(False) + + msg = ( + "Manual: prompt to subscribe to mods in Steam. " + "Auto: unmanned downloads." + ) + eb = InfoEventBox(msg) + + msg = "Synchronize all local mods. Automatic mode must be enabled." + eb2 = InfoEventBox(msg) + + mod_rows = [ + [LeftLabel("Mod install mode"), self.mod_install_toggle, eb], + [LeftLabel("Force update local mods"), self.force_button, eb2], + ] + + self.branch_combo = Gtk.ComboBoxText() + self.branch_combo.append_text("Stable") + self.branch_combo.append_text("Testing") + self.branch_combo.set_active(0) + self.branch_combo.connect("changed", self._on_branch_changed) + + msg = ( + "Stable: only contains stable features. " + "Testing: pre-release beta, contains new features." + ) + eb = InfoEventBox(msg) + + version_rows = [[LeftLabel("Branch"), self.branch_combo, eb]] + + api_grid = self._make_grid(api_rows) + prefs_grid = self._make_grid(pref_rows) + mods_grid = self._make_grid(mod_rows) + version_grid = self._make_grid(version_rows) + + col = 1 + row = 1 + grid = Gtk.Grid( + orientation=Gtk.Orientation.VERTICAL, + row_spacing=30, + hexpand=True, + ) + + for frame in [ + self.make_frame(api_grid, "API Keys"), + self.make_frame(prefs_grid, "Preferences"), + self.make_frame(mods_grid, "Mods"), + self.make_frame(version_grid, "Version"), + ]: + grid.attach( + frame, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT + ) + row += 1 + + self.scrollable = Gtk.ScrolledWindow(vexpand=True) + self.scrollable.add(grid) + self.add(self.scrollable) + + def _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") + + entry.sibling = button + entry.get_property("buffer").sibling = button + + button.connect("clicked", self._on_save_clicked, entry, context) + entry.connect("insert-text", self._on_text_typed, context) + entry.connect("activate", self._on_field_activated, context) + entry.get_property("buffer").connect( + "deleted-text", self._on_text_deleted, context + ) + + if private: + entry.set_icon_from_icon_name( + Gtk.EntryIconPosition.SECONDARY, "view-reveal-symbolic" + ) + 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 + else: + self.bm_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 + ) -> None: + text = entry.get_text() + button = entry.sibling + if not self._is_valid_text(text, context): return - App.notebook.set_page_enum(NotebookPage.MAIN) + self._on_save_clicked(button, entry, context) + + def _make_grid(self, rows: list) -> Gtk.Grid: + grid = Gtk.Grid( + orientation=Gtk.Orientation.VERTICAL, + column_spacing=10, + row_spacing=5, + margin_start=5, + margin_end=5, + margin_top=10, + margin_bottom=10, + ) + row = 1 + for record in rows: + col = 1 + for el in record: + grid.attach( + el, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT + ) + col += 1 + row += 1 + return grid + + def _on_save_clicked( + self, button: Gtk.Button, entry: Gtk.Entry, context: Preferences + ) -> None: + show_wait_dialog = True + wait_msg = "Working" + button.set_sensitive(False) + match context: + case Preferences.NAME: + toggle = RowType.CHNG_PLAYER + show_wait_dialog = False + text = entry.get_text().strip() + case Preferences.STEAM: + toggle = RowType.CHNG_STEAM_API + text = "".join(entry.get_text().split()) + case Preferences.BM: + toggle = RowType.CHNG_BM_API + text = "".join(entry.get_text().split()) + cmd_string = toggle.dict["label"] + call_on_thread(show_wait_dialog, cmd_string, wait_msg, text) + + def revert(self, mode: Preferences) -> None: + if mode == Preferences.STEAM: + self.steam_entry.set_text(self.old_steam) + else: + self.bm_entry.set_text(self.old_bm) + pass + + def _on_force_update_clicked(self, button: Gtk.Button) -> None: + wait_msg = "Updating mods" + cmd = "Force update local mods" + show_wait_dialog = True + call_on_thread(show_wait_dialog, cmd, wait_msg, "") + + def _on_branch_changed(self, combo: Gtk.ComboBoxText) -> None: + # prevent triggering on initial init + if App.treeview.subpage is None: + return + process_toggle(RowType.TGL_BRANCH) + + def _on_radio_toggled( + self, button: Gtk.RadioButton, context: Preferences + ) -> None: + if App.treeview.subpage is None: + return + match context: + case Preferences.CLIENT: + toggle = RowType.TGL_STEAM + case Preferences.INSTALL: + toggle = RowType.TGL_INSTALL + state = button.get_group()[0].get_active() + self.force_button.set_sensitive(state) + case Preferences.WINDOW: + toggle = RowType.TGL_FULLSCREEN + process_toggle(toggle) + + 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 + case Preferences.BM: + old = self.old_bm + if text == old: + return False + return True + + def _on_text_deleted( + self, + buffer: Gtk.EntryBuffer, + position: int, + chars: int, + context: Preferences, + ) -> None: + + text = buffer.get_text() + state = self._is_valid_text(text, context) + buffer.sibling.set_sensitive(state) + + def _on_text_typed( + self, + entry: Gtk.Entry, + text: str, + length: int, + pos: int, + context: Preferences, + ) -> None: + + buffer = entry.get_property("buffer") + text = buffer.get_text() + text + state = self._is_valid_text(text, context) + entry.sibling.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, False, False, 0) + hbox.pack_start(radio2, False, False, 0) + + return hbox + + def make_frame(self, widget: Gtk.Widget, text: str) -> Gtk.Box: + label = Gtk.Label(label=text) + label.set_halign(Gtk.Align.START) + add_class(label, "settings-subheading") + + frame = Gtk.Frame(hexpand=True) + frame.add(widget) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + box.add(label) + box.add(frame) + + return box + + def populate_settings(self) -> None: + if not os.path.isfile(config_file): + msg = ( + "DZGUI configuration file not found. " + "Please exit and restart to regenerate it." + ) + spawn_dialog(msg, Popup.QUIT) + config_vals.clear() + for i in query_config(): + config_vals.append(i) + + branch = config_vals[0] + install = config_vals[2] + name = config_vals[3] + client = config_vals[5] + fullscreen = config_vals[6] + + try: + steam = query_config("steam_api")[0] + bm = query_config("api_key")[0] + except IndexError: + return + + self.old_steam = steam + self.old_bm = bm + self.old_name = name + + self.steam_entry.set_text(steam) + self.bm_entry.set_text(bm) + self.player_box.get_children()[0].set_text(name) + + if install == "1": + radio = 1 + self.force_button.set_sensitive(True) + else: + radio = 0 + self.mod_install_toggle.get_children()[radio].set_active(True) + + if client == "flatpak": + radio = 1 + else: + radio = 0 + self.steam_toggle.get_children()[radio].set_active(True) + + if fullscreen == "true": + radio = 1 + else: + radio = 0 + self.fullscreen_toggle.get_children()[radio].set_active(True) + + for field in ( + [name, self.player_box], + [steam, self.steam_box], + [bm, self.bm_box], + ): + if field[0] == "": + field[1].get_children()[1].set_sensitive(False) + + if branch == "testing": + self.branch_combo.set_active(1) + else: + self.branch_combo.set_active(0) + + def _on_icon_release( + self, + widget: Gtk.Entry, + icon_pos: Gtk.EntryIconPosition, + event: Gdk.Event, + ) -> None: + visible = widget.get_visibility() + if visible: + icon = "view-reveal-symbolic" + state = False + else: + icon = "view-conceal-symbolic" + state = True + widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + widget.set_visibility(state) class KeybindingsDialog(Gtk.Box): @@ -3420,19 +3946,22 @@ class Notebook(Gtk.Notebook): def __init__(self): super().__init__(show_tabs=False, show_border=False) - clog = Changelog() - self.clog = ScrollableNote(clog) + self.clog = ScrollableNote(Changelog()) self.clog.type = RowType.CHANGELOG self.clog.show_all() self.append_page(self.clog) self.prior_page: int - self.prior_subpage = None self.keys = ScrollableNote(KeybindingsDialog()) self.keys.type = RowType.KEYBINDINGS self.keys.show_all() self.append_page(self.keys) + self.settings = Options() + self.settings.type = RowType.OPTIONS + self.settings.show_all() + self.append_page(self.settings) + self.connect("switch-page", self._on_page_changed) self.connect("key-press-event", self._on_keypress) @@ -3477,12 +4006,30 @@ class Notebook(Gtk.Notebook): case Gdk.KEY_G: self._set_adjustment(VAdjustment.BOTTOM) + def return_prior(self) -> None: + page = self.get_nth_page(self.prior_page) + if hasattr(page, "steam_entry"): + """ + Gtk.Notebook focuses the first input field when changing pages; + this workaround unhighlights the selected region and makes entry fields + unfocusable prior to the page 'switch-page' signal, + then makes them focusable again + """ + entries = page.steam_entry, page.bm_entry + for entry in entries: + entry.set_position(-1) + entry.set_can_focus(False) + self.set_current_page(self.prior_page) + for entry in entries: + entry.set_can_focus(True) + self.set_current_page(self.prior_page) + def toggle_keybindings(self) -> None: if self.get_current_page() == NotebookPage.KEYS.value: - self.set_current_page(self.prior_page) + self.return_prior() else: self.prior_page = self.get_current_page() - self.set_page_enum(NotebookPage.KEYS) + self.set_page_by_enum(NotebookPage.KEYS) def focus_current(self) -> None: widget = self.get_page() @@ -3497,7 +4044,7 @@ class Notebook(Gtk.Notebook): return None return widget - def set_page_enum(self, enum: NotebookPage) -> None: + def set_page_by_enum(self, enum: NotebookPage) -> None: self.prior_page = self.get_current_page() self.set_current_page(enum.value) self.focus_current() @@ -3507,12 +4054,7 @@ class Notebook(Gtk.Notebook): def _on_page_changed( self, notebook: "Notebook", page: Gtk.Widget, page_num: int ) -> None: - pass - if page.type is None: - App.treeview.subpage = self.prior_subpage - else: - self.prior_subpage = App.treeview.subpage - App.treeview.subpage = page.type + App.treeview.subpage = page.type class Statusbar(Gtk.Statusbar): @@ -3539,7 +4081,15 @@ class Statusbar(Gtk.Statusbar): self.push(meta, string) def refresh(self) -> None: - self.update_app_meta() + unsupported_contexts = [ + RowType.KEYBINDINGS, + RowType.SHOW_LOG, + RowType.CHANGELOG, + RowType.OPTIONS, + ] + if App.treeview.subpage in unsupported_contexts: + self.set_text("") + return command = App.treeview.get_value_at_index(0) formatted = format_metadata(command) if len(formatted) > 0: @@ -3580,14 +4130,7 @@ class Statusbar(Gtk.Statusbar): config_vals.clear() for i in query_config(): config_vals.append(i) - _branch = config_vals[0] - _branch = _branch.upper() - _debug = config_vals[1] - if _debug == "": - _debug = "NORMAL" - else: - _debug = "DEBUG" - concat_label = f"{_branch} | {_debug} | {_VERSION}" + concat_label = f"{_VERSION}" self.status_right_label.set_text(concat_label)