From 16b55082339f3ab7d165a58a49d6bd482d2ce5c0 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Tue, 27 Jan 2026 10:43:10 -0500 Subject: [PATCH 1/3] Fix borrowed reference return in some callbacks --- src/PyDrawable.cpp | 7 +- src/UIDrawable.cpp | 28 ++-- .../issue_callback_refcount_test.py | 157 ++++++++++++++++++ 3 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 tests/regression/issue_callback_refcount_test.py diff --git a/src/PyDrawable.cpp b/src/PyDrawable.cpp index 61a8151..849409f 100644 --- a/src/PyDrawable.cpp +++ b/src/PyDrawable.cpp @@ -10,10 +10,11 @@ static PyObject* PyDrawable_get_click(PyDrawableObject* self, void* closure) Py_RETURN_NONE; PyObject* ptr = self->data->click_callable->borrow(); - if (ptr && ptr != Py_None) + if (ptr && ptr != Py_None) { + Py_INCREF(ptr); // Return new reference, not borrowed return ptr; - else - Py_RETURN_NONE; + } + Py_RETURN_NONE; } // Click property setter diff --git a/src/UIDrawable.cpp b/src/UIDrawable.cpp index a1fe1cd..a3aff1a 100644 --- a/src/UIDrawable.cpp +++ b/src/UIDrawable.cpp @@ -259,10 +259,11 @@ PyObject* UIDrawable::get_click(PyObject* self, void* closure) { PyErr_SetString(PyExc_TypeError, "no idea how you did that; invalid UIDrawable derived instance for _get_click"); return NULL; } - if (ptr && ptr != Py_None) + if (ptr && ptr != Py_None) { + Py_INCREF(ptr); // Return new reference, not borrowed return ptr; - else - return Py_None; + } + Py_RETURN_NONE; } int UIDrawable::set_click(PyObject* self, PyObject* value, void* closure) { @@ -1615,10 +1616,11 @@ PyObject* UIDrawable::get_on_enter(PyObject* self, void* closure) { PyErr_SetString(PyExc_TypeError, "Invalid UIDrawable derived instance for on_enter"); return NULL; } - if (ptr && ptr != Py_None) + if (ptr && ptr != Py_None) { + Py_INCREF(ptr); // Return new reference, not borrowed return ptr; - else - Py_RETURN_NONE; + } + Py_RETURN_NONE; } int UIDrawable::set_on_enter(PyObject* self, PyObject* value, void* closure) { @@ -1698,10 +1700,11 @@ PyObject* UIDrawable::get_on_exit(PyObject* self, void* closure) { PyErr_SetString(PyExc_TypeError, "Invalid UIDrawable derived instance for on_exit"); return NULL; } - if (ptr && ptr != Py_None) + if (ptr && ptr != Py_None) { + Py_INCREF(ptr); // Return new reference, not borrowed return ptr; - else - Py_RETURN_NONE; + } + Py_RETURN_NONE; } int UIDrawable::set_on_exit(PyObject* self, PyObject* value, void* closure) { @@ -1790,10 +1793,11 @@ PyObject* UIDrawable::get_on_move(PyObject* self, void* closure) { PyErr_SetString(PyExc_TypeError, "Invalid UIDrawable derived instance for on_move"); return NULL; } - if (ptr && ptr != Py_None) + if (ptr && ptr != Py_None) { + Py_INCREF(ptr); // Return new reference, not borrowed return ptr; - else - Py_RETURN_NONE; + } + Py_RETURN_NONE; } int UIDrawable::set_on_move(PyObject* self, PyObject* value, void* closure) { diff --git a/tests/regression/issue_callback_refcount_test.py b/tests/regression/issue_callback_refcount_test.py new file mode 100644 index 0000000..da4b563 --- /dev/null +++ b/tests/regression/issue_callback_refcount_test.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Test for callback property reference counting fix. + +This test verifies that accessing callback properties (on_click, on_enter, etc.) +returns correctly reference-counted objects, preventing use-after-free bugs. + +The bug: Callback getters were returning borrowed references instead of new +references, causing objects to be freed prematurely when Python DECREFs them. +""" +import mcrfpy +import sys +import gc + +def test_callback_refcount(): + """Test that callback getters return new references.""" + errors = [] + + # Create a scene + scene = mcrfpy.Scene("test_callback_refcount") + + # Test Frame + frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + + # Set a callback + def my_callback(pos, button, action): + pass + + frame.on_click = my_callback + + # Read the callback back multiple times + # If borrowing incorrectly, this could cause use-after-free + for i in range(10): + cb = frame.on_click + if cb is None: + errors.append(f"on_click returned None on iteration {i}") + break + if not callable(cb): + errors.append(f"on_click returned non-callable on iteration {i}: {type(cb)}") + break + # Explicitly delete to trigger any refcount issues + del cb + gc.collect() + + # Final check - should still return the callback + final_cb = frame.on_click + if final_cb is None: + errors.append("on_click returned None after repeated access") + elif not callable(final_cb): + 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 + + for name in ['on_enter', 'on_exit', 'on_move']: + for i in range(5): + cb = getattr(frame, name) + if cb is None: + errors.append(f"{name} returned None on iteration {i}") + break + del cb + gc.collect() + + return errors + + +def test_grid_cell_callbacks(): + """Test Grid cell callback getters (these were already correct).""" + errors = [] + + grid = mcrfpy.Grid(pos=(0, 0), size=(100, 100), grid_size=(5, 5), + texture=mcrfpy.default_texture, zoom=1.0) + + grid.on_cell_enter = lambda pos: None + grid.on_cell_exit = lambda pos: None + grid.on_cell_click = lambda pos: None + + for name in ['on_cell_enter', 'on_cell_exit', 'on_cell_click']: + for i in range(5): + cb = getattr(grid, name) + if cb is None: + errors.append(f"{name} returned None on iteration {i}") + break + del cb + gc.collect() + + return errors + + +def test_subclass_callback(): + """Test callback access on Python subclasses.""" + errors = [] + + class MyFrame(mcrfpy.Frame): + pass + + obj = MyFrame(pos=(0, 0), size=(100, 100)) + + # Set callback via property + obj.on_click = lambda pos, button, action: print("clicked") + + # Read back multiple times + for i in range(5): + cb = obj.on_click + if cb is None: + errors.append(f"Subclass on_click returned None on iteration {i}") + break + if not callable(cb): + errors.append(f"Subclass on_click returned non-callable: {type(cb)}") + break + del cb + gc.collect() + + return errors + + +def run_tests(): + """Run all callback refcount tests.""" + all_errors = [] + + print("Testing callback property refcount...") + errors = test_callback_refcount() + if errors: + all_errors.extend(errors) + print(f" FAIL: {len(errors)} errors") + else: + print(" PASS: on_click, on_enter, on_exit, on_move") + + print("Testing Grid cell callbacks...") + errors = test_grid_cell_callbacks() + if errors: + all_errors.extend(errors) + print(f" FAIL: {len(errors)} errors") + else: + print(" PASS: on_cell_enter, on_cell_exit, on_cell_click") + + print("Testing subclass callbacks...") + errors = test_subclass_callback() + if errors: + all_errors.extend(errors) + print(f" FAIL: {len(errors)} errors") + else: + print(" PASS: MyFrame(Frame) subclass") + + if all_errors: + print(f"\nFAILED with {len(all_errors)} errors:") + for e in all_errors: + print(f" - {e}") + sys.exit(1) + else: + print("\nAll callback refcount tests PASSED") + sys.exit(0) + + +if __name__ == "__main__": + run_tests() From 86bfebefcb1c069f42dd02b57145fa8756afc4f0 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Tue, 27 Jan 2026 13:21:10 -0500 Subject: [PATCH 2/3] Fix: Derivable drawable types participate in garbage collector cycle detection --- src/UIArc.h | 41 ++++++++- src/UICaption.h | 52 ++++++++++- src/UICircle.h | 41 ++++++++- src/UIFrame.h | 57 +++++++++++- src/UIGrid.h | 66 +++++++++++++- src/UILine.h | 41 ++++++++- src/UISprite.h | 47 +++++++++- .../subclass_callback_segfault_test.py | 87 +++++++++++++++++++ 8 files changed, 420 insertions(+), 12 deletions(-) create mode 100644 tests/regression/subclass_callback_segfault_test.py diff --git a/src/UIArc.h b/src/UIArc.h index 888b8ab..74019cb 100644 --- a/src/UIArc.h +++ b/src/UIArc.h @@ -118,14 +118,21 @@ namespace mcrfpydef { .tp_itemsize = 0, .tp_dealloc = (destructor)[](PyObject* self) { PyUIArcObject* obj = (PyUIArcObject*)self; + PyObject_GC_UnTrack(self); if (obj->weakreflist != NULL) { PyObject_ClearWeakRefs(self); } + if (obj->data) { + obj->data->click_unregister(); + obj->data->on_enter_unregister(); + obj->data->on_exit_unregister(); + obj->data->on_move_unregister(); + } obj->data.reset(); Py_TYPE(self)->tp_free(self); }, .tp_repr = (reprfunc)UIArc::repr, - .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, .tp_doc = PyDoc_STR( "Arc(center=None, radius=0, start_angle=0, end_angle=90, color=None, thickness=1, **kwargs)\n\n" "An arc UI element for drawing curved line segments.\n\n" @@ -162,6 +169,38 @@ namespace mcrfpydef { " horiz_margin (float): Horizontal margin override\n" " vert_margin (float): Vertical margin override\n" ), + .tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int { + PyUIArcObject* obj = (PyUIArcObject*)self; + if (obj->data) { + if (obj->data->click_callable) { + PyObject* cb = obj->data->click_callable->borrow(); + if (cb && cb != Py_None) Py_VISIT(cb); + } + if (obj->data->on_enter_callable) { + PyObject* cb = obj->data->on_enter_callable->borrow(); + if (cb && cb != Py_None) Py_VISIT(cb); + } + if (obj->data->on_exit_callable) { + PyObject* cb = obj->data->on_exit_callable->borrow(); + if (cb && cb != Py_None) Py_VISIT(cb); + } + if (obj->data->on_move_callable) { + PyObject* cb = obj->data->on_move_callable->borrow(); + if (cb && cb != Py_None) Py_VISIT(cb); + } + } + return 0; + }, + .tp_clear = [](PyObject* self) -> int { + PyUIArcObject* obj = (PyUIArcObject*)self; + if (obj->data) { + obj->data->click_unregister(); + obj->data->on_enter_unregister(); + obj->data->on_exit_unregister(); + obj->data->on_move_unregister(); + } + return 0; + }, .tp_methods = UIArc_methods, .tp_getset = UIArc::getsetters, .tp_base = &mcrfpydef::PyDrawableType, diff --git a/src/UICaption.h b/src/UICaption.h index da4fe5c..b66c847 100644 --- a/src/UICaption.h +++ b/src/UICaption.h @@ -59,13 +59,21 @@ namespace mcrfpydef { .tp_dealloc = (destructor)[](PyObject* self) { PyUICaptionObject* obj = (PyUICaptionObject*)self; + // Untrack from GC before destroying + PyObject_GC_UnTrack(self); // Clear weak references if (obj->weakreflist != NULL) { PyObject_ClearWeakRefs(self); } - // TODO - reevaluate with PyFont usage; UICaption does not own the font - // release reference to font object - if (obj->font) Py_DECREF(obj->font); + // Clear Python references to break cycles + if (obj->data) { + obj->data->click_unregister(); + obj->data->on_enter_unregister(); + obj->data->on_exit_unregister(); + obj->data->on_move_unregister(); + } + // Release reference to font object + Py_CLEAR(obj->font); obj->data.reset(); Py_TYPE(self)->tp_free(self); }, @@ -73,7 +81,7 @@ namespace mcrfpydef { //.tp_hash = NULL, //.tp_iter //.tp_iternext - .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, .tp_doc = PyDoc_STR("Caption(pos=None, font=None, text='', **kwargs)\n\n" "A text display UI element with customizable font and styling.\n\n" "Args:\n" @@ -114,6 +122,42 @@ namespace mcrfpydef { " margin (float): General margin for alignment\n" " horiz_margin (float): Horizontal margin override\n" " vert_margin (float): Vertical margin override"), + // tp_traverse visits Python object references for GC cycle detection + .tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int { + PyUICaptionObject* obj = (PyUICaptionObject*)self; + Py_VISIT(obj->font); + if (obj->data) { + if (obj->data->click_callable) { + PyObject* callback = obj->data->click_callable->borrow(); + if (callback && callback != Py_None) Py_VISIT(callback); + } + if (obj->data->on_enter_callable) { + PyObject* callback = obj->data->on_enter_callable->borrow(); + if (callback && callback != Py_None) Py_VISIT(callback); + } + if (obj->data->on_exit_callable) { + PyObject* callback = obj->data->on_exit_callable->borrow(); + if (callback && callback != Py_None) Py_VISIT(callback); + } + if (obj->data->on_move_callable) { + PyObject* callback = obj->data->on_move_callable->borrow(); + if (callback && callback != Py_None) Py_VISIT(callback); + } + } + return 0; + }, + // tp_clear breaks reference cycles by clearing Python references + .tp_clear = [](PyObject* self) -> int { + PyUICaptionObject* obj = (PyUICaptionObject*)self; + Py_CLEAR(obj->font); + if (obj->data) { + obj->data->click_unregister(); + obj->data->on_enter_unregister(); + obj->data->on_exit_unregister(); + obj->data->on_move_unregister(); + } + return 0; + }, .tp_methods = UICaption_methods, //.tp_members = PyUIFrame_members, .tp_getset = UICaption::getsetters, diff --git a/src/UICircle.h b/src/UICircle.h index 5928808..966b6f3 100644 --- a/src/UICircle.h +++ b/src/UICircle.h @@ -107,14 +107,21 @@ namespace mcrfpydef { .tp_itemsize = 0, .tp_dealloc = (destructor)[](PyObject* self) { PyUICircleObject* obj = (PyUICircleObject*)self; + PyObject_GC_UnTrack(self); if (obj->weakreflist != NULL) { PyObject_ClearWeakRefs(self); } + if (obj->data) { + obj->data->click_unregister(); + obj->data->on_enter_unregister(); + obj->data->on_exit_unregister(); + obj->data->on_move_unregister(); + } obj->data.reset(); Py_TYPE(self)->tp_free(self); }, .tp_repr = (reprfunc)UICircle::repr, - .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, .tp_doc = PyDoc_STR( "Circle(radius=0, center=None, fill_color=None, outline_color=None, outline=0, **kwargs)\n\n" "A circle UI element for drawing filled or outlined circles.\n\n" @@ -149,6 +156,38 @@ namespace mcrfpydef { " horiz_margin (float): Horizontal margin override\n" " vert_margin (float): Vertical margin override\n" ), + .tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int { + PyUICircleObject* obj = (PyUICircleObject*)self; + if (obj->data) { + if (obj->data->click_callable) { + PyObject* cb = obj->data->click_callable->borrow(); + if (cb && cb != Py_None) Py_VISIT(cb); + } + if (obj->data->on_enter_callable) { + PyObject* cb = obj->data->on_enter_callable->borrow(); + if (cb && cb != Py_None) Py_VISIT(cb); + } + if (obj->data->on_exit_callable) { + PyObject* cb = obj->data->on_exit_callable->borrow(); + if (cb && cb != Py_None) Py_VISIT(cb); + } + if (obj->data->on_move_callable) { + PyObject* cb = obj->data->on_move_callable->borrow(); + if (cb && cb != Py_None) Py_VISIT(cb); + } + } + return 0; + }, + .tp_clear = [](PyObject* self) -> int { + PyUICircleObject* obj = (PyUICircleObject*)self; + if (obj->data) { + obj->data->click_unregister(); + obj->data->on_enter_unregister(); + obj->data->on_exit_unregister(); + obj->data->on_move_unregister(); + } + return 0; + }, .tp_methods = UICircle_methods, .tp_getset = UICircle::getsetters, .tp_base = &mcrfpydef::PyDrawableType, diff --git a/src/UIFrame.h b/src/UIFrame.h index 75e5d48..1e753b4 100644 --- a/src/UIFrame.h +++ b/src/UIFrame.h @@ -84,10 +84,19 @@ namespace mcrfpydef { .tp_dealloc = (destructor)[](PyObject* self) { PyUIFrameObject* obj = (PyUIFrameObject*)self; + // Untrack from GC before destroying + PyObject_GC_UnTrack(self); // Clear weak references if (obj->weakreflist != NULL) { PyObject_ClearWeakRefs(self); } + // Clear Python references to break cycles + if (obj->data) { + obj->data->click_unregister(); + obj->data->on_enter_unregister(); + obj->data->on_exit_unregister(); + obj->data->on_move_unregister(); + } obj->data.reset(); Py_TYPE(self)->tp_free(self); }, @@ -95,7 +104,7 @@ namespace mcrfpydef { //.tp_hash = NULL, //.tp_iter //.tp_iternext - .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, .tp_doc = PyDoc_STR("Frame(pos=None, size=None, **kwargs)\n\n" "A rectangular frame UI element that can contain other drawable elements.\n\n" "Args:\n" @@ -139,6 +148,49 @@ namespace mcrfpydef { " margin (float): General margin for alignment\n" " horiz_margin (float): Horizontal margin override\n" " vert_margin (float): Vertical margin override"), + // tp_traverse visits Python object references for GC cycle detection + .tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int { + PyUIFrameObject* obj = (PyUIFrameObject*)self; + if (obj->data) { + // Visit callback references + if (obj->data->click_callable) { + PyObject* callback = obj->data->click_callable->borrow(); + if (callback && callback != Py_None) { + Py_VISIT(callback); + } + } + if (obj->data->on_enter_callable) { + PyObject* callback = obj->data->on_enter_callable->borrow(); + if (callback && callback != Py_None) { + Py_VISIT(callback); + } + } + if (obj->data->on_exit_callable) { + PyObject* callback = obj->data->on_exit_callable->borrow(); + if (callback && callback != Py_None) { + Py_VISIT(callback); + } + } + if (obj->data->on_move_callable) { + PyObject* callback = obj->data->on_move_callable->borrow(); + if (callback && callback != Py_None) { + Py_VISIT(callback); + } + } + } + return 0; + }, + // tp_clear breaks reference cycles by clearing Python references + .tp_clear = [](PyObject* self) -> int { + PyUIFrameObject* obj = (PyUIFrameObject*)self; + if (obj->data) { + obj->data->click_unregister(); + obj->data->on_enter_unregister(); + obj->data->on_exit_unregister(); + obj->data->on_move_unregister(); + } + return 0; + }, .tp_methods = UIFrame_methods, //.tp_members = PyUIFrame_members, .tp_getset = UIFrame::getsetters, @@ -150,6 +202,9 @@ namespace mcrfpydef { if (self) { self->data = std::make_shared(); self->weakreflist = nullptr; + // Note: For GC types, tracking happens automatically via tp_alloc + // when Py_TPFLAGS_HAVE_GC is set. Do NOT call PyObject_GC_Track here + // as it would double-track and cause corruption. } return (PyObject*)self; } diff --git a/src/UIGrid.h b/src/UIGrid.h index e2ab942..18b367d 100644 --- a/src/UIGrid.h +++ b/src/UIGrid.h @@ -235,16 +235,29 @@ namespace mcrfpydef { .tp_dealloc = (destructor)[](PyObject* self) { PyUIGridObject* obj = (PyUIGridObject*)self; + // Untrack from GC before destroying + PyObject_GC_UnTrack(self); // Clear weak references if (obj->weakreflist != NULL) { PyObject_ClearWeakRefs(self); } + // Clear Python references to break cycles + if (obj->data) { + obj->data->click_unregister(); + obj->data->on_enter_unregister(); + obj->data->on_exit_unregister(); + obj->data->on_move_unregister(); + // Grid-specific cell callbacks + obj->data->on_cell_enter_callable.reset(); + obj->data->on_cell_exit_callable.reset(); + obj->data->on_cell_click_callable.reset(); + } obj->data.reset(); Py_TYPE(self)->tp_free(self); }, .tp_repr = (reprfunc)UIGrid::repr, .tp_as_mapping = &UIGrid::mpmethods, // Enable grid[x, y] subscript access - .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, .tp_doc = PyDoc_STR("Grid(pos=None, size=None, grid_size=None, texture=None, **kwargs)\n\n" "A grid-based UI element for tile-based rendering and entity management.\n\n" "Args:\n" @@ -296,6 +309,57 @@ namespace mcrfpydef { " margin (float): General margin for alignment\n" " horiz_margin (float): Horizontal margin override\n" " vert_margin (float): Vertical margin override"), + // tp_traverse visits Python object references for GC cycle detection + .tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int { + PyUIGridObject* obj = (PyUIGridObject*)self; + if (obj->data) { + // Base class callbacks + if (obj->data->click_callable) { + PyObject* callback = obj->data->click_callable->borrow(); + if (callback && callback != Py_None) Py_VISIT(callback); + } + if (obj->data->on_enter_callable) { + PyObject* callback = obj->data->on_enter_callable->borrow(); + if (callback && callback != Py_None) Py_VISIT(callback); + } + if (obj->data->on_exit_callable) { + PyObject* callback = obj->data->on_exit_callable->borrow(); + if (callback && callback != Py_None) Py_VISIT(callback); + } + if (obj->data->on_move_callable) { + PyObject* callback = obj->data->on_move_callable->borrow(); + if (callback && callback != Py_None) Py_VISIT(callback); + } + // Grid-specific cell callbacks + if (obj->data->on_cell_enter_callable) { + PyObject* callback = obj->data->on_cell_enter_callable->borrow(); + if (callback && callback != Py_None) Py_VISIT(callback); + } + if (obj->data->on_cell_exit_callable) { + PyObject* callback = obj->data->on_cell_exit_callable->borrow(); + if (callback && callback != Py_None) Py_VISIT(callback); + } + if (obj->data->on_cell_click_callable) { + PyObject* callback = obj->data->on_cell_click_callable->borrow(); + if (callback && callback != Py_None) Py_VISIT(callback); + } + } + return 0; + }, + // tp_clear breaks reference cycles by clearing Python references + .tp_clear = [](PyObject* self) -> int { + PyUIGridObject* obj = (PyUIGridObject*)self; + if (obj->data) { + obj->data->click_unregister(); + obj->data->on_enter_unregister(); + obj->data->on_exit_unregister(); + obj->data->on_move_unregister(); + obj->data->on_cell_enter_callable.reset(); + obj->data->on_cell_exit_callable.reset(); + obj->data->on_cell_click_callable.reset(); + } + return 0; + }, .tp_methods = UIGrid_all_methods, //.tp_members = UIGrid::members, .tp_getset = UIGrid::getsetters, diff --git a/src/UILine.h b/src/UILine.h index 2912f93..af85b6c 100644 --- a/src/UILine.h +++ b/src/UILine.h @@ -104,14 +104,21 @@ namespace mcrfpydef { .tp_itemsize = 0, .tp_dealloc = (destructor)[](PyObject* self) { PyUILineObject* obj = (PyUILineObject*)self; + PyObject_GC_UnTrack(self); if (obj->weakreflist != NULL) { PyObject_ClearWeakRefs(self); } + if (obj->data) { + obj->data->click_unregister(); + obj->data->on_enter_unregister(); + obj->data->on_exit_unregister(); + obj->data->on_move_unregister(); + } obj->data.reset(); Py_TYPE(self)->tp_free(self); }, .tp_repr = (reprfunc)UILine::repr, - .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, .tp_doc = PyDoc_STR( "Line(start=None, end=None, thickness=1.0, color=None, **kwargs)\n\n" "A line UI element for drawing straight lines between two points.\n\n" @@ -144,6 +151,38 @@ namespace mcrfpydef { " horiz_margin (float): Horizontal margin override\n" " vert_margin (float): Vertical margin override\n" ), + .tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int { + PyUILineObject* obj = (PyUILineObject*)self; + if (obj->data) { + if (obj->data->click_callable) { + PyObject* cb = obj->data->click_callable->borrow(); + if (cb && cb != Py_None) Py_VISIT(cb); + } + if (obj->data->on_enter_callable) { + PyObject* cb = obj->data->on_enter_callable->borrow(); + if (cb && cb != Py_None) Py_VISIT(cb); + } + if (obj->data->on_exit_callable) { + PyObject* cb = obj->data->on_exit_callable->borrow(); + if (cb && cb != Py_None) Py_VISIT(cb); + } + if (obj->data->on_move_callable) { + PyObject* cb = obj->data->on_move_callable->borrow(); + if (cb && cb != Py_None) Py_VISIT(cb); + } + } + return 0; + }, + .tp_clear = [](PyObject* self) -> int { + PyUILineObject* obj = (PyUILineObject*)self; + if (obj->data) { + obj->data->click_unregister(); + obj->data->on_enter_unregister(); + obj->data->on_exit_unregister(); + obj->data->on_move_unregister(); + } + return 0; + }, .tp_methods = UILine_methods, .tp_getset = UILine::getsetters, .tp_base = &mcrfpydef::PyDrawableType, diff --git a/src/UISprite.h b/src/UISprite.h index d3ddb12..f409a23 100644 --- a/src/UISprite.h +++ b/src/UISprite.h @@ -91,12 +91,19 @@ namespace mcrfpydef { .tp_dealloc = (destructor)[](PyObject* self) { PyUISpriteObject* obj = (PyUISpriteObject*)self; + // Untrack from GC before destroying + PyObject_GC_UnTrack(self); // Clear weak references if (obj->weakreflist != NULL) { PyObject_ClearWeakRefs(self); } - // release reference to font object - //if (obj->texture) Py_DECREF(obj->texture); + // Clear Python references to break cycles + if (obj->data) { + obj->data->click_unregister(); + obj->data->on_enter_unregister(); + obj->data->on_exit_unregister(); + obj->data->on_move_unregister(); + } obj->data.reset(); Py_TYPE(self)->tp_free(self); }, @@ -104,7 +111,7 @@ namespace mcrfpydef { //.tp_hash = NULL, //.tp_iter //.tp_iternext - .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, .tp_doc = PyDoc_STR("Sprite(pos=None, texture=None, sprite_index=0, **kwargs)\n\n" "A sprite UI element that displays a texture or portion of a texture atlas.\n\n" "Args:\n" @@ -143,6 +150,40 @@ namespace mcrfpydef { " margin (float): General margin for alignment\n" " horiz_margin (float): Horizontal margin override\n" " vert_margin (float): Vertical margin override"), + // tp_traverse visits Python object references for GC cycle detection + .tp_traverse = [](PyObject* self, visitproc visit, void* arg) -> int { + PyUISpriteObject* obj = (PyUISpriteObject*)self; + if (obj->data) { + if (obj->data->click_callable) { + PyObject* callback = obj->data->click_callable->borrow(); + if (callback && callback != Py_None) Py_VISIT(callback); + } + if (obj->data->on_enter_callable) { + PyObject* callback = obj->data->on_enter_callable->borrow(); + if (callback && callback != Py_None) Py_VISIT(callback); + } + if (obj->data->on_exit_callable) { + PyObject* callback = obj->data->on_exit_callable->borrow(); + if (callback && callback != Py_None) Py_VISIT(callback); + } + if (obj->data->on_move_callable) { + PyObject* callback = obj->data->on_move_callable->borrow(); + if (callback && callback != Py_None) Py_VISIT(callback); + } + } + return 0; + }, + // tp_clear breaks reference cycles by clearing Python references + .tp_clear = [](PyObject* self) -> int { + PyUISpriteObject* obj = (PyUISpriteObject*)self; + if (obj->data) { + obj->data->click_unregister(); + obj->data->on_enter_unregister(); + obj->data->on_exit_unregister(); + obj->data->on_move_unregister(); + } + return 0; + }, .tp_methods = UISprite_methods, //.tp_members = PyUIFrame_members, .tp_getset = UISprite::getsetters, diff --git a/tests/regression/subclass_callback_segfault_test.py b/tests/regression/subclass_callback_segfault_test.py new file mode 100644 index 0000000..19bed76 --- /dev/null +++ b/tests/regression/subclass_callback_segfault_test.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Minimal reproduction of segfault when calling subclass method callback. + +The issue: When a Frame subclass assigns self.on_click = self._on_click, +reading it back works but there's a segfault during cleanup. +""" +import mcrfpy +import sys +import gc + +class MyFrame(mcrfpy.Frame): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.on_click = self._on_click + + def _on_click(self, pos, button, action): + print(f"Clicked at {pos}, button={button}, action={action}") + + +def test_minimal(): + """Minimal test case.""" + print("Creating MyFrame...") + obj = MyFrame(pos=(100, 100), size=(100, 100)) + + print(f"Reading on_click: {obj.on_click}") + print(f"Type: {type(obj.on_click)}") + + print("Attempting to call on_click...") + try: + obj.on_click((50, 50), "left", "start") + print("Call succeeded!") + except Exception as e: + print(f"Exception: {type(e).__name__}: {e}") + + print("Clearing callback...") + obj.on_click = None + + print("Deleting object...") + del obj + + print("Running GC...") + gc.collect() + + print("About to exit...") + sys.exit(0) + + +def test_without_callback_clear(): + """Test without clearing callback first.""" + print("Creating MyFrame...") + obj = MyFrame(pos=(100, 100), size=(100, 100)) + + print("Calling...") + obj.on_click((50, 50), "left", "start") + + print("Deleting without clearing callback...") + del obj + gc.collect() + + print("About to exit...") + sys.exit(0) + + +def test_added_to_scene(): + """Test when added to scene.""" + print("Creating scene and MyFrame...") + scene = mcrfpy.Scene("test") + obj = MyFrame(pos=(100, 100), size=(100, 100)) + scene.children.append(obj) + + print("Calling via scene.children[0]...") + scene.children[0].on_click((50, 50), "left", "start") + + print("About to exit...") + sys.exit(0) + + +if __name__ == "__main__": + # Try different scenarios + import sys + if len(sys.argv) > 1: + if sys.argv[1] == "2": + test_without_callback_clear() + elif sys.argv[1] == "3": + test_added_to_scene() + else: + test_minimal() From c7cf3f0e5bdfad04e7340f3c47fbfb58601c2ba9 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Tue, 27 Jan 2026 20:42:50 -0500 Subject: [PATCH 3/3] standardize mouse callback signature on derived classes --- src/PyScene.cpp | 67 +++++++++++++++- tests/unit/test_uidrawable_monkeypatch.py | 80 ++++++++++--------- .../test_uidrawable_subclass_callbacks.py | 59 ++++++++------ 3 files changed, 140 insertions(+), 66 deletions(-) diff --git a/src/PyScene.cpp b/src/PyScene.cpp index c4cef36..7c1bbc3 100644 --- a/src/PyScene.cpp +++ b/src/PyScene.cpp @@ -6,6 +6,9 @@ #include "UIGrid.h" #include "McRFPy_Automation.h" // #111 - For simulated mouse position #include "PythonObjectCache.h" // #184 - For subclass callback support +#include "McRFPy_API.h" // For Vector type access +#include "PyMouseButton.h" // For MouseButton enum +#include "PyInputState.h" // For InputState enum #include #include @@ -15,6 +18,7 @@ // Try to call a Python method on a UIDrawable subclass // Returns true if a method was found and called, false otherwise +// Signature matches property callbacks: (Vector, MouseButton, InputState) static bool tryCallPythonMethod(UIDrawable* drawable, const char* method_name, sf::Vector2f mousepos, const char* button, const char* action) { if (!drawable->is_python_subclass) return false; @@ -45,14 +49,69 @@ static bool tryCallPythonMethod(UIDrawable* drawable, const char* method_name, return false; } - // Get and call the method + // Get the method PyObject* method = PyObject_GetAttrString(pyObj, method_name); bool called = false; if (method && PyCallable_Check(method) && method != Py_None) { - // Call with (x, y, button, action) signature - PyObject* result = PyObject_CallFunction(method, "ffss", - mousepos.x, mousepos.y, button, action); + // Create Vector object for position (matches property callback signature) + 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; + } + + // Convert button string to MouseButton enum + int button_val = 0; + if (strcmp(button, "left") == 0) button_val = 0; + else if (strcmp(button, "right") == 0) button_val = 1; + else if (strcmp(button, "middle") == 0) button_val = 2; + else if (strcmp(button, "x1") == 0) button_val = 3; + else if (strcmp(button, "x2") == 0) button_val = 4; + // For hover events, button might be "enter", "exit", "move" - use LEFT as default + + PyObject* button_enum = nullptr; + if (PyMouseButton::mouse_button_enum_class) { + button_enum = PyObject_CallFunction(PyMouseButton::mouse_button_enum_class, "i", button_val); + } + if (!button_enum) { + PyErr_Clear(); + button_enum = PyLong_FromLong(button_val); // Fallback to int + } + + // Convert action string to InputState enum + int action_val = (strcmp(action, "start") == 0) ? 0 : 1; // PRESSED=0, RELEASED=1 + + PyObject* action_enum = nullptr; + if (PyInputState::input_state_enum_class) { + action_enum = PyObject_CallFunction(PyInputState::input_state_enum_class, "i", action_val); + } + if (!action_enum) { + PyErr_Clear(); + action_enum = PyLong_FromLong(action_val); // Fallback to int + } + + // Call with (Vector, MouseButton, InputState) signature + PyObject* args = Py_BuildValue("(OOO)", pos, button_enum, action_enum); + Py_DECREF(pos); + Py_DECREF(button_enum); + Py_DECREF(action_enum); + + PyObject* result = PyObject_Call(method, args, NULL); + Py_DECREF(args); + if (result) { Py_DECREF(result); called = true; diff --git a/tests/unit/test_uidrawable_monkeypatch.py b/tests/unit/test_uidrawable_monkeypatch.py index f9eae08..6bdb540 100644 --- a/tests/unit/test_uidrawable_monkeypatch.py +++ b/tests/unit/test_uidrawable_monkeypatch.py @@ -5,6 +5,9 @@ Test monkey-patching support for UIDrawable subclass callbacks (#184) This tests that users can dynamically add callback methods at runtime (monkey-patching) and have them work correctly with the callback cache invalidation system. + +Callback signature: (pos: Vector, button: MouseButton, action: InputState) +This matches property callbacks for consistency. """ import mcrfpy import sys @@ -21,6 +24,12 @@ def test_failed(name, error): print(f" FAIL: {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.""" + return (mcrfpy.Vector(x, y), mcrfpy.MouseButton.LEFT, mcrfpy.InputState.PRESSED) + + # ============================================================================== # Test Classes # ============================================================================== @@ -38,8 +47,8 @@ class PartialFrame(mcrfpy.Frame): super().__init__(*args, **kwargs) self.call_log = [] - def on_click(self, x, y, button, action): - self.call_log.append(('click', x, y)) + def on_click(self, pos, button, action): + self.call_log.append(('click', pos.x, pos.y)) # ============================================================================== @@ -59,8 +68,8 @@ try: "EmptyFrame should not have on_click in its own __dict__ initially" # Add on_click method to class dynamically - def dynamic_on_click(self, x, y, button, action): - self.call_log.append(('dynamic_click', x, y)) + def dynamic_on_click(self, pos, button, action): + self.call_log.append(('dynamic_click', pos.x, pos.y)) EmptyFrame.on_click = dynamic_on_click @@ -68,13 +77,13 @@ try: assert 'on_click' in EmptyFrame.__dict__, "EmptyFrame should now have on_click in __dict__" # Test calling the method directly - frame1.on_click(10.0, 20.0, "left", "start") + frame1.on_click(*make_click_args(10.0, 20.0)) assert ('dynamic_click', 10.0, 20.0) in frame1.call_log, \ f"Dynamic method should have been called, log: {frame1.call_log}" # Create new instance - should also have the method frame2 = EmptyFrame(pos=(0, 0), size=(100, 100)) - frame2.on_click(30.0, 40.0, "left", "start") + frame2.on_click(*make_click_args(30.0, 40.0)) assert ('dynamic_click', 30.0, 40.0) in frame2.call_log, \ f"New instance should have dynamic method, log: {frame2.call_log}" @@ -87,20 +96,20 @@ try: frame = PartialFrame(pos=(0, 0), size=(100, 100)) # Call original method - frame.on_click(1.0, 2.0, "left", "start") + frame.on_click(*make_click_args(1.0, 2.0)) assert ('click', 1.0, 2.0) in frame.call_log, \ f"Original method should work, log: {frame.call_log}" frame.call_log.clear() # Replace the method - def new_on_click(self, x, y, button, action): - self.call_log.append(('replaced_click', x, y)) + def new_on_click(self, pos, button, action): + self.call_log.append(('replaced_click', pos.x, pos.y)) PartialFrame.on_click = new_on_click # Call again - should use new method - frame.on_click(3.0, 4.0, "left", "start") + frame.on_click(*make_click_args(3.0, 4.0)) assert ('replaced_click', 3.0, 4.0) in frame.call_log, \ f"Replaced method should work, log: {frame.call_log}" @@ -119,20 +128,17 @@ try: frame_a = FreshFrame(pos=(0, 0), size=(100, 100)) frame_b = FreshFrame(pos=(0, 0), size=(100, 100)) - # Add method to instance only - def instance_on_click(x, y, button, action): - frame_a.instance_log.append(('instance_click', x, y)) + # Add method to instance only (property callback style - no self) + def instance_on_click(pos, button, action): + frame_a.instance_log.append(('instance_click', pos.x, pos.y)) frame_a.on_click = instance_on_click # frame_a should have the method assert hasattr(frame_a, 'on_click'), "frame_a should have on_click" - # frame_b should NOT have the method (unless inherited from class) - # Actually, both will have on_click now since it's an instance attribute - # Verify instance method works - frame_a.on_click(5.0, 6.0, "left", "start") + frame_a.on_click(*make_click_args(5.0, 6.0)) assert ('instance_click', 5.0, 6.0) in frame_a.instance_log, \ f"Instance method should work, log: {frame_a.instance_log}" @@ -150,7 +156,7 @@ try: initial_gen = getattr(TrackedFrame, '_mcrf_callback_gen', 0) # Add a callback method - def tracked_on_enter(self, x, y, button, action): + def tracked_on_enter(self, pos, button, action): pass TrackedFrame.on_enter = tracked_on_enter @@ -174,30 +180,30 @@ try: frame = MultiCallbackFrame(pos=(0, 0), size=(100, 100)) # Add on_click - def multi_on_click(self, x, y, button, action): + def multi_on_click(self, pos, button, action): self.events.append('click') MultiCallbackFrame.on_click = multi_on_click # Add on_enter - def multi_on_enter(self, x, y, button, action): + def multi_on_enter(self, pos, button, action): self.events.append('enter') MultiCallbackFrame.on_enter = multi_on_enter # Add on_exit - def multi_on_exit(self, x, y, button, action): + def multi_on_exit(self, pos, button, action): self.events.append('exit') MultiCallbackFrame.on_exit = multi_on_exit # Add on_move - def multi_on_move(self, x, y, button, action): + def multi_on_move(self, pos, button, action): self.events.append('move') MultiCallbackFrame.on_move = multi_on_move # Call all methods - frame.on_click(0, 0, "left", "start") - frame.on_enter(0, 0, "enter", "start") - frame.on_exit(0, 0, "exit", "start") - frame.on_move(0, 0, "move", "start") + frame.on_click(*make_click_args()) + frame.on_enter(*make_click_args()) + frame.on_exit(*make_click_args()) + frame.on_move(*make_click_args()) assert frame.events == ['click', 'enter', 'exit', 'move'], \ f"All callbacks should fire, got: {frame.events}" @@ -213,7 +219,7 @@ try: super().__init__(*args, **kwargs) self.clicked = False - def on_click(self, x, y, button, action): + def on_click(self, pos, button, action): self.clicked = True frame = DeletableFrame(pos=(0, 0), size=(100, 100)) @@ -222,7 +228,7 @@ try: assert 'on_click' in DeletableFrame.__dict__, "Should have on_click in __dict__ initially" # Call it - frame.on_click(0, 0, "left", "start") + frame.on_click(*make_click_args()) assert frame.clicked, "Method should work" # Delete the method from subclass @@ -246,13 +252,13 @@ try: self.method_called = False self.property_called = False - def on_click(self, x, y, button, action): + def on_click(self, pos, button, action): self.method_called = True frame = MixedFrame(pos=(0, 0), size=(100, 100)) - # Set property callback - def prop_callback(x, y, button, action): + # Set property callback (no self parameter) + def prop_callback(pos, button, action): frame.property_called = True frame.click = prop_callback @@ -264,11 +270,11 @@ try: assert hasattr(frame, 'on_click'), "on_click method should exist" # Can call method directly - frame.on_click(0, 0, "left", "start") + frame.on_click(*make_click_args()) assert frame.method_called, "Method should be callable directly" # Can call property callback - frame.click(0, 0, "left", "start") + frame.click(*make_click_args()) assert frame.property_called, "Property callback should be callable" test_passed("Property callback and method coexist") @@ -282,16 +288,16 @@ try: super().__init__(*args, **kwargs) self.clicks = [] - def on_click(self, x, y, button, action): + def on_click(self, pos, button, action): self.clicks.append('base') class DerivedClickable(BaseClickable): - def on_click(self, x, y, button, action): - super().on_click(x, y, button, action) + def on_click(self, pos, button, action): + super().on_click(pos, button, action) self.clicks.append('derived') frame = DerivedClickable(pos=(0, 0), size=(100, 100)) - frame.on_click(0, 0, "left", "start") + frame.on_click(*make_click_args()) assert frame.clicks == ['base', 'derived'], \ f"Inheritance chain should work, got: {frame.clicks}" diff --git a/tests/unit/test_uidrawable_subclass_callbacks.py b/tests/unit/test_uidrawable_subclass_callbacks.py index 05e0b15..c4b29e4 100644 --- a/tests/unit/test_uidrawable_subclass_callbacks.py +++ b/tests/unit/test_uidrawable_subclass_callbacks.py @@ -5,6 +5,9 @@ Test UIDrawable subclass callback methods (#184) 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. """ import mcrfpy import sys @@ -31,9 +34,9 @@ class ClickableFrame(mcrfpy.Frame): self.click_count = 0 self.last_click_args = None - def on_click(self, x, y, button, action): + def on_click(self, pos, button, action): self.click_count += 1 - self.last_click_args = (x, y, button, action) + self.last_click_args = (pos, button, action) # ============================================================================== @@ -45,14 +48,14 @@ class HoverFrame(mcrfpy.Frame): super().__init__(*args, **kwargs) self.events = [] - def on_enter(self, x, y, button, action): - self.events.append(('enter', x, y)) + def on_enter(self, pos, button, action): + self.events.append(('enter', pos.x, pos.y)) - def on_exit(self, x, y, button, action): - self.events.append(('exit', x, y)) + def on_exit(self, pos, button, action): + self.events.append(('exit', pos.x, pos.y)) - def on_move(self, x, y, button, action): - self.events.append(('move', x, y)) + def on_move(self, pos, button, action): + self.events.append(('move', pos.x, pos.y)) # ============================================================================== @@ -64,7 +67,7 @@ class ClickableCaption(mcrfpy.Caption): super().__init__(*args, **kwargs) self.clicked = False - def on_click(self, x, y, button, action): + def on_click(self, pos, button, action): self.clicked = True @@ -77,7 +80,7 @@ class ClickableSprite(mcrfpy.Sprite): super().__init__(*args, **kwargs) self.clicked = False - def on_click(self, x, y, button, action): + def on_click(self, pos, button, action): self.clicked = True @@ -90,7 +93,7 @@ class ClickableGrid(mcrfpy.Grid): super().__init__(*args, **kwargs) self.clicked = False - def on_click(self, x, y, button, action): + def on_click(self, pos, button, action): self.clicked = True @@ -103,7 +106,7 @@ class ClickableCircle(mcrfpy.Circle): super().__init__(*args, **kwargs) self.clicked = False - def on_click(self, x, y, button, action): + def on_click(self, pos, button, action): self.clicked = True @@ -116,7 +119,7 @@ class ClickableLine(mcrfpy.Line): super().__init__(*args, **kwargs) self.clicked = False - def on_click(self, x, y, button, action): + def on_click(self, pos, button, action): self.clicked = True @@ -129,7 +132,7 @@ class ClickableArc(mcrfpy.Arc): super().__init__(*args, **kwargs) self.clicked = False - def on_click(self, x, y, button, action): + def on_click(self, pos, button, action): self.clicked = True @@ -143,7 +146,7 @@ class FrameWithBoth(mcrfpy.Frame): self.method_called = False self.property_called = False - def on_click(self, x, y, button, action): + def on_click(self, pos, button, action): self.method_called = True @@ -240,26 +243,32 @@ try: except Exception as e: test_failed("Base types are NOT marked as subclasses", e) -# Test 10: Verify subclass methods are callable +# Test 10: Verify subclass methods are callable with typed arguments try: frame = ClickableFrame(pos=(100, 100), size=(100, 100)) # Verify method exists and is callable assert hasattr(frame, 'on_click'), "ClickableFrame should have on_click method" assert callable(frame.on_click), "on_click should be callable" - # Manually call to verify it works - frame.on_click(50.0, 50.0, "left", "start") + # Manually call with proper typed objects to verify it works + pos = mcrfpy.Vector(50.0, 50.0) + button = mcrfpy.MouseButton.LEFT + action = mcrfpy.InputState.PRESSED + frame.on_click(pos, button, action) assert frame.click_count == 1, f"click_count should be 1, got {frame.click_count}" - assert frame.last_click_args == (50.0, 50.0, "left", "start"), f"last_click_args mismatch: {frame.last_click_args}" + assert frame.last_click_args[0].x == 50.0, f"pos.x mismatch: {frame.last_click_args[0].x}" + assert frame.last_click_args[0].y == 50.0, f"pos.y mismatch: {frame.last_click_args[0].y}" + assert frame.last_click_args[1] == mcrfpy.MouseButton.LEFT, f"button mismatch: {frame.last_click_args[1]}" + assert frame.last_click_args[2] == mcrfpy.InputState.PRESSED, f"action mismatch: {frame.last_click_args[2]}" test_passed("Subclass methods are callable and work") except Exception as e: test_failed("Subclass methods are callable and work", e) -# Test 11: Verify HoverFrame methods work +# Test 11: Verify HoverFrame methods work with typed arguments try: hover = HoverFrame(pos=(250, 100), size=(100, 100)) - hover.on_enter(10.0, 20.0, "enter", "start") - hover.on_exit(30.0, 40.0, "exit", "start") - hover.on_move(50.0, 60.0, "move", "start") + 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) 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]}" @@ -272,7 +281,7 @@ except Exception as e: try: both = FrameWithBoth(pos=(400, 250), size=(100, 100)) property_was_called = [False] - def property_callback(x, y, btn, action): + def property_callback(pos, btn, action): property_was_called[0] = True both.click = property_callback # Assign to property # Property callback should be set @@ -288,7 +297,7 @@ try: frame = ClickableFrame(pos=(100, 100), size=(100, 100)) frame.custom_attr = "test_value" assert frame.custom_attr == "test_value", "Custom attribute should persist" - frame.on_click(0, 0, "left", "start") + frame.on_click(mcrfpy.Vector(0, 0), mcrfpy.MouseButton.LEFT, mcrfpy.InputState.PRESSED) assert frame.click_count == 1, "Click count should be 1" # Verify frame is still usable after attribute access assert frame.x == 100, f"Frame x should be 100, got {frame.x}"