From deb5d81ab69f00d6e18fb46a12ec0b926668d753 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Wed, 26 Nov 2025 05:24:55 -0500 Subject: [PATCH] feat: Add .find() method to UICollection and EntityCollection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements name-based search for UI elements and entities: - Exact match returns single element or None - Wildcard patterns (prefix*, *suffix, *contains*) return list - Recursive search for nested Frame children (UICollection only) API: ui.find("player_frame") # exact match ui.find("enemy*") # starts with ui.find("*_button", recursive=True) # recursive search grid.entities.find("*goblin*") # entity search Closes #41, closes #40 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/UICollection.cpp | 156 ++++++++++++++++++++- src/UICollection.h | 1 + src/UIGrid.cpp | 125 ++++++++++++++++- src/UIGrid.h | 1 + tests/unit/collection_find_test.py | 209 +++++++++++++++++++++++++++++ 5 files changed, 482 insertions(+), 10 deletions(-) create mode 100644 tests/unit/collection_find_test.py diff --git a/src/UICollection.cpp b/src/UICollection.cpp index 7e91e41..36b2e64 100644 --- a/src/UICollection.cpp +++ b/src/UICollection.cpp @@ -905,12 +905,158 @@ PyObject* UICollection::count(PyUICollectionObject* self, PyObject* value) { return PyLong_FromSsize_t(count); } +// Helper function to match names with optional wildcard support +static bool matchName(const std::string& name, const std::string& pattern) { + // Check for wildcard pattern + if (pattern.find('*') != std::string::npos) { + // Simple wildcard matching: only support * at start, end, or both + if (pattern == "*") { + return true; // Match everything + } else if (pattern.front() == '*' && pattern.back() == '*' && pattern.length() > 2) { + // *substring* - contains match + std::string substring = pattern.substr(1, pattern.length() - 2); + return name.find(substring) != std::string::npos; + } else if (pattern.front() == '*') { + // *suffix - ends with + std::string suffix = pattern.substr(1); + return name.length() >= suffix.length() && + name.compare(name.length() - suffix.length(), suffix.length(), suffix) == 0; + } else if (pattern.back() == '*') { + // prefix* - starts with + std::string prefix = pattern.substr(0, pattern.length() - 1); + return name.compare(0, prefix.length(), prefix) == 0; + } + // For more complex patterns, fall back to exact match + return name == pattern; + } + // Exact match + return name == pattern; +} + +PyObject* UICollection::find(PyUICollectionObject* self, PyObject* args, PyObject* kwds) { + const char* name = nullptr; + int recursive = 0; + + static const char* kwlist[] = {"name", "recursive", NULL}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "s|p", const_cast(kwlist), + &name, &recursive)) { + return NULL; + } + + auto vec = self->data.get(); + if (!vec) { + PyErr_SetString(PyExc_RuntimeError, "Collection data is null"); + return NULL; + } + + std::string pattern(name); + bool has_wildcard = (pattern.find('*') != std::string::npos); + + if (has_wildcard) { + // Return list of all matches + PyObject* results = PyList_New(0); + if (!results) return NULL; + + for (auto& drawable : *vec) { + if (matchName(drawable->name, pattern)) { + PyObject* py_drawable = convertDrawableToPython(drawable); + if (!py_drawable) { + Py_DECREF(results); + return NULL; + } + if (PyList_Append(results, py_drawable) < 0) { + Py_DECREF(py_drawable); + Py_DECREF(results); + return NULL; + } + Py_DECREF(py_drawable); // PyList_Append increfs + } + + // Recursive search into Frame children + if (recursive && drawable->derived_type() == PyObjectsEnum::UIFRAME) { + auto frame = std::static_pointer_cast(drawable); + // Create temporary collection object for recursive call + PyTypeObject* collType = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "UICollection"); + if (collType) { + PyUICollectionObject* child_coll = (PyUICollectionObject*)collType->tp_alloc(collType, 0); + if (child_coll) { + child_coll->data = frame->children; + PyObject* child_results = find(child_coll, args, kwds); + if (child_results && PyList_Check(child_results)) { + // Extend results with child results + for (Py_ssize_t i = 0; i < PyList_Size(child_results); i++) { + PyObject* item = PyList_GetItem(child_results, i); + Py_INCREF(item); + PyList_Append(results, item); + Py_DECREF(item); + } + Py_DECREF(child_results); + } + Py_DECREF(child_coll); + } + Py_DECREF(collType); + } + } + } + + return results; + } else { + // Return first exact match or None + for (auto& drawable : *vec) { + if (drawable->name == pattern) { + return convertDrawableToPython(drawable); + } + + // Recursive search into Frame children + if (recursive && drawable->derived_type() == PyObjectsEnum::UIFRAME) { + auto frame = std::static_pointer_cast(drawable); + PyTypeObject* collType = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "UICollection"); + if (collType) { + PyUICollectionObject* child_coll = (PyUICollectionObject*)collType->tp_alloc(collType, 0); + if (child_coll) { + child_coll->data = frame->children; + PyObject* result = find(child_coll, args, kwds); + Py_DECREF(child_coll); + Py_DECREF(collType); + if (result && result != Py_None) { + return result; + } + Py_XDECREF(result); + } else { + Py_DECREF(collType); + } + } + } + } + + Py_RETURN_NONE; + } +} + PyMethodDef UICollection::methods[] = { - {"append", (PyCFunction)UICollection::append, METH_O}, - {"extend", (PyCFunction)UICollection::extend, METH_O}, - {"remove", (PyCFunction)UICollection::remove, METH_O}, - {"index", (PyCFunction)UICollection::index_method, METH_O}, - {"count", (PyCFunction)UICollection::count, METH_O}, + {"append", (PyCFunction)UICollection::append, METH_O, + "Add an element to the end of the collection"}, + {"extend", (PyCFunction)UICollection::extend, METH_O, + "Add all elements from an iterable to the collection"}, + {"remove", (PyCFunction)UICollection::remove, METH_O, + "Remove element at the given index"}, + {"index", (PyCFunction)UICollection::index_method, METH_O, + "Return the index of an element in the collection"}, + {"count", (PyCFunction)UICollection::count, METH_O, + "Count occurrences of an element in the collection"}, + {"find", (PyCFunction)UICollection::find, METH_VARARGS | METH_KEYWORDS, + "find(name, recursive=False) -> element or list\n\n" + "Find elements by name.\n\n" + "Args:\n" + " name (str): Name to search for. Supports wildcards:\n" + " - 'exact' for exact match (returns single element or None)\n" + " - 'prefix*' for starts-with match (returns list)\n" + " - '*suffix' for ends-with match (returns list)\n" + " - '*substring*' for contains match (returns list)\n" + " recursive (bool): If True, search in Frame children recursively.\n\n" + "Returns:\n" + " Single element if exact match, list if wildcard, None if not found."}, {NULL, NULL, 0, NULL} }; diff --git a/src/UICollection.h b/src/UICollection.h index bb8d254..b70fcf2 100644 --- a/src/UICollection.h +++ b/src/UICollection.h @@ -32,6 +32,7 @@ public: static PyObject* remove(PyUICollectionObject* self, PyObject* o); static PyObject* index_method(PyUICollectionObject* self, PyObject* value); static PyObject* count(PyUICollectionObject* self, PyObject* value); + static PyObject* find(PyUICollectionObject* self, PyObject* args, PyObject* kwds); static PyMethodDef methods[]; static PyObject* repr(PyUICollectionObject* self); static int init(PyUICollectionObject* self, PyObject* args, PyObject* kwds); diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index 751adcc..56c3197 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -2216,12 +2216,127 @@ PyMappingMethods UIEntityCollection::mpmethods = { .mp_ass_subscript = (objobjargproc)UIEntityCollection::ass_subscript }; +// Helper function for entity name matching with wildcards +static bool matchEntityName(const std::string& name, const std::string& pattern) { + if (pattern.find('*') != std::string::npos) { + if (pattern == "*") { + return true; + } else if (pattern.front() == '*' && pattern.back() == '*' && pattern.length() > 2) { + std::string substring = pattern.substr(1, pattern.length() - 2); + return name.find(substring) != std::string::npos; + } else if (pattern.front() == '*') { + std::string suffix = pattern.substr(1); + return name.length() >= suffix.length() && + name.compare(name.length() - suffix.length(), suffix.length(), suffix) == 0; + } else if (pattern.back() == '*') { + std::string prefix = pattern.substr(0, pattern.length() - 1); + return name.compare(0, prefix.length(), prefix) == 0; + } + return name == pattern; + } + return name == pattern; +} + +PyObject* UIEntityCollection::find(PyUIEntityCollectionObject* self, PyObject* args, PyObject* kwds) { + const char* name = nullptr; + + static const char* kwlist[] = {"name", NULL}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast(kwlist), &name)) { + return NULL; + } + + auto list = self->data.get(); + if (!list) { + PyErr_SetString(PyExc_RuntimeError, "Collection data is null"); + return NULL; + } + + std::string pattern(name); + bool has_wildcard = (pattern.find('*') != std::string::npos); + + // Get the Entity type for creating Python objects + PyTypeObject* entityType = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"); + if (!entityType) { + PyErr_SetString(PyExc_RuntimeError, "Could not find Entity type"); + return NULL; + } + + if (has_wildcard) { + // Return list of all matches + PyObject* results = PyList_New(0); + if (!results) { + Py_DECREF(entityType); + return NULL; + } + + for (auto& entity : *list) { + // Entity name is stored in sprite.name + if (matchEntityName(entity->sprite.name, pattern)) { + PyUIEntityObject* py_entity = (PyUIEntityObject*)entityType->tp_alloc(entityType, 0); + if (!py_entity) { + Py_DECREF(results); + Py_DECREF(entityType); + return NULL; + } + py_entity->data = entity; + py_entity->weakreflist = NULL; + + if (PyList_Append(results, (PyObject*)py_entity) < 0) { + Py_DECREF(py_entity); + Py_DECREF(results); + Py_DECREF(entityType); + return NULL; + } + Py_DECREF(py_entity); // PyList_Append increfs + } + } + + Py_DECREF(entityType); + return results; + } else { + // Return first exact match or None + for (auto& entity : *list) { + if (entity->sprite.name == pattern) { + PyUIEntityObject* py_entity = (PyUIEntityObject*)entityType->tp_alloc(entityType, 0); + if (!py_entity) { + Py_DECREF(entityType); + return NULL; + } + py_entity->data = entity; + py_entity->weakreflist = NULL; + Py_DECREF(entityType); + return (PyObject*)py_entity; + } + } + + Py_DECREF(entityType); + Py_RETURN_NONE; + } +} + PyMethodDef UIEntityCollection::methods[] = { - {"append", (PyCFunction)UIEntityCollection::append, METH_O}, - {"extend", (PyCFunction)UIEntityCollection::extend, METH_O}, - {"remove", (PyCFunction)UIEntityCollection::remove, METH_O}, - {"index", (PyCFunction)UIEntityCollection::index_method, METH_O}, - {"count", (PyCFunction)UIEntityCollection::count, METH_O}, + {"append", (PyCFunction)UIEntityCollection::append, METH_O, + "Add an entity to the collection"}, + {"extend", (PyCFunction)UIEntityCollection::extend, METH_O, + "Add all entities from an iterable"}, + {"remove", (PyCFunction)UIEntityCollection::remove, METH_O, + "Remove an entity from the collection"}, + {"index", (PyCFunction)UIEntityCollection::index_method, METH_O, + "Return the index of an entity"}, + {"count", (PyCFunction)UIEntityCollection::count, METH_O, + "Count occurrences of an entity"}, + {"find", (PyCFunction)UIEntityCollection::find, METH_VARARGS | METH_KEYWORDS, + "find(name) -> entity or list\n\n" + "Find entities by name.\n\n" + "Args:\n" + " name (str): Name to search for. Supports wildcards:\n" + " - 'exact' for exact match (returns single entity or None)\n" + " - 'prefix*' for starts-with match (returns list)\n" + " - '*suffix' for ends-with match (returns list)\n" + " - '*substring*' for contains match (returns list)\n\n" + "Returns:\n" + " Single entity if exact match, list if wildcard, None if not found."}, {NULL, NULL, 0, NULL} }; diff --git a/src/UIGrid.h b/src/UIGrid.h index bbf6b4e..f2c9633 100644 --- a/src/UIGrid.h +++ b/src/UIGrid.h @@ -143,6 +143,7 @@ public: static PyObject* remove(PyUIEntityCollectionObject* self, PyObject* o); static PyObject* index_method(PyUIEntityCollectionObject* self, PyObject* value); static PyObject* count(PyUIEntityCollectionObject* self, PyObject* value); + static PyObject* find(PyUIEntityCollectionObject* self, PyObject* args, PyObject* kwds); static PyMethodDef methods[]; static PyObject* repr(PyUIEntityCollectionObject* self); static int init(PyUIEntityCollectionObject* self, PyObject* args, PyObject* kwds); diff --git a/tests/unit/collection_find_test.py b/tests/unit/collection_find_test.py new file mode 100644 index 0000000..86a1733 --- /dev/null +++ b/tests/unit/collection_find_test.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Test for UICollection.find() and EntityCollection.find() methods. + +Tests issue #40 (search and replace by name) and #41 (.find on collections). +""" + +import mcrfpy +import sys + +def test_uicollection_find(): + """Test UICollection.find() with exact and wildcard matches.""" + print("Testing UICollection.find()...") + + # Create a scene with named elements + mcrfpy.createScene("test_find") + ui = mcrfpy.sceneUI("test_find") + + # Create frames with names + frame1 = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + frame1.name = "main_frame" + ui.append(frame1) + + frame2 = mcrfpy.Frame(pos=(100, 0), size=(100, 100)) + frame2.name = "sidebar_frame" + ui.append(frame2) + + frame3 = mcrfpy.Frame(pos=(200, 0), size=(100, 100)) + frame3.name = "player_status" + ui.append(frame3) + + caption1 = mcrfpy.Caption(text="Hello", pos=(0, 200)) + caption1.name = "player_name" + ui.append(caption1) + + # Create an unnamed element + unnamed = mcrfpy.Caption(text="Unnamed", pos=(0, 250)) + ui.append(unnamed) + + # Test exact match - found + result = ui.find("main_frame") + assert result is not None, "Exact match should find element" + assert result.name == "main_frame", f"Found wrong element: {result.name}" + print(" [PASS] Exact match found") + + # Test exact match - not found + result = ui.find("nonexistent") + assert result is None, "Should return None when not found" + print(" [PASS] Not found returns None") + + # Test prefix wildcard (starts with) + results = ui.find("player*") + assert isinstance(results, list), "Wildcard should return list" + assert len(results) == 2, f"Expected 2 matches, got {len(results)}" + names = [r.name for r in results] + assert "player_status" in names, "player_status should match player*" + assert "player_name" in names, "player_name should match player*" + print(" [PASS] Prefix wildcard works") + + # Test suffix wildcard (ends with) + results = ui.find("*_frame") + assert isinstance(results, list), "Wildcard should return list" + assert len(results) == 2, f"Expected 2 matches, got {len(results)}" + names = [r.name for r in results] + assert "main_frame" in names + assert "sidebar_frame" in names + print(" [PASS] Suffix wildcard works") + + # Test contains wildcard + results = ui.find("*bar*") + assert isinstance(results, list), "Wildcard should return list" + assert len(results) == 1, f"Expected 1 match, got {len(results)}" + assert results[0].name == "sidebar_frame" + print(" [PASS] Contains wildcard works") + + # Test match all + results = ui.find("*") + # Should match all named elements (4 named + 1 unnamed with empty name) + assert isinstance(results, list), "Match all should return list" + assert len(results) == 5, f"Expected 5 matches, got {len(results)}" + print(" [PASS] Match all wildcard works") + + # Test empty pattern matches elements with empty names (unnamed elements) + result = ui.find("") + # The unnamed caption has an empty name, so exact match should find it + assert result is not None, "Empty name exact match should find the unnamed element" + print(" [PASS] Empty pattern finds unnamed elements") + + print("UICollection.find() tests passed!") + return True + + +def test_entitycollection_find(): + """Test EntityCollection.find() with exact and wildcard matches.""" + print("\nTesting EntityCollection.find()...") + + # Create a grid with entities + mcrfpy.createScene("test_entity_find") + ui = mcrfpy.sceneUI("test_entity_find") + + grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(400, 400)) + ui.append(grid) + + # Add named entities + player = mcrfpy.Entity(grid_pos=(1, 1)) + player.name = "player" + grid.entities.append(player) + + enemy1 = mcrfpy.Entity(grid_pos=(2, 2)) + enemy1.name = "enemy_goblin" + grid.entities.append(enemy1) + + enemy2 = mcrfpy.Entity(grid_pos=(3, 3)) + enemy2.name = "enemy_orc" + grid.entities.append(enemy2) + + item = mcrfpy.Entity(grid_pos=(4, 4)) + item.name = "item_sword" + grid.entities.append(item) + + # Test exact match + result = grid.entities.find("player") + assert result is not None, "Should find player" + assert result.name == "player" + print(" [PASS] Entity exact match works") + + # Test not found + result = grid.entities.find("boss") + assert result is None, "Should return None when not found" + print(" [PASS] Entity not found returns None") + + # Test prefix wildcard + results = grid.entities.find("enemy*") + assert isinstance(results, list) + assert len(results) == 2, f"Expected 2 enemies, got {len(results)}" + print(" [PASS] Entity prefix wildcard works") + + # Test suffix wildcard + results = grid.entities.find("*_orc") + assert isinstance(results, list) + assert len(results) == 1 + assert results[0].name == "enemy_orc" + print(" [PASS] Entity suffix wildcard works") + + print("EntityCollection.find() tests passed!") + return True + + +def test_recursive_find(): + """Test recursive find in nested Frame children.""" + print("\nTesting recursive find in nested frames...") + + mcrfpy.createScene("test_recursive") + ui = mcrfpy.sceneUI("test_recursive") + + # Create nested structure + parent = mcrfpy.Frame(pos=(0, 0), size=(400, 400)) + parent.name = "parent" + ui.append(parent) + + child = mcrfpy.Frame(pos=(10, 10), size=(200, 200)) + child.name = "child_frame" + parent.children.append(child) + + grandchild = mcrfpy.Caption(text="Deep", pos=(5, 5)) + grandchild.name = "deep_caption" + child.children.append(grandchild) + + # Non-recursive find should not find nested elements + result = ui.find("deep_caption") + assert result is None, "Non-recursive find should not find nested element" + print(" [PASS] Non-recursive doesn't find nested elements") + + # Recursive find should find nested elements + result = ui.find("deep_caption", recursive=True) + assert result is not None, "Recursive find should find nested element" + assert result.name == "deep_caption" + print(" [PASS] Recursive find locates nested elements") + + # Recursive wildcard should find all matches + results = ui.find("*_frame", recursive=True) + assert isinstance(results, list) + names = [r.name for r in results] + assert "child_frame" in names, "Should find child_frame" + print(" [PASS] Recursive wildcard finds nested matches") + + print("Recursive find tests passed!") + return True + + +if __name__ == "__main__": + try: + all_passed = True + all_passed &= test_uicollection_find() + all_passed &= test_entitycollection_find() + all_passed &= test_recursive_find() + + if all_passed: + print("\n" + "="*50) + print("All find() tests PASSED!") + print("="*50) + sys.exit(0) + else: + print("\nSome tests FAILED!") + sys.exit(1) + except Exception as e: + print(f"\nTest failed with exception: {e}") + import traceback + traceback.print_exc() + sys.exit(1)