Add missing markDirty()/markCompositeDirty() to all Python property setters

Fixes a systemic bug where Python tp_getset property setters bypassed the
render cache dirty flag system (#144). The animation/C++ setProperty() path
had correct dirty propagation, but direct Python property assignments
(e.g. frame.x = 50, caption.text = "Hello") did not invalidate the parent
Frame's render cache when clip_children or cache_subtree was enabled.

Changes by file:
- UIDrawable.cpp: Add markCompositeDirty() to set_float_member (x/y),
  set_pos, set_grid_pos; add markDirty() for w/h resize
- UICaption.cpp: Add markDirty() to set_text, set_color_member,
  set_float_member (outline/font_size); markCompositeDirty() for position
- UICollection.cpp: Add markContentDirty() on owner in append, remove,
  pop, insert, extend, setitem, and slice assignment/deletion
- UISprite.cpp: Add markDirty() to scale/sprite_index/texture setters;
  markCompositeDirty() to position setters
- UICircle.cpp: Add markDirty() to radius/fill_color/outline_color/outline;
  markCompositeDirty() to center setter
- UILine.cpp: Add markDirty() to start/end/color/thickness setters
- UIArc.cpp: Add markDirty() to radius/angles/color/thickness setters;
  markCompositeDirty() to center setter
- UIGrid.cpp: Add markDirty() to center/zoom/camera_rotation/fill_color/
  size/perspective/fov setters

Closes #288, closes #289, closes #290, closes #291

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-04-10 01:01:41 -04:00
commit e58b44ef82
9 changed files with 403 additions and 14 deletions

View file

@ -404,6 +404,7 @@ int UIArc::set_center(PyUIArcObject* self, PyObject* value, void* closure) {
return -1;
}
self->data->setCenter(vec->data);
self->data->markCompositeDirty(); // #291: position change
return 0;
}
@ -417,6 +418,7 @@ int UIArc::set_radius(PyUIArcObject* self, PyObject* value, void* closure) {
return -1;
}
self->data->setRadius(static_cast<float>(PyFloat_AsDouble(value)));
self->data->markDirty(); // #291: visual change
return 0;
}
@ -430,6 +432,7 @@ int UIArc::set_start_angle(PyUIArcObject* self, PyObject* value, void* closure)
return -1;
}
self->data->setStartAngle(static_cast<float>(PyFloat_AsDouble(value)));
self->data->markDirty(); // #291: visual change
return 0;
}
@ -443,6 +446,7 @@ int UIArc::set_end_angle(PyUIArcObject* self, PyObject* value, void* closure) {
return -1;
}
self->data->setEndAngle(static_cast<float>(PyFloat_AsDouble(value)));
self->data->markDirty(); // #291: visual change
return 0;
}
@ -462,6 +466,7 @@ int UIArc::set_color(PyUIArcObject* self, PyObject* value, void* closure) {
return -1;
}
self->data->setColor(color->data);
self->data->markDirty(); // #291: color change
return 0;
}
@ -475,6 +480,7 @@ int UIArc::set_thickness(PyUIArcObject* self, PyObject* value, void* closure) {
return -1;
}
self->data->setThickness(static_cast<float>(PyFloat_AsDouble(value)));
self->data->markDirty(); // #291: visual change
return 0;
}

View file

@ -200,14 +200,22 @@ int UICaption::set_float_member(PyUICaptionObject* self, PyObject* value, void*
PyErr_SetString(PyExc_TypeError, "Value must be a number (int or float)");
return -1;
}
if (member_ptr == 0) //x
if (member_ptr == 0) { //x
self->data->text.setPosition(val, self->data->text.getPosition().y);
else if (member_ptr == 1) //y
self->data->markCompositeDirty(); // #289: position change invalidates parent cache
}
else if (member_ptr == 1) { //y
self->data->text.setPosition(self->data->text.getPosition().x, val);
else if (member_ptr == 4) //outline
self->data->markCompositeDirty(); // #289: position change invalidates parent cache
}
else if (member_ptr == 4) { //outline
self->data->text.setOutlineThickness(val);
else if (member_ptr == 5) // character size
self->data->markDirty(); // #289: content change invalidates own + parent cache
}
else if (member_ptr == 5) { // character size
self->data->text.setCharacterSize(val);
self->data->markDirty(); // #289: content change invalidates own + parent cache
}
return 0;
}
@ -219,6 +227,7 @@ PyObject* UICaption::get_vec_member(PyUICaptionObject* self, void* closure)
int UICaption::set_vec_member(PyUICaptionObject* self, PyObject* value, void* closure)
{
self->data->text.setPosition(PyVector::fromPy(value));
self->data->markCompositeDirty(); // #289: position change invalidates parent cache
return 0;
}
@ -289,10 +298,12 @@ int UICaption::set_color_member(PyUICaptionObject* self, PyObject* value, void*
if (member_ptr == 0)
{
self->data->text.setFillColor(sf::Color(r, g, b, a));
self->data->markDirty(); // #289: color change invalidates own + parent cache
}
else if (member_ptr == 1)
{
self->data->text.setOutlineColor(sf::Color(r, g, b, a));
self->data->markDirty(); // #289: color change invalidates own + parent cache
}
else
{
@ -329,6 +340,7 @@ int UICaption::set_text(PyUICaptionObject* self, PyObject* value, void* closure)
Py_DECREF(temp_bytes);
}
self->data->text.setString(Resources::caption_buffer);
self->data->markDirty(); // #289: text change invalidates own + parent cache
return 0;
}

View file

@ -337,6 +337,7 @@ int UICircle::set_radius(PyUICircleObject* self, PyObject* value, void* closure)
return -1;
}
self->data->setRadius(static_cast<float>(PyFloat_AsDouble(value)));
self->data->markDirty(); // #291: visual change
return 0;
}
@ -355,6 +356,7 @@ int UICircle::set_center(PyUICircleObject* self, PyObject* value, void* closure)
return -1;
}
self->data->setCenter(vec->data);
self->data->markCompositeDirty(); // #291: position change
return 0;
}
@ -382,6 +384,7 @@ int UICircle::set_fill_color(PyUICircleObject* self, PyObject* value, void* clos
return -1;
}
self->data->setFillColor(color);
self->data->markDirty(); // #291: color change
return 0;
}
@ -409,6 +412,7 @@ int UICircle::set_outline_color(PyUICircleObject* self, PyObject* value, void* c
return -1;
}
self->data->setOutlineColor(color);
self->data->markDirty(); // #291: color change
return 0;
}
@ -422,6 +426,7 @@ int UICircle::set_outline(PyUICircleObject* self, PyObject* value, void* closure
return -1;
}
self->data->setOutline(static_cast<float>(PyFloat_AsDouble(value)));
self->data->markDirty(); // #291: visual change
return 0;
}

View file

@ -271,6 +271,11 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject
// #122: Clear the parent before removing
(*self->data)[index]->setParent(nullptr);
self->data->erase(self->data->begin() + index);
// #288: Invalidate parent Frame's render cache
auto owner_ptr = self->owner.lock();
if (owner_ptr) {
owner_ptr->markContentDirty();
}
return 0;
}
@ -302,6 +307,12 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject
// Mark scene as needing resort after replacing element
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
auto owner_ptr = self->owner.lock();
if (owner_ptr) {
owner_ptr->markContentDirty();
}
return 0;
}
@ -494,7 +505,14 @@ int UICollection::ass_subscript(PyUICollectionObject* self, PyObject* key, PyObj
// Mark scene as needing resort after slice deletion
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
{
auto owner_ptr = self->owner.lock();
if (owner_ptr) {
owner_ptr->markContentDirty();
}
}
return 0;
} else {
// Assignment
@ -564,7 +582,14 @@ int UICollection::ass_subscript(PyUICollectionObject* self, PyObject* key, PyObj
// Mark scene as needing resort after slice assignment
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
{
auto owner_ptr = self->owner.lock();
if (owner_ptr) {
owner_ptr->markContentDirty();
}
}
return 0;
}
} else {
@ -635,6 +660,12 @@ PyObject* UICollection::append(PyUICollectionObject* self, PyObject* o)
// Mark scene as needing resort after adding element
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
auto owner_ptr = self->owner.lock();
if (owner_ptr) {
owner_ptr->markContentDirty();
}
Py_INCREF(Py_None);
return Py_None;
}
@ -689,7 +720,12 @@ PyObject* UICollection::extend(PyUICollectionObject* self, PyObject* iterable)
// Mark scene as needing resort after adding elements
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
if (owner_ptr) {
owner_ptr->markContentDirty();
}
Py_INCREF(Py_None);
return Py_None;
}
@ -717,6 +753,11 @@ PyObject* UICollection::remove(PyUICollectionObject* self, PyObject* o)
(*it)->setParent(nullptr);
vec->erase(it);
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
auto owner_ptr = self->owner.lock();
if (owner_ptr) {
owner_ptr->markContentDirty();
}
Py_RETURN_NONE;
}
}
@ -766,6 +807,12 @@ PyObject* UICollection::pop(PyUICollectionObject* self, PyObject* args)
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
auto owner_ptr = self->owner.lock();
if (owner_ptr) {
owner_ptr->markContentDirty();
}
// Convert to Python object and return
return convertDrawableToPython(drawable);
}
@ -817,6 +864,12 @@ PyObject* UICollection::insert(PyUICollectionObject* self, PyObject* args)
McRFPy_API::markSceneNeedsSort();
// #288: Invalidate parent Frame's render cache
auto owner_ptr2 = self->owner.lock();
if (owner_ptr2) {
owner_ptr2->markContentDirty();
}
Py_RETURN_NONE;
}

View file

@ -545,10 +545,12 @@ int UIDrawable::set_float_member(PyObject* self, PyObject* value, void* closure)
case 0: // x
drawable->position.x = val;
drawable->onPositionChanged();
drawable->markCompositeDirty(); // #290: position change invalidates parent cache
break;
case 1: // y
drawable->position.y = val;
drawable->onPositionChanged();
drawable->markCompositeDirty(); // #290: position change invalidates parent cache
break;
case 2: // w
case 3: // h
@ -559,6 +561,7 @@ int UIDrawable::set_float_member(PyObject* self, PyObject* value, void* closure)
} else {
drawable->resize(bounds.width, val);
}
drawable->markDirty(); // #290: size change invalidates own + parent cache
}
break;
default:
@ -638,6 +641,7 @@ int UIDrawable::set_pos(PyObject* self, PyObject* value, void* closure) {
drawable->position = sf::Vector2f(x, y);
drawable->onPositionChanged();
drawable->markCompositeDirty(); // #290: position change invalidates parent cache
return 0;
}
@ -873,6 +877,7 @@ int UIDrawable::set_grid_pos(PyObject* self, PyObject* value, void* closure) {
drawable->position.x = grid_x * cell_size.x;
drawable->position.y = grid_y * cell_size.y;
drawable->onPositionChanged();
drawable->markCompositeDirty(); // #290: position change invalidates parent cache
return 0;
}

View file

@ -1074,7 +1074,8 @@ int UIGrid::set_size(PyUIGridObject* self, PyObject* value, void* closure) {
tex_height = std::min(tex_height, 4096u);
self->data->renderTexture.create(tex_width, tex_height);
self->data->markDirty(); // #291: size change
return 0;
}
@ -1091,6 +1092,7 @@ int UIGrid::set_center(PyUIGridObject* self, PyObject* value, void* closure) {
}
self->data->center_x = x;
self->data->center_y = y;
self->data->markDirty(); // #291: camera position change
return 0;
}
@ -1186,6 +1188,14 @@ int UIGrid::set_float_member(PyUIGridObject* self, PyObject* value, void* closur
else if (member_ptr == 7) self->view->camera_rotation = val;
self->view->position = self->view->box.getPosition();
}
// #291: Dirty flag propagation for visual property changes
if (member_ptr == 0 || member_ptr == 1) {
self->data->markCompositeDirty(); // position change
} else {
self->data->markDirty(); // content/size change
}
return 0;
}
// TODO (7DRL Day 2, item 5.) return Texture object
@ -1310,6 +1320,7 @@ int UIGrid::set_fill_color(PyUIGridObject* self, PyObject* value, void* closure)
PyColorObject* color = (PyColorObject*)value;
self->data->fill_color = color->data;
self->data->markDirty(); // #291: color change
return 0;
}
@ -1342,6 +1353,7 @@ int UIGrid::set_perspective(PyUIGridObject* self, PyObject* value, void* closure
if (value == Py_None) {
// Clear perspective but keep perspective_enabled unchanged
self->data->perspective_entity.reset();
self->data->markDirty(); // #291: FOV rendering change
return 0;
}
@ -1354,6 +1366,7 @@ int UIGrid::set_perspective(PyUIGridObject* self, PyObject* value, void* closure
PyUIEntityObject* entity_obj = (PyUIEntityObject*)value;
self->data->perspective_entity = entity_obj->data;
self->data->perspective_enabled = true; // Enable perspective when entity assigned
self->data->markDirty(); // #291: FOV rendering change
return 0;
}
@ -1369,6 +1382,7 @@ int UIGrid::set_perspective_enabled(PyUIGridObject* self, PyObject* value, void*
return -1; // Error occurred
}
self->data->perspective_enabled = enabled;
self->data->markDirty(); // #291: FOV rendering toggle
return 0;
}
@ -1401,6 +1415,7 @@ int UIGrid::set_fov(PyUIGridObject* self, PyObject* value, void* closure)
return -1;
}
self->data->fov_algorithm = algo;
self->data->markDirty(); // #291: FOV algorithm change
return 0;
}
@ -1425,6 +1440,7 @@ int UIGrid::set_fov_radius(PyUIGridObject* self, PyObject* value, void* closure)
return -1;
}
self->data->fov_radius = (int)radius;
self->data->markDirty(); // #291: FOV radius change
return 0;
}

View file

@ -423,6 +423,7 @@ int UILine::set_start(PyUILineObject* self, PyObject* value, void* closure) {
return -1;
}
self->data->setStart(vec->data);
self->data->markDirty(); // #291: visual change
return 0;
}
@ -443,6 +444,7 @@ int UILine::set_end(PyUILineObject* self, PyObject* value, void* closure) {
return -1;
}
self->data->setEnd(vec->data);
self->data->markDirty(); // #291: visual change
return 0;
}
@ -462,6 +464,7 @@ int UILine::set_color(PyUILineObject* self, PyObject* value, void* closure) {
return -1;
}
self->data->setColor(color->data);
self->data->markDirty(); // #291: color change
return 0;
}
@ -486,6 +489,7 @@ int UILine::set_thickness(PyUILineObject* self, PyObject* value, void* closure)
}
self->data->setThickness(thickness);
self->data->markDirty(); // #291: visual change
return 0;
}

View file

@ -285,16 +285,26 @@ int UISprite::set_float_member(PyUISpriteObject* self, PyObject* value, void* cl
PyErr_SetString(PyExc_TypeError, "Value must be a number (int or float)");
return -1;
}
if (member_ptr == 0) //x
if (member_ptr == 0) { //x
self->data->setPosition(sf::Vector2f(val, self->data->getPosition().y));
else if (member_ptr == 1) //y
self->data->markCompositeDirty(); // #291: position change
}
else if (member_ptr == 1) { //y
self->data->setPosition(sf::Vector2f(self->data->getPosition().x, val));
else if (member_ptr == 2) // scale (uniform)
self->data->markCompositeDirty(); // #291: position change
}
else if (member_ptr == 2) { // scale (uniform)
self->data->setScale(sf::Vector2f(val, val));
else if (member_ptr == 3) // scale_x
self->data->markDirty(); // #291: visual change
}
else if (member_ptr == 3) { // scale_x
self->data->setScale(sf::Vector2f(val, self->data->getScale().y));
else if (member_ptr == 4) // scale_y
self->data->markDirty(); // #291: visual change
}
else if (member_ptr == 4) { // scale_y
self->data->setScale(sf::Vector2f(self->data->getScale().x, val));
self->data->markDirty(); // #291: visual change
}
return 0;
}
@ -339,6 +349,7 @@ int UISprite::set_int_member(PyUISpriteObject* self, PyObject* value, void* clos
}
self->data->setSpriteIndex(val);
self->data->markDirty(); // #291: sprite content change
return 0;
}
@ -364,7 +375,8 @@ int UISprite::set_texture(PyUISpriteObject* self, PyObject* value, void* closure
// Update the sprite's texture
self->data->setTexture(pytexture->data);
self->data->markDirty(); // #291: texture change
return 0;
}
@ -387,6 +399,7 @@ int UISprite::set_pos(PyUISpriteObject* self, PyObject* value, void* closure)
return -1;
}
self->data->setPosition(vec->data);
self->data->markCompositeDirty(); // #291: position change
return 0;
}