standardize mouse callback signature on derived classes

This commit is contained in:
John McCardle 2026-01-27 20:42:50 -05:00
commit c7cf3f0e5b
3 changed files with 140 additions and 66 deletions

View file

@ -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 <algorithm>
#include <functional>
@ -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;

View file

@ -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}"

View file

@ -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}"