feat: Implement Phase A UI hierarchy foundations (closes #122, #102, #116, #118)

Parent-Child UI System (#122):
- Add parent weak_ptr to UIDrawable for hierarchy tracking
- Add setParent(), getParent(), removeFromParent() methods
- UICollection now tracks owner and sets parent on append/insert
- Auto-remove from old parent when adding to new collection

Global Position Property (#102):
- Add get_global_position() that walks up parent chain
- Expose as read-only 'global_position' property on all UI types
- Add UIDRAWABLE_PARENT_GETSETTERS macro for consistent bindings

Dirty Flag System (#116):
- Modify markDirty() to propagate up the parent chain
- Add isDirty() and clearDirty() methods for render optimization

Scene as Drawable (#118):
- Add position, visible, opacity properties to Scene
- Add setProperty()/getProperty() for animation support
- Apply scene transformations in PyScene::render()
- Fix lifecycle callbacks to clear errors when methods don't exist
- Add GameEngine::getScene() public accessor

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-11-27 16:33:17 -05:00
parent bfadab7486
commit e3d8f54d46
19 changed files with 988 additions and 67 deletions

View File

@ -136,6 +136,10 @@ void GameEngine::cleanup()
} }
Scene* GameEngine::currentScene() { return scenes[scene]; } Scene* GameEngine::currentScene() { return scenes[scene]; }
Scene* GameEngine::getScene(const std::string& name) {
auto it = scenes.find(name);
return (it != scenes.end()) ? it->second : nullptr;
}
void GameEngine::changeScene(std::string s) void GameEngine::changeScene(std::string s)
{ {
changeScene(s, TransitionType::None, 0.0f); changeScene(s, TransitionType::None, 0.0f);

View File

@ -139,6 +139,7 @@ public:
GameEngine(const McRogueFaceConfig& cfg); GameEngine(const McRogueFaceConfig& cfg);
~GameEngine(); ~GameEngine();
Scene* currentScene(); Scene* currentScene();
Scene* getScene(const std::string& name); // #118: Get scene by name
void changeScene(std::string); void changeScene(std::string);
void changeScene(std::string sceneName, TransitionType transitionType, float duration); void changeScene(std::string sceneName, TransitionType transitionType, float duration);
void createScene(std::string); void createScene(std::string);

View File

@ -64,6 +64,11 @@ void PyScene::doAction(std::string name, std::string type)
void PyScene::render() void PyScene::render()
{ {
// #118: Skip rendering if scene is not visible
if (!visible) {
return;
}
game->getRenderTarget().clear(); game->getRenderTarget().clear();
// Only sort if z_index values have changed // Only sort if z_index values have changed
@ -75,7 +80,7 @@ void PyScene::render()
ui_elements_need_sort = false; ui_elements_need_sort = false;
} }
// Render in sorted order (no need to copy anymore) // Render in sorted order with scene-level transformations
for (auto e: *ui_elements) for (auto e: *ui_elements)
{ {
if (e) { if (e) {
@ -86,7 +91,20 @@ void PyScene::render()
// Count this as a draw call (each visible element = 1+ draw calls) // Count this as a draw call (each visible element = 1+ draw calls)
game->metrics.drawCalls++; game->metrics.drawCalls++;
} }
e->render();
// #118: Apply scene-level opacity to element
float original_opacity = e->opacity;
if (opacity < 1.0f) {
e->opacity = original_opacity * opacity;
}
// #118: Render with scene position offset
e->render(position, game->getRenderTarget());
// #118: Restore original opacity
if (opacity < 1.0f) {
e->opacity = original_opacity;
}
} }
} }

View File

@ -137,6 +137,155 @@ PyObject* PySceneClass::get_active(PySceneObject* self, void* closure)
return PyBool_FromLong(game->scene == self->name); return PyBool_FromLong(game->scene == self->name);
} }
// #118: Scene position getter
static PyObject* PySceneClass_get_pos(PySceneObject* self, void* closure)
{
GameEngine* game = McRFPy_API::game;
if (!game) {
Py_RETURN_NONE;
}
// Get the scene by name using the public accessor
auto scene = game->getScene(self->name);
if (!scene) {
Py_RETURN_NONE;
}
// Create a Vector object
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector");
if (!type) return NULL;
PyObject* args = Py_BuildValue("(ff)", scene->position.x, scene->position.y);
PyObject* result = PyObject_CallObject((PyObject*)type, args);
Py_DECREF(type);
Py_DECREF(args);
return result;
}
// #118: Scene position setter
static int PySceneClass_set_pos(PySceneObject* self, PyObject* value, void* closure)
{
GameEngine* game = McRFPy_API::game;
if (!game) {
PyErr_SetString(PyExc_RuntimeError, "No game engine");
return -1;
}
auto scene = game->getScene(self->name);
if (!scene) {
PyErr_SetString(PyExc_RuntimeError, "Scene not found");
return -1;
}
// Accept tuple or Vector
float x, y;
if (PyTuple_Check(value) && PyTuple_Size(value) == 2) {
x = PyFloat_AsDouble(PyTuple_GetItem(value, 0));
y = PyFloat_AsDouble(PyTuple_GetItem(value, 1));
} else if (PyObject_HasAttrString(value, "x") && PyObject_HasAttrString(value, "y")) {
PyObject* xobj = PyObject_GetAttrString(value, "x");
PyObject* yobj = PyObject_GetAttrString(value, "y");
x = PyFloat_AsDouble(xobj);
y = PyFloat_AsDouble(yobj);
Py_DECREF(xobj);
Py_DECREF(yobj);
} else {
PyErr_SetString(PyExc_TypeError, "pos must be a tuple (x, y) or Vector");
return -1;
}
scene->position = sf::Vector2f(x, y);
return 0;
}
// #118: Scene visible getter
static PyObject* PySceneClass_get_visible(PySceneObject* self, void* closure)
{
GameEngine* game = McRFPy_API::game;
if (!game) {
Py_RETURN_TRUE;
}
auto scene = game->getScene(self->name);
if (!scene) {
Py_RETURN_TRUE;
}
return PyBool_FromLong(scene->visible);
}
// #118: Scene visible setter
static int PySceneClass_set_visible(PySceneObject* self, PyObject* value, void* closure)
{
GameEngine* game = McRFPy_API::game;
if (!game) {
PyErr_SetString(PyExc_RuntimeError, "No game engine");
return -1;
}
auto scene = game->getScene(self->name);
if (!scene) {
PyErr_SetString(PyExc_RuntimeError, "Scene not found");
return -1;
}
if (!PyBool_Check(value)) {
PyErr_SetString(PyExc_TypeError, "visible must be a boolean");
return -1;
}
scene->visible = PyObject_IsTrue(value);
return 0;
}
// #118: Scene opacity getter
static PyObject* PySceneClass_get_opacity(PySceneObject* self, void* closure)
{
GameEngine* game = McRFPy_API::game;
if (!game) {
return PyFloat_FromDouble(1.0);
}
auto scene = game->getScene(self->name);
if (!scene) {
return PyFloat_FromDouble(1.0);
}
return PyFloat_FromDouble(scene->opacity);
}
// #118: Scene opacity setter
static int PySceneClass_set_opacity(PySceneObject* self, PyObject* value, void* closure)
{
GameEngine* game = McRFPy_API::game;
if (!game) {
PyErr_SetString(PyExc_RuntimeError, "No game engine");
return -1;
}
auto scene = game->getScene(self->name);
if (!scene) {
PyErr_SetString(PyExc_RuntimeError, "Scene not found");
return -1;
}
double opacity;
if (PyFloat_Check(value)) {
opacity = PyFloat_AsDouble(value);
} else if (PyLong_Check(value)) {
opacity = PyLong_AsDouble(value);
} else {
PyErr_SetString(PyExc_TypeError, "opacity must be a number");
return -1;
}
// Clamp to valid range
if (opacity < 0.0) opacity = 0.0;
if (opacity > 1.0) opacity = 1.0;
scene->opacity = opacity;
return 0;
}
// Lifecycle callbacks // Lifecycle callbacks
void PySceneClass::call_on_enter(PySceneObject* self) void PySceneClass::call_on_enter(PySceneObject* self)
{ {
@ -148,8 +297,12 @@ void PySceneClass::call_on_enter(PySceneObject* self)
} else { } else {
PyErr_Print(); PyErr_Print();
} }
} Py_DECREF(method);
} else {
// Clear AttributeError if method doesn't exist
PyErr_Clear();
Py_XDECREF(method); Py_XDECREF(method);
}
} }
void PySceneClass::call_on_exit(PySceneObject* self) void PySceneClass::call_on_exit(PySceneObject* self)
@ -162,8 +315,12 @@ void PySceneClass::call_on_exit(PySceneObject* self)
} else { } else {
PyErr_Print(); PyErr_Print();
} }
} Py_DECREF(method);
} else {
// Clear AttributeError if method doesn't exist
PyErr_Clear();
Py_XDECREF(method); Py_XDECREF(method);
}
} }
void PySceneClass::call_on_keypress(PySceneObject* self, std::string key, std::string action) void PySceneClass::call_on_keypress(PySceneObject* self, std::string key, std::string action)
@ -178,8 +335,12 @@ void PySceneClass::call_on_keypress(PySceneObject* self, std::string key, std::s
} else { } else {
PyErr_Print(); PyErr_Print();
} }
} Py_DECREF(method);
} else {
// Clear AttributeError if method doesn't exist
PyErr_Clear();
Py_XDECREF(method); Py_XDECREF(method);
}
PyGILState_Release(gstate); PyGILState_Release(gstate);
} }
@ -194,8 +355,12 @@ void PySceneClass::call_update(PySceneObject* self, float dt)
} else { } else {
PyErr_Print(); PyErr_Print();
} }
} Py_DECREF(method);
} else {
// Clear AttributeError if method doesn't exist
PyErr_Clear();
Py_XDECREF(method); Py_XDECREF(method);
}
} }
void PySceneClass::call_on_resize(PySceneObject* self, int width, int height) void PySceneClass::call_on_resize(PySceneObject* self, int width, int height)
@ -208,8 +373,12 @@ void PySceneClass::call_on_resize(PySceneObject* self, int width, int height)
} else { } else {
PyErr_Print(); PyErr_Print();
} }
} Py_DECREF(method);
} else {
// Clear AttributeError if method doesn't exist
PyErr_Clear();
Py_XDECREF(method); Py_XDECREF(method);
}
} }
// Properties // Properties
@ -218,6 +387,13 @@ PyGetSetDef PySceneClass::getsetters[] = {
MCRF_PROPERTY(name, "Scene name (str, read-only). Unique identifier for this scene."), NULL}, MCRF_PROPERTY(name, "Scene name (str, read-only). Unique identifier for this scene."), NULL},
{"active", (getter)get_active, NULL, {"active", (getter)get_active, NULL,
MCRF_PROPERTY(active, "Whether this scene is currently active (bool, read-only). Only one scene can be active at a time."), NULL}, MCRF_PROPERTY(active, "Whether this scene is currently active (bool, read-only). Only one scene can be active at a time."), NULL},
// #118: Scene-level UIDrawable-like properties
{"pos", (getter)PySceneClass_get_pos, (setter)PySceneClass_set_pos,
MCRF_PROPERTY(pos, "Scene position offset (Vector). Applied to all UI elements during rendering."), NULL},
{"visible", (getter)PySceneClass_get_visible, (setter)PySceneClass_set_visible,
MCRF_PROPERTY(visible, "Scene visibility (bool). If False, scene is not rendered."), NULL},
{"opacity", (getter)PySceneClass_get_opacity, (setter)PySceneClass_set_opacity,
MCRF_PROPERTY(opacity, "Scene opacity (0.0-1.0). Applied to all UI elements during rendering."), NULL},
{NULL} {NULL}
}; };

View File

@ -54,3 +54,43 @@ void Scene::key_unregister()
*/ */
key_callable.reset(); key_callable.reset();
} }
// #118: Scene animation property support
bool Scene::setProperty(const std::string& name, float value)
{
if (name == "x") {
position.x = value;
return true;
}
if (name == "y") {
position.y = value;
return true;
}
if (name == "opacity") {
opacity = std::max(0.0f, std::min(1.0f, value));
return true;
}
if (name == "visible") {
visible = (value != 0.0f);
return true;
}
return false;
}
bool Scene::setProperty(const std::string& name, const sf::Vector2f& value)
{
if (name == "pos" || name == "position") {
position = value;
return true;
}
return false;
}
float Scene::getProperty(const std::string& name) const
{
if (name == "x") return position.x;
if (name == "y") return position.y;
if (name == "opacity") return opacity;
if (name == "visible") return visible ? 1.0f : 0.0f;
return 0.0f;
}

View File

@ -43,4 +43,14 @@ public:
std::unique_ptr<PyKeyCallable> key_callable; std::unique_ptr<PyKeyCallable> key_callable;
void key_register(PyObject*); void key_register(PyObject*);
void key_unregister(); void key_unregister();
// #118: Scene-level UIDrawable-like properties for animations/transitions
sf::Vector2f position{0.0f, 0.0f}; // Offset applied to all ui_elements
bool visible = true; // Controls rendering of scene
float opacity = 1.0f; // Applied to all ui_elements (0.0-1.0)
// Animation support for scene properties
bool setProperty(const std::string& name, float value);
bool setProperty(const std::string& name, const sf::Vector2f& value);
float getProperty(const std::string& name) const;
}; };

View File

@ -413,6 +413,7 @@ PyGetSetDef UIArc::getsetters[] = {
{"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos, {"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos,
"Position as a Vector (same as center).", (void*)PyObjectsEnum::UIARC}, "Position as a Vector (same as center).", (void*)PyObjectsEnum::UIARC},
UIDRAWABLE_GETSETTERS, UIDRAWABLE_GETSETTERS,
UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIARC),
{NULL} {NULL}
}; };

View File

@ -162,4 +162,18 @@ static int UIDrawable_set_opacity(T* self, PyObject* value, void* closure)
"Automatically clamped to valid range [0.0, 1.0]." \ "Automatically clamped to valid range [0.0, 1.0]." \
), NULL} ), NULL}
// #122 & #102: Macro for parent/global_position properties (requires closure with type enum)
// These need the PyObjectsEnum value in closure, so they're added separately in each class
#define UIDRAWABLE_PARENT_GETSETTERS(type_enum) \
{"parent", (getter)UIDrawable::get_parent, NULL, \
MCRF_PROPERTY(parent, \
"Parent drawable (read-only). " \
"Returns the parent Frame/Grid if nested, or None if at scene level." \
), (void*)type_enum}, \
{"global_position", (getter)UIDrawable::get_global_pos, NULL, \
MCRF_PROPERTY(global_position, \
"Global screen position (read-only). " \
"Calculates absolute position by walking up the parent chain." \
), (void*)type_enum}
// UIEntity specializations are defined in UIEntity.cpp after UIEntity class is complete // UIEntity specializations are defined in UIEntity.cpp after UIEntity class is complete

View File

@ -285,6 +285,7 @@ PyGetSetDef UICaption::getsetters[] = {
), (void*)PyObjectsEnum::UICAPTION}, ), (void*)PyObjectsEnum::UICAPTION},
{"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UICAPTION}, {"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UICAPTION},
UIDRAWABLE_GETSETTERS, UIDRAWABLE_GETSETTERS,
UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UICAPTION),
{NULL} {NULL}
}; };

View File

@ -369,6 +369,7 @@ PyGetSetDef UICircle::getsetters[] = {
{"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos, {"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos,
"Position as a Vector (same as center).", (void*)PyObjectsEnum::UICIRCLE}, "Position as a Vector (same as center).", (void*)PyObjectsEnum::UICIRCLE},
UIDRAWABLE_GETSETTERS, UIDRAWABLE_GETSETTERS,
UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UICIRCLE),
{NULL} {NULL}
}; };

View File

@ -220,6 +220,8 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject
// Handle deletion // Handle deletion
if (value == NULL) { if (value == NULL) {
// #122: Clear the parent before removing
(*self->data)[index]->setParent(nullptr);
self->data->erase(self->data->begin() + index); self->data->erase(self->data->begin() + index);
return 0; return 0;
} }
@ -256,9 +258,20 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject
return -1; return -1;
} }
// #122: Clear parent of old element
(*vec)[index]->setParent(nullptr);
// #122: Remove new drawable from its old parent if it has one
if (auto old_parent = new_drawable->getParent()) {
new_drawable->removeFromParent();
}
// Preserve the z_index of the replaced element // Preserve the z_index of the replaced element
new_drawable->z_index = old_z_index; new_drawable->z_index = old_z_index;
// #122: Set new parent
new_drawable->setParent(self->owner.lock());
// Replace the element // Replace the element
(*vec)[index] = new_drawable; (*vec)[index] = new_drawable;
@ -638,47 +651,51 @@ PyObject* UICollection::append(PyUICollectionObject* self, PyObject* o)
} }
} }
// #122: Get the owner as parent for this drawable
std::shared_ptr<UIDrawable> owner_ptr = self->owner.lock();
// Helper lambda to add drawable with parent tracking
auto addDrawable = [&](std::shared_ptr<UIDrawable> drawable) {
// #122: Remove from old parent if it has one
if (auto old_parent = drawable->getParent()) {
drawable->removeFromParent();
}
drawable->z_index = new_z_index;
// #122: Set new parent (owner of this collection)
drawable->setParent(owner_ptr);
self->data->push_back(drawable);
};
if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame"))) if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame")))
{ {
PyUIFrameObject* frame = (PyUIFrameObject*)o; addDrawable(((PyUIFrameObject*)o)->data);
frame->data->z_index = new_z_index;
self->data->push_back(frame->data);
} }
if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption"))) if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption")))
{ {
PyUICaptionObject* caption = (PyUICaptionObject*)o; addDrawable(((PyUICaptionObject*)o)->data);
caption->data->z_index = new_z_index;
self->data->push_back(caption->data);
} }
if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite"))) if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite")))
{ {
PyUISpriteObject* sprite = (PyUISpriteObject*)o; addDrawable(((PyUISpriteObject*)o)->data);
sprite->data->z_index = new_z_index;
self->data->push_back(sprite->data);
} }
if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid"))) if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid")))
{ {
PyUIGridObject* grid = (PyUIGridObject*)o; addDrawable(((PyUIGridObject*)o)->data);
grid->data->z_index = new_z_index;
self->data->push_back(grid->data);
} }
if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Line"))) if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Line")))
{ {
PyUILineObject* line = (PyUILineObject*)o; addDrawable(((PyUILineObject*)o)->data);
line->data->z_index = new_z_index;
self->data->push_back(line->data);
} }
if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Circle"))) if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Circle")))
{ {
PyUICircleObject* circle = (PyUICircleObject*)o; addDrawable(((PyUICircleObject*)o)->data);
circle->data->z_index = new_z_index;
self->data->push_back(circle->data);
} }
if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Arc"))) if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Arc")))
{ {
PyUIArcObject* arc = (PyUIArcObject*)o; addDrawable(((PyUIArcObject*)o)->data);
arc->data->z_index = new_z_index;
self->data->push_back(arc->data);
} }
// Mark scene as needing resort after adding element // Mark scene as needing resort after adding element
@ -734,41 +751,41 @@ PyObject* UICollection::extend(PyUICollectionObject* self, PyObject* iterable)
current_z_index = INT_MAX; current_z_index = INT_MAX;
} }
// #122: Get the owner as parent for this drawable
std::shared_ptr<UIDrawable> owner_ptr = self->owner.lock();
// Helper lambda to add drawable with parent tracking
auto addDrawable = [&](std::shared_ptr<UIDrawable> drawable) {
// #122: Remove from old parent if it has one
if (auto old_parent = drawable->getParent()) {
drawable->removeFromParent();
}
drawable->z_index = current_z_index;
drawable->setParent(owner_ptr);
self->data->push_back(drawable);
};
// Add the item based on its type // Add the item based on its type
if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame"))) { if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame"))) {
PyUIFrameObject* frame = (PyUIFrameObject*)item; addDrawable(((PyUIFrameObject*)item)->data);
frame->data->z_index = current_z_index;
self->data->push_back(frame->data);
} }
else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption"))) { else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption"))) {
PyUICaptionObject* caption = (PyUICaptionObject*)item; addDrawable(((PyUICaptionObject*)item)->data);
caption->data->z_index = current_z_index;
self->data->push_back(caption->data);
} }
else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite"))) { else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite"))) {
PyUISpriteObject* sprite = (PyUISpriteObject*)item; addDrawable(((PyUISpriteObject*)item)->data);
sprite->data->z_index = current_z_index;
self->data->push_back(sprite->data);
} }
else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid"))) { else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid"))) {
PyUIGridObject* grid = (PyUIGridObject*)item; addDrawable(((PyUIGridObject*)item)->data);
grid->data->z_index = current_z_index;
self->data->push_back(grid->data);
} }
else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Line"))) { else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Line"))) {
PyUILineObject* line = (PyUILineObject*)item; addDrawable(((PyUILineObject*)item)->data);
line->data->z_index = current_z_index;
self->data->push_back(line->data);
} }
else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Circle"))) { else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Circle"))) {
PyUICircleObject* circle = (PyUICircleObject*)item; addDrawable(((PyUICircleObject*)item)->data);
circle->data->z_index = current_z_index;
self->data->push_back(circle->data);
} }
else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Arc"))) { else if (PyObject_IsInstance(item, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Arc"))) {
PyUIArcObject* arc = (PyUIArcObject*)item; addDrawable(((PyUIArcObject*)item)->data);
arc->data->z_index = current_z_index;
self->data->push_back(arc->data);
} }
Py_DECREF(item); Py_DECREF(item);
@ -825,6 +842,8 @@ PyObject* UICollection::remove(PyUICollectionObject* self, PyObject* o)
// Search for the object and remove first occurrence // Search for the object and remove first occurrence
for (auto it = vec->begin(); it != vec->end(); ++it) { for (auto it = vec->begin(); it != vec->end(); ++it) {
if (it->get() == search_drawable.get()) { if (it->get() == search_drawable.get()) {
// #122: Clear the parent before removing
(*it)->setParent(nullptr);
vec->erase(it); vec->erase(it);
McRFPy_API::markSceneNeedsSort(); McRFPy_API::markSceneNeedsSort();
Py_RETURN_NONE; Py_RETURN_NONE;
@ -868,6 +887,9 @@ PyObject* UICollection::pop(PyUICollectionObject* self, PyObject* args)
// Get the element before removing // Get the element before removing
std::shared_ptr<UIDrawable> drawable = (*vec)[index]; std::shared_ptr<UIDrawable> drawable = (*vec)[index];
// #122: Clear the parent before removing
drawable->setParent(nullptr);
// Remove from vector // Remove from vector
vec->erase(vec->begin() + index); vec->erase(vec->begin() + index);
@ -929,6 +951,14 @@ PyObject* UICollection::insert(PyUICollectionObject* self, PyObject* args)
index = size; index = size;
} }
// #122: Remove from old parent if it has one
if (auto old_parent = drawable->getParent()) {
drawable->removeFromParent();
}
// #122: Set new parent
drawable->setParent(self->owner.lock());
// Insert at position // Insert at position
vec->insert(vec->begin() + index, drawable); vec->insert(vec->begin() + index, drawable);

View File

@ -685,3 +685,215 @@ int UIDrawable::set_pos(PyObject* self, PyObject* value, void* closure) {
drawable->onPositionChanged(); drawable->onPositionChanged();
return 0; return 0;
} }
// #122 - Parent-child hierarchy implementation
void UIDrawable::setParent(std::shared_ptr<UIDrawable> new_parent) {
parent = new_parent;
}
std::shared_ptr<UIDrawable> UIDrawable::getParent() const {
return parent.lock();
}
void UIDrawable::removeFromParent() {
auto p = parent.lock();
if (!p) return;
// Check if parent is a UIFrame (has children vector)
if (p->derived_type() == PyObjectsEnum::UIFRAME) {
auto frame = std::static_pointer_cast<UIFrame>(p);
auto& children = *frame->children;
// Find and remove this drawable from parent's children
// We need to find ourselves - but we don't have shared_from_this
// Instead, compare raw pointers
for (auto it = children.begin(); it != children.end(); ++it) {
if (it->get() == this) {
children.erase(it);
break;
}
}
frame->children_need_sort = true;
}
// TODO: Handle UIGrid children when needed
parent.reset();
}
// #102 - Global position calculation
sf::Vector2f UIDrawable::get_global_position() const {
sf::Vector2f global_pos = position;
auto p = parent.lock();
while (p) {
global_pos += p->position;
p = p->parent.lock();
}
return global_pos;
}
// #116 - Dirty flag propagation up parent chain
void UIDrawable::markDirty() {
if (render_dirty) return; // Already dirty, no need to propagate
render_dirty = true;
// Propagate to parent
auto p = parent.lock();
if (p) {
p->markDirty();
}
}
// Python API - get parent drawable
PyObject* UIDrawable::get_parent(PyObject* self, void* closure) {
PyObjectsEnum objtype = static_cast<PyObjectsEnum>(reinterpret_cast<long>(closure));
UIDrawable* drawable = nullptr;
switch (objtype) {
case PyObjectsEnum::UIFRAME:
drawable = ((PyUIFrameObject*)self)->data.get();
break;
case PyObjectsEnum::UICAPTION:
drawable = ((PyUICaptionObject*)self)->data.get();
break;
case PyObjectsEnum::UISPRITE:
drawable = ((PyUISpriteObject*)self)->data.get();
break;
case PyObjectsEnum::UIGRID:
drawable = ((PyUIGridObject*)self)->data.get();
break;
case PyObjectsEnum::UILINE:
drawable = ((PyUILineObject*)self)->data.get();
break;
case PyObjectsEnum::UICIRCLE:
drawable = ((PyUICircleObject*)self)->data.get();
break;
case PyObjectsEnum::UIARC:
drawable = ((PyUIArcObject*)self)->data.get();
break;
default:
PyErr_SetString(PyExc_TypeError, "Invalid UIDrawable derived instance");
return NULL;
}
auto parent_ptr = drawable->getParent();
if (!parent_ptr) {
Py_RETURN_NONE;
}
// Convert parent to Python object using the cache/conversion system
// Re-use the pattern from UICollection
PyTypeObject* type = nullptr;
PyObject* obj = nullptr;
switch (parent_ptr->derived_type()) {
case PyObjectsEnum::UIFRAME:
{
type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame");
if (!type) return nullptr;
auto pyObj = (PyUIFrameObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UIFrame>(parent_ptr);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UICAPTION:
{
type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption");
if (!type) return nullptr;
auto pyObj = (PyUICaptionObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UICaption>(parent_ptr);
pyObj->font = nullptr;
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UISPRITE:
{
type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite");
if (!type) return nullptr;
auto pyObj = (PyUISpriteObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UISprite>(parent_ptr);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
case PyObjectsEnum::UIGRID:
{
type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid");
if (!type) return nullptr;
auto pyObj = (PyUIGridObject*)type->tp_alloc(type, 0);
if (pyObj) {
pyObj->data = std::static_pointer_cast<UIGrid>(parent_ptr);
pyObj->weakreflist = NULL;
}
obj = (PyObject*)pyObj;
break;
}
default:
Py_RETURN_NONE;
}
if (type) {
Py_DECREF(type);
}
return obj;
}
// Python API - get global position (read-only)
PyObject* UIDrawable::get_global_pos(PyObject* self, void* closure) {
PyObjectsEnum objtype = static_cast<PyObjectsEnum>(reinterpret_cast<long>(closure));
UIDrawable* drawable = nullptr;
switch (objtype) {
case PyObjectsEnum::UIFRAME:
drawable = ((PyUIFrameObject*)self)->data.get();
break;
case PyObjectsEnum::UICAPTION:
drawable = ((PyUICaptionObject*)self)->data.get();
break;
case PyObjectsEnum::UISPRITE:
drawable = ((PyUISpriteObject*)self)->data.get();
break;
case PyObjectsEnum::UIGRID:
drawable = ((PyUIGridObject*)self)->data.get();
break;
case PyObjectsEnum::UILINE:
drawable = ((PyUILineObject*)self)->data.get();
break;
case PyObjectsEnum::UICIRCLE:
drawable = ((PyUICircleObject*)self)->data.get();
break;
case PyObjectsEnum::UIARC:
drawable = ((PyUIArcObject*)self)->data.get();
break;
default:
PyErr_SetString(PyExc_TypeError, "Invalid UIDrawable derived instance");
return NULL;
}
sf::Vector2f global_pos = drawable->get_global_position();
// Create a Python Vector object
PyObject* module = PyImport_ImportModule("mcrfpy");
if (!module) return NULL;
PyObject* vector_type = PyObject_GetAttrString(module, "Vector");
Py_DECREF(module);
if (!vector_type) return NULL;
PyObject* args = Py_BuildValue("(ff)", global_pos.x, global_pos.y);
PyObject* result = PyObject_CallObject(vector_type, args);
Py_DECREF(vector_type);
Py_DECREF(args);
return result;
}

View File

@ -5,6 +5,7 @@
#include "IndexTexture.h" #include "IndexTexture.h"
#include "Resources.h" #include "Resources.h"
#include <list> #include <list>
#include <memory>
#include "PyCallable.h" #include "PyCallable.h"
#include "PyTexture.h" #include "PyTexture.h"
@ -77,6 +78,25 @@ public:
// Position in pixel coordinates (moved from derived classes) // Position in pixel coordinates (moved from derived classes)
sf::Vector2f position; sf::Vector2f position;
// Parent-child hierarchy (#122)
std::weak_ptr<UIDrawable> parent;
// Set the parent of this drawable (called by collections when adding)
void setParent(std::shared_ptr<UIDrawable> new_parent);
// Get the parent drawable (returns nullptr if no parent or expired)
std::shared_ptr<UIDrawable> getParent() const;
// Remove this drawable from its current parent's children
void removeFromParent();
// Get the global (screen) position by walking up the parent chain (#102)
sf::Vector2f get_global_position() const;
// Python API for parent/global_position
static PyObject* get_parent(PyObject* self, void* closure);
static PyObject* get_global_pos(PyObject* self, void* closure);
// New properties for Phase 1 // New properties for Phase 1
bool visible = true; // #87 - visibility flag bool visible = true; // #87 - visibility flag
float opacity = 1.0f; // #88 - opacity (0.0 = transparent, 1.0 = opaque) float opacity = 1.0f; // #88 - opacity (0.0 = transparent, 1.0 = opaque)
@ -117,13 +137,20 @@ protected:
void updateRenderTexture(); void updateRenderTexture();
public: public:
// Mark this drawable as needing redraw // Mark this drawable as needing redraw (#116 - propagates up parent chain)
void markDirty() { render_dirty = true; } void markDirty();
// Check if this drawable needs redraw
bool isDirty() const { return render_dirty; }
// Clear dirty flag (called after rendering)
void clearDirty() { render_dirty = false; }
}; };
typedef struct { typedef struct {
PyObject_HEAD PyObject_HEAD
std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>> data; std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>> data;
std::weak_ptr<UIDrawable> owner; // #122: Parent drawable (for Frame.children, Grid.children)
} PyUICollectionObject; } PyUICollectionObject;
typedef struct { typedef struct {

View File

@ -168,11 +168,13 @@ void UIFrame::render(sf::Vector2f offset, sf::RenderTarget& target)
PyObject* UIFrame::get_children(PyUIFrameObject* self, void* closure) PyObject* UIFrame::get_children(PyUIFrameObject* self, void* closure)
{ {
// create PyUICollection instance pointing to self->data->children // create PyUICollection instance pointing to self->data->children
//PyUICollectionObject* o = (PyUICollectionObject*)mcrfpydef::PyUICollectionType.tp_alloc(&mcrfpydef::PyUICollectionType, 0);
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "UICollection"); auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "UICollection");
auto o = (PyUICollectionObject*)type->tp_alloc(type, 0); auto o = (PyUICollectionObject*)type->tp_alloc(type, 0);
if (o) Py_DECREF(type);
if (o) {
o->data = self->data->children; o->data = self->data->children;
o->owner = self->data; // #122: Set owner for parent tracking
}
return (PyObject*)o; return (PyObject*)o;
} }
@ -412,6 +414,7 @@ PyGetSetDef UIFrame::getsetters[] = {
{"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos, "Position as a Vector", (void*)PyObjectsEnum::UIFRAME}, {"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos, "Position as a Vector", (void*)PyObjectsEnum::UIFRAME},
{"clip_children", (getter)UIFrame::get_clip_children, (setter)UIFrame::set_clip_children, "Whether to clip children to frame bounds", NULL}, {"clip_children", (getter)UIFrame::get_clip_children, (setter)UIFrame::set_clip_children, "Whether to clip children to frame bounds", NULL},
UIDRAWABLE_GETSETTERS, UIDRAWABLE_GETSETTERS,
UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIFRAME),
{NULL} {NULL}
}; };

View File

@ -1499,6 +1499,7 @@ PyGetSetDef UIGrid::getsetters[] = {
), (void*)PyObjectsEnum::UIGRID}, ), (void*)PyObjectsEnum::UIGRID},
{"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UIGRID}, {"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UIGRID},
UIDRAWABLE_GETSETTERS, UIDRAWABLE_GETSETTERS,
UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIGRID),
{NULL} /* Sentinel */ {NULL} /* Sentinel */
}; };
@ -1519,8 +1520,10 @@ PyObject* UIGrid::get_children(PyUIGridObject* self, void* closure)
// Returns UICollection for UIDrawable children (speech bubbles, effects, overlays) // Returns UICollection for UIDrawable children (speech bubbles, effects, overlays)
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "UICollection"); auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "UICollection");
auto o = (PyUICollectionObject*)type->tp_alloc(type, 0); auto o = (PyUICollectionObject*)type->tp_alloc(type, 0);
Py_DECREF(type);
if (o) { if (o) {
o->data = self->data->children; o->data = self->data->children;
o->owner = self->data; // #122: Set owner for parent tracking
} }
return (PyObject*)o; return (PyObject*)o;
} }

View File

@ -435,6 +435,7 @@ PyGetSetDef UILine::getsetters[] = {
MCRF_PROPERTY(pos, "Position as a Vector (midpoint of line)."), MCRF_PROPERTY(pos, "Position as a Vector (midpoint of line)."),
(void*)PyObjectsEnum::UILINE}, (void*)PyObjectsEnum::UILINE},
UIDRAWABLE_GETSETTERS, UIDRAWABLE_GETSETTERS,
UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UILINE),
{NULL} {NULL}
}; };

View File

@ -352,6 +352,7 @@ PyGetSetDef UISprite::getsetters[] = {
{"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UISPRITE}, {"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UISPRITE},
{"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos, "Position as a Vector", (void*)PyObjectsEnum::UISPRITE}, {"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos, "Position as a Vector", (void*)PyObjectsEnum::UISPRITE},
UIDRAWABLE_GETSETTERS, UIDRAWABLE_GETSETTERS,
UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UISPRITE),
{NULL} {NULL}
}; };

View File

@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
Test #122: Parent-Child UI System
Test #102: Global Position Property
Test #116: Dirty Flag System (partial - propagation)
"""
import mcrfpy
import sys
def test_parent_property():
"""Test that children get parent reference when added to Frame"""
print("Testing parent property...")
# Create scene and get UI
mcrfpy.createScene("test")
ui = mcrfpy.sceneUI("test")
# Create a parent frame
parent = mcrfpy.Frame(pos=(100, 100), size=(200, 200))
ui.append(parent)
# Create a child caption
child = mcrfpy.Caption(text="Child", pos=(10, 10))
# Before adding, parent should be None
assert child.parent is None, f"Child should have no parent before adding, got: {child.parent}"
# Add child to parent
parent.children.append(child)
# After adding, parent should be set
assert child.parent is not None, "Child should have parent after adding"
# The parent should be the same Frame we added to
# (checking by position since identity comparison is tricky)
assert child.parent.x == parent.x, f"Parent x mismatch: {child.parent.x} vs {parent.x}"
assert child.parent.y == parent.y, f"Parent y mismatch: {child.parent.y} vs {parent.y}"
print(" - Parent property: PASS")
def test_global_position():
"""Test global position calculation through parent chain"""
print("Testing global_position property...")
# Create scene and get UI
mcrfpy.createScene("test2")
ui = mcrfpy.sceneUI("test2")
# Create nested hierarchy:
# root (50, 50)
# -> child1 (20, 20) -> global (70, 70)
# -> child2 (10, 10) -> global (80, 80)
root = mcrfpy.Frame(pos=(50, 50), size=(200, 200))
ui.append(root)
child1 = mcrfpy.Frame(pos=(20, 20), size=(100, 100))
root.children.append(child1)
child2 = mcrfpy.Caption(text="Deep", pos=(10, 10))
child1.children.append(child2)
# Check global positions
# root has no parent, global should equal local
assert root.global_position.x == 50, f"Root global x: expected 50, got {root.global_position.x}"
assert root.global_position.y == 50, f"Root global y: expected 50, got {root.global_position.y}"
# child1 is at (20, 20) inside root at (50, 50) -> global (70, 70)
assert child1.global_position.x == 70, f"Child1 global x: expected 70, got {child1.global_position.x}"
assert child1.global_position.y == 70, f"Child1 global y: expected 70, got {child1.global_position.y}"
# child2 is at (10, 10) inside child1 at global (70, 70) -> global (80, 80)
assert child2.global_position.x == 80, f"Child2 global x: expected 80, got {child2.global_position.x}"
assert child2.global_position.y == 80, f"Child2 global y: expected 80, got {child2.global_position.y}"
print(" - Global position: PASS")
def test_parent_changes_on_move():
"""Test that moving child to different parent updates parent reference"""
print("Testing parent changes on move...")
mcrfpy.createScene("test3")
ui = mcrfpy.sceneUI("test3")
parent1 = mcrfpy.Frame(pos=(0, 0), size=(100, 100), fill_color=(255, 0, 0, 255))
parent2 = mcrfpy.Frame(pos=(200, 0), size=(100, 100), fill_color=(0, 255, 0, 255))
ui.append(parent1)
ui.append(parent2)
child = mcrfpy.Caption(text="Movable", pos=(5, 5))
parent1.children.append(child)
# Child should be in parent1
assert child.parent is not None, "Child should have parent"
assert child.parent.x == 0, f"Child parent should be parent1, x={child.parent.x}"
# Move child to parent2 (should auto-remove from parent1)
parent2.children.append(child)
# Child should now be in parent2
assert child.parent is not None, "Child should still have parent"
assert child.parent.x == 200, f"Child parent should be parent2, x={child.parent.x}"
# parent1 should have no children
assert len(parent1.children) == 0, f"parent1 should have 0 children, has {len(parent1.children)}"
# parent2 should have one child
assert len(parent2.children) == 1, f"parent2 should have 1 child, has {len(parent2.children)}"
print(" - Parent changes on move: PASS")
def test_remove_clears_parent():
"""Test that removing child clears parent reference"""
print("Testing remove clears parent...")
mcrfpy.createScene("test4")
ui = mcrfpy.sceneUI("test4")
parent = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
ui.append(parent)
child = mcrfpy.Caption(text="Removable", pos=(5, 5))
parent.children.append(child)
assert child.parent is not None, "Child should have parent"
# Remove child
parent.children.remove(child)
assert child.parent is None, f"Child should have no parent after remove, got: {child.parent}"
assert len(parent.children) == 0, f"Parent should have no children after remove"
print(" - Remove clears parent: PASS")
def test_scene_level_elements():
"""Test that scene-level elements have no parent"""
print("Testing scene-level elements...")
mcrfpy.createScene("test5")
ui = mcrfpy.sceneUI("test5")
frame = mcrfpy.Frame(pos=(10, 10), size=(50, 50))
ui.append(frame)
# Scene-level elements should have no parent
assert frame.parent is None, f"Scene-level element should have no parent, got: {frame.parent}"
# Global position should equal local position
assert frame.global_position.x == 10, f"Global x should equal local x"
assert frame.global_position.y == 10, f"Global y should equal local y"
print(" - Scene-level elements: PASS")
def test_all_drawable_types():
"""Test parent/global_position on all drawable types"""
print("Testing all drawable types...")
mcrfpy.createScene("test6")
ui = mcrfpy.sceneUI("test6")
parent = mcrfpy.Frame(pos=(100, 100), size=(300, 300))
ui.append(parent)
# Test all types
types_to_test = [
("Frame", mcrfpy.Frame(pos=(10, 10), size=(50, 50))),
("Caption", mcrfpy.Caption(text="Test", pos=(10, 70))),
("Sprite", mcrfpy.Sprite(pos=(10, 130))), # May need texture
("Grid", mcrfpy.Grid(grid_size=(5, 5), pos=(10, 190), size=(80, 80))),
]
for name, child in types_to_test:
parent.children.append(child)
assert child.parent is not None, f"{name} should have parent"
# Global position should be local + parent's position
expected_x = child.x + 100
expected_y = child.y + 100
assert child.global_position.x == expected_x, f"{name} global_x: expected {expected_x}, got {child.global_position.x}"
assert child.global_position.y == expected_y, f"{name} global_y: expected {expected_y}, got {child.global_position.y}"
print(" - All drawable types: PASS")
if __name__ == "__main__":
try:
test_parent_property()
test_global_position()
test_parent_changes_on_move()
test_remove_clears_parent()
test_scene_level_elements()
test_all_drawable_types()
print("\n=== All tests passed! ===")
sys.exit(0)
except Exception as e:
print(f"\nTEST FAILED: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

View File

@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Test Scene properties (#118: Scene as Drawable)"""
import mcrfpy
import sys
# Create test scenes
mcrfpy.createScene("test_scene")
def test_scene_pos():
"""Test Scene pos property"""
print("Testing scene pos property...")
# Create a Scene subclass to test
class TestScene(mcrfpy.Scene):
def __init__(self, name):
super().__init__(name)
scene = TestScene("scene_pos_test")
# Test initial position
pos = scene.pos
assert pos.x == 0.0, f"Initial pos.x should be 0.0, got {pos.x}"
assert pos.y == 0.0, f"Initial pos.y should be 0.0, got {pos.y}"
# Test setting position with tuple
scene.pos = (100.0, 200.0)
pos = scene.pos
assert pos.x == 100.0, f"pos.x should be 100.0, got {pos.x}"
assert pos.y == 200.0, f"pos.y should be 200.0, got {pos.y}"
# Test setting position with Vector
scene.pos = mcrfpy.Vector(50.0, 75.0)
pos = scene.pos
assert pos.x == 50.0, f"pos.x should be 50.0, got {pos.x}"
assert pos.y == 75.0, f"pos.y should be 75.0, got {pos.y}"
print(" - Scene pos property: PASS")
def test_scene_visible():
"""Test Scene visible property"""
print("Testing scene visible property...")
class TestScene(mcrfpy.Scene):
def __init__(self, name):
super().__init__(name)
scene = TestScene("scene_vis_test")
# Test initial visibility (should be True)
assert scene.visible == True, f"Initial visible should be True, got {scene.visible}"
# Test setting to False
scene.visible = False
assert scene.visible == False, f"visible should be False, got {scene.visible}"
# Test setting back to True
scene.visible = True
assert scene.visible == True, f"visible should be True, got {scene.visible}"
print(" - Scene visible property: PASS")
def test_scene_opacity():
"""Test Scene opacity property"""
print("Testing scene opacity property...")
class TestScene(mcrfpy.Scene):
def __init__(self, name):
super().__init__(name)
scene = TestScene("scene_opa_test")
# Test initial opacity (should be 1.0)
assert abs(scene.opacity - 1.0) < 0.001, f"Initial opacity should be 1.0, got {scene.opacity}"
# Test setting opacity
scene.opacity = 0.5
assert abs(scene.opacity - 0.5) < 0.001, f"opacity should be 0.5, got {scene.opacity}"
# Test clamping to 0.0
scene.opacity = -0.5
assert scene.opacity >= 0.0, f"opacity should be clamped to >= 0.0, got {scene.opacity}"
# Test clamping to 1.0
scene.opacity = 1.5
assert scene.opacity <= 1.0, f"opacity should be clamped to <= 1.0, got {scene.opacity}"
print(" - Scene opacity property: PASS")
def test_scene_name():
"""Test Scene name property (read-only)"""
print("Testing scene name property...")
class TestScene(mcrfpy.Scene):
def __init__(self, name):
super().__init__(name)
scene = TestScene("my_test_scene")
# Test name
assert scene.name == "my_test_scene", f"name should be 'my_test_scene', got {scene.name}"
# Name should be read-only (trying to set should raise)
try:
scene.name = "other_name"
print(" - Scene name should be read-only: FAIL")
sys.exit(1)
except AttributeError:
pass # Expected
print(" - Scene name property: PASS")
def test_scene_active():
"""Test Scene active property"""
print("Testing scene active property...")
class TestScene(mcrfpy.Scene):
def __init__(self, name):
super().__init__(name)
scene1 = TestScene("active_test_1")
scene2 = TestScene("active_test_2")
# Activate scene1
scene1.activate()
assert scene1.active == True, f"scene1.active should be True after activation"
assert scene2.active == False, f"scene2.active should be False"
# Activate scene2
scene2.activate()
assert scene1.active == False, f"scene1.active should be False after activating scene2"
assert scene2.active == True, f"scene2.active should be True"
print(" - Scene active property: PASS")
def test_scene_get_ui():
"""Test Scene get_ui method"""
print("Testing scene get_ui method...")
class TestScene(mcrfpy.Scene):
def __init__(self, name):
super().__init__(name)
scene = TestScene("ui_test_scene")
# Get UI collection
ui = scene.get_ui()
assert ui is not None, "get_ui() should return a collection"
# Add some elements
ui.append(mcrfpy.Frame(pos=(10, 20), size=(100, 100)))
ui.append(mcrfpy.Caption(text="Test", pos=(50, 50)))
# Verify length
assert len(ui) == 2, f"UI should have 2 elements, got {len(ui)}"
print(" - Scene get_ui method: PASS")
# Run all tests
if __name__ == "__main__":
try:
test_scene_pos()
test_scene_visible()
test_scene_opacity()
test_scene_name()
test_scene_active()
test_scene_get_ui()
print("\n=== All Scene property tests passed! ===")
sys.exit(0)
except Exception as e:
print(f"\nFAIL: {e}")
import traceback
traceback.print_exc()
sys.exit(1)