Monkey Patch support + Robust callback tracking

McRogueFace needs to accept callable objects (properties on C++ objects)
and also support subclassing (getattr on user objects). Only direct
properties were supported previously, now shadowing a callback by name
will allow custom objects to "just work".
- Added CallbackCache struct and is_python_subclass flag to UIDrawable.h
- Created metaclass for tracking class-level callback changes
- Updated all UI type init functions to detect subclasses
- Modified PyScene.cpp event dispatch to try subclass methods
This commit is contained in:
John McCardle 2026-01-09 21:37:23 -05:00
commit a77ac6c501
14 changed files with 1003 additions and 25 deletions

View file

@ -1,4 +1,5 @@
#include "McRFPy_API.h"
#include "UIDrawable.h"
#include "McRFPy_Automation.h"
#include "McRFPy_Libtcod.h"
#include "McRFPy_Doc.h"
@ -40,6 +41,39 @@ PyObject* McRFPy_API::mcrf_module;
std::atomic<bool> McRFPy_API::exception_occurred{false};
std::atomic<int> McRFPy_API::exit_code{0};
// ============================================================================
// #184: Metaclass for UI types - tracks callback generation for cache invalidation
// ============================================================================
// tp_setattro for the metaclass - intercepts class attribute assignments
static int McRFPyMetaclass_setattro(PyObject* type, PyObject* name, PyObject* value) {
// First, do the normal attribute set on the class
int result = PyType_Type.tp_setattro(type, name, value);
if (result < 0) return result;
// Check if it's a callback attribute (on_click, on_enter, on_exit, on_move)
const char* attr_name = PyUnicode_AsUTF8(name);
if (attr_name && strncmp(attr_name, "on_", 3) == 0) {
// Check if it's one of our callback names
if (strcmp(attr_name, "on_click") == 0 ||
strcmp(attr_name, "on_enter") == 0 ||
strcmp(attr_name, "on_exit") == 0 ||
strcmp(attr_name, "on_move") == 0) {
// Increment the callback generation for this class
UIDrawable::incrementCallbackGeneration(type);
}
}
return 0;
}
// The metaclass type object - initialized in api_init() because
// designated initializers in C++ require declaration order
static PyTypeObject McRFPyMetaclassType = {PyVarObject_HEAD_INIT(&PyType_Type, 0)};
static bool McRFPyMetaclass_initialized = false;
// ============================================================================
// #151: Module-level __getattr__ for dynamic properties (current_scene, scenes)
static PyObject* mcrfpy_module_getattr(PyObject* self, PyObject* args)
{
@ -307,8 +341,34 @@ PyObject* PyInit_mcrfpy()
// Change the module's type to our custom type
Py_SET_TYPE(m, &McRFPyModuleType);
// #184: Set up the UI metaclass for callback generation tracking
if (!McRFPyMetaclass_initialized) {
McRFPyMetaclassType.tp_name = "mcrfpy._UIMetaclass";
McRFPyMetaclassType.tp_basicsize = sizeof(PyHeapTypeObject);
McRFPyMetaclassType.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE;
McRFPyMetaclassType.tp_doc = PyDoc_STR("Metaclass for UI types that tracks callback method changes");
McRFPyMetaclassType.tp_setattro = McRFPyMetaclass_setattro;
McRFPyMetaclassType.tp_base = &PyType_Type;
McRFPyMetaclass_initialized = true;
}
if (PyType_Ready(&McRFPyMetaclassType) < 0) {
std::cout << "ERROR: PyType_Ready failed for McRFPyMetaclassType" << std::endl;
return NULL;
}
using namespace mcrfpydef;
// #184: Set the metaclass for UI types that support callback methods
// This must be done BEFORE PyType_Ready is called on these types
PyTypeObject* ui_types_with_callbacks[] = {
&PyUIFrameType, &PyUICaptionType, &PyUISpriteType, &PyUIGridType,
&PyUILineType, &PyUICircleType, &PyUIArcType,
nullptr
};
for (int i = 0; ui_types_with_callbacks[i] != nullptr; i++) {
Py_SET_TYPE(ui_types_with_callbacks[i], &McRFPyMetaclassType);
}
// Types that are exported to Python (visible in module namespace)
PyTypeObject* exported_types[] = {
/*SFML exposed types*/

View file

@ -5,9 +5,89 @@
#include "UIFrame.h"
#include "UIGrid.h"
#include "McRFPy_Automation.h" // #111 - For simulated mouse position
#include "PythonObjectCache.h" // #184 - For subclass callback support
#include <algorithm>
#include <functional>
// ============================================================================
// #184: Helper functions for calling Python subclass methods
// ============================================================================
// Try to call a Python method on a UIDrawable subclass
// Returns true if a method was found and called, false otherwise
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;
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_click") == 0) {
has_method = drawable->callback_cache.has_on_click;
} else 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 and call 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);
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) {
// Check for property-assigned callable first
if (strcmp(event_type, "click") == 0) {
if (drawable->click_callable && !drawable->click_callable->isNone()) return true;
} else if (strcmp(event_type, "enter") == 0) {
if (drawable->on_enter_callable && !drawable->on_enter_callable->isNone()) return true;
} else if (strcmp(event_type, "exit") == 0) {
if (drawable->on_exit_callable && !drawable->on_exit_callable->isNone()) return true;
} else if (strcmp(event_type, "move") == 0) {
if (drawable->on_move_callable && !drawable->on_move_callable->isNone()) return true;
}
// If it's a Python subclass, it might have a method
return drawable->is_python_subclass;
}
// ============================================================================
PyScene::PyScene(GameEngine* g) : Scene(g)
{
// mouse events
@ -51,10 +131,24 @@ void PyScene::do_mouse_input(std::string button, std::string type)
for (auto it = ui_elements->rbegin(); it != ui_elements->rend(); ++it) {
const auto& element = *it;
if (!element->visible) continue;
if (auto target = element->click_at(sf::Vector2f(mousepos))) {
target->click_callable->call(mousepos, button, type);
return; // Stop after first handler
// #184: Try property-assigned callable first (fast path)
if (target->click_callable && !target->click_callable->isNone()) {
target->click_callable->call(mousepos, button, type);
return; // Stop after first handler
}
// #184: Try Python subclass method
if (tryCallPythonMethod(target, "on_click", mousepos, button.c_str(), type.c_str())) {
return; // Stop after first handler
}
// Element claimed the click but had no handler - still stop propagation
// (This maintains consistent behavior for subclasses that don't define on_click)
if (target->is_python_subclass) {
return;
}
}
}
}
@ -91,20 +185,32 @@ void PyScene::do_mouse_hover(int x, int y)
if (is_inside && !was_hovered) {
// Mouse entered
drawable->hovered = true;
if (drawable->on_enter_callable) {
// #184: Try property-assigned callable first, then Python subclass method
if (drawable->on_enter_callable && !drawable->on_enter_callable->isNone()) {
drawable->on_enter_callable->call(mousepos, "enter", "start");
} else if (drawable->is_python_subclass) {
tryCallPythonMethod(drawable, "on_enter", mousepos, "enter", "start");
}
} else if (!is_inside && was_hovered) {
// Mouse exited
drawable->hovered = false;
if (drawable->on_exit_callable) {
// #184: Try property-assigned callable first, then Python subclass method
if (drawable->on_exit_callable && !drawable->on_exit_callable->isNone()) {
drawable->on_exit_callable->call(mousepos, "exit", "start");
} else if (drawable->is_python_subclass) {
tryCallPythonMethod(drawable, "on_exit", mousepos, "exit", "start");
}
}
// #141 - Fire on_move if mouse is inside and has a move callback
if (is_inside && drawable->on_move_callable) {
drawable->on_move_callable->call(mousepos, "move", "start");
// #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
// 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");
} else if (drawable->is_python_subclass) {
tryCallPythonMethod(drawable, "on_move", mousepos, "move", "start");
}
}
// Process children for Frame elements

View file

@ -1,5 +1,6 @@
#include "UIArc.h"
#include "McRFPy_API.h"
#include "PythonObjectCache.h"
#include <cmath>
#include <sstream>
@ -141,6 +142,9 @@ void UIArc::render(sf::Vector2f offset, sf::RenderTarget& target) {
UIDrawable* UIArc::click_at(sf::Vector2f point) {
if (!visible) return nullptr;
// #184: Also check for Python subclass (might have on_click method)
if (!click_callable && !is_python_subclass) return nullptr;
// Calculate distance from center
float dx = point.x - center.x;
float dy = point.y - center.y;
@ -542,5 +546,22 @@ int UIArc::init(PyUIArcObject* self, PyObject* args, PyObject* kwds) {
self->data->name = name;
}
// Register in Python object cache
if (self->data->serial_number == 0) {
self->data->serial_number = PythonObjectCache::getInstance().assignSerial();
PyObject* weakref = PyWeakref_NewRef((PyObject*)self, NULL);
if (weakref) {
PythonObjectCache::getInstance().registerObject(self->data->serial_number, weakref);
Py_DECREF(weakref);
}
}
// #184: Check if this is a Python subclass (for callback method support)
PyObject* arc_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Arc");
if (arc_type) {
self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != arc_type;
Py_DECREF(arc_type);
}
return 0;
}

View file

@ -21,7 +21,8 @@ UICaption::UICaption()
UIDrawable* UICaption::click_at(sf::Vector2f point)
{
if (click_callable)
// #184: Also check for Python subclass (might have on_click method)
if (click_callable || is_python_subclass)
{
if (text.getGlobalBounds().contains(point)) return this;
}
@ -484,7 +485,14 @@ int UICaption::init(PyUICaptionObject* self, PyObject* args, PyObject* kwds)
Py_DECREF(weakref); // Cache owns the reference now
}
}
// #184: Check if this is a Python subclass (for callback method support)
PyObject* caption_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption");
if (caption_type) {
self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != caption_type;
Py_DECREF(caption_type);
}
return 0;
}

View file

@ -127,7 +127,8 @@ void UICircle::render(sf::Vector2f offset, sf::RenderTarget& target) {
}
UIDrawable* UICircle::click_at(sf::Vector2f point) {
if (!click_callable) return nullptr;
// #184: Also check for Python subclass (might have on_click method)
if (!click_callable && !is_python_subclass) return nullptr;
// Check if point is within the circle (including outline)
float dx = point.x - position.x;
@ -511,5 +512,22 @@ int UICircle::init(PyUICircleObject* self, PyObject* args, PyObject* kwds) {
self->data->name = name;
}
// Register in Python object cache
if (self->data->serial_number == 0) {
self->data->serial_number = PythonObjectCache::getInstance().assignSerial();
PyObject* weakref = PyWeakref_NewRef((PyObject*)self, NULL);
if (weakref) {
PythonObjectCache::getInstance().registerObject(self->data->serial_number, weakref);
Py_DECREF(weakref);
}
}
// #184: Check if this is a Python subclass (for callback method support)
PyObject* circle_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Circle");
if (circle_type) {
self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != circle_type;
Py_DECREF(circle_type);
}
return 0;
}

View file

@ -1728,3 +1728,79 @@ PyObject* UIDrawable_animate_impl(std::shared_ptr<UIDrawable> self, PyObject* ar
pyAnim->data = animation;
return (PyObject*)pyAnim;
}
// ============================================================================
// Callback Cache Support (#184) - Python subclass method resolution
// ============================================================================
// Key for storing callback generation on Python type objects
static const char* CALLBACK_GEN_ATTR = "_mcrf_callback_gen";
uint32_t UIDrawable::getCallbackGeneration(PyObject* type) {
if (!type) return 0;
PyObject* gen = PyObject_GetAttrString(type, CALLBACK_GEN_ATTR);
if (gen) {
uint32_t result = static_cast<uint32_t>(PyLong_AsUnsignedLong(gen));
Py_DECREF(gen);
return result;
}
// No generation set yet - initialize to 0
PyErr_Clear();
return 0;
}
void UIDrawable::incrementCallbackGeneration(PyObject* type) {
if (!type) return;
uint32_t current = getCallbackGeneration(type);
PyObject* new_gen = PyLong_FromUnsignedLong(current + 1);
if (new_gen) {
PyObject_SetAttrString(type, CALLBACK_GEN_ATTR, new_gen);
Py_DECREF(new_gen);
}
PyErr_Clear(); // Clear any errors from SetAttr
}
bool UIDrawable::isCallbackCacheValid(PyObject* type) const {
if (!callback_cache.valid) return false;
return callback_cache.generation == getCallbackGeneration(type);
}
void UIDrawable::refreshCallbackCache(PyObject* pyObj) {
if (!pyObj) return;
PyObject* type = (PyObject*)Py_TYPE(pyObj);
// Update generation
callback_cache.generation = getCallbackGeneration(type);
callback_cache.valid = true;
// Check for each callback method
// We check the object (not just the class) to handle instance attributes too
// on_click
PyObject* attr = PyObject_GetAttrString(pyObj, "on_click");
callback_cache.has_on_click = (attr && PyCallable_Check(attr) && attr != Py_None);
Py_XDECREF(attr);
PyErr_Clear();
// on_enter
attr = PyObject_GetAttrString(pyObj, "on_enter");
callback_cache.has_on_enter = (attr && PyCallable_Check(attr) && attr != Py_None);
Py_XDECREF(attr);
PyErr_Clear();
// on_exit
attr = PyObject_GetAttrString(pyObj, "on_exit");
callback_cache.has_on_exit = (attr && PyCallable_Check(attr) && attr != Py_None);
Py_XDECREF(attr);
PyErr_Clear();
// on_move
attr = PyObject_GetAttrString(pyObj, "on_move");
callback_cache.has_on_move = (attr && PyCallable_Check(attr) && attr != Py_None);
Py_XDECREF(attr);
PyErr_Clear();
}

View file

@ -171,7 +171,34 @@ public:
// Python object cache support
uint64_t serial_number = 0;
// Python subclass callback support (#184)
// Enables subclass method overrides like: class MyFrame(Frame): def on_click(self, ...): ...
bool is_python_subclass = false;
// Callback method cache - avoids repeated Python lookups
struct CallbackCache {
uint32_t generation = 0; // Class generation when cache was populated
bool valid = false; // Whether cache has been populated
bool has_on_click = false;
bool has_on_enter = false;
bool has_on_exit = false;
bool has_on_move = false;
};
CallbackCache callback_cache;
// Check if callback cache is still valid (compares against class generation)
bool isCallbackCacheValid(PyObject* type) const;
// Refresh the callback cache by checking for methods on the Python object
void refreshCallbackCache(PyObject* pyObj);
// Get the current callback generation for a type
static uint32_t getCallbackGeneration(PyObject* type);
// Increment callback generation for a type (called when on_* attributes change)
static void incrementCallbackGeneration(PyObject* type);
protected:
// RenderTexture support (opt-in)
std::unique_ptr<sf::RenderTexture> render_texture;

View file

@ -16,25 +16,26 @@ UIDrawable* UIFrame::click_at(sf::Vector2f point)
if (point.x < x || point.y < y || point.x >= x+w || point.y >= y+h) {
return nullptr;
}
// Transform to local coordinates for children
sf::Vector2f localPoint = point - position;
// Check children in reverse order (top to bottom, highest z-index first)
for (auto it = children->rbegin(); it != children->rend(); ++it) {
auto& child = *it;
if (!child->visible) continue;
if (auto target = child->click_at(localPoint)) {
return target;
}
}
// No child handled it, check if we have a handler
if (click_callable) {
// #184: Also check for Python subclass (might have on_click method)
if (click_callable || is_python_subclass) {
return this;
}
return nullptr;
}
@ -698,7 +699,14 @@ int UIFrame::init(PyUIFrameObject* self, PyObject* args, PyObject* kwds)
Py_DECREF(weakref); // Cache owns the reference now
}
}
// #184: Check if this is a Python subclass (for callback method support)
PyObject* frame_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame");
if (frame_type) {
self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != frame_type;
Py_DECREF(frame_type);
}
return 0;
}

View file

@ -679,7 +679,8 @@ UIDrawable* UIGrid::click_at(sf::Vector2f point)
}
// No entity handled it, check if grid itself has handler
if (click_callable) {
// #184: Also check for Python subclass (might have on_click method)
if (click_callable || is_python_subclass) {
// #142 - Fire on_cell_click if we have the callback and clicked on a valid cell
if (on_cell_click_callable) {
int cell_x = static_cast<int>(std::floor(grid_x));
@ -987,7 +988,14 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
Py_DECREF(weakref); // Cache owns the reference now
}
}
// #184: Check if this is a Python subclass (for callback method support)
PyObject* grid_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid");
if (grid_type) {
self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != grid_type;
Py_DECREF(grid_type);
}
return 0; // Success
}

View file

@ -137,7 +137,8 @@ void UILine::render(sf::Vector2f offset, sf::RenderTarget& target) {
}
UIDrawable* UILine::click_at(sf::Vector2f point) {
if (!click_callable) return nullptr;
// #184: Also check for Python subclass (might have on_click method)
if (!click_callable && !is_python_subclass) return nullptr;
// Check if point is close enough to the line
// Using a simple bounding box check plus distance-to-line calculation
@ -586,5 +587,12 @@ int UILine::init(PyUILineObject* self, PyObject* args, PyObject* kwds) {
}
}
// #184: Check if this is a Python subclass (for callback method support)
PyObject* line_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Line");
if (line_type) {
self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != line_type;
Py_DECREF(line_type);
}
return 0;
}

View file

@ -7,7 +7,8 @@
UIDrawable* UISprite::click_at(sf::Vector2f point)
{
if (click_callable)
// #184: Also check for Python subclass (might have on_click method)
if (click_callable || is_python_subclass)
{
if(sprite.getGlobalBounds().contains(point)) return this;
}
@ -533,6 +534,13 @@ int UISprite::init(PyUISpriteObject* self, PyObject* args, PyObject* kwds)
}
}
// #184: Check if this is a Python subclass (for callback method support)
PyObject* sprite_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite");
if (sprite_type) {
self->data->is_python_subclass = (PyObject*)Py_TYPE(self) != sprite_type;
Py_DECREF(sprite_type);
}
return 0;
}