diff --git a/marketing/demo.blend b/marketing/demo.blend index 840c2f6..242bf8f 100644 Binary files a/marketing/demo.blend and b/marketing/demo.blend differ diff --git a/marketing/demo.blend1 b/marketing/demo.blend1 index db0dfbe..5aa7082 100644 Binary files a/marketing/demo.blend1 and b/marketing/demo.blend1 differ diff --git a/src/__pycache__/__init__.cpython-311.pyc b/src/__pycache__/__init__.cpython-311.pyc index 66b24bc..6cfc2a6 100644 Binary files a/src/__pycache__/__init__.cpython-311.pyc and b/src/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/operators/__init__.py b/src/operators/__init__.py index 3d2f75b..f2dab60 100644 --- a/src/operators/__init__.py +++ b/src/operators/__init__.py @@ -21,6 +21,7 @@ try: from .preset_ops import ( TEXT_TEXTURE_OT_save_preset, TEXT_TEXTURE_OT_save_preset_enter, + TEXT_TEXTURE_OT_clear_preset_search, TEXT_TEXTURE_OT_load_preset, TEXT_TEXTURE_OT_delete_preset, TEXT_TEXTURE_OT_refresh_presets, @@ -31,6 +32,7 @@ try: PRESET_OPERATORS = [ 'TEXT_TEXTURE_OT_save_preset', 'TEXT_TEXTURE_OT_save_preset_enter', + 'TEXT_TEXTURE_OT_clear_preset_search', 'TEXT_TEXTURE_OT_load_preset', 'TEXT_TEXTURE_OT_delete_preset', 'TEXT_TEXTURE_OT_refresh_presets', @@ -108,4 +110,4 @@ __all__.extend(PRESET_OPERATORS) # Add text editor operators if available __all__.extend(TEXT_EDITOR_OPERATORS) -__all__.extend(UTILITY_OPERATORS) \ No newline at end of file +__all__.extend(UTILITY_OPERATORS) diff --git a/src/operators/__pycache__/__init__.cpython-311.pyc b/src/operators/__pycache__/__init__.cpython-311.pyc index 6eff467..2120dd6 100644 Binary files a/src/operators/__pycache__/__init__.cpython-311.pyc and b/src/operators/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/operators/__pycache__/preset_ops.cpython-311.pyc b/src/operators/__pycache__/preset_ops.cpython-311.pyc index 6df10d0..e5db1f3 100644 Binary files a/src/operators/__pycache__/preset_ops.cpython-311.pyc and b/src/operators/__pycache__/preset_ops.cpython-311.pyc differ diff --git a/src/operators/preset_ops.py b/src/operators/preset_ops.py index b136319..6e5d196 100644 --- a/src/operators/preset_ops.py +++ b/src/operators/preset_ops.py @@ -168,15 +168,24 @@ class TEXT_TEXTURE_OT_save_preset(Operator): existing_preset = preset break + target_index = None + if not existing_preset: pass preset = props.presets.add() preset.name = preset_name preset.source = 'BLEND_FILE' if props.save_with_blend_file else 'PERSISTENT_FILE' + target_index = len(props.presets) - 1 else: pass # Update source based on where it was saved existing_preset.source = 'BLEND_FILE' if props.save_with_blend_file else 'PERSISTENT_FILE' + for idx, collection_preset in enumerate(props.presets): + pass + if collection_preset.name == preset_name: + pass + target_index = idx + break # Provide clear feedback location = "with .blend file" if props.save_with_blend_file else "to persistent storage" @@ -188,6 +197,13 @@ class TEXT_TEXTURE_OT_save_preset(Operator): pass self.report({'INFO'}, f"✅ Preset '{preset_name}' saved {location}") + if target_index is not None and hasattr(props, "active_preset_index"): + pass + props.active_preset_index = target_index + if hasattr(props, "last_loaded_preset"): + pass + props.last_loaded_preset = preset_name + except (OSError, IOError, json.JSONEncodeError) as e: pass self.report({'ERROR'}, f"Failed to save preset: {str(e)}") @@ -234,6 +250,22 @@ class TEXT_TEXTURE_OT_save_preset_enter(Operator): # Delegate to the main save preset operator return bpy.ops.text_texture.save_preset('INVOKE_DEFAULT') +class TEXT_TEXTURE_OT_clear_preset_search(Operator): + pass + """Clear the preset search filter""" + bl_idname = "text_texture.clear_preset_search" + bl_label = "Clear Preset Search" + bl_description = "Reset the preset search field" + bl_options = {'INTERNAL'} + + def execute(self, context): + pass + props = context.scene.text_texture_props + if hasattr(props, "preset_search_query"): + pass + props.preset_search_query = "" + return {'FINISHED'} + class TEXT_TEXTURE_OT_load_preset(Operator): pass """Load preset""" @@ -382,6 +414,18 @@ class TEXT_TEXTURE_OT_load_preset(Operator): from ..core.generation_engine import generate_preview generate_preview(props, props.preview_size if hasattr(props, 'preview_size') else 512, props.preview_size if hasattr(props, 'preview_size') else 512) + if hasattr(props, "last_loaded_preset"): + pass + props.last_loaded_preset = self.preset_name + if hasattr(props, "active_preset_index"): + pass + for idx, preset in enumerate(props.presets): + pass + if preset.name == self.preset_name: + pass + props.active_preset_index = idx + break + source_info = { 'BLEND_FILE': "from blend file", 'PERSISTENT_FILE': "from persistent storage", @@ -438,13 +482,27 @@ class TEXT_TEXTURE_OT_delete_preset(Operator): self.report({'WARNING'}, f"Preset '{self.preset_name}' not found in blend file or local storage") # Remove from UI list + removed_index = None for i, preset in enumerate(props.presets): pass if preset.name == self.preset_name: pass props.presets.remove(i) + removed_index = i break + if hasattr(props, "last_loaded_preset") and props.last_loaded_preset == self.preset_name: + pass + props.last_loaded_preset = "" + if removed_index is not None and hasattr(props, "active_preset_index"): + pass + if len(props.presets) == 0: + pass + props.active_preset_index = 0 + else: + pass + props.active_preset_index = min(removed_index, len(props.presets) - 1) + delete_info = [] if blend_deleted: pass @@ -809,6 +867,7 @@ class TEXT_TEXTURE_OT_import_presets(Operator): __all__ = [ 'TEXT_TEXTURE_OT_save_preset', 'TEXT_TEXTURE_OT_save_preset_enter', + 'TEXT_TEXTURE_OT_clear_preset_search', 'TEXT_TEXTURE_OT_load_preset', 'TEXT_TEXTURE_OT_delete_preset', 'TEXT_TEXTURE_OT_refresh_presets', diff --git a/src/properties/__pycache__/property_groups.cpython-311.pyc b/src/properties/__pycache__/property_groups.cpython-311.pyc index 0bc47d9..fcbddcd 100644 Binary files a/src/properties/__pycache__/property_groups.cpython-311.pyc and b/src/properties/__pycache__/property_groups.cpython-311.pyc differ diff --git a/src/properties/property_groups.py b/src/properties/property_groups.py index 9fbe664..f95335f 100644 --- a/src/properties/property_groups.py +++ b/src/properties/property_groups.py @@ -606,6 +606,18 @@ class TEXT_TEXTURE_Properties(PropertyGroup): description="Name for new preset (Press Enter to save)", default="New Preset" ) + + preset_search_query: StringProperty( + name="Preset Search", + description="Filter presets by name", + default="" + ) + + last_loaded_preset: StringProperty( + name="Last Loaded Preset", + description="Tracks the most recently loaded preset name for highlighting", + default="" + ) # Property to track when user wants to save with Enter preset_save_requested: BoolProperty( diff --git a/src/ui/__pycache__/panels.cpython-311.pyc b/src/ui/__pycache__/panels.cpython-311.pyc index df9ab04..d2534c7 100644 Binary files a/src/ui/__pycache__/panels.cpython-311.pyc and b/src/ui/__pycache__/panels.cpython-311.pyc differ diff --git a/src/ui/panels.py b/src/ui/panels.py index 8945a5a..46a2dc2 100644 --- a/src/ui/panels.py +++ b/src/ui/panels.py @@ -498,78 +498,104 @@ def draw_presets_section(layout, props): draw_feature_lock_indicator(layout, 'presets') return - # Save preset section - use safe operator calls - save_row = layout.row(align=True) + col = layout.column(align=True) + + # Save preset controls + save_row = col.row(align=True) save_row.prop(props, "new_preset_name", text="", placeholder="Preset Name") premium_lock = create_premium_lock_function() safe_operator_call(save_row, "text_texture.save_preset", premium_lock, text="Save", icon='FILE_NEW') - # Save with blend file checkbox - layout.prop(props, "save_with_blend_file", text="Project-specific (only available in this .blend file)") + save_location_row = col.row(align=True) + save_location_row.prop(props, "save_with_blend_file", text="Project-specific (saved inside this .blend)") - layout.separator() - - # Import/Export and refresh buttons - use safe operator calls - backup_row = layout.row(align=True) + col.separator() + # Import/export/refresh controls + backup_row = col.row(align=True) export_lock = create_premium_lock_function() import_lock = create_premium_lock_function() - safe_operator_call(backup_row, "text_texture.export_presets", export_lock, text="Export Backup", icon='EXPORT') safe_operator_call(backup_row, "text_texture.import_presets", import_lock, text="Import Backup", icon='IMPORT') - utility_row = layout.row(align=True) - + utility_row = col.row(align=True) refresh_lock = create_premium_lock_function() - safe_operator_call(utility_row, "text_texture.refresh_presets", refresh_lock, - text="Refresh", icon='FILE_REFRESH') + text="Refresh List", icon='FILE_REFRESH') - layout.separator() + col.separator() - # Load presets section - unified list - if props.presets: + total_presets = len(props.presets) + + if total_presets: pass - layout.label(text="Presets", icon='PRESET') + blend_count = len([preset for preset in props.presets if preset.source == 'BLEND_FILE']) + persistent_count = total_presets - blend_count - # Sort presets alphabetically for consistent display + summary_row = col.row(align=True) + summary_row.label(text=f"{total_presets} presets", icon='PRESET_HLT') + summary_row.label(text=f"{blend_count} project", icon='FILE_BLEND') + summary_row.label(text=f"{persistent_count} global", icon='FILE_CACHE') + + search_row = col.row(align=True) + search_row.prop(props, "preset_search_query", text="", icon='VIEWZOOM') + if getattr(props, "preset_search_query", "").strip(): + search_row.operator("text_texture.clear_preset_search", text="", icon='PANEL_CLOSE') + + # Prepare sorted and filtered presets for display + search_query = getattr(props, "preset_search_query", "").strip().lower() sorted_presets = sorted(props.presets, key=lambda p: p.name.lower()) + if search_query: + visible_presets = [preset for preset in sorted_presets if search_query in preset.name.lower()] + else: + visible_presets = sorted_presets - for preset in sorted_presets: + if visible_presets: pass - row = layout.row(align=True) - row.scale_y = 0.9 - - # Load button - use safe operator call - load_lock = create_premium_lock_function() - load_btn = safe_operator_call(row, "text_texture.load_preset", load_lock, - text=preset.name) - if load_btn: - load_btn.preset_name = preset.name - - # Quick overwrite button - use safe operator call - overwrite_lock = create_premium_lock_function() - overwrite_btn = safe_operator_call(row, "text_texture.save_preset", overwrite_lock, - text="", icon='FILE_REFRESH') - if overwrite_btn: - overwrite_btn.overwrite = True - overwrite_btn.preset_name = preset.name - - # Delete button - use safe operator call - delete_lock = create_premium_lock_function() - delete_btn = safe_operator_call(row, "text_texture.delete_preset", delete_lock, - text="", icon='TRASH') - if delete_btn: - delete_btn.preset_name = preset.name - + for preset in visible_presets: + pass + row = col.row(align=True) + row.scale_y = 0.95 + + is_active = getattr(props, "last_loaded_preset", "") == preset.name + load_icon = 'CHECKMARK' if is_active else 'PRESET' + + load_lock = create_premium_lock_function() + load_btn = safe_operator_call(row, "text_texture.load_preset", load_lock, + text=preset.name, icon=load_icon) + if load_btn: + load_btn.preset_name = preset.name + + source_icon = 'FILE_BLEND' if preset.source == 'BLEND_FILE' else 'FILE_CACHE' + source_label = "Project" if preset.source == 'BLEND_FILE' else "Global" + row.label(text=source_label, icon=source_icon) + + actions = row.row(align=True) + actions.alignment = 'RIGHT' + + overwrite_lock = create_premium_lock_function() + overwrite_btn = safe_operator_call(actions, "text_texture.save_preset", overwrite_lock, + text="", icon='FILE_REFRESH') + if overwrite_btn: + overwrite_btn.overwrite = True + overwrite_btn.preset_name = preset.name + + delete_lock = create_premium_lock_function() + delete_btn = safe_operator_call(actions, "text_texture.delete_preset", delete_lock, + text="", icon='TRASH') + if delete_btn: + delete_btn.preset_name = preset.name + else: + pass + col.label(text="No presets match your search", icon='INFO') else: pass - layout.label(text="No presets yet") - layout.label(text="Save your settings above to create your first preset.") + col.label(text="No presets yet", icon='INFO') + col.label(text="Save your current settings to start your preset library.") def draw_shader_section(layout, props): pass diff --git a/src/utils/__pycache__/constants.cpython-311.pyc b/src/utils/__pycache__/constants.cpython-311.pyc index f4ea3ec..a684cf0 100644 Binary files a/src/utils/__pycache__/constants.cpython-311.pyc and b/src/utils/__pycache__/constants.cpython-311.pyc differ diff --git a/tests/unit/operators/test_preset_ui_controls.py b/tests/unit/operators/test_preset_ui_controls.py new file mode 100644 index 0000000..367966f --- /dev/null +++ b/tests/unit/operators/test_preset_ui_controls.py @@ -0,0 +1,17 @@ +from unittest.mock import Mock + +from src.operators.preset_ops import TEXT_TEXTURE_OT_clear_preset_search + + +def test_clear_preset_search_resets_query(): + props = Mock() + props.preset_search_query = "Glow" + + context = Mock() + context.scene.text_texture_props = props + + op = TEXT_TEXTURE_OT_clear_preset_search() + result = op.execute(context) + + assert result == {'FINISHED'} + assert props.preset_search_query == "" diff --git a/tests/unit/presets/__init__.py b/tests/unit/presets/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/unit/presets/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/unit/presets/test_storage_system.py b/tests/unit/presets/test_storage_system.py new file mode 100644 index 0000000..101d47f --- /dev/null +++ b/tests/unit/presets/test_storage_system.py @@ -0,0 +1,263 @@ +import json +import os +from pathlib import Path +from unittest.mock import Mock + +import pytest + +import src.presets.storage_system as storage_system + + +class MockPresetItem: + """Simple container for preset collection entries.""" + + def __init__(self): + self.name = "" + self.source = "" + + +class MockPresetCollection: + """Minimal stand-in for Blender's CollectionProperty.""" + + def __init__(self): + self._items = [] + self.cleared = False + + def clear(self): + self.cleared = True + self._items.clear() + + def add(self): + item = MockPresetItem() + self._items.append(item) + return item + + def remove(self, index): + self._items.pop(index) + + def __iter__(self): + return iter(self._items) + + def __len__(self): + return len(self._items) + + +class MockProps: + """Mock text texture properties containing preset collection.""" + + def __init__(self): + self.presets = MockPresetCollection() + self.save_with_blend_file = False + self.new_preset_name = "" + self.active_preset_index = 0 + self.preset_search_query = "" + self.last_loaded_preset = "" + + +class MockScene(dict): + """Dictionary-like scene object supporting custom properties.""" + + def __init__(self, props=None): + super().__init__() + self.text_texture_props = props + + def __bool__(self): + # Scene should be truthy even when no custom properties are set + return True + + +@pytest.fixture +def make_scene(mock_bpy_fixture): + """Factory for assigning a mock scene to bpy.context.""" + + def _make_scene(props=None): + scene = MockScene(props) + mock_bpy_fixture.context.scene = scene + return scene + + return _make_scene + + +def test_get_persistent_preset_path_creates_directory(tmp_path, mock_bpy_fixture): + config_dir = tmp_path / "config" + config_dir.mkdir() + mock_bpy_fixture.utils.user_resource.return_value = str(config_dir) + + path = storage_system.get_persistent_preset_path() + expected = config_dir / "text_texture_generator" / "presets" + + assert Path(path) == expected + assert expected.is_dir() + + +def test_save_preset_unified_persistent_writes_file(tmp_path, monkeypatch): + persistent_dir = tmp_path / "persistent" + persistent_dir.mkdir() + monkeypatch.setattr(storage_system, "get_persistent_preset_path", lambda: str(persistent_dir)) + + preset_data = {"text": "hello", "font_size": 24} + result = storage_system.save_preset_unified(preset_data, "SamplePreset", save_to_blend_file=False) + + saved_path = persistent_dir / "SamplePreset.json" + assert result is True + assert saved_path.exists() + with saved_path.open("r") as handle: + assert json.load(handle) == preset_data + + +def test_save_preset_unified_blend_delegates(monkeypatch): + mock_save = Mock(return_value=True) + monkeypatch.setattr(storage_system, "save_blend_file_preset", mock_save) + + payload = {"text": "blend storage"} + result = storage_system.save_preset_unified(payload, "BlendPreset", save_to_blend_file=True) + + assert result is True + mock_save.assert_called_once_with("BlendPreset", payload) + + +def test_get_blend_file_presets_initializes_empty_collection(make_scene): + scene = make_scene() + + presets = storage_system.get_blend_file_presets() + + assert presets == {} + assert scene["text_texture_presets"] == "{}" + + +def test_save_blend_file_preset_stores_json_in_scene(make_scene): + scene = make_scene() + preset_content = {"text": "stored", "font_size": 18} + + result = storage_system.save_blend_file_preset("StoredPreset", preset_content) + + assert result is True + stored = json.loads(scene["text_texture_presets"]) + assert stored["StoredPreset"] == preset_content + roundtrip = storage_system.get_blend_file_presets() + assert roundtrip["StoredPreset"] == preset_content + + +def test_delete_blend_file_preset_removes_entry(make_scene): + scene = make_scene() + storage_system.save_blend_file_preset("TempPreset", {"value": 1}) + + removed = storage_system.delete_blend_file_preset("TempPreset") + assert removed is True + assert json.loads(scene["text_texture_presets"]) == {} + + removed_again = storage_system.delete_blend_file_preset("TempPreset") + assert removed_again is False + + +def test_get_unified_preset_list_prioritizes_blend_presets(tmp_path, monkeypatch): + persistent_dir = tmp_path / "persistent" + persistent_dir.mkdir() + monkeypatch.setattr(storage_system, "get_persistent_preset_path", lambda: str(persistent_dir)) + + with (persistent_dir / "alpha.json").open("w") as handle: + json.dump({"source": "persistent"}, handle) + with (persistent_dir / "shared.json").open("w") as handle: + json.dump({"source": "persistent-shared"}, handle) + + monkeypatch.setattr( + storage_system, + "get_blend_file_presets", + lambda: { + "shared": {"source": "blend"}, + "blend_only": {"source": "blend-only"}, + }, + ) + + presets = storage_system.get_unified_preset_list() + + assert presets["alpha"][1] == "PERSISTENT_FILE" + assert presets["alpha"][0]["source"] == "persistent" + assert presets["shared"][1] == "BLEND_FILE" + assert presets["shared"][0]["source"] == "blend" + assert presets["blend_only"][1] == "BLEND_FILE" + + +def test_migrate_from_legacy_system_migrates_files_and_creates_backup(tmp_path, monkeypatch): + legacy_dir = tmp_path / "legacy" + persistent_dir = tmp_path / "persistent" + legacy_dir.mkdir() + persistent_dir.mkdir() + + legacy_payload = {"example": True} + with (legacy_dir / "legacy_preset.json").open("w") as handle: + json.dump(legacy_payload, handle) + with (persistent_dir / "existing.json").open("w") as handle: + json.dump({"existing": True}, handle) + + monkeypatch.setattr(storage_system, "get_preset_path", lambda: str(legacy_dir)) + monkeypatch.setattr(storage_system, "get_persistent_preset_path", lambda: str(persistent_dir)) + + result = storage_system.migrate_from_legacy_system() + + migrated_file = persistent_dir / "legacy_preset.json" + backup_file = persistent_dir / "legacy_backup" / "legacy_preset.json" + marker_file = persistent_dir / ".migration_complete" + + assert result is True + assert migrated_file.exists() + assert json.loads(migrated_file.read_text()) == legacy_payload + assert backup_file.exists() + assert marker_file.exists() + + +def test_initialize_presets_populates_props_collection(make_scene, monkeypatch): + props = MockProps() + # Seed with existing entry to ensure clear() is invoked + props.presets.add().name = "stale" + migrate_mock = Mock(return_value=True) + monkeypatch.setattr(storage_system, "migrate_from_legacy_system", migrate_mock) + monkeypatch.setattr( + storage_system, + "get_unified_preset_list", + lambda: { + "BlendPreset": ({"value": "blend"}, "BLEND_FILE"), + "PersistentPreset": ({"value": "persistent"}, "PERSISTENT_FILE"), + }, + ) + + scene = make_scene(props) + storage_system.initialize_presets() + + assert migrate_mock.called + assert props.presets.cleared is True + assert len(props.presets) == 2 + entries = {(item.name, item.source) for item in props.presets} + assert ("BlendPreset", "BLEND_FILE") in entries + assert ("PersistentPreset", "PERSISTENT_FILE") in entries + assert "text_texture_presets" not in scene # ensure scene custom data untouched + + +def test_ensure_presets_available_refreshes_when_empty(make_scene, monkeypatch): + props = MockProps() + make_scene(props) + refresh_mock = Mock(return_value=True) + monkeypatch.setattr(storage_system, "refresh_presets_sync", refresh_mock) + + result = storage_system.ensure_presets_available() + + assert result is True + refresh_mock.assert_called_once() + + +def test_ensure_presets_available_returns_true_when_presets_loaded(make_scene, monkeypatch): + props = MockProps() + props.presets.add().name = "cached" + make_scene(props) + refresh_mock = Mock(return_value=True) + monkeypatch.setattr(storage_system, "refresh_presets_sync", refresh_mock) + + result = storage_system.ensure_presets_available() + + assert result is True + refresh_mock.assert_not_called() + + +def test_refresh_presets_sync_returns_false_without_scene(mock_bpy_fixture): + mock_bpy_fixture.context.scene = None + assert storage_system.refresh_presets_sync() is False