diff --git a/Pack/MKWFaraphel/mod_config.json b/Pack/MKWFaraphel/mod_config.json index 9f30b16..71e0f19 100644 --- a/Pack/MKWFaraphel/mod_config.json +++ b/Pack/MKWFaraphel/mod_config.json @@ -6,16 +6,10 @@ "global_settings": { "include_track_if": { - "text": { - "en": "Include track if *", - "fr": "Inclure la course si *" - } + "suffix": "*" }, "sort_tracks": { - "text": { - "en": "Sort tracks by *", - "fr": "Trier les courses par *" - } + "suffix": "*" }, "replace_random_new": { "default": "'Retro' not in track.tags and track.warning == 0" @@ -52,9 +46,10 @@ }, "balancing": { "text": { - "en": "Balancing *", - "fr": "Équilibrage *" + "en": "Balancing", + "fr": "Équilibrage" }, + "suffix": "*", "description": { "en": "Should the mod balancing (fake item box, blooper) be enabled ?", "fr": "Est-ce que l'équilibrage du mod (fausse boite d'objet, bloops) devrait être activé ?" diff --git a/assets/language/en.json b/assets/language/en.json index cb42289..4fa30ba 100644 --- a/assets/language/en.json +++ b/assets/language/en.json @@ -105,6 +105,9 @@ "CAN_ONLY_CALL_METHOD_OF_CONSTANT": "You can only call methods on constant", "CAN_ONLY_CALL_FUNCTION_IN_ENV": "You can only call function from the environment", "ENABLE_DEVELOPER_MODE": "Enable the developer mode", - "TESTING_MOD_SETTINGS": "Test mod settings" + "TESTING_MOD_SETTINGS": "Test mod settings", + "SETTINGS_FILE": "Settings file", + "EXPORT_SETTINGS": "Export settings", + "IMPORT_SETTINGS": "Import settings" } } \ No newline at end of file diff --git a/assets/language/fr.json b/assets/language/fr.json index 5c658c3..89c4e12 100644 --- a/assets/language/fr.json +++ b/assets/language/fr.json @@ -106,6 +106,9 @@ "CAN_ONLY_CALL_METHOD_OF_CONSTANT": "Vous ne pouvez appeler que des méthodes sur des constantes", "CAN_ONLY_CALL_FUNCTION_IN_ENV": "Vous ne pouvez appeler que des fonctions dans l'environnement", "ENABLE_DEVELOPER_MODE": "Activer le mode développeur", - "TESTING_MOD_SETTINGS": "Paramètre de test" + "TESTING_MOD_SETTINGS": "Paramètre de test", + "SETTINGS_FILE": "Fichier de paramètres", + "EXPORT_SETTINGS": "Exporter les paramètres", + "IMPORT_SETTINGS": "Importer les paramètres" } } \ No newline at end of file diff --git a/source/gui/mod_settings.py b/source/gui/mod_settings.py index c511bc3..5703006 100644 --- a/source/gui/mod_settings.py +++ b/source/gui/mod_settings.py @@ -1,9 +1,12 @@ +import json import tkinter +from pathlib import Path from tkinter import ttk +from tkinter import filedialog from typing import TYPE_CHECKING from source.translation import translate as _, translate_external -from source.mkw import ModSettings +from source.mkw.ModSettings import SETTINGS_FILE_EXTENSION if TYPE_CHECKING: from source.mkw.ModConfig import ModConfig @@ -44,14 +47,25 @@ class Window(tkinter.Toplevel): _("TESTING_MOD_SETTINGS") ) + self.frame_action = FrameAction(self) + self.frame_action.grid(row=10, column=1) + # add at the end a message from the mod creator where he can put some additional note about the settings. if text := translate_external( self.mod_config, self.options.language.get(), self.mod_config.messages.get("settings_description", {}).get("text", {}) ): - self.label_description = ttk.Label(self, text="\n" + text, foreground="gray") - self.label_description.grid(row=2, column=1) + self.label_description = ttk.Label(self, text=text, foreground="gray") + self.label_description.grid(row=20, column=1) + + def restart(self): + """ + Restart the window + """ + + self.destroy() + self.__init__(self.mod_config, self.options) class NotebookSettings(ttk.Notebook): @@ -80,7 +94,7 @@ class FrameSettings(ttk.Frame): description = translate_external(self.root.mod_config, language, settings_data.description) enabled_variable = tkinter.BooleanVar(value=False) - checkbox = ttk.Checkbutton(self, text=text, variable=enabled_variable) + checkbox = ttk.Checkbutton(self, text=f"{text} {settings_data.suffix}", variable=enabled_variable) frame = ttk.LabelFrame(self, labelwidget=checkbox) frame.grid(row=index, column=1, sticky="NEWS") @@ -103,18 +117,84 @@ class FrameTesting(ttk.Frame): master.add(self, text=text) self.root = self.master.root + from source.mkw.ModSettings.SettingsType.Choices import Choices + from source.mkw.ModSettings.SettingsType.Boolean import Boolean + from source.mkw.ModSettings.SettingsType.String import String + self.columnconfigure(1, weight=1) for index, (settings_name, settings_data) in enumerate({ - "TEST_PREVIEW_FORMATTING": ModSettings.String.String(preview="track_formatting"), - "TEST_PREVIEW_SELECTING": ModSettings.String.String(preview="track_selecting"), - "TEST_PREVIEW_SORTING": ModSettings.String.String(preview="track_sorting"), + "TEST_PREVIEW_FORMATTING": String(preview="track_formatting"), + "TEST_PREVIEW_SELECTING": String(preview="track_selecting"), + "TEST_PREVIEW_SORTING": String(preview="track_sorting"), - "TEST_STRING": ModSettings.String.String(), - "TEST_CHOICES": ModSettings.Choices.Choices(["test1", "test2", "test3"]), - "TEST_BOOLEAN": ModSettings.Boolean.Boolean(), + "TEST_STRING": String(), + "TEST_CHOICES": Choices(["test1", "test2", "test3"]), + "TEST_BOOLEAN": Boolean(), }.items()): frame = ttk.LabelFrame(self, text=settings_name) frame.root = self.root frame.grid(row=index, column=1, sticky="NEWS") settings_data.tkinter_show(frame) + + +class FrameAction(ttk.Frame): + def __init__(self, master): + super().__init__(master) + self.root = master.root + + self.button_import_settings = ttk.Button(self, text=_("IMPORT_SETTINGS"), width=20, + command=self.import_settings) + self.button_import_settings.grid(row=1, column=1) + self.button_export_settings = ttk.Button(self, text=_("EXPORT_SETTINGS"), width=20, + command=self.export_settings) + self.button_export_settings.grid(row=1, column=2) + + def import_settings(self) -> None: + """ + Import a settings values file into the settings + """ + + path = filedialog.askopenfilename( + title=_("IMPORT_SETTINGS"), + filetypes=[(_("SETTINGS_FILE"), f"*{SETTINGS_FILE_EXTENSION}")] + ) + + # si le fichier n'a pas été choisi, ignore + if path == "": return + path = Path(path) + + with open(path, "r", encoding="utf8") as file: + values_dict: dict[str, dict] = json.load(file) + + self.root.mod_config.global_settings.import_values(values_dict["global_settings"]) + self.root.mod_config.specific_settings.import_values(values_dict["specific_settings"]) + + self.root.restart() # restart the window to update the values + + def export_settings(self) -> None: + """ + Export settings values into a file + """ + + path = filedialog.asksaveasfilename( + title=_("EXPORT_SETTINGS"), + filetypes=[(_("SETTINGS_FILE"), f"*{SETTINGS_FILE_EXTENSION}")] + ) + + # si le fichier n'a pas été choisi, ignore + if path == "": return + # s'il manque une extension au fichier, ignore + if not path.endswith(SETTINGS_FILE_EXTENSION): path += SETTINGS_FILE_EXTENSION + path = Path(path) + + with open(path, "w", encoding="utf8") as file: + json.dump( + { + "global_settings": self.root.mod_config.global_settings.export_values(), + "specific_settings": self.root.mod_config.specific_settings.export_values(), + }, + file, + ensure_ascii=False, + indent=4 + ) diff --git a/source/mkw/ModConfig.py b/source/mkw/ModConfig.py index e5623c3..4596a24 100644 --- a/source/mkw/ModConfig.py +++ b/source/mkw/ModConfig.py @@ -6,7 +6,8 @@ import json from PIL import Image from source import threaded -from source.mkw import Tag, ModSettings +from source.mkw import Tag +from source.mkw.ModSettings.ModSettingsGroup import ModSettingsGroup from source.mkw.Track.Cup import Cup from source.mkw.collection import MKWColor, Slot from source.mkw.Track import CustomTrack, DefaultTrack, Arena @@ -64,21 +65,6 @@ default_global_settings: dict[str, dict[str, str]] = { } -def merge_dict(dict1: dict[str, dict] | None, dict2: dict[str, dict] | None, - dict_keys: Iterable[str] = None) -> dict[str, dict]: - """ - Merge 2 dict subdict together - :return: the merged dict - { "option": {"speed": 1} }, { "option": {"mode": "hard"} } -> { "option": {"speed": 1, "mode": "hard"} } - - """ - if dict1 is None: dict1 = {} - if dict2 is None: dict2 = {} - if dict_keys is None: dict_keys = dict1.keys() | dict2.keys() - - return {key: dict1.get(key, {}) | dict2.get(key, {}) for key in dict_keys} - - @dataclass(init=True, slots=True) class ModConfig: """ @@ -108,8 +94,8 @@ class ModConfig: macros: dict[str, "TemplateSafeEval"] = field(default_factory=dict) messages: dict[str, dict[str, "TemplateMultipleSafeEval"]] = field(default_factory=dict) - global_settings: dict[str, "AbstractModSettings | dict"] = field(default_factory=dict) - specific_settings: dict[str, "AbstractModSettings | dict"] = field(default_factory=dict) + global_settings: ModSettingsGroup = field(default_factory=ModSettingsGroup) + specific_settings: ModSettingsGroup = field(default_factory=ModSettingsGroup) lpar_template: "TemplateMultipleSafeEval" = "normal.lpar" @@ -120,11 +106,12 @@ class ModConfig: self._tracks = [CustomTrack.from_dict(self, track) for track in self._tracks if isinstance(track, dict)] self._arenas = [Arena.from_dict(self, arena) for arena in self._arenas if isinstance(arena, dict)] - self.global_settings = {name: ModSettings.get(data) for name, data in merge_dict( - default_global_settings, self.global_settings, dict_keys=default_global_settings.keys() - # Avoid modder to add their own settings to globals one - ).items()} - self.specific_settings = {name: ModSettings.get(data) for name, data in self.specific_settings.items()} + # Settings + user_global_settings = self.global_settings + self.global_settings = ModSettingsGroup(default_global_settings) + self.global_settings.import_values(user_global_settings) + + self.specific_settings = ModSettingsGroup(self.specific_settings) def __hash__(self) -> int: return hash(self.name) diff --git a/source/mkw/ModSettings/AbstractModSettings.py b/source/mkw/ModSettings/AbstractModSettings.py new file mode 100644 index 0000000..0148ef5 --- /dev/null +++ b/source/mkw/ModSettings/AbstractModSettings.py @@ -0,0 +1,126 @@ +from abc import ABC, abstractmethod +from typing import Type, TYPE_CHECKING + +from source.translation import translate as _ + +if TYPE_CHECKING: + import tkinter + + +class InvalidSettingsType(Exception): + def __init__(self, settings_type: str): + super().__init__(_("TYPE_MOD_SETTINGS", " '", settings_type, "' ", "NOT_FOUND")) + + +class AbstractModSettings(ABC): + """ + Base class for every different type of ModSettings + """ + + type: str # type name of the settings + + def __init__(self, text: dict[str, str] = None, suffix: str = None, description: dict[str, str] = None, + enabled: bool = False, default: str | None = None, value: any = None): + + self.text = text if text is not None else {} # text to display in the window settings. + self.suffix = suffix if suffix is not None else "" # suffix after the text to display + self.description = description if description is not None else {} # desc to display in the window settings. + + self.enabled = enabled # is the settings enabled + self.default: str | None = default # default value of the settings (used is disabled) + self._value: any = value if value is not None else default # value for the settings + + @property + def value(self) -> "any | None": + """ + If the option is enabled, return the value, else return the default value + :return: value if the setting is enabled, default otherwise + """ + return self._value if self.enabled else self.default + + @property + def is_modified(self) -> bool: + """ + Return if the settings have been modified compared the the default value + """ + return self.value == self.default + + @abstractmethod + def tkinter_show(self, master, enabled_variable: "tkinter.BooleanVar" = None) -> None: + """ + Show the option inside a tkinter widget + :master: master widget + :checkbox: checkbox inside the labelframe allowing to enable or disable the setting + """ + import tkinter + if enabled_variable is None: enabled_variable = tkinter.BooleanVar() + + enabled_variable.set(self.enabled) + enabled_variable.trace_add("write", lambda *_: setattr(self, "enabled", enabled_variable.get())) + ... + + def tkinter_variable(self, vartype: Type["tkinter.Variable"]) -> "tkinter.Variable": + """ + Create a tkinter variable that is linked to the ModSettings value + :param vartype: the type of variable (boolean, int string) + :return: the tkinter variable + """ + variable = vartype(value=self._value) + variable.trace_add("write", lambda *_: setattr(self, "_value", variable.get())) + return variable + + @staticmethod + def tkinter_bind(master, enabled_variable: "tkinter.BooleanVar" = None) -> None: + """ + Bind all widget of the master so that clicking on the frame enable automatically the option + :param master: the frame containing the elements + :param enabled_variable: the variable associated with the enable state + :return: + """ + if enabled_variable is None: return + + for child in master.winfo_children(): + child.bind("", lambda e: enabled_variable.set(True)) + + def export_value(self) -> dict: + """ + Convert the settings value to a dictionary + :return: the dictionary form of the setting value + """ + return { + "value": self._value, + "default": self.default, + "enabled": self.enabled, + "suffix": self.suffix, + } + + def import_value(self, value: any = None, default: any = None, enabled: bool = None, suffix: str = None, + **kwargs) -> None: + """ + Import values into the settings. + :param value: the imported value + :param default: the imported default value + :param enabled: the imported state of the settings + :param suffix: the imported suffix of the settings title + :param kwargs: ignore others value for potential futur compatibility + """ + if value is not None: self._value = value + if default is not None: self.default = default + if enabled is not None: self.enabled = enabled + if suffix is not None: self.suffix = suffix + + +def get(settings_data: dict) -> "AbstractModSettings": + """ + Load all the settings in mod_settings_dict + :param settings_data: dictionary containing all the settings defined for the mod + """ + for subclass in filter(lambda subclass: subclass.type == settings_data["type"], + AbstractModSettings.__subclasses__()): + settings_data.pop("type") + return subclass(**settings_data) + else: raise InvalidSettingsType(settings_data["type"]) + + +# these import load the different ModSettings, and so get_mod_settings will be able to fetch them with __subclasses__ +from source.mkw.ModSettings.SettingsType import Choices, Boolean, String diff --git a/source/mkw/ModSettings/ModSettingsGroup.py b/source/mkw/ModSettings/ModSettingsGroup.py new file mode 100644 index 0000000..41d40fa --- /dev/null +++ b/source/mkw/ModSettings/ModSettingsGroup.py @@ -0,0 +1,30 @@ +from source.mkw.ModSettings import AbstractModSettings + + +class ModSettingsGroup(dict): + """ + Represent a group of mod settings + """ + + def __init__(self, d: dict) -> None: + """ + Convert a json type dict into a mod settings dictionary + :param d: the json dict + :return: the new dictionary + """ + super().__init__({name: AbstractModSettings.get(data) for name, data in d.items()}) + + def export_values(self) -> dict[str, dict]: + """ + Export the settings values into a dictionary + :return: the settings values + """ + return {key: value.export_value() for key, value in self.items()} + + def import_values(self, values_dict: dict[str, dict]) -> None: + """ + Import values to the settings + :param values_dict: the dictionary with the settings values + """ + for name, values in values_dict.items(): + self[name].import_value(**values) diff --git a/source/mkw/ModSettings/Boolean.py b/source/mkw/ModSettings/SettingsType/Boolean.py similarity index 91% rename from source/mkw/ModSettings/Boolean.py rename to source/mkw/ModSettings/SettingsType/Boolean.py index be5eb26..e395e07 100644 --- a/source/mkw/ModSettings/Boolean.py +++ b/source/mkw/ModSettings/SettingsType/Boolean.py @@ -1,4 +1,4 @@ -from source.mkw.ModSettings import AbstractModSettings +from source.mkw.ModSettings.AbstractModSettings import AbstractModSettings from source.translation import translate as _ diff --git a/source/mkw/ModSettings/Choices.py b/source/mkw/ModSettings/SettingsType/Choices.py similarity index 85% rename from source/mkw/ModSettings/Choices.py rename to source/mkw/ModSettings/SettingsType/Choices.py index e12eca0..5bcf913 100644 --- a/source/mkw/ModSettings/Choices.py +++ b/source/mkw/ModSettings/SettingsType/Choices.py @@ -1,4 +1,4 @@ -from source.mkw.ModSettings import AbstractModSettings +from source.mkw.ModSettings.AbstractModSettings import AbstractModSettings class Choices(AbstractModSettings): @@ -23,7 +23,7 @@ class Choices(AbstractModSettings): master.grid_columnconfigure(1, weight=1) combobox = ttk.Combobox(master, values=self.choices, textvariable=variable) - combobox.set(self.default) + combobox.set(self.default if self._value is None else self._value) combobox.grid(row=1, column=1, sticky="EW") self.tkinter_bind(master, checkbox) diff --git a/source/mkw/ModSettings/String.py b/source/mkw/ModSettings/SettingsType/String.py similarity index 83% rename from source/mkw/ModSettings/String.py rename to source/mkw/ModSettings/SettingsType/String.py index f04de8f..d7aad72 100644 --- a/source/mkw/ModSettings/String.py +++ b/source/mkw/ModSettings/SettingsType/String.py @@ -1,4 +1,4 @@ -from source.mkw.ModSettings import AbstractModSettings +from source.mkw.ModSettings.AbstractModSettings import AbstractModSettings from source.gui.preview import AbstractPreviewWindow @@ -20,6 +20,11 @@ class String(AbstractModSettings): super().tkinter_show(master, checkbox) variable = self.tkinter_variable(tkinter.StringVar) + + text = self.default if self.default is not None else "" + text = text if self._value is None else self._value + variable.set(text) + master.grid_columnconfigure(1, weight=1) entry = ttk.Entry(master, textvariable=variable) diff --git a/source/mkw/ModSettings/SettingsType/__init__.py b/source/mkw/ModSettings/SettingsType/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/source/mkw/ModSettings/__init__.py b/source/mkw/ModSettings/__init__.py index d643d7f..e919f40 100644 --- a/source/mkw/ModSettings/__init__.py +++ b/source/mkw/ModSettings/__init__.py @@ -1,98 +1 @@ -from abc import ABC, abstractmethod -from typing import Type, TYPE_CHECKING - -from source.translation import translate as _ - -if TYPE_CHECKING: - import tkinter - - -class InvalidSettingsType(Exception): - def __init__(self, settings_type: str): - super().__init__(_("TYPE_MOD_SETTINGS", " '", settings_type, "' ", "NOT_FOUND")) - - -class AbstractModSettings(ABC): - """ - Base class for every different type of ModSettings - """ - - type: str # type name of the settings - - def __init__(self, text: dict[str, str] = None, description: dict[str, str] = None, enabled: bool = False, - default: str | None = None, value: any = None): - - self.text = text if text is not None else {} # text to display in the window settings. - self.description = description if description is not None else {} # desc to display in the window settings. - - self.enabled = enabled # is the settings enabled - self.default: str | None = default # default value of the settings (used is disabled) - self._value: any = value if value is not None else default # value for the settings - - @property - def value(self) -> "any | None": - """ - If the option is enabled, return the value, else return the default value - :return: value if the setting is enabled, default otherwise - """ - return self._value if self.enabled else self.default - - @property - def is_modified(self) -> bool: - """ - Return if the settings have been modified compared the the default value - """ - return self.value == self.default - - @abstractmethod - def tkinter_show(self, master, enabled_variable: "tkinter.BooleanVar" = None) -> None: - """ - Show the option inside a tkinter widget - :master: master widget - :checkbox: checkbox inside the labelframe allowing to enable or disable the setting - """ - import tkinter - if enabled_variable is None: enabled_variable = tkinter.BooleanVar() - - enabled_variable.set(self.enabled) - enabled_variable.trace_add("write", lambda *_: setattr(self, "enabled", enabled_variable.get())) - ... - - def tkinter_variable(self, vartype: Type["tkinter.Variable"]) -> "tkinter.Variable": - """ - Create a tkinter variable that is linked to the ModSettings value - :param vartype: the type of variable (boolean, int string) - :return: the tkinter variable - """ - variable = vartype(value=self._value) - variable.trace_add("write", lambda *_: setattr(self, "_value", variable.get())) - return variable - - @staticmethod - def tkinter_bind(master, enabled_variable: "tkinter.BooleanVar" = None) -> None: - """ - Bind all widget of the master so that clicking on the frame enable automatically the option - :param master: the frame containing the elements - :param enabled_variable: the variable associated with the enable state - :return: - """ - if enabled_variable is None: return - - for child in master.winfo_children(): - child.bind("", lambda e: enabled_variable.set(True)) - - -def get(settings_data: dict) -> "AbstractModSettings": - """ - Load all the settings in mod_settings_dict - :param settings_data: dictionary containing all the settings defined for the mod - """ - for subclass in filter(lambda subclass: subclass.type == settings_data["type"], - AbstractModSettings.__subclasses__()): - settings_data.pop("type") - return subclass(**settings_data) - else: raise InvalidSettingsType(settings_data["type"]) - - -# these import load the different ModSettings, and so get_mod_settings will be able to fetch them with __subclasses__ -from source.mkw.ModSettings import Choices, String, Boolean +SETTINGS_FILE_EXTENSION: str = ".mkwf.settings"