diff --git a/src/Animation.cpp b/src/Animation.cpp index 9b4c7ba..22ba433 100644 --- a/src/Animation.cpp +++ b/src/Animation.cpp @@ -5,6 +5,14 @@ #include "McRFPy_API.h" #include "GameEngine.h" #include "PythonObjectCache.h" +// #229 - Includes for animation callback target conversion +#include "UIFrame.h" +#include "UICaption.h" +#include "UISprite.h" +#include "UIGrid.h" +#include "UILine.h" +#include "UICircle.h" +#include "UIArc.h" #include #include #include @@ -395,26 +403,239 @@ void Animation::applyValue(UIEntity* entity, const AnimationValue& value) { }, value); } +// #229 - Helper to convert UIDrawable target to Python object +static PyObject* convertDrawableToPython(std::shared_ptr drawable) { + if (!drawable) { + Py_RETURN_NONE; + } + + // Check cache first + if (drawable->serial_number != 0) { + PyObject* cached = PythonObjectCache::getInstance().lookup(drawable->serial_number); + if (cached) { + return cached; // Already INCREF'd by lookup + } + } + + PyTypeObject* type = nullptr; + PyObject* obj = nullptr; + + switch (drawable->derived_type()) { + case PyObjectsEnum::UIFRAME: + { + type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame"); + if (!type) return nullptr; + auto pyObj = (PyUIFrameObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + case PyObjectsEnum::UICAPTION: + { + type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption"); + if (!type) return nullptr; + auto pyObj = (PyUICaptionObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->font = nullptr; + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + case PyObjectsEnum::UISPRITE: + { + type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite"); + if (!type) return nullptr; + auto pyObj = (PyUISpriteObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + case PyObjectsEnum::UIGRID: + { + type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid"); + if (!type) return nullptr; + auto pyObj = (PyUIGridObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + case PyObjectsEnum::UILINE: + { + type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Line"); + if (!type) return nullptr; + auto pyObj = (PyUILineObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + case PyObjectsEnum::UICIRCLE: + { + type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Circle"); + if (!type) return nullptr; + auto pyObj = (PyUICircleObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + case PyObjectsEnum::UIARC: + { + type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Arc"); + if (!type) return nullptr; + auto pyObj = (PyUIArcObject*)type->tp_alloc(type, 0); + if (pyObj) { + pyObj->data = std::static_pointer_cast(drawable); + pyObj->weakreflist = NULL; + } + obj = (PyObject*)pyObj; + break; + } + default: + Py_RETURN_NONE; + } + + if (type) { + Py_DECREF(type); + } + + return obj ? obj : Py_None; +} + +// #229 - Helper to convert UIEntity target to Python object +static PyObject* convertEntityToPython(std::shared_ptr entity) { + if (!entity) { + Py_RETURN_NONE; + } + + // Check cache first + if (entity->serial_number != 0) { + PyObject* cached = PythonObjectCache::getInstance().lookup(entity->serial_number); + if (cached) { + return cached; // Already INCREF'd by lookup + } + } + + PyTypeObject* type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"); + if (!type) { + Py_RETURN_NONE; + } + + auto pyObj = (PyUIEntityObject*)type->tp_alloc(type, 0); + Py_DECREF(type); + + if (!pyObj) { + Py_RETURN_NONE; + } + + pyObj->data = entity; + pyObj->weakreflist = NULL; + + return (PyObject*)pyObj; +} + +// #229 - Helper to convert AnimationValue to Python object +static PyObject* animationValueToPython(const AnimationValue& value) { + return std::visit([](const auto& val) -> PyObject* { + using T = std::decay_t; + + if constexpr (std::is_same_v) { + return PyFloat_FromDouble(val); + } + else if constexpr (std::is_same_v) { + return PyLong_FromLong(val); + } + else if constexpr (std::is_same_v>) { + // Sprite frame list - return current frame as int + // (the interpolate function returns the current frame) + if (!val.empty()) { + return PyLong_FromLong(val.back()); + } + return PyLong_FromLong(0); + } + else if constexpr (std::is_same_v) { + return Py_BuildValue("(iiii)", val.r, val.g, val.b, val.a); + } + else if constexpr (std::is_same_v) { + return Py_BuildValue("(ff)", val.x, val.y); + } + else if constexpr (std::is_same_v) { + return PyUnicode_FromString(val.c_str()); + } + + Py_RETURN_NONE; + }, value); +} + void Animation::triggerCallback() { if (!pythonCallback) return; - + // Ensure we only trigger once if (callbackTriggered) return; callbackTriggered = true; - + PyGILState_STATE gstate = PyGILState_Ensure(); - - // TODO: In future, create PyAnimation wrapper for this animation - // For now, pass None for both parameters - PyObject* args = PyTuple_New(2); - Py_INCREF(Py_None); - Py_INCREF(Py_None); - PyTuple_SetItem(args, 0, Py_None); // animation parameter - PyTuple_SetItem(args, 1, Py_None); // target parameter - + + // #229 - Pass (target, property, final_value) instead of (None, None) + // Convert target to Python object + PyObject* targetObj = nullptr; + if (auto drawable = targetWeak.lock()) { + targetObj = convertDrawableToPython(drawable); + } else if (auto entity = entityTargetWeak.lock()) { + targetObj = convertEntityToPython(entity); + } + + // If target conversion failed, use None + if (!targetObj) { + targetObj = Py_None; + Py_INCREF(targetObj); + } + + // Property name + PyObject* propertyObj = PyUnicode_FromString(targetProperty.c_str()); + if (!propertyObj) { + Py_DECREF(targetObj); + PyGILState_Release(gstate); + return; + } + + // Final value (interpolated at t=1.0) + PyObject* valueObj = animationValueToPython(interpolate(1.0f)); + if (!valueObj) { + Py_DECREF(targetObj); + Py_DECREF(propertyObj); + PyGILState_Release(gstate); + return; + } + + PyObject* args = Py_BuildValue("(OOO)", targetObj, propertyObj, valueObj); + Py_DECREF(targetObj); + Py_DECREF(propertyObj); + Py_DECREF(valueObj); + + if (!args) { + PyGILState_Release(gstate); + return; + } + PyObject* result = PyObject_CallObject(pythonCallback, args); Py_DECREF(args); - + if (!result) { std::cerr << "Animation callback raised an exception:" << std::endl; PyErr_Print(); @@ -427,7 +648,7 @@ void Animation::triggerCallback() { } else { Py_DECREF(result); } - + PyGILState_Release(gstate); } diff --git a/src/PyCallable.cpp b/src/PyCallable.cpp index b6927c0..2ea603e 100644 --- a/src/PyCallable.cpp +++ b/src/PyCallable.cpp @@ -186,3 +186,123 @@ void PyKeyCallable::call(std::string key, std::string action) std::cout << "KeyCallable returned a non-None value. It's not an error, it's just not being saved or used." << std::endl; } } + +// #230 - PyHoverCallable implementation (position-only for on_enter/on_exit/on_move) +PyHoverCallable::PyHoverCallable(PyObject* _target) +: PyCallable(_target) +{} + +PyHoverCallable::PyHoverCallable() +: PyCallable(Py_None) +{} + +void PyHoverCallable::call(sf::Vector2f mousepos) +{ + if (target == Py_None || target == NULL) return; + + // Create a Vector object for the position + PyObject* vector_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector"); + if (!vector_type) { + std::cerr << "Failed to get Vector type for hover callback" << std::endl; + PyErr_Print(); + PyErr_Clear(); + return; + } + PyObject* pos = PyObject_CallFunction(vector_type, "ff", mousepos.x, mousepos.y); + Py_DECREF(vector_type); + if (!pos) { + std::cerr << "Failed to create Vector object for hover callback" << std::endl; + PyErr_Print(); + PyErr_Clear(); + return; + } + + // #230 - Hover callbacks take only (pos), not (pos, button, action) + PyObject* args = Py_BuildValue("(O)", pos); + Py_DECREF(pos); + + PyObject* retval = PyCallable::call(args, NULL); + Py_DECREF(args); + if (!retval) + { + std::cerr << "Hover callback raised an exception:" << std::endl; + PyErr_Print(); + PyErr_Clear(); + + // Check if we should exit on exception + if (McRFPy_API::game && McRFPy_API::game->getConfig().exit_on_exception) { + McRFPy_API::signalPythonException(); + } + } else if (retval != Py_None) + { + std::cout << "HoverCallable returned a non-None value. It's not an error, it's just not being saved or used." << std::endl; + Py_DECREF(retval); + } else { + Py_DECREF(retval); + } +} + +PyObject* PyHoverCallable::borrow() +{ + return target; +} + +// #230 - PyCellHoverCallable implementation (cell position-only for on_cell_enter/on_cell_exit) +PyCellHoverCallable::PyCellHoverCallable(PyObject* _target) +: PyCallable(_target) +{} + +PyCellHoverCallable::PyCellHoverCallable() +: PyCallable(Py_None) +{} + +void PyCellHoverCallable::call(sf::Vector2i cellpos) +{ + if (target == Py_None || target == NULL) return; + + // Create a Vector object for the cell position + PyObject* vector_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector"); + if (!vector_type) { + std::cerr << "Failed to get Vector type for cell hover callback" << std::endl; + PyErr_Print(); + PyErr_Clear(); + return; + } + PyObject* pos = PyObject_CallFunction(vector_type, "ii", cellpos.x, cellpos.y); + Py_DECREF(vector_type); + if (!pos) { + std::cerr << "Failed to create Vector object for cell hover callback" << std::endl; + PyErr_Print(); + PyErr_Clear(); + return; + } + + // #230 - Cell hover callbacks take only (cell_pos), not (cell_pos, button, action) + PyObject* args = Py_BuildValue("(O)", pos); + Py_DECREF(pos); + + PyObject* retval = PyCallable::call(args, NULL); + Py_DECREF(args); + if (!retval) + { + std::cerr << "Cell hover callback raised an exception:" << std::endl; + PyErr_Print(); + PyErr_Clear(); + + // Check if we should exit on exception + if (McRFPy_API::game && McRFPy_API::game->getConfig().exit_on_exception) { + McRFPy_API::signalPythonException(); + } + } else if (retval != Py_None) + { + std::cout << "CellHoverCallable returned a non-None value. It's not an error, it's just not being saved or used." << std::endl; + Py_DECREF(retval); + } else { + Py_DECREF(retval); + } +} + +PyObject* PyCellHoverCallable::borrow() +{ + return target; +} diff --git a/src/PyCallable.h b/src/PyCallable.h index 5fa876c..848d33d 100644 --- a/src/PyCallable.h +++ b/src/PyCallable.h @@ -39,3 +39,33 @@ public: PyKeyCallable(PyObject*); PyKeyCallable(); }; + +// #230 - Hover callbacks (on_enter, on_exit, on_move) take only position +class PyHoverCallable: public PyCallable +{ +public: + void call(sf::Vector2f mousepos); + PyObject* borrow(); + PyHoverCallable(PyObject*); + PyHoverCallable(); + PyHoverCallable(const PyHoverCallable& other) : PyCallable(other) {} + PyHoverCallable& operator=(const PyHoverCallable& other) { + PyCallable::operator=(other); + return *this; + } +}; + +// #230 - Cell hover callbacks (on_cell_enter, on_cell_exit) take only cell position +class PyCellHoverCallable: public PyCallable +{ +public: + void call(sf::Vector2i cellpos); + PyObject* borrow(); + PyCellHoverCallable(PyObject*); + PyCellHoverCallable(); + PyCellHoverCallable(const PyCellHoverCallable& other) : PyCallable(other) {} + PyCellHoverCallable& operator=(const PyCellHoverCallable& other) { + PyCallable::operator=(other); + return *this; + } +}; diff --git a/src/PyScene.cpp b/src/PyScene.cpp index 500bfce..c00aadc 100644 --- a/src/PyScene.cpp +++ b/src/PyScene.cpp @@ -127,6 +127,81 @@ static bool tryCallPythonMethod(UIDrawable* drawable, const char* method_name, return called; } +// #230: Overload for hover events that take only position (no button/action) +static bool tryCallPythonMethod(UIDrawable* drawable, const char* method_name, + sf::Vector2f mousepos) { + if (!drawable->is_python_subclass) return false; + + PyObject* pyObj = PythonObjectCache::getInstance().lookup(drawable->serial_number); + if (!pyObj) return false; + + // Check and refresh cache if needed + PyObject* type = (PyObject*)Py_TYPE(pyObj); + if (!drawable->isCallbackCacheValid(type)) { + drawable->refreshCallbackCache(pyObj); + } + + // Check if this method exists in the cache + bool has_method = false; + if (strcmp(method_name, "on_enter") == 0) { + has_method = drawable->callback_cache.has_on_enter; + } else if (strcmp(method_name, "on_exit") == 0) { + has_method = drawable->callback_cache.has_on_exit; + } else if (strcmp(method_name, "on_move") == 0) { + has_method = drawable->callback_cache.has_on_move; + } + + if (!has_method) { + Py_DECREF(pyObj); + return false; + } + + // Get the method + PyObject* method = PyObject_GetAttrString(pyObj, method_name); + bool called = false; + + if (method && PyCallable_Check(method) && method != Py_None) { + // Create Vector object for position + PyObject* vector_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector"); + if (!vector_type) { + PyErr_Print(); + PyErr_Clear(); + Py_XDECREF(method); + Py_DECREF(pyObj); + return false; + } + PyObject* pos = PyObject_CallFunction(vector_type, "ff", mousepos.x, mousepos.y); + Py_DECREF(vector_type); + if (!pos) { + PyErr_Print(); + PyErr_Clear(); + Py_XDECREF(method); + Py_DECREF(pyObj); + return false; + } + + // #230: Call with just (Vector) signature for hover events + PyObject* args = Py_BuildValue("(O)", pos); + Py_DECREF(pos); + + PyObject* result = PyObject_Call(method, args, NULL); + Py_DECREF(args); + + if (result) { + Py_DECREF(result); + called = true; + } else { + PyErr_Print(); + } + } + + PyErr_Clear(); + Py_XDECREF(method); + Py_DECREF(pyObj); + + return called; +} + // Check if a UIDrawable can potentially handle an event // (has either a callable property OR is a Python subclass that might have a method) static bool canHandleEvent(UIDrawable* drawable, const char* event_type) { @@ -274,30 +349,33 @@ void PyScene::do_mouse_hover(int x, int y) // Mouse entered drawable->hovered = true; // #184: Try property-assigned callable first, then Python subclass method + // #230: Hover callbacks now take only (pos) if (drawable->on_enter_callable && !drawable->on_enter_callable->isNone()) { - drawable->on_enter_callable->call(mousepos, "enter", "start"); + drawable->on_enter_callable->call(mousepos); } else if (drawable->is_python_subclass) { - tryCallPythonMethod(drawable, "on_enter", mousepos, "enter", "start"); + tryCallPythonMethod(drawable, "on_enter", mousepos); } } else if (!is_inside && was_hovered) { // Mouse exited drawable->hovered = false; // #184: Try property-assigned callable first, then Python subclass method + // #230: Hover callbacks now take only (pos) if (drawable->on_exit_callable && !drawable->on_exit_callable->isNone()) { - drawable->on_exit_callable->call(mousepos, "exit", "start"); + drawable->on_exit_callable->call(mousepos); } else if (drawable->is_python_subclass) { - tryCallPythonMethod(drawable, "on_exit", mousepos, "exit", "start"); + tryCallPythonMethod(drawable, "on_exit", mousepos); } } // #141 - Fire on_move if mouse is inside and has a move/on_move callback // #184: Try property-assigned callable first, then Python subclass method + // #230: Hover callbacks now take only (pos) // Check is_python_subclass before function call to avoid overhead on hot path if (is_inside) { if (drawable->on_move_callable && !drawable->on_move_callable->isNone()) { - drawable->on_move_callable->call(mousepos, "move", "start"); + drawable->on_move_callable->call(mousepos); } else if (drawable->is_python_subclass) { - tryCallPythonMethod(drawable, "on_move", mousepos, "move", "start"); + tryCallPythonMethod(drawable, "on_move", mousepos); } } diff --git a/src/UIDrawable.cpp b/src/UIDrawable.cpp index a3aff1a..996e1bf 100644 --- a/src/UIDrawable.cpp +++ b/src/UIDrawable.cpp @@ -60,16 +60,16 @@ UIDrawable::UIDrawable(const UIDrawable& other) if (other.click_callable) { click_callable = std::make_unique(*other.click_callable); } - // #140 - Deep copy enter/exit callables + // #140, #230 - Deep copy enter/exit callables (now PyHoverCallable) if (other.on_enter_callable) { - on_enter_callable = std::make_unique(*other.on_enter_callable); + on_enter_callable = std::make_unique(*other.on_enter_callable); } if (other.on_exit_callable) { - on_exit_callable = std::make_unique(*other.on_exit_callable); + on_exit_callable = std::make_unique(*other.on_exit_callable); } - // #141 - Deep copy move callable + // #141, #230 - Deep copy move callable (now PyHoverCallable) if (other.on_move_callable) { - on_move_callable = std::make_unique(*other.on_move_callable); + on_move_callable = std::make_unique(*other.on_move_callable); } // Deep copy render texture if needed @@ -100,20 +100,20 @@ UIDrawable& UIDrawable::operator=(const UIDrawable& other) { } else { click_callable.reset(); } - // #140 - Deep copy enter/exit callables + // #140, #230 - Deep copy enter/exit callables (now PyHoverCallable) if (other.on_enter_callable) { - on_enter_callable = std::make_unique(*other.on_enter_callable); + on_enter_callable = std::make_unique(*other.on_enter_callable); } else { on_enter_callable.reset(); } if (other.on_exit_callable) { - on_exit_callable = std::make_unique(*other.on_exit_callable); + on_exit_callable = std::make_unique(*other.on_exit_callable); } else { on_exit_callable.reset(); } - // #141 - Deep copy move callable + // #141, #230 - Deep copy move callable (now PyHoverCallable) if (other.on_move_callable) { - on_move_callable = std::make_unique(*other.on_move_callable); + on_move_callable = std::make_unique(*other.on_move_callable); } else { on_move_callable.reset(); } @@ -311,10 +311,10 @@ void UIDrawable::click_register(PyObject* callable) click_callable = std::make_unique(callable); } -// #140 - Mouse enter/exit callback registration +// #140, #230 - Mouse enter/exit callback registration (now PyHoverCallable) void UIDrawable::on_enter_register(PyObject* callable) { - on_enter_callable = std::make_unique(callable); + on_enter_callable = std::make_unique(callable); } void UIDrawable::on_enter_unregister() @@ -324,7 +324,7 @@ void UIDrawable::on_enter_unregister() void UIDrawable::on_exit_register(PyObject* callable) { - on_exit_callable = std::make_unique(callable); + on_exit_callable = std::make_unique(callable); } void UIDrawable::on_exit_unregister() @@ -332,10 +332,10 @@ void UIDrawable::on_exit_unregister() on_exit_callable.reset(); } -// #141 - Mouse move callback registration +// #141, #230 - Mouse move callback registration (now PyHoverCallable) void UIDrawable::on_move_register(PyObject* callable) { - on_move_callable = std::make_unique(callable); + on_move_callable = std::make_unique(callable); } void UIDrawable::on_move_unregister() diff --git a/src/UIDrawable.h b/src/UIDrawable.h index 7f3be1e..7f0e053 100644 --- a/src/UIDrawable.h +++ b/src/UIDrawable.h @@ -45,9 +45,9 @@ public: // Mouse input handling - callable objects for click, enter, exit, move events std::unique_ptr click_callable; - std::unique_ptr on_enter_callable; // #140 - std::unique_ptr on_exit_callable; // #140 - std::unique_ptr on_move_callable; // #141 + std::unique_ptr on_enter_callable; // #140, #230 - position-only + std::unique_ptr on_exit_callable; // #140, #230 - position-only + std::unique_ptr on_move_callable; // #141, #230 - position-only virtual UIDrawable* click_at(sf::Vector2f point) = 0; void click_register(PyObject*); diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index 4612e85..9af5354 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -40,8 +40,9 @@ UIGrid::UIGrid() box.setPosition(position); // Sync box position box.setFillColor(sf::Color(0, 0, 0, 0)); - // Initialize render texture (small default size) + // #228 - Initialize render texture to game resolution (small default until game init) renderTexture.create(1, 1); + renderTextureSize = {1, 1}; // Initialize output sprite output.setTextureRect(sf::IntRect(0, 0, 0, 0)); @@ -76,8 +77,8 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr _ptex, sf::Vector2f _x box.setPosition(position); // Sync box position box.setFillColor(sf::Color(0,0,0,0)); - // create renderTexture with maximum theoretical size; sprite can resize to show whatever amount needs to be rendered - renderTexture.create(1920, 1080); // TODO - renderTexture should be window size; above 1080p this will cause rendering errors + // #228 - create renderTexture sized to game resolution (dynamically resized as needed) + ensureRenderTextureSize(); // Only initialize sprite if texture is available if (ptex) { @@ -145,6 +146,9 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target) // Check visibility if (!visible) return; + // #228 - Ensure renderTexture matches current game resolution + ensureRenderTextureSize(); + // TODO: Apply opacity to output sprite // Get cell dimensions - use texture if available, otherwise defaults @@ -464,6 +468,26 @@ UIGrid::~UIGrid() } } +void UIGrid::ensureRenderTextureSize() +{ + // Get game resolution (or use sensible defaults during early init) + sf::Vector2u resolution{1920, 1080}; + if (Resources::game) { + resolution = Resources::game->getGameResolution(); + } + + // Clamp to reasonable maximum (SFML texture size limits) + unsigned int required_w = std::min(resolution.x, 4096u); + unsigned int required_h = std::min(resolution.y, 4096u); + + // Only recreate if size changed + if (renderTextureSize.x != required_w || renderTextureSize.y != required_h) { + renderTexture.create(required_w, required_h); + renderTextureSize = {required_w, required_h}; + output.setTexture(renderTexture.getTexture()); + } +} + PyObjectsEnum UIGrid::derived_type() { return PyObjectsEnum::UIGRID; @@ -2339,11 +2363,12 @@ PyObject* UIGrid::get_on_cell_enter(PyUIGridObject* self, void* closure) { Py_RETURN_NONE; } +// #230 - Cell hover callbacks now use PyCellHoverCallable (position-only) int UIGrid::set_on_cell_enter(PyUIGridObject* self, PyObject* value, void* closure) { if (value == Py_None) { self->data->on_cell_enter_callable.reset(); } else { - self->data->on_cell_enter_callable = std::make_unique(value); + self->data->on_cell_enter_callable = std::make_unique(value); } return 0; } @@ -2357,11 +2382,12 @@ PyObject* UIGrid::get_on_cell_exit(PyUIGridObject* self, void* closure) { Py_RETURN_NONE; } +// #230 - Cell hover callbacks now use PyCellHoverCallable (position-only) int UIGrid::set_on_cell_exit(PyUIGridObject* self, PyObject* value, void* closure) { if (value == Py_None) { self->data->on_cell_exit_callable.reset(); } else { - self->data->on_cell_exit_callable = std::make_unique(value); + self->data->on_cell_exit_callable = std::make_unique(value); } return 0; } @@ -2553,6 +2579,26 @@ static PyObject* createCellCallbackArgs(sf::Vector2i cell, const std::string& bu return args; } +// #230 - Helper to create cell hover callback arguments: (Vector) only +static PyObject* createCellHoverArgs(sf::Vector2i cell) { + // Create Vector object for cell position + PyObject* vector_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector"); + if (!vector_type) { + PyErr_Print(); + return nullptr; + } + PyObject* cell_pos = PyObject_CallFunction(vector_type, "ii", cell.x, cell.y); + Py_DECREF(vector_type); + if (!cell_pos) { + PyErr_Print(); + return nullptr; + } + + PyObject* args = Py_BuildValue("(O)", cell_pos); + Py_DECREF(cell_pos); + return args; +} + // Fire cell click callback with full signature (cell_pos, button, action) bool UIGrid::fireCellClick(sf::Vector2i cell, const std::string& button, const std::string& action) { // Try property-assigned callback first @@ -2604,23 +2650,12 @@ bool UIGrid::fireCellClick(sf::Vector2i cell, const std::string& button, const s return false; } -// Fire cell enter callback with full signature (cell_pos, button, action) -bool UIGrid::fireCellEnter(sf::Vector2i cell, const std::string& button, const std::string& action) { - // Try property-assigned callback first +// #230 - Fire cell enter callback with position-only signature (cell_pos) +bool UIGrid::fireCellEnter(sf::Vector2i cell) { + // Try property-assigned callback first (now PyCellHoverCallable) if (on_cell_enter_callable && !on_cell_enter_callable->isNone()) { - PyObject* args = createCellCallbackArgs(cell, button, action); - if (args) { - PyObject* result = PyObject_CallObject(on_cell_enter_callable->borrow(), args); - Py_DECREF(args); - if (!result) { - std::cerr << "Cell enter callback raised an exception:" << std::endl; - PyErr_Print(); - PyErr_Clear(); - } else { - Py_DECREF(result); - } - return true; - } + on_cell_enter_callable->call(cell); + return true; } // Try Python subclass method @@ -2631,7 +2666,8 @@ bool UIGrid::fireCellEnter(sf::Vector2i cell, const std::string& button, const s if (cell_callback_cache.has_on_cell_enter) { PyObject* method = PyObject_GetAttrString(pyObj, "on_cell_enter"); if (method && PyCallable_Check(method)) { - PyObject* args = createCellCallbackArgs(cell, button, action); + // #230: Cell hover takes only (cell_pos) + PyObject* args = createCellHoverArgs(cell); if (args) { PyObject* result = PyObject_CallObject(method, args); Py_DECREF(args); @@ -2655,23 +2691,12 @@ bool UIGrid::fireCellEnter(sf::Vector2i cell, const std::string& button, const s return false; } -// Fire cell exit callback with full signature (cell_pos, button, action) -bool UIGrid::fireCellExit(sf::Vector2i cell, const std::string& button, const std::string& action) { - // Try property-assigned callback first +// #230 - Fire cell exit callback with position-only signature (cell_pos) +bool UIGrid::fireCellExit(sf::Vector2i cell) { + // Try property-assigned callback first (now PyCellHoverCallable) if (on_cell_exit_callable && !on_cell_exit_callable->isNone()) { - PyObject* args = createCellCallbackArgs(cell, button, action); - if (args) { - PyObject* result = PyObject_CallObject(on_cell_exit_callable->borrow(), args); - Py_DECREF(args); - if (!result) { - std::cerr << "Cell exit callback raised an exception:" << std::endl; - PyErr_Print(); - PyErr_Clear(); - } else { - Py_DECREF(result); - } - return true; - } + on_cell_exit_callable->call(cell); + return true; } // Try Python subclass method @@ -2682,7 +2707,8 @@ bool UIGrid::fireCellExit(sf::Vector2i cell, const std::string& button, const st if (cell_callback_cache.has_on_cell_exit) { PyObject* method = PyObject_GetAttrString(pyObj, "on_cell_exit"); if (method && PyCallable_Check(method)) { - PyObject* args = createCellCallbackArgs(cell, button, action); + // #230: Cell hover takes only (cell_pos) + PyObject* args = createCellHoverArgs(cell); if (args) { PyObject* result = PyObject_CallObject(method, args); Py_DECREF(args); @@ -2707,19 +2733,23 @@ bool UIGrid::fireCellExit(sf::Vector2i cell, const std::string& button, const st } // #142 - Update cell hover state and fire callbacks +// #230 - Cell hover callbacks now take only (cell_pos), no button/action void UIGrid::updateCellHover(sf::Vector2f mousepos, const std::string& button, const std::string& action) { + (void)button; // #230 - No longer used for hover callbacks + (void)action; // #230 - No longer used for hover callbacks + auto new_cell = screenToCell(mousepos); // Check if cell changed if (new_cell != hovered_cell) { // Fire exit callback for old cell if (hovered_cell.has_value()) { - fireCellExit(hovered_cell.value(), button, action); + fireCellExit(hovered_cell.value()); } // Fire enter callback for new cell if (new_cell.has_value()) { - fireCellEnter(new_cell.value(), button, action); + fireCellEnter(new_cell.value()); } hovered_cell = new_cell; diff --git a/src/UIGrid.h b/src/UIGrid.h index 68efb96..7b8191c 100644 --- a/src/UIGrid.h +++ b/src/UIGrid.h @@ -84,6 +84,10 @@ public: std::shared_ptr getTexture(); sf::Sprite sprite, output; sf::RenderTexture renderTexture; + sf::Vector2u renderTextureSize{0, 0}; // Track current allocation for resize detection + + // Helper to ensure renderTexture matches game resolution + void ensureRenderTextureSize(); // Intermediate texture for camera_rotation (larger than viewport to hold rotated content) sf::RenderTexture rotationTexture; @@ -131,9 +135,10 @@ public: TCOD_fov_algorithm_t fov_algorithm; // Default FOV algorithm (from mcrfpy.default_fov) int fov_radius; // Default FOV radius - // #142 - Grid cell mouse events - std::unique_ptr on_cell_enter_callable; - std::unique_ptr on_cell_exit_callable; + // #142, #230 - Grid cell mouse events + // Cell hover callbacks take only (cell_pos); cell click still takes (cell_pos, button, action) + std::unique_ptr on_cell_enter_callable; + std::unique_ptr on_cell_exit_callable; std::unique_ptr on_cell_click_callable; std::optional hovered_cell; // Currently hovered cell or nullopt std::optional last_clicked_cell; // Cell clicked during click_at @@ -158,11 +163,13 @@ public: // Now takes button/action for consistent callback signatures void updateCellHover(sf::Vector2f mousepos, const std::string& button, const std::string& action); - // Fire cell callbacks with full signature (cell_pos, button, action) + // Fire cell callbacks + // #230: Cell hover callbacks (enter/exit) now take only (cell_pos) + // Cell click still takes (cell_pos, button, action) // Returns true if a callback was fired bool fireCellClick(sf::Vector2i cell, const std::string& button, const std::string& action); - bool fireCellEnter(sf::Vector2i cell, const std::string& button, const std::string& action); - bool fireCellExit(sf::Vector2i cell, const std::string& button, const std::string& action); + bool fireCellEnter(sf::Vector2i cell); + bool fireCellExit(sf::Vector2i cell); // Refresh cell callback cache for subclass method support void refreshCellCallbackCache(PyObject* pyObj); diff --git a/tests/cookbook/primitives/demo_drag_drop_frame.py b/tests/cookbook/primitives/demo_drag_drop_frame.py new file mode 100644 index 0000000..804d6f8 --- /dev/null +++ b/tests/cookbook/primitives/demo_drag_drop_frame.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Drag and Drop (Frame) Demo - Sort colored frames into target bins + +Interactive controls: + Mouse drag: Move frames + ESC: Return to menu + +This demonstrates: + - Frame drag and drop using on_click + on_move (Pythonic method override pattern) + - Hit testing for drop targets + - State tracking and validation +""" +import mcrfpy +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +class DraggableFrame(mcrfpy.Frame): + """A frame that can be dragged around the screen. + + Uses Pythonic method override pattern - just define on_click and on_move + methods directly, no need for self.on_click = self._on_click assignment. + """ + + def __init__(self, pos, size, color, color_type): + """ + Args: + pos: Initial position tuple (x, y) + size: Size tuple (w, h) + color: Fill color tuple (r, g, b) + color_type: 'red' or 'blue' for sorting validation + """ + super().__init__(pos, size, fill_color=color, outline=2, outline_color=(255, 255, 255)) + self.color_type = color_type + self.dragging = False + self.drag_offset = (0, 0) + self.original_pos = pos + # No need for self.on_click = self._on_click - just define on_click method below! + + def on_click(self, pos, button, action): + """Handle click events for drag start/end. + + Args: + pos: mcrfpy.Vector with x, y coordinates + button: mcrfpy.MouseButton enum (LEFT, RIGHT, etc.) + action: mcrfpy.InputState enum (PRESSED, RELEASED) + """ + if button != mcrfpy.MouseButton.LEFT: + return + + if action == mcrfpy.InputState.PRESSED: + # Begin dragging - calculate offset from frame origin + self.dragging = True + self.drag_offset = (pos.x - self.x, pos.y - self.y) + elif action == mcrfpy.InputState.RELEASED: + if self.dragging: + self.dragging = False + # Notify demo of drop + if hasattr(self, 'on_drop_callback'): + self.on_drop_callback(self) + + def on_move(self, pos): + """Handle mouse movement for dragging. + + Args: + pos: mcrfpy.Vector with x, y coordinates + Note: #230 - on_move now only receives position, not button/action + """ + if self.dragging: + self.x = pos.x - self.drag_offset[0] + self.y = pos.y - self.drag_offset[1] + + +class DragDropFrameDemo: + """Demo showing frame drag and drop with sorting bins.""" + + def __init__(self): + self.scene = mcrfpy.Scene("demo_drag_drop_frame") + self.ui = self.scene.children + self.draggables = [] + self.setup() + + def setup(self): + """Build the demo UI.""" + # Background + bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=(30, 30, 35)) + self.ui.append(bg) + + # Title + title = mcrfpy.Caption( + text="Drag & Drop: Sort by Color", + pos=(512, 30), + font_size=28, + fill_color=(255, 255, 255) + ) + title.outline = 2 + title.outline_color = (0, 0, 0) + self.ui.append(title) + + # Score caption + self.score_caption = mcrfpy.Caption( + text="Sorted: 0 / 8", + pos=(512, 70), + font_size=20, + fill_color=(200, 200, 200) + ) + self.ui.append(self.score_caption) + + # Target bins (bottom half) + # Red bin on the left + self.red_bin = mcrfpy.Frame( + pos=(20, 500), + size=(482, 248), + fill_color=(96, 0, 0), + outline=3, + outline_color=(200, 50, 50) + ) + self.ui.append(self.red_bin) + + red_label = mcrfpy.Caption( + text="RED BIN", + pos=(261, 600), + font_size=32, + fill_color=(200, 100, 100) + ) + self.ui.append(red_label) + + # Blue bin on the right + self.blue_bin = mcrfpy.Frame( + pos=(522, 500), + size=(482, 248), + fill_color=(0, 0, 96), + outline=3, + outline_color=(50, 50, 200) + ) + self.ui.append(self.blue_bin) + + blue_label = mcrfpy.Caption( + text="BLUE BIN", + pos=(763, 600), + font_size=32, + fill_color=(100, 100, 200) + ) + self.ui.append(blue_label) + + # Create draggable frames (top half) + # 4 red frames, 4 blue frames, arranged in 2 rows + frame_size = (100, 80) + spacing = 20 + start_x = 100 + start_y = 120 + + positions = [] + for row in range(2): + for col in range(4): + x = start_x + col * (frame_size[0] + spacing + 80) + y = start_y + row * (frame_size[1] + spacing + 40) + positions.append((x, y)) + + # Interleave red and blue + colors = [ + ((255, 64, 64), 'red'), + ((64, 64, 255), 'blue'), + ((255, 64, 64), 'red'), + ((64, 64, 255), 'blue'), + ((64, 64, 255), 'blue'), + ((255, 64, 64), 'red'), + ((64, 64, 255), 'blue'), + ((255, 64, 64), 'red'), + ] + + for i, (pos, (color, color_type)) in enumerate(zip(positions, colors)): + frame = DraggableFrame(pos, frame_size, color, color_type) + frame.on_drop_callback = self._on_frame_drop + self.draggables.append(frame) + self.ui.append(frame) + + # Add label inside frame + label = mcrfpy.Caption( + text=f"{i+1}", + pos=(40, 25), + font_size=24, + fill_color=(255, 255, 255) + ) + frame.children.append(label) + + # Instructions + instr = mcrfpy.Caption( + text="Drag red frames to red bin, blue frames to blue bin | ESC to exit", + pos=(512, 470), + font_size=14, + fill_color=(150, 150, 150) + ) + self.ui.append(instr) + + # Initial score update + self._update_score() + + def _point_in_frame(self, x, y, frame): + """Check if point (x, y) is inside frame.""" + return (frame.x <= x <= frame.x + frame.w and + frame.y <= y <= frame.y + frame.h) + + def _frame_in_bin(self, draggable, bin_frame): + """Check if draggable frame's center is in bin.""" + center_x = draggable.x + draggable.w / 2 + center_y = draggable.y + draggable.h / 2 + return self._point_in_frame(center_x, center_y, bin_frame) + + def _on_frame_drop(self, frame): + """Called when a frame is dropped.""" + self._update_score() + + def _update_score(self): + """Count and display correctly sorted frames.""" + correct = 0 + for frame in self.draggables: + if frame.color_type == 'red' and self._frame_in_bin(frame, self.red_bin): + correct += 1 + frame.outline_color = (0, 255, 0) # Green outline for correct + elif frame.color_type == 'blue' and self._frame_in_bin(frame, self.blue_bin): + correct += 1 + frame.outline_color = (0, 255, 0) + else: + frame.outline_color = (255, 255, 255) # White outline otherwise + + self.score_caption.text = f"Sorted: {correct} / 8" + + if correct == 8: + self.score_caption.text = "All Sorted! Well done!" + self.score_caption.fill_color = (100, 255, 100) + + def on_key(self, key, state): + """Handle keyboard input.""" + if state != "start": + return + if key == "Escape": + # Return to cookbook menu or exit + try: + from cookbook_main import main + main() + except: + sys.exit(0) + + def activate(self): + """Activate the demo scene.""" + self.scene.on_key = self.on_key + mcrfpy.current_scene = self.scene + + +def main(): + """Run the demo.""" + demo = DragDropFrameDemo() + demo.activate() + + # Headless screenshot + try: + if mcrfpy.headless_mode(): + from mcrfpy import automation + # Move some frames to bins for screenshot + demo.draggables[0].x = 100 + demo.draggables[0].y = 550 + demo.draggables[1].x = 600 + demo.draggables[1].y = 550 + demo._update_score() + + mcrfpy.Timer("screenshot", lambda rt: ( + automation.screenshot("screenshots/primitives/drag_drop_frame.png"), + sys.exit(0) + ), 100) + except AttributeError: + pass + + +if __name__ == "__main__": + main() diff --git a/tests/regression/issue_callback_refcount_test.py b/tests/regression/issue_callback_refcount_test.py index da4b563..f2397b4 100644 --- a/tests/regression/issue_callback_refcount_test.py +++ b/tests/regression/issue_callback_refcount_test.py @@ -49,9 +49,10 @@ def test_callback_refcount(): errors.append(f"on_click returned non-callable after repeated access: {type(final_cb)}") # Test on_enter, on_exit, on_move - frame.on_enter = lambda pos, button, action: None - frame.on_exit = lambda pos, button, action: None - frame.on_move = lambda pos, button, action: None + # #230 - Hover callbacks now take only (pos) + frame.on_enter = lambda pos: None + frame.on_exit = lambda pos: None + frame.on_move = lambda pos: None for name in ['on_enter', 'on_exit', 'on_move']: for i in range(5): diff --git a/tests/unit/test_animation_callback_simple.py b/tests/unit/test_animation_callback_simple.py index f75d33d..26fdcf0 100644 --- a/tests/unit/test_animation_callback_simple.py +++ b/tests/unit/test_animation_callback_simple.py @@ -10,11 +10,13 @@ print("=" * 30) # Global state to track callback callback_count = 0 -def my_callback(anim, target): +# #229 - Animation callbacks now receive (target, property, value) instead of (anim, target) +def my_callback(target, prop, value): """Simple callback that prints when animation completes""" global callback_count callback_count += 1 print(f"Animation completed! Callback #{callback_count}") + print(f" Target: {type(target).__name__}, Property: {prop}, Value: {value}") # Create scene callback_demo = mcrfpy.Scene("callback_demo") diff --git a/tests/unit/test_callback_enums.py b/tests/unit/test_callback_enums.py index ebc392b..a9b3657 100644 --- a/tests/unit/test_callback_enums.py +++ b/tests/unit/test_callback_enums.py @@ -61,13 +61,14 @@ try: assert isinstance(cell_pos, mcrfpy.Vector), f"cell_pos should be Vector, got {type(cell_pos)}" self.cell_events.append(('click', cell_pos.x, cell_pos.y, button, action)) - def on_cell_enter(self, cell_pos, button, action): + # #230 - Cell hover callbacks now only receive (cell_pos) + def on_cell_enter(self, cell_pos): assert isinstance(cell_pos, mcrfpy.Vector), f"cell_pos should be Vector, got {type(cell_pos)}" - self.cell_events.append(('enter', cell_pos.x, cell_pos.y, button, action)) + self.cell_events.append(('enter', cell_pos.x, cell_pos.y)) - def on_cell_exit(self, cell_pos, button, action): + def on_cell_exit(self, cell_pos): assert isinstance(cell_pos, mcrfpy.Vector), f"cell_pos should be Vector, got {type(cell_pos)}" - self.cell_events.append(('exit', cell_pos.x, cell_pos.y, button, action)) + self.cell_events.append(('exit', cell_pos.x, cell_pos.y)) texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) grid = GridWithCellCallbacks(grid_size=(5, 5), texture=texture, pos=(0, 0), size=(100, 100)) @@ -78,8 +79,9 @@ try: # Manually call methods to verify signature works grid.on_cell_click(mcrfpy.Vector(1.0, 2.0), mcrfpy.MouseButton.LEFT, mcrfpy.InputState.PRESSED) - grid.on_cell_enter(mcrfpy.Vector(3.0, 4.0), mcrfpy.MouseButton.RIGHT, mcrfpy.InputState.RELEASED) - grid.on_cell_exit(mcrfpy.Vector(5.0, 6.0), mcrfpy.MouseButton.LEFT, mcrfpy.InputState.PRESSED) + # #230 - Cell hover callbacks now only receive (cell_pos) + grid.on_cell_enter(mcrfpy.Vector(3.0, 4.0)) + grid.on_cell_exit(mcrfpy.Vector(5.0, 6.0)) assert len(grid.cell_events) == 3, f"Should have 3 events, got {len(grid.cell_events)}" assert grid.cell_events[0][0] == 'click', "First event should be click" diff --git a/tests/unit/test_callback_vector.py b/tests/unit/test_callback_vector.py index f81aa6b..c918f08 100644 --- a/tests/unit/test_callback_vector.py +++ b/tests/unit/test_callback_vector.py @@ -25,7 +25,8 @@ def test_click_callback_signature(pos, button, action): results.append(("on_click button/action are strings", False)) print(f"FAIL: button={type(button).__name__}, action={type(action).__name__}") -def test_on_enter_callback_signature(pos, button, action): +# #230 - Hover callbacks now receive only (pos), not (pos, button, action) +def test_on_enter_callback_signature(pos): """Test on_enter callback receives Vector.""" if isinstance(pos, mcrfpy.Vector): results.append(("on_enter pos is Vector", True)) @@ -34,7 +35,7 @@ def test_on_enter_callback_signature(pos, button, action): results.append(("on_enter pos is Vector", False)) print(f"FAIL: on_enter receives {type(pos).__name__} instead of Vector") -def test_on_exit_callback_signature(pos, button, action): +def test_on_exit_callback_signature(pos): """Test on_exit callback receives Vector.""" if isinstance(pos, mcrfpy.Vector): results.append(("on_exit pos is Vector", True)) @@ -43,7 +44,7 @@ def test_on_exit_callback_signature(pos, button, action): results.append(("on_exit pos is Vector", False)) print(f"FAIL: on_exit receives {type(pos).__name__} instead of Vector") -def test_on_move_callback_signature(pos, button, action): +def test_on_move_callback_signature(pos): """Test on_move callback receives Vector.""" if isinstance(pos, mcrfpy.Vector): results.append(("on_move pos is Vector", True)) @@ -52,8 +53,9 @@ def test_on_move_callback_signature(pos, button, action): results.append(("on_move pos is Vector", False)) print(f"FAIL: on_move receives {type(pos).__name__} instead of Vector") -def test_cell_click_callback_signature(cell_pos): - """Test on_cell_click callback receives Vector.""" +# #230 - Cell click still receives (cell_pos, button, action) +def test_cell_click_callback_signature(cell_pos, button, action): + """Test on_cell_click callback receives Vector, MouseButton, InputState.""" if isinstance(cell_pos, mcrfpy.Vector): results.append(("on_cell_click pos is Vector", True)) print(f"PASS: on_cell_click receives Vector: {cell_pos}") @@ -61,6 +63,7 @@ def test_cell_click_callback_signature(cell_pos): results.append(("on_cell_click pos is Vector", False)) print(f"FAIL: on_cell_click receives {type(cell_pos).__name__} instead of Vector") +# #230 - Cell hover callbacks now receive only (cell_pos) def test_cell_enter_callback_signature(cell_pos): """Test on_cell_enter callback receives Vector.""" if isinstance(cell_pos, mcrfpy.Vector): @@ -119,11 +122,15 @@ def run_test(runtime): print("\n--- Simulating callback calls ---") # Test that the callbacks are set up correctly + # on_click still takes (pos, button, action) test_click_callback_signature(mcrfpy.Vector(150, 150), "left", "start") - test_on_enter_callback_signature(mcrfpy.Vector(100, 100), "enter", "start") - test_on_exit_callback_signature(mcrfpy.Vector(300, 300), "exit", "start") - test_on_move_callback_signature(mcrfpy.Vector(125, 175), "move", "start") - test_cell_click_callback_signature(mcrfpy.Vector(5, 3)) + # #230 - Hover callbacks now take only (pos) + test_on_enter_callback_signature(mcrfpy.Vector(100, 100)) + test_on_exit_callback_signature(mcrfpy.Vector(300, 300)) + test_on_move_callback_signature(mcrfpy.Vector(125, 175)) + # #230 - on_cell_click still takes (cell_pos, button, action) + test_cell_click_callback_signature(mcrfpy.Vector(5, 3), mcrfpy.MouseButton.LEFT, mcrfpy.InputState.PRESSED) + # #230 - Cell hover callbacks now take only (cell_pos) test_cell_enter_callback_signature(mcrfpy.Vector(2, 7)) test_cell_exit_callback_signature(mcrfpy.Vector(8, 1)) @@ -147,4 +154,4 @@ def run_test(runtime): sys.exit(1) # Run the test -mcrfpy.setTimer("test", run_test, 100) +mcrfpy.Timer("test", run_test, 100) diff --git a/tests/unit/test_mouse_enter_exit.py b/tests/unit/test_mouse_enter_exit.py index 9caae60..84e6598 100644 --- a/tests/unit/test_mouse_enter_exit.py +++ b/tests/unit/test_mouse_enter_exit.py @@ -21,11 +21,11 @@ def test_callback_assignment(): frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200)) ui.append(frame) - # Callbacks receive (x, y, button, action) - 4 arguments - def on_enter_cb(x, y, button, action): + # #230 - Hover callbacks now receive only (pos) - 1 argument + def on_enter_cb(pos): pass - def on_exit_cb(x, y, button, action): + def on_exit_cb(pos): pass # Test assignment @@ -87,7 +87,8 @@ def test_all_types_have_events(): ("Grid", mcrfpy.Grid(grid_size=(5, 5), pos=(0, 0), size=(100, 100))), ] - def dummy_cb(x, y, button, action): + # #230 - Hover callbacks now receive only (pos) + def dummy_cb(pos): pass for name, obj in types_to_test: @@ -129,15 +130,16 @@ def test_enter_exit_simulation(): frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200)) ui.append(frame) - def on_enter(x, y, button, action): + # #230 - Hover callbacks now receive only (pos) + def on_enter(pos): global enter_count, enter_positions enter_count += 1 - enter_positions.append((x, y)) + enter_positions.append((pos.x, pos.y)) - def on_exit(x, y, button, action): + def on_exit(pos): global exit_count, exit_positions exit_count += 1 - exit_positions.append((x, y)) + exit_positions.append((pos.x, pos.y)) frame.on_enter = on_enter frame.on_exit = on_exit diff --git a/tests/unit/test_uidrawable_monkeypatch.py b/tests/unit/test_uidrawable_monkeypatch.py index 6bdb540..b464ad6 100644 --- a/tests/unit/test_uidrawable_monkeypatch.py +++ b/tests/unit/test_uidrawable_monkeypatch.py @@ -26,9 +26,14 @@ def test_failed(name, error): # Helper to create typed callback arguments def make_click_args(x=0.0, y=0.0): - """Create properly typed callback arguments for testing.""" + """Create properly typed callback arguments for testing on_click.""" return (mcrfpy.Vector(x, y), mcrfpy.MouseButton.LEFT, mcrfpy.InputState.PRESSED) +# #230 - Hover callbacks now only receive position +def make_hover_args(x=0.0, y=0.0): + """Create properly typed callback arguments for testing on_enter/on_exit/on_move.""" + return (mcrfpy.Vector(x, y),) + # ============================================================================== # Test Classes @@ -156,7 +161,8 @@ try: initial_gen = getattr(TrackedFrame, '_mcrf_callback_gen', 0) # Add a callback method - def tracked_on_enter(self, pos, button, action): + # #230 - Hover callbacks now only receive (pos) + def tracked_on_enter(self, pos): pass TrackedFrame.on_enter = tracked_on_enter @@ -184,26 +190,26 @@ try: self.events.append('click') MultiCallbackFrame.on_click = multi_on_click - # Add on_enter - def multi_on_enter(self, pos, button, action): + # Add on_enter - #230: now only takes (pos) + def multi_on_enter(self, pos): self.events.append('enter') MultiCallbackFrame.on_enter = multi_on_enter - # Add on_exit - def multi_on_exit(self, pos, button, action): + # Add on_exit - #230: now only takes (pos) + def multi_on_exit(self, pos): self.events.append('exit') MultiCallbackFrame.on_exit = multi_on_exit - # Add on_move - def multi_on_move(self, pos, button, action): + # Add on_move - #230: now only takes (pos) + def multi_on_move(self, pos): self.events.append('move') MultiCallbackFrame.on_move = multi_on_move # Call all methods frame.on_click(*make_click_args()) - frame.on_enter(*make_click_args()) - frame.on_exit(*make_click_args()) - frame.on_move(*make_click_args()) + frame.on_enter(*make_hover_args()) + frame.on_exit(*make_hover_args()) + frame.on_move(*make_hover_args()) assert frame.events == ['click', 'enter', 'exit', 'move'], \ f"All callbacks should fire, got: {frame.events}" diff --git a/tests/unit/test_uidrawable_subclass_callbacks.py b/tests/unit/test_uidrawable_subclass_callbacks.py index c4b29e4..15996ca 100644 --- a/tests/unit/test_uidrawable_subclass_callbacks.py +++ b/tests/unit/test_uidrawable_subclass_callbacks.py @@ -1,13 +1,14 @@ #!/usr/bin/env python3 """ -Test UIDrawable subclass callback methods (#184) +Test UIDrawable subclass callback methods (#184, #230) This tests the ability to define callback methods (on_click, on_enter, on_exit, on_move) directly in Python subclasses of UIDrawable types (Frame, Caption, Sprite, Grid, Line, Circle, Arc). -Callback signature: (pos: Vector, button: MouseButton, action: InputState) -This matches property callbacks for consistency. +Callback signatures: +- on_click: (pos: Vector, button: MouseButton, action: InputState) +- on_enter/on_exit/on_move: (pos: Vector) - #230: simplified to position-only """ import mcrfpy import sys @@ -41,6 +42,7 @@ class ClickableFrame(mcrfpy.Frame): # ============================================================================== # Test 2: Frame subclass with all hover callbacks +# #230: Hover callbacks now take only (pos), not (pos, button, action) # ============================================================================== class HoverFrame(mcrfpy.Frame): """Frame subclass with on_enter, on_exit, on_move""" @@ -48,13 +50,13 @@ class HoverFrame(mcrfpy.Frame): super().__init__(*args, **kwargs) self.events = [] - def on_enter(self, pos, button, action): + def on_enter(self, pos): self.events.append(('enter', pos.x, pos.y)) - def on_exit(self, pos, button, action): + def on_exit(self, pos): self.events.append(('exit', pos.x, pos.y)) - def on_move(self, pos, button, action): + def on_move(self, pos): self.events.append(('move', pos.x, pos.y)) @@ -264,11 +266,12 @@ except Exception as e: test_failed("Subclass methods are callable and work", e) # Test 11: Verify HoverFrame methods work with typed arguments +# #230: Hover callbacks now take only (pos) try: hover = HoverFrame(pos=(250, 100), size=(100, 100)) - hover.on_enter(mcrfpy.Vector(10.0, 20.0), mcrfpy.MouseButton.LEFT, mcrfpy.InputState.PRESSED) - hover.on_exit(mcrfpy.Vector(30.0, 40.0), mcrfpy.MouseButton.LEFT, mcrfpy.InputState.PRESSED) - hover.on_move(mcrfpy.Vector(50.0, 60.0), mcrfpy.MouseButton.LEFT, mcrfpy.InputState.PRESSED) + hover.on_enter(mcrfpy.Vector(10.0, 20.0)) + hover.on_exit(mcrfpy.Vector(30.0, 40.0)) + hover.on_move(mcrfpy.Vector(50.0, 60.0)) assert len(hover.events) == 3, f"Should have 3 events, got {len(hover.events)}" assert hover.events[0] == ('enter', 10.0, 20.0), f"Event mismatch: {hover.events[0]}" assert hover.events[1] == ('exit', 30.0, 40.0), f"Event mismatch: {hover.events[1]}"