from pathlib import Path from typing import Callable, TYPE_CHECKING from dzgui.api import pefile as PeFile from dzgui.config import query from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, APPNAME_DAYZ, APPNAME_DAYZ_EXP_HUMAN, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, FLATPAK_STEAM_PATH, NO_EXPAND, NO_FILL, NO_PADDING, VIEW_CONCEAL, VIEW_REVEAL, ) from dzgui.const.endpoints import STEAM_API_SETUP from dzgui.const.enum import Preferences, ServerTab from dzgui.strings import options from dzgui.util import strings, css, open_links 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 SpinnerButton, WebButton from dzgui.views.components.frame import HeadingFrame from dzgui.views.components.misc import ErrorPopover from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.mixins.scrollable_mixin import ScrollableMixin import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk # noqa if TYPE_CHECKING: from dzgui.controllers.mc import Controller from dzgui.controllers.emitter import Emitter 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("changed", 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, ) -> None: text = entry.get_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.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 FlatpakToggleField(ToggleField): def __init__( self, controller: "Controller", ) -> None: super().__init__( controller, first_option=options.flatpak_standard, second_option=options.flatpak_container, context=Preferences.CLIENT, ) self.add(InfoEventBox(options.flatpak_info, self.controller)) self.controller = controller self.radio1.connect("toggled", self._on_radio_toggled, Preferences.CLIENT) def _on_radio_toggled(self, button: Gtk.RadioButton, context: Preferences) -> None: cmd = FLATPAK_RUN_CMD if self.radio1.get_active() else FLATPAK_SANDBOX self.controller.update_config(context, cmd) class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore def __init__(self, controller: "Controller"): super().__init__( margin_start=10, margin_end=10, ) self.controller = controller self.controller.register_widget("options", self) 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 = SteamSubmitField(controller) api_rows = [ [LeftLabel(strings.options.steam_placeholder), self.steam_box, self.steam], ] self.player_box = NameSubmitField(controller) self.player_box.set_halign(Gtk.Align.START) self.player_box.get_children()[0].set_width_chars(30) # type: ignore self.flatpak_toggle = FlatpakToggleField(self.controller) self.fullscreen_toggle = ToggleField( self.controller, strings.options.last_used, strings.options.always_fs, Preferences.WINDOW, ) self.distance_toggle = ToggleField( self.controller, strings.options.km, strings.options.mi, Preferences.DIST ) combo_store = Gtk.ListStore(str, object) tabs = ( (options.server_combo, ServerTab.BROWSER), (options.saved_combo, ServerTab.SAVED), (options.recent_combo, ServerTab.RECENT), (options.lan_combo, ServerTab.LAN), ) for tab in tabs: combo_store.append(tab) self.start_tab_combo = Gtk.ComboBox.new_with_model(combo_store) renderer_text = Gtk.CellRendererText() self.start_tab_combo.pack_start(renderer_text, True) self.start_tab_combo.add_attribute(renderer_text, "text", 0) self.start_tab_combo.set_active(0) start_tab_hbox = ShortHBox(self.start_tab_combo) self.start_tab_combo.connect("changed", self._on_start_tab_changed) pref_rows = [ [LeftLabel(strings.options.window_size), self.fullscreen_toggle], [LeftLabel(strings.options.distance), self.distance_toggle], [LeftLabel(options.start_tab), start_tab_hbox], [LeftLabel(strings.options.name), self.player_box], ] default = self.controller.query_config(Preferences.DEFAULT) if FLATPAK_STEAM_PATH in default: pref_rows.insert( 0, [LeftLabel(options.flatpak_label), self.flatpak_toggle], ) self.dayz_version_label = Gtk.Label(label=strings.null) self.dayz_exp_version_label = Gtk.Label(label=strings.null) version_rows = [ [LeftLabel(APPNAME_DAYZ), self.dayz_version_label], [LeftLabel(APPNAME_DAYZ_EXP_HUMAN), self.dayz_exp_version_label], ] api_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) api_grid = self._make_grid(api_rows) api_box.add(api_grid) prefs_grid = self._make_grid(pref_rows) version_grid = self._make_grid(version_rows) col = 1 row = 1 grid = Gtk.Grid( orientation=Gtk.Orientation.VERTICAL, row_spacing=30, hexpand=True, ) developers = Gtk.Button(label=options.developers, halign=Gtk.Align.START) developers.connect("clicked", self._on_developers_clicked) prefs = self.controller.get_prefs() if prefs.is_debug: grid.attach(developers, 1, 0, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT) for pair in [ (api_box, strings.options.api_key), (prefs_grid, strings.options.prefs), (version_grid, strings.options.version), ]: frame = HeadingFrame.new_with_widget_and_label(*pair) grid.attach(frame, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT) row += 1 label = Gtk.Label(label=strings.options.header) label.set_halign(Gtk.Align.CENTER) css.add_class(label, "page-heading") box = VBox() box.add(label) box.add(grid) self.add(box) self.connect("key-press-event", self._on_keypress) def block_text_entry(self) -> None: self.steam_box.block_text_entry() def unblock_text_entry(self) -> None: self.steam_box.unblock_text_entry() def _on_developers_clicked(self, button: Gtk.Button) -> None: self.controller.show_developers_page() def _on_link_button_clicked(self, button: Gtk.Button, url: str) -> None: open_links.open_link_by_url(url) 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_start_tab_changed(self, combo: Gtk.ComboBoxText) -> None: _iter = combo.get_active_iter() if _iter is None: raise ValueError(f"No active iterator set on {combo}") enum = combo.get_model()[_iter][1] index = enum.value self.controller.update_config(Preferences.START_TAB, index) 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") config = query.get_config(prefs.paths.config) # TODO: use newer config enums name = self.controller.query_config(Preferences.NAME) default_steam_path = self.controller.query_config(Preferences.DEFAULT) steam = self.controller.query_config(Preferences.STEAM) steam_path = Path(default_steam_path) self.steam_box.set_text(steam) self.player_box.set_text(name) fs = config["fullscreen"] miles = config["use_miles"] if prefs.is_steam_deck: self.fullscreen_toggle.set_sensitive(False) eb = InfoEventBox(options.fullscreen_eventbox, self.controller) self.fullscreen_toggle.add(eb) 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: dayz_version = strings.null dayz_exp_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) if dayz_exp_version is None: dayz_exp_version = strings.null self.dayz_version_label.set_text(dayz_version) self.dayz_exp_version_label.set_text(dayz_exp_version) start_tab = self.controller.query_config(Preferences.START_TAB) self.start_tab_combo.set_active(start_tab) def _suppress_toggles(self, state: bool) -> None: for toggle in [ self.fullscreen_toggle, self.distance_toggle, ]: self.controller.suppress_signal( self, toggle.get_children()[0], "_on_radio_toggled", state ) def grab_content_area(self) -> None: self.grab_focus()