settings can now be exported and imported. Simplified syntax for modder defined global_settings

This commit is contained in:
Faraphel 2022-08-23 22:59:55 +02:00
parent f1a9a9bd1c
commit b9d6913e22
12 changed files with 279 additions and 147 deletions

View file

@ -6,16 +6,10 @@
"global_settings": { "global_settings": {
"include_track_if": { "include_track_if": {
"text": { "suffix": "*"
"en": "Include track if *",
"fr": "Inclure la course si *"
}
}, },
"sort_tracks": { "sort_tracks": {
"text": { "suffix": "*"
"en": "Sort tracks by *",
"fr": "Trier les courses par *"
}
}, },
"replace_random_new": { "replace_random_new": {
"default": "'Retro' not in track.tags and track.warning == 0" "default": "'Retro' not in track.tags and track.warning == 0"
@ -52,9 +46,10 @@
}, },
"balancing": { "balancing": {
"text": { "text": {
"en": "Balancing *", "en": "Balancing",
"fr": "Équilibrage *" "fr": "Équilibrage"
}, },
"suffix": "*",
"description": { "description": {
"en": "Should the mod balancing (fake item box, blooper) be enabled ?", "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é ?" "fr": "Est-ce que l'équilibrage du mod (fausse boite d'objet, bloops) devrait être activé ?"

View file

@ -105,6 +105,9 @@
"CAN_ONLY_CALL_METHOD_OF_CONSTANT": "You can only call methods on constant", "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", "CAN_ONLY_CALL_FUNCTION_IN_ENV": "You can only call function from the environment",
"ENABLE_DEVELOPER_MODE": "Enable the developer mode", "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"
} }
} }

View file

@ -106,6 +106,9 @@
"CAN_ONLY_CALL_METHOD_OF_CONSTANT": "Vous ne pouvez appeler que des méthodes sur des constantes", "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", "CAN_ONLY_CALL_FUNCTION_IN_ENV": "Vous ne pouvez appeler que des fonctions dans l'environnement",
"ENABLE_DEVELOPER_MODE": "Activer le mode développeur", "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"
} }
} }

View file

@ -1,9 +1,12 @@
import json
import tkinter import tkinter
from pathlib import Path
from tkinter import ttk from tkinter import ttk
from tkinter import filedialog
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from source.translation import translate as _, translate_external from source.translation import translate as _, translate_external
from source.mkw import ModSettings from source.mkw.ModSettings import SETTINGS_FILE_EXTENSION
if TYPE_CHECKING: if TYPE_CHECKING:
from source.mkw.ModConfig import ModConfig from source.mkw.ModConfig import ModConfig
@ -44,14 +47,25 @@ class Window(tkinter.Toplevel):
_("TESTING_MOD_SETTINGS") _("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. # add at the end a message from the mod creator where he can put some additional note about the settings.
if text := translate_external( if text := translate_external(
self.mod_config, self.mod_config,
self.options.language.get(), self.options.language.get(),
self.mod_config.messages.get("settings_description", {}).get("text", {}) self.mod_config.messages.get("settings_description", {}).get("text", {})
): ):
self.label_description = ttk.Label(self, text="\n" + text, foreground="gray") self.label_description = ttk.Label(self, text=text, foreground="gray")
self.label_description.grid(row=2, column=1) 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): class NotebookSettings(ttk.Notebook):
@ -80,7 +94,7 @@ class FrameSettings(ttk.Frame):
description = translate_external(self.root.mod_config, language, settings_data.description) description = translate_external(self.root.mod_config, language, settings_data.description)
enabled_variable = tkinter.BooleanVar(value=False) 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 = ttk.LabelFrame(self, labelwidget=checkbox)
frame.grid(row=index, column=1, sticky="NEWS") frame.grid(row=index, column=1, sticky="NEWS")
@ -103,18 +117,84 @@ class FrameTesting(ttk.Frame):
master.add(self, text=text) master.add(self, text=text)
self.root = self.master.root 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) self.columnconfigure(1, weight=1)
for index, (settings_name, settings_data) in enumerate({ for index, (settings_name, settings_data) in enumerate({
"TEST_PREVIEW_FORMATTING": ModSettings.String.String(preview="track_formatting"), "TEST_PREVIEW_FORMATTING": String(preview="track_formatting"),
"TEST_PREVIEW_SELECTING": ModSettings.String.String(preview="track_selecting"), "TEST_PREVIEW_SELECTING": String(preview="track_selecting"),
"TEST_PREVIEW_SORTING": ModSettings.String.String(preview="track_sorting"), "TEST_PREVIEW_SORTING": String(preview="track_sorting"),
"TEST_STRING": ModSettings.String.String(), "TEST_STRING": String(),
"TEST_CHOICES": ModSettings.Choices.Choices(["test1", "test2", "test3"]), "TEST_CHOICES": Choices(["test1", "test2", "test3"]),
"TEST_BOOLEAN": ModSettings.Boolean.Boolean(), "TEST_BOOLEAN": Boolean(),
}.items()): }.items()):
frame = ttk.LabelFrame(self, text=settings_name) frame = ttk.LabelFrame(self, text=settings_name)
frame.root = self.root frame.root = self.root
frame.grid(row=index, column=1, sticky="NEWS") frame.grid(row=index, column=1, sticky="NEWS")
settings_data.tkinter_show(frame) 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
)

View file

@ -6,7 +6,8 @@ import json
from PIL import Image from PIL import Image
from source import threaded 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.Track.Cup import Cup
from source.mkw.collection import MKWColor, Slot from source.mkw.collection import MKWColor, Slot
from source.mkw.Track import CustomTrack, DefaultTrack, Arena 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) @dataclass(init=True, slots=True)
class ModConfig: class ModConfig:
""" """
@ -108,8 +94,8 @@ class ModConfig:
macros: dict[str, "TemplateSafeEval"] = field(default_factory=dict) macros: dict[str, "TemplateSafeEval"] = field(default_factory=dict)
messages: dict[str, dict[str, "TemplateMultipleSafeEval"]] = field(default_factory=dict) messages: dict[str, dict[str, "TemplateMultipleSafeEval"]] = field(default_factory=dict)
global_settings: dict[str, "AbstractModSettings | dict"] = field(default_factory=dict) global_settings: ModSettingsGroup = field(default_factory=ModSettingsGroup)
specific_settings: dict[str, "AbstractModSettings | dict"] = field(default_factory=dict) specific_settings: ModSettingsGroup = field(default_factory=ModSettingsGroup)
lpar_template: "TemplateMultipleSafeEval" = "normal.lpar" 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._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._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( # Settings
default_global_settings, self.global_settings, dict_keys=default_global_settings.keys() user_global_settings = self.global_settings
# Avoid modder to add their own settings to globals one self.global_settings = ModSettingsGroup(default_global_settings)
).items()} self.global_settings.import_values(user_global_settings)
self.specific_settings = {name: ModSettings.get(data) for name, data in self.specific_settings.items()}
self.specific_settings = ModSettingsGroup(self.specific_settings)
def __hash__(self) -> int: def __hash__(self) -> int:
return hash(self.name) return hash(self.name)

View file

@ -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("<Button-1>", 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

View file

@ -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)

View file

@ -1,4 +1,4 @@
from source.mkw.ModSettings import AbstractModSettings from source.mkw.ModSettings.AbstractModSettings import AbstractModSettings
from source.translation import translate as _ from source.translation import translate as _

View file

@ -1,4 +1,4 @@
from source.mkw.ModSettings import AbstractModSettings from source.mkw.ModSettings.AbstractModSettings import AbstractModSettings
class Choices(AbstractModSettings): class Choices(AbstractModSettings):
@ -23,7 +23,7 @@ class Choices(AbstractModSettings):
master.grid_columnconfigure(1, weight=1) master.grid_columnconfigure(1, weight=1)
combobox = ttk.Combobox(master, values=self.choices, textvariable=variable) 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") combobox.grid(row=1, column=1, sticky="EW")
self.tkinter_bind(master, checkbox) self.tkinter_bind(master, checkbox)

View file

@ -1,4 +1,4 @@
from source.mkw.ModSettings import AbstractModSettings from source.mkw.ModSettings.AbstractModSettings import AbstractModSettings
from source.gui.preview import AbstractPreviewWindow from source.gui.preview import AbstractPreviewWindow
@ -20,6 +20,11 @@ class String(AbstractModSettings):
super().tkinter_show(master, checkbox) super().tkinter_show(master, checkbox)
variable = self.tkinter_variable(tkinter.StringVar) 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) master.grid_columnconfigure(1, weight=1)
entry = ttk.Entry(master, textvariable=variable) entry = ttk.Entry(master, textvariable=variable)

View file

@ -1,98 +1 @@
from abc import ABC, abstractmethod SETTINGS_FILE_EXTENSION: str = ".mkwf.settings"
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("<Button-1>", 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