diff --git a/docs/tutorial.md b/docs/tutorial.md index 7fb2539..5a4e1a2 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -220,6 +220,11 @@ You can choose the images following case. If this option is enabled, Screencast Keys will be enabled automatically when blender will startup up. +### Persist Preferences +If this option is enabled, Screencast Keys will store its preferences +in a local file and will restore them when reloaded. +The preferences are stored in blender's user configuration directory: `config/screencast_keys/preferences.json` + ### UI #### Sidebar diff --git a/src/screencast_keys/__init__.py b/src/screencast_keys/__init__.py index f975105..dbf0fd8 100644 --- a/src/screencast_keys/__init__.py +++ b/src/screencast_keys/__init__.py @@ -153,28 +153,17 @@ def register(): context = bpy.context user_prefs = context.preferences prefs = user_prefs.addons[__package__].preferences + preferences.load_persisted_preferences(prefs) # Only default panel location is available in < 2.80 if utils.compatibility.check_version(2, 80, 0) < 0: prefs.panel_space_type = 'VIEW_3D' prefs.panel_category = "Screencast Key" prefs.show_ui_in_sidebar = True prefs.show_ui_in_overlay = False + preferences.ensure_display_event_text_aliases(prefs) preferences.SK_Preferences.ui_in_sidebar_update_fn(prefs, context) preferences.SK_Preferences.ui_in_overlay_update_fn(prefs, context) - # Fix: https://github.com/nutti/Screencast-Keys/issues/195 - # TODO: This workaround is hacky. We need to apply a better solution. - if len(prefs.display_event_text_aliases_props) == 0: - for event in list(ops.EventType): - item = prefs.display_event_text_aliases_props.add() - item.event_id = event.name - if event in ops.SK_OT_ScreencastKeys.MODIFIER_EVENT_TYPES: - item.default_text = common.fix_modifier_display_text( - ops.EventType.names[event.name] - ) - else: - item.default_text = ops.EventType.names[event.name] - try: common.reload_custom_mouse_image(prefs, context) # pylint: disable=W0702 @@ -187,7 +176,11 @@ def unregister(): user_prefs = context.preferences prefs = user_prefs.addons[__package__].preferences preferences.remove_custom_mouse_image(prefs, context) - prefs.display_event_text_aliases_props.clear() + if prefs.persist_preferences: + preferences.save_persisted_preferences(prefs) + else: + preferences.remove_persisted_preferences_file() + prefs.display_event_text_aliases_props.clear() bpy.app.handlers.load_post.remove(load_post_handler) unregister_shortcut_key() diff --git a/src/screencast_keys/blender_manifest.toml b/src/screencast_keys/blender_manifest.toml index b239dbe..6da7cd8 100644 --- a/src/screencast_keys/blender_manifest.toml +++ b/src/screencast_keys/blender_manifest.toml @@ -13,3 +13,6 @@ blender_version_min = "4.2.0" license = [ "SPDX:GPL-3.0-or-later", ] + +[permissions] +files = "Save settings to a file and restore/delete them according to preferences" diff --git a/src/screencast_keys/preferences.py b/src/screencast_keys/preferences.py index df316f4..210bd1f 100644 --- a/src/screencast_keys/preferences.py +++ b/src/screencast_keys/preferences.py @@ -19,6 +19,9 @@ # +import json +import os + import bpy from bpy.props import ( StringProperty, @@ -32,6 +35,22 @@ from .utils.bl_class_registry import BlClassRegistry from . import common from . import c_structure as cstruct # extensions.blender.org: Delete line +from . import ops + + +PERSISTED_PREFERENCE_EXCLUDES = { + "bl_idname", + "updater_branch_to_update", +} + +PERSISTED_PREFERENCE_TYPES = { + 'BOOLEAN', + 'COLLECTION', + 'ENUM', + 'FLOAT', + 'INT', + 'STRING', +} # extensions.blender.org: Delete block start @@ -132,6 +151,151 @@ class DisplayEventTextAliasProperties(bpy.types.PropertyGroup): event_id: bpy.props.StringProperty(options={'HIDDEN'}) +def persisted_preferences_filepath(create=False): + config_dir = bpy.utils.user_resource( + 'CONFIG', path="screencast_keys", create=create) + if not config_dir: + return "" + + return os.path.join(config_dir, "preferences.json") + + +def _rna_properties(data): + for prop in data.bl_rna.properties: + if prop.identifier == "rna_type": + continue + if prop.identifier in PERSISTED_PREFERENCE_EXCLUDES: + continue + if prop.type not in PERSISTED_PREFERENCE_TYPES: + continue + if prop.is_readonly and prop.type != 'COLLECTION': + continue + yield prop + + +def _serialize_property(data, prop): + value = getattr(data, prop.identifier) + if prop.type == 'COLLECTION': + return [_serialize_preferences(item) for item in value] + if getattr(prop, "array_length", 0) > 0: + return list(value) + + return value + + +def _serialize_preferences(data): + preferences = {} + for prop in _rna_properties(data): + try: + preferences[prop.identifier] = _serialize_property(data, prop) + except TypeError: + continue + + return preferences + + +def _apply_preferences(data, preferences): + properties = { + prop.identifier: prop + for prop in _rna_properties(data) + } + for identifier, value in preferences.items(): + if identifier not in properties: + continue + + prop = properties[identifier] + if prop.type == 'COLLECTION': + if not isinstance(value, list): + continue + collection = getattr(data, identifier) + collection.clear() + for item_data in value: + if not isinstance(item_data, dict): + continue + item = collection.add() + _apply_preferences(item, item_data) + else: + try: + data[identifier] = value + except (AttributeError, KeyError, TypeError, ValueError): + try: + setattr(data, identifier, value) + except (TypeError, ValueError): + continue + + +def ensure_display_event_text_aliases(prefs): + + + event_aliases = { + item.event_id: item + for item in prefs.display_event_text_aliases_props + } + for event in list(ops.EventType): + if event in ops.SK_OT_ScreencastKeys.MODIFIER_EVENT_TYPES: + default_text = common.fix_modifier_display_text( + ops.EventType.names[event.name] + ) + else: + default_text = ops.EventType.names[event.name] + + if event.name in event_aliases: + event_aliases[event.name].default_text = default_text + else: + item = prefs.display_event_text_aliases_props.add() + item.event_id = event.name + item.default_text = default_text + + +def load_persisted_preferences(prefs): + filepath = persisted_preferences_filepath() + if not filepath or not os.path.exists(filepath): + return + + try: + with open(filepath, "r", encoding="utf-8") as file: + data = json.load(file) + except (OSError, json.JSONDecodeError): + return + + if not data.get("persist_preferences", False): + return + + _apply_preferences(prefs, data.get("preferences", {})) + prefs.persist_preferences = True + + +def save_persisted_preferences(prefs): + filepath = persisted_preferences_filepath(create=True) + if not filepath: + return + + data = { + "version": "1.0.0", + "persist_preferences": True, + "preferences": _serialize_preferences(prefs), + } + + try: + tmp_filepath = "{}.tmp".format(filepath) + with open(tmp_filepath, "w", encoding="utf-8") as file: + json.dump(data, file, ensure_ascii=False, indent=2) + os.replace(tmp_filepath, filepath) + except (OSError, TypeError): + return + + +def remove_persisted_preferences_file(): + filepath = persisted_preferences_filepath() + if not filepath: + return + + try: + os.remove(filepath) + except OSError: + return + + # pylint: disable=W0613 def remove_custom_mouse_image(self, _): def remove_image(image_name): @@ -525,6 +689,13 @@ def ui_in_overlay_update_fn(self, _): update=ui_in_overlay_update_fn, ) + persist_preferences: bpy.props.BoolProperty( + name="Persist Preferences", + description="Store add-on preferences in Blender user configuration " + "and restore them when this add-on is enabled again", + default=False, + ) + display_event_text_aliases_props: bpy.props.CollectionProperty( type=DisplayEventTextAliasProperties ) @@ -547,6 +718,7 @@ def draw(self, _): layout.separator() layout.prop(self, "enable_on_startup") + layout.prop(self, "persist_preferences") column = layout.column() split = column.split()