From 2daebc84b5e11f949007a217dc0a2da89b4e455c Mon Sep 17 00:00:00 2001 From: John McCardle Date: Wed, 28 Jan 2026 17:36:02 -0500 Subject: [PATCH] Simplify on_enter/on_exit callbacks to position-only signature BREAKING CHANGE: Hover callbacks now take only (pos) instead of (pos, button, action) - Add PyHoverCallable class for on_enter/on_exit/on_move callbacks (position-only) - Add PyCellHoverCallable class for on_cell_enter/on_cell_exit callbacks - Change UIDrawable member types from PyClickCallable to PyHoverCallable - Update PyScene::do_mouse_hover() to call hover callbacks with only position - Add tryCallPythonMethod overload for position-only subclass method calls - Update UIGrid::fireCellEnter/fireCellExit to use position-only signature - Update all tests for new callback signatures New callback signatures: | Callback | Old | New | |----------------|--------------------------|------------| | on_enter | (pos, button, action) | (pos) | | on_exit | (pos, button, action) | (pos) | | on_move | (pos, button, action) | (pos) | | on_cell_enter | (cell_pos, button, action)| (cell_pos)| | on_cell_exit | (cell_pos, button, action)| (cell_pos)| | on_click | unchanged | unchanged | | on_cell_click | unchanged | unchanged | closes #230 Co-Authored-By: Claude Opus 4.5 --- src/PyCallable.cpp | 120 ++++++++ src/PyCallable.h | 30 ++ src/PyScene.cpp | 90 +++++- src/UIDrawable.cpp | 30 +- src/UIDrawable.h | 6 +- .../primitives/demo_drag_drop_frame.py | 278 ++++++++++++++++++ .../issue_callback_refcount_test.py | 7 +- tests/unit/test_callback_enums.py | 14 +- tests/unit/test_callback_vector.py | 27 +- tests/unit/test_mouse_enter_exit.py | 18 +- tests/unit/test_uidrawable_monkeypatch.py | 28 +- .../test_uidrawable_subclass_callbacks.py | 21 +- 12 files changed, 598 insertions(+), 71 deletions(-) create mode 100644 tests/cookbook/primitives/demo_drag_drop_frame.py 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/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_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]}"