diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 1e0c2f6..dee9c1e 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -13,6 +13,7 @@ #include "PyFOV.h" #include "PyTransition.h" #include "PyEasing.h" +#include "PyAlignment.h" #include "PyKey.h" #include "PyMouseButton.h" #include "PyInputState.h" @@ -603,6 +604,13 @@ PyObject* PyInit_mcrfpy() PyErr_Clear(); } + // Add Alignment enum class for automatic child positioning + PyObject* alignment_class = PyAlignment::create_enum_class(m); + if (!alignment_class) { + // If enum creation fails, continue without it (non-fatal) + PyErr_Clear(); + } + // Add automation submodule PyObject* automation_module = McRFPy_Automation::init_automation_module(); if (automation_module != NULL) { diff --git a/src/PyAlignment.cpp b/src/PyAlignment.cpp new file mode 100644 index 0000000..75c4d34 --- /dev/null +++ b/src/PyAlignment.cpp @@ -0,0 +1,232 @@ +#include "PyAlignment.h" +#include "McRFPy_API.h" + +// Static storage for cached enum class reference +PyObject* PyAlignment::alignment_enum_class = nullptr; + +// Alignment table - maps enum value to name +struct AlignmentEntry { + const char* name; + int value; + AlignmentType type; +}; + +static const AlignmentEntry alignment_table[] = { + {"TOP_LEFT", 0, AlignmentType::TOP_LEFT}, + {"TOP_CENTER", 1, AlignmentType::TOP_CENTER}, + {"TOP_RIGHT", 2, AlignmentType::TOP_RIGHT}, + {"CENTER_LEFT", 3, AlignmentType::CENTER_LEFT}, + {"CENTER", 4, AlignmentType::CENTER}, + {"CENTER_RIGHT", 5, AlignmentType::CENTER_RIGHT}, + {"BOTTOM_LEFT", 6, AlignmentType::BOTTOM_LEFT}, + {"BOTTOM_CENTER", 7, AlignmentType::BOTTOM_CENTER}, + {"BOTTOM_RIGHT", 8, AlignmentType::BOTTOM_RIGHT}, +}; + +// Legacy camelCase names (for backwards compatibility if desired) +static const char* legacy_names[] = { + "topLeft", "topCenter", "topRight", + "centerLeft", "center", "centerRight", + "bottomLeft", "bottomCenter", "bottomRight" +}; + +static const int NUM_ALIGNMENT_ENTRIES = sizeof(alignment_table) / sizeof(alignment_table[0]); + +const char* PyAlignment::alignment_name(AlignmentType value) { + int idx = static_cast(value); + if (idx >= 0 && idx < NUM_ALIGNMENT_ENTRIES) { + return alignment_table[idx].name; + } + return "NONE"; +} + +PyObject* PyAlignment::create_enum_class(PyObject* module) { + // Import IntEnum from enum module + PyObject* enum_module = PyImport_ImportModule("enum"); + if (!enum_module) { + return NULL; + } + + PyObject* int_enum = PyObject_GetAttrString(enum_module, "IntEnum"); + Py_DECREF(enum_module); + if (!int_enum) { + return NULL; + } + + // Create dict of enum members + PyObject* members = PyDict_New(); + if (!members) { + Py_DECREF(int_enum); + return NULL; + } + + // Add all alignment members + for (int i = 0; i < NUM_ALIGNMENT_ENTRIES; i++) { + PyObject* value = PyLong_FromLong(alignment_table[i].value); + if (!value) { + Py_DECREF(members); + Py_DECREF(int_enum); + return NULL; + } + if (PyDict_SetItemString(members, alignment_table[i].name, value) < 0) { + Py_DECREF(value); + Py_DECREF(members); + Py_DECREF(int_enum); + return NULL; + } + Py_DECREF(value); + } + + // Call IntEnum("Alignment", members) to create the enum class + PyObject* name = PyUnicode_FromString("Alignment"); + if (!name) { + Py_DECREF(members); + Py_DECREF(int_enum); + return NULL; + } + + // IntEnum(name, members) using functional API + PyObject* args = PyTuple_Pack(2, name, members); + Py_DECREF(name); + Py_DECREF(members); + if (!args) { + Py_DECREF(int_enum); + return NULL; + } + + PyObject* alignment_class = PyObject_Call(int_enum, args, NULL); + Py_DECREF(args); + Py_DECREF(int_enum); + + if (!alignment_class) { + return NULL; + } + + // Cache the reference for fast type checking + alignment_enum_class = alignment_class; + Py_INCREF(alignment_enum_class); + + // Add docstring to the enum class + static const char* alignment_doc = + "Alignment enum for positioning UI elements relative to parent bounds.\n\n" + "Values:\n" + " TOP_LEFT, TOP_CENTER, TOP_RIGHT\n" + " CENTER_LEFT, CENTER, CENTER_RIGHT\n" + " BOTTOM_LEFT, BOTTOM_CENTER, BOTTOM_RIGHT\n\n" + "Margin Validation Rules:\n" + " Margins define distance from parent edge when aligned.\n\n" + " - CENTER: No margins allowed (raises ValueError if margin != 0)\n" + " - TOP_CENTER, BOTTOM_CENTER: Only vert_margin applies (horiz_margin raises ValueError)\n" + " - CENTER_LEFT, CENTER_RIGHT: Only horiz_margin applies (vert_margin raises ValueError)\n" + " - Corner alignments (TOP_LEFT, etc.): All margins valid\n\n" + "Properties:\n" + " align: Alignment value or None to disable\n" + " margin: General margin for all applicable edges\n" + " horiz_margin: Override for horizontal edge (0 = use general margin)\n" + " vert_margin: Override for vertical edge (0 = use general margin)\n\n" + "Example:\n" + " # Center a panel in the scene\n" + " panel = Frame(size=(200, 100), align=Alignment.CENTER)\n" + " scene.children.append(panel)\n\n" + " # Place button in bottom-right with 10px margin\n" + " button = Frame(size=(80, 30), align=Alignment.BOTTOM_RIGHT, margin=10)\n" + " panel.children.append(button)"; + PyObject* doc = PyUnicode_FromString(alignment_doc); + if (doc) { + PyObject_SetAttrString(alignment_class, "__doc__", doc); + Py_DECREF(doc); + } + + // Add to module + if (PyModule_AddObject(module, "Alignment", alignment_class) < 0) { + Py_DECREF(alignment_class); + alignment_enum_class = nullptr; + return NULL; + } + + return alignment_class; +} + +int PyAlignment::from_arg(PyObject* arg, AlignmentType* out_align, bool* was_none) { + if (was_none) *was_none = false; + + // Accept None -> NONE alignment (no alignment) + if (arg == Py_None) { + if (was_none) *was_none = true; + *out_align = AlignmentType::NONE; + return 1; + } + + // Accept Alignment enum member (check if it's an instance of our enum) + if (alignment_enum_class && PyObject_IsInstance(arg, alignment_enum_class)) { + // IntEnum members have a 'value' attribute + PyObject* value = PyObject_GetAttrString(arg, "value"); + if (!value) { + return 0; + } + long val = PyLong_AsLong(value); + Py_DECREF(value); + if (val == -1 && PyErr_Occurred()) { + return 0; + } + if (val >= 0 && val < NUM_ALIGNMENT_ENTRIES) { + *out_align = alignment_table[val].type; + return 1; + } + PyErr_Format(PyExc_ValueError, + "Invalid Alignment value: %ld. Must be 0-%d.", val, NUM_ALIGNMENT_ENTRIES - 1); + return 0; + } + + // Accept int (for direct enum value access) + if (PyLong_Check(arg)) { + long val = PyLong_AsLong(arg); + if (val == -1 && PyErr_Occurred()) { + return 0; + } + if (val >= 0 && val < NUM_ALIGNMENT_ENTRIES) { + *out_align = alignment_table[val].type; + return 1; + } + PyErr_Format(PyExc_ValueError, + "Invalid alignment value: %ld. Must be 0-%d or use mcrfpy.Alignment enum.", + val, NUM_ALIGNMENT_ENTRIES - 1); + return 0; + } + + // Accept string (for backwards compatibility) + if (PyUnicode_Check(arg)) { + const char* name = PyUnicode_AsUTF8(arg); + if (!name) { + return 0; + } + + // Check legacy camelCase names first + for (int i = 0; i < NUM_ALIGNMENT_ENTRIES; i++) { + if (strcmp(name, legacy_names[i]) == 0) { + *out_align = alignment_table[i].type; + return 1; + } + } + + // Also check enum-style names (TOP_LEFT, CENTER, etc.) + for (int i = 0; i < NUM_ALIGNMENT_ENTRIES; i++) { + if (strcmp(name, alignment_table[i].name) == 0) { + *out_align = alignment_table[i].type; + return 1; + } + } + + // Build error message with available options + PyErr_Format(PyExc_ValueError, + "Unknown alignment: '%s'. Use mcrfpy.Alignment enum (e.g., Alignment.CENTER) " + "or string names: 'topLeft', 'topCenter', 'topRight', 'centerLeft', 'center', " + "'centerRight', 'bottomLeft', 'bottomCenter', 'bottomRight'.", + name); + return 0; + } + + PyErr_SetString(PyExc_TypeError, + "Alignment must be mcrfpy.Alignment enum member, string, int, or None"); + return 0; +} diff --git a/src/PyAlignment.h b/src/PyAlignment.h new file mode 100644 index 0000000..9ee2e92 --- /dev/null +++ b/src/PyAlignment.h @@ -0,0 +1,42 @@ +#pragma once +#include "Common.h" +#include "Python.h" + +// Alignment type enum - used internally in C++ +enum class AlignmentType { + NONE = -1, // No alignment (static positioning) + TOP_LEFT = 0, + TOP_CENTER = 1, + TOP_RIGHT = 2, + CENTER_LEFT = 3, + CENTER = 4, + CENTER_RIGHT = 5, + BOTTOM_LEFT = 6, + BOTTOM_CENTER = 7, + BOTTOM_RIGHT = 8 +}; + +// Module-level Alignment enum class (created at runtime using Python's IntEnum) +// Stored as a module attribute: mcrfpy.Alignment + +class PyAlignment { +public: + // Create the Alignment enum class and add to module + // Returns the enum class (new reference), or NULL on error + static PyObject* create_enum_class(PyObject* module); + + // Helper to extract alignment from Python arg + // Accepts Alignment enum, string, int, or None + // Returns 1 on success, 0 on error (with exception set) + // If arg is None, sets *out_align to NONE and sets *was_none to true + static int from_arg(PyObject* arg, AlignmentType* out_align, bool* was_none = nullptr); + + // Convert alignment enum value to string name + static const char* alignment_name(AlignmentType value); + + // Cached reference to the Alignment enum class for fast type checking + static PyObject* alignment_enum_class; + + // Number of alignment options (excluding NONE) + static const int NUM_ALIGNMENTS = 9; +}; diff --git a/src/PySceneObject.cpp b/src/PySceneObject.cpp index e388384..8104c2a 100644 --- a/src/PySceneObject.cpp +++ b/src/PySceneObject.cpp @@ -500,6 +500,30 @@ PyGetSetDef PySceneClass::getsetters[] = { {NULL} }; +// Scene.realign() - recalculate alignment for all children +static PyObject* PySceneClass_realign(PySceneObject* self, PyObject* args) +{ + GameEngine* game = McRFPy_API::game; + if (!game) { + PyErr_SetString(PyExc_RuntimeError, "No game engine"); + return NULL; + } + + auto scene = game->getScene(self->name); + if (!scene || !scene->ui_elements) { + Py_RETURN_NONE; + } + + // Iterate through all UI elements and realign those with alignment set + for (auto& drawable : *scene->ui_elements) { + if (drawable && drawable->align_type != AlignmentType::NONE) { + drawable->applyAlignment(); + } + } + + Py_RETURN_NONE; +} + // Methods PyMethodDef PySceneClass::methods[] = { {"activate", (PyCFunction)activate, METH_VARARGS | METH_KEYWORDS, @@ -521,6 +545,13 @@ PyMethodDef PySceneClass::methods[] = { MCRF_RETURNS("None") MCRF_NOTE("Alternative to setting on_key property. Handler is called for both key press and release events.") )}, + {"realign", (PyCFunction)PySceneClass_realign, METH_NOARGS, + MCRF_METHOD(SceneClass, realign, + MCRF_SIG("()", "None"), + MCRF_DESC("Recalculate alignment for all children with alignment set."), + MCRF_NOTE("Call this after window resize or when game_resolution changes. " + "For responsive layouts, connect this to on_resize callback.") + )}, {NULL} }; diff --git a/src/UIArc.cpp b/src/UIArc.cpp index 9398fdf..a92d7ef 100644 --- a/src/UIArc.cpp +++ b/src/UIArc.cpp @@ -1,6 +1,7 @@ #include "UIArc.h" #include "McRFPy_API.h" #include "PythonObjectCache.h" +#include "PyAlignment.h" #include #include @@ -206,6 +207,12 @@ void UIArc::resize(float w, float h) { vertices_dirty = true; } +void UIArc::onPositionChanged() { + // Sync center from position (for alignment system) + center = position; + vertices_dirty = true; +} + // Property setters bool UIArc::setProperty(const std::string& name, float value) { if (name == "radius") { @@ -443,6 +450,7 @@ PyGetSetDef UIArc::getsetters[] = { "Position as a Vector (same as center).", (void*)PyObjectsEnum::UIARC}, UIDRAWABLE_GETSETTERS, UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIARC), + UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UIARC), {NULL} }; @@ -481,17 +489,23 @@ int UIArc::init(PyUIArcObject* self, PyObject* args, PyObject* kwds) { float opacity = 1.0f; int z_index = 0; const char* name = nullptr; + PyObject* align_obj = nullptr; // Alignment enum or None + float margin = 0.0f; + float horiz_margin = -1.0f; + float vert_margin = -1.0f; static const char* kwlist[] = { "center", "radius", "start_angle", "end_angle", "color", "thickness", "on_click", "visible", "opacity", "z_index", "name", + "align", "margin", "horiz_margin", "vert_margin", nullptr }; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OfffOfOifiz", const_cast(kwlist), + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OfffOfOifizOfff", const_cast(kwlist), ¢er_obj, &radius, &start_angle, &end_angle, &color_obj, &thickness, - &click_handler, &visible, &opacity, &z_index, &name)) { + &click_handler, &visible, &opacity, &z_index, &name, + &align_obj, &margin, &horiz_margin, &vert_margin)) { return -1; } @@ -546,6 +560,9 @@ int UIArc::init(PyUIArcObject* self, PyObject* args, PyObject* kwds) { self->data->name = name; } + // Process alignment arguments + UIDRAWABLE_PROCESS_ALIGNMENT(self->data, align_obj, margin, horiz_margin, vert_margin); + // Register in Python object cache if (self->data->serial_number == 0) { self->data->serial_number = PythonObjectCache::getInstance().assignSerial(); diff --git a/src/UIArc.h b/src/UIArc.h index d8bef3a..888b8ab 100644 --- a/src/UIArc.h +++ b/src/UIArc.h @@ -76,6 +76,7 @@ public: sf::FloatRect get_bounds() const override; void move(float dx, float dy) override; void resize(float w, float h) override; + void onPositionChanged() override; // Property system for animations bool setProperty(const std::string& name, float value) override; @@ -140,7 +141,11 @@ namespace mcrfpydef { " visible (bool): Visibility state. Default: True\n" " opacity (float): Opacity (0.0-1.0). Default: 1.0\n" " z_index (int): Rendering order. Default: 0\n" - " name (str): Element name for finding. Default: None\n\n" + " name (str): Element name for finding. Default: None\n" + " align (Alignment): Alignment relative to parent. Default: None\n" + " margin (float): Margin from parent edge when aligned. Default: 0\n" + " horiz_margin (float): Horizontal margin override. Default: 0 (use margin)\n" + " vert_margin (float): Vertical margin override. Default: 0 (use margin)\n\n" "Attributes:\n" " center (Vector): Center position\n" " radius (float): Arc radius\n" @@ -152,6 +157,10 @@ namespace mcrfpydef { " opacity (float): Opacity value\n" " z_index (int): Rendering order\n" " name (str): Element name\n" + " align (Alignment): Alignment relative to parent (or None)\n" + " margin (float): General margin for alignment\n" + " horiz_margin (float): Horizontal margin override\n" + " vert_margin (float): Vertical margin override\n" ), .tp_methods = UIArc_methods, .tp_getset = UIArc::getsetters, diff --git a/src/UIBase.h b/src/UIBase.h index 2a89e15..f02fe1c 100644 --- a/src/UIBase.h +++ b/src/UIBase.h @@ -123,11 +123,38 @@ static PyObject* UIDrawable_animate(T* self, PyObject* args, PyObject* kwds) MCRF_RETURNS("Animation object for monitoring progress") \ MCRF_RAISES("ValueError", "If property name is not valid for this drawable type") \ MCRF_NOTE("This is a convenience method that creates an Animation, starts it, and adds it to the AnimationManager.") \ + )}, \ + {"realign", (PyCFunction)UIDrawable::py_realign, METH_NOARGS, \ + MCRF_METHOD(Drawable, realign, \ + MCRF_SIG("()", "None"), \ + MCRF_DESC("Reapply alignment relative to parent, useful for responsive layouts."), \ + MCRF_NOTE("Call this to recalculate position after parent changes size. " \ + "For elements with align=None, this has no effect.") \ )} // Legacy macro for backwards compatibility - same as UIDRAWABLE_METHODS #define UIDRAWABLE_METHODS_FULL UIDRAWABLE_METHODS +// Macro for handling alignment in constructors +// Usage: UIDRAWABLE_PROCESS_ALIGNMENT(self->data, align_obj, margin, horiz_margin, vert_margin) +// Returns -1 on error (suitable for use in tp_init functions) +#define UIDRAWABLE_PROCESS_ALIGNMENT(self_data, align_obj, margin, horiz_margin, vert_margin) \ + do { \ + if ((align_obj) && (align_obj) != Py_None) { \ + AlignmentType _align; \ + if (!PyAlignment::from_arg(align_obj, &_align)) { \ + return -1; \ + } \ + if (!UIDrawable::validateMargins(_align, margin, horiz_margin, vert_margin)) { \ + return -1; \ + } \ + (self_data)->align_type = _align; \ + (self_data)->align_margin = margin; \ + (self_data)->align_horiz_margin = horiz_margin; \ + (self_data)->align_vert_margin = vert_margin; \ + } \ + } while (0) + // Property getters/setters for visible and opacity template static PyObject* UIDrawable_get_visible(T* self, void* closure) @@ -230,4 +257,29 @@ static int UIDrawable_set_opacity(T* self, PyObject* value, void* closure) "Performance note: Called frequently during movement - keep handlers fast." \ ), (void*)type_enum} +// Alignment system - automatic positioning relative to parent bounds +#define UIDRAWABLE_ALIGNMENT_GETSETTERS(type_enum) \ + {"align", (getter)UIDrawable::get_align, (setter)UIDrawable::set_align, \ + MCRF_PROPERTY(align, \ + "Alignment relative to parent bounds (Alignment enum or None). " \ + "When set, position is automatically calculated when parent is assigned or resized. " \ + "Set to None to disable alignment and use manual positioning." \ + ), (void*)type_enum}, \ + {"margin", (getter)UIDrawable::get_margin, (setter)UIDrawable::set_margin, \ + MCRF_PROPERTY(margin, \ + "General margin from edge when aligned (float). " \ + "Applied to both horizontal and vertical edges unless overridden. " \ + "Invalid for CENTER alignment (raises ValueError)." \ + ), (void*)type_enum}, \ + {"horiz_margin", (getter)UIDrawable::get_horiz_margin, (setter)UIDrawable::set_horiz_margin, \ + MCRF_PROPERTY(horiz_margin, \ + "Horizontal margin override (float, 0 = use general margin). " \ + "Invalid for vertically-centered alignments (TOP_CENTER, BOTTOM_CENTER, CENTER)." \ + ), (void*)type_enum}, \ + {"vert_margin", (getter)UIDrawable::get_vert_margin, (setter)UIDrawable::set_vert_margin, \ + MCRF_PROPERTY(vert_margin, \ + "Vertical margin override (float, 0 = use general margin). " \ + "Invalid for horizontally-centered alignments (CENTER_LEFT, CENTER_RIGHT, CENTER)." \ + ), (void*)type_enum} + // UIEntity specializations are defined in UIEntity.cpp after UIEntity class is complete diff --git a/src/UICaption.cpp b/src/UICaption.cpp index d6527bd..44d0e84 100644 --- a/src/UICaption.cpp +++ b/src/UICaption.cpp @@ -4,6 +4,7 @@ #include "PyVector.h" #include "PyFont.h" #include "PythonObjectCache.h" +#include "PyAlignment.h" // UIDrawable methods now in UIBase.h #include @@ -310,6 +311,7 @@ PyGetSetDef UICaption::getsetters[] = { {"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UICAPTION}, UIDRAWABLE_GETSETTERS, UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UICAPTION), + UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UICAPTION), {NULL} }; @@ -350,21 +352,27 @@ int UICaption::init(PyUICaptionObject* self, PyObject* args, PyObject* kwds) int z_index = 0; const char* name = nullptr; float x = 0.0f, y = 0.0f; - + PyObject* align_obj = nullptr; // Alignment enum or None + float margin = 0.0f; + float horiz_margin = -1.0f; + float vert_margin = -1.0f; + // Keywords list matches the new spec: positional args first, then all keyword args static const char* kwlist[] = { "pos", "font", "text", // Positional args (as per spec) // Keyword-only args "fill_color", "outline_color", "outline", "font_size", "on_click", "visible", "opacity", "z_index", "name", "x", "y", + "align", "margin", "horiz_margin", "vert_margin", nullptr }; - + // Parse arguments with | for optional positional args - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOzOOffOifizff", const_cast(kwlist), + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOzOOffOifizffOfff", const_cast(kwlist), &pos_obj, &font, &text, // Positional &fill_color, &outline_color, &outline, &font_size, &click_handler, - &visible, &opacity, &z_index, &name, &x, &y)) { + &visible, &opacity, &z_index, &name, &x, &y, + &align_obj, &margin, &horiz_margin, &vert_margin)) { return -1; } @@ -463,7 +471,10 @@ int UICaption::init(PyUICaptionObject* self, PyObject* args, PyObject* kwds) if (name) { self->data->name = std::string(name); } - + + // Process alignment arguments + UIDRAWABLE_PROCESS_ALIGNMENT(self->data, align_obj, margin, horiz_margin, vert_margin); + // Handle click handler if (click_handler && click_handler != Py_None) { if (!PyCallable_Check(click_handler)) { @@ -472,7 +483,7 @@ int UICaption::init(PyUICaptionObject* self, PyObject* args, PyObject* kwds) } self->data->click_register(click_handler); } - + // Initialize weak reference list self->weakreflist = NULL; diff --git a/src/UICaption.h b/src/UICaption.h index 09aa16c..da4fe5c 100644 --- a/src/UICaption.h +++ b/src/UICaption.h @@ -91,7 +91,11 @@ namespace mcrfpydef { " z_index (int): Rendering order. Default: 0\n" " name (str): Element name for finding. Default: None\n" " x (float): X position override. Default: 0\n" - " y (float): Y position override. Default: 0\n\n" + " y (float): Y position override. Default: 0\n" + " align (Alignment): Alignment relative to parent. Default: None\n" + " margin (float): Margin from parent edge when aligned. Default: 0\n" + " horiz_margin (float): Horizontal margin override. Default: 0 (use margin)\n" + " vert_margin (float): Vertical margin override. Default: 0 (use margin)\n\n" "Attributes:\n" " text (str): The displayed text content\n" " x, y (float): Position in pixels\n" @@ -105,7 +109,11 @@ namespace mcrfpydef { " opacity (float): Opacity value\n" " z_index (int): Rendering order\n" " name (str): Element name\n" - " w, h (float): Read-only computed size based on text and font"), + " w, h (float): Read-only computed size based on text and font\n" + " align (Alignment): Alignment relative to parent (or None)\n" + " margin (float): General margin for alignment\n" + " horiz_margin (float): Horizontal margin override\n" + " vert_margin (float): Vertical margin override"), .tp_methods = UICaption_methods, //.tp_members = PyUIFrame_members, .tp_getset = UICaption::getsetters, diff --git a/src/UICircle.cpp b/src/UICircle.cpp index 0f8a22e..1e3451b 100644 --- a/src/UICircle.cpp +++ b/src/UICircle.cpp @@ -4,6 +4,7 @@ #include "PyVector.h" #include "PyColor.h" #include "PythonObjectCache.h" +#include "PyAlignment.h" #include UICircle::UICircle() @@ -395,6 +396,7 @@ PyGetSetDef UICircle::getsetters[] = { "Position as a Vector (same as center).", (void*)PyObjectsEnum::UICIRCLE}, UIDRAWABLE_GETSETTERS, UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UICIRCLE), + UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UICIRCLE), {NULL} }; @@ -418,7 +420,8 @@ PyObject* UICircle::repr(PyUICircleObject* self) { int UICircle::init(PyUICircleObject* self, PyObject* args, PyObject* kwds) { static const char* kwlist[] = { "radius", "center", "fill_color", "outline_color", "outline", - "on_click", "visible", "opacity", "z_index", "name", NULL + "on_click", "visible", "opacity", "z_index", "name", + "align", "margin", "horiz_margin", "vert_margin", NULL }; float radius = 10.0f; @@ -433,10 +436,15 @@ int UICircle::init(PyUICircleObject* self, PyObject* args, PyObject* kwds) { float opacity_val = 1.0f; int z_index = 0; const char* name = NULL; + PyObject* align_obj = NULL; // Alignment enum or None + float margin = 0.0f; + float horiz_margin = -1.0f; + float vert_margin = -1.0f; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|fOOOfOpfis", (char**)kwlist, + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|fOOOfOpfisOfff", (char**)kwlist, &radius, ¢er_obj, &fill_color_obj, &outline_color_obj, &outline, - &click_obj, &visible, &opacity_val, &z_index, &name)) { + &click_obj, &visible, &opacity_val, &z_index, &name, + &align_obj, &margin, &horiz_margin, &vert_margin)) { return -1; } @@ -512,6 +520,9 @@ int UICircle::init(PyUICircleObject* self, PyObject* args, PyObject* kwds) { self->data->name = name; } + // Process alignment arguments + UIDRAWABLE_PROCESS_ALIGNMENT(self->data, align_obj, margin, horiz_margin, vert_margin); + // Register in Python object cache if (self->data->serial_number == 0) { self->data->serial_number = PythonObjectCache::getInstance().assignSerial(); diff --git a/src/UICircle.h b/src/UICircle.h index 5210b2e..5928808 100644 --- a/src/UICircle.h +++ b/src/UICircle.h @@ -129,7 +129,11 @@ namespace mcrfpydef { " visible (bool): Visibility state. Default: True\n" " opacity (float): Opacity (0.0-1.0). Default: 1.0\n" " z_index (int): Rendering order. Default: 0\n" - " name (str): Element name for finding. Default: None\n\n" + " name (str): Element name for finding. Default: None\n" + " align (Alignment): Alignment relative to parent. Default: None\n" + " margin (float): Margin from parent edge when aligned. Default: 0\n" + " horiz_margin (float): Horizontal margin override. Default: 0 (use margin)\n" + " vert_margin (float): Vertical margin override. Default: 0 (use margin)\n\n" "Attributes:\n" " radius (float): Circle radius\n" " center (Vector): Center position\n" @@ -140,6 +144,10 @@ namespace mcrfpydef { " opacity (float): Opacity value\n" " z_index (int): Rendering order\n" " name (str): Element name\n" + " align (Alignment): Alignment relative to parent (or None)\n" + " margin (float): General margin for alignment\n" + " horiz_margin (float): Horizontal margin override\n" + " vert_margin (float): Vertical margin override\n" ), .tp_methods = UICircle_methods, .tp_getset = UICircle::getsetters, diff --git a/src/UIDrawable.cpp b/src/UIDrawable.cpp index a51d57f..1accce5 100644 --- a/src/UIDrawable.cpp +++ b/src/UIDrawable.cpp @@ -14,6 +14,30 @@ #include "PyEasing.h" #include "PySceneObject.h" // #183: For scene parent lookup +// Helper function to extract UIDrawable* from any Python UI object +// Returns nullptr and sets Python error on failure +static UIDrawable* extractDrawable(PyObject* self, PyObjectsEnum objtype) { + switch (objtype) { + case PyObjectsEnum::UIFRAME: + return ((PyUIFrameObject*)self)->data.get(); + case PyObjectsEnum::UICAPTION: + return ((PyUICaptionObject*)self)->data.get(); + case PyObjectsEnum::UISPRITE: + return ((PyUISpriteObject*)self)->data.get(); + case PyObjectsEnum::UIGRID: + return ((PyUIGridObject*)self)->data.get(); + case PyObjectsEnum::UILINE: + return ((PyUILineObject*)self)->data.get(); + case PyObjectsEnum::UICIRCLE: + return ((PyUICircleObject*)self)->data.get(); + case PyObjectsEnum::UIARC: + return ((PyUIArcObject*)self)->data.get(); + default: + PyErr_SetString(PyExc_TypeError, "Invalid UIDrawable derived instance"); + return nullptr; + } +} + UIDrawable::UIDrawable() : position(0.0f, 0.0f) { click_callable = NULL; } UIDrawable::UIDrawable(const UIDrawable& other) @@ -306,68 +330,16 @@ void UIDrawable::on_move_unregister() PyObject* UIDrawable::get_int(PyObject* self, void* closure) { PyObjectsEnum objtype = static_cast(reinterpret_cast(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; - } + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; return PyLong_FromLong(drawable->z_index); } int UIDrawable::set_int(PyObject* self, PyObject* value, void* closure) { PyObjectsEnum objtype = static_cast(reinterpret_cast(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 -1; - } + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return -1; if (!PyLong_Check(value)) { PyErr_SetString(PyExc_TypeError, "z_index must be an integer"); @@ -406,68 +378,16 @@ void UIDrawable::notifyZIndexChanged() { PyObject* UIDrawable::get_name(PyObject* self, void* closure) { PyObjectsEnum objtype = static_cast(reinterpret_cast(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; - } + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; return PyUnicode_FromString(drawable->name.c_str()); } int UIDrawable::set_name(PyObject* self, PyObject* value, void* closure) { PyObjectsEnum objtype = static_cast(reinterpret_cast(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 -1; - } + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return -1; if (value == NULL || value == Py_None) { drawable->name = ""; @@ -524,34 +444,8 @@ void UIDrawable::updateRenderTexture() { PyObject* UIDrawable::get_float_member(PyObject* self, void* closure) { PyObjectsEnum objtype = static_cast(reinterpret_cast(closure) >> 8); int member = reinterpret_cast(closure) & 0xFF; - 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; - } + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; switch (member) { case 0: // x @@ -571,34 +465,8 @@ PyObject* UIDrawable::get_float_member(PyObject* self, void* closure) { int UIDrawable::set_float_member(PyObject* self, PyObject* value, void* closure) { PyObjectsEnum objtype = static_cast(reinterpret_cast(closure) >> 8); int member = reinterpret_cast(closure) & 0xFF; - 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 -1; - } + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return -1; float val = 0.0f; if (PyFloat_Check(value)) { @@ -640,34 +508,8 @@ int UIDrawable::set_float_member(PyObject* self, PyObject* value, void* closure) PyObject* UIDrawable::get_pos(PyObject* self, void* closure) { PyObjectsEnum objtype = static_cast(reinterpret_cast(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; - } + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; // Create a Python Vector object from position PyObject* module = PyImport_ImportModule("mcrfpy"); @@ -687,34 +529,8 @@ PyObject* UIDrawable::get_pos(PyObject* self, void* closure) { int UIDrawable::set_pos(PyObject* self, PyObject* value, void* closure) { PyObjectsEnum objtype = static_cast(reinterpret_cast(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 -1; - } + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return -1; // Accept tuple or Vector float x, y; @@ -766,11 +582,21 @@ int UIDrawable::set_pos(PyObject* self, PyObject* value, void* closure) { void UIDrawable::setParent(std::shared_ptr new_parent) { parent = new_parent; parent_scene.clear(); // #183: Clear scene parent when setting drawable parent + + // Apply alignment when parent is set (if alignment is configured) + if (new_parent && align_type != AlignmentType::NONE) { + applyAlignment(); + } } void UIDrawable::setParentScene(const std::string& scene_name) { parent.reset(); // #183: Clear drawable parent when setting scene parent parent_scene = scene_name; + + // Apply alignment when scene parent is set (if alignment is configured) + if (!scene_name.empty() && align_type != AlignmentType::NONE) { + applyAlignment(); + } } std::shared_ptr UIDrawable::getParent() const { @@ -893,34 +719,8 @@ void UIDrawable::markDirty() { // Python API - get parent drawable PyObject* UIDrawable::get_parent(PyObject* self, void* closure) { PyObjectsEnum objtype = static_cast(reinterpret_cast(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; - } + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; // #183: Check for scene parent first if (!drawable->parent_scene.empty()) { @@ -1126,34 +926,8 @@ int UIDrawable::set_parent(PyObject* self, PyObject* value, void* closure) { // Python API - get global position (read-only) PyObject* UIDrawable::get_global_pos(PyObject* self, void* closure) { PyObjectsEnum objtype = static_cast(reinterpret_cast(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; - } + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; sf::Vector2f global_pos = drawable->get_global_position(); @@ -1176,34 +950,8 @@ PyObject* UIDrawable::get_global_pos(PyObject* self, void* closure) { // #138, #188 - Python API for bounds property - returns (pos, size) as pair of Vectors PyObject* UIDrawable::get_bounds_py(PyObject* self, void* closure) { PyObjectsEnum objtype = static_cast(reinterpret_cast(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; - } + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; sf::FloatRect bounds = drawable->get_bounds(); @@ -1237,34 +985,8 @@ PyObject* UIDrawable::get_bounds_py(PyObject* self, void* closure) { // #138, #188 - Python API for global_bounds property - returns (pos, size) as pair of Vectors PyObject* UIDrawable::get_global_bounds_py(PyObject* self, void* closure) { PyObjectsEnum objtype = static_cast(reinterpret_cast(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; - } + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; sf::FloatRect bounds = drawable->get_global_bounds(); @@ -1464,34 +1186,8 @@ int UIDrawable::set_on_exit(PyObject* self, PyObject* value, void* closure) { // #140 - Python API for hovered property (read-only) PyObject* UIDrawable::get_hovered(PyObject* self, void* closure) { PyObjectsEnum objtype = static_cast(reinterpret_cast(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; - } + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; return PyBool_FromLong(drawable->hovered); } @@ -1804,3 +1500,358 @@ void UIDrawable::refreshCallbackCache(PyObject* pyObj) { Py_XDECREF(attr); PyErr_Clear(); } + +// ============================================================================ +// Alignment System Implementation +// ============================================================================ + +void UIDrawable::applyAlignment() { + if (align_type == AlignmentType::NONE) return; + + float pw, ph; // Parent width/height + auto p = parent.lock(); + + if (p) { + // Parent is another UIDrawable (Frame, Grid, etc.) + sf::FloatRect parent_bounds = p->get_bounds(); + pw = parent_bounds.width; + ph = parent_bounds.height; + } else if (!parent_scene.empty()) { + // Parent is a Scene - use window's game resolution + GameEngine* game = McRFPy_API::game; + if (!game) return; + sf::Vector2u resolution = game->getGameResolution(); + pw = static_cast(resolution.x); + ph = static_cast(resolution.y); + } else { + return; // No parent at all = can't align + } + + sf::FloatRect self_bounds = get_bounds(); + float cw = self_bounds.width, ch = self_bounds.height; + + // Use specific margins if set (>= 0), otherwise inherit from general margin + // -1.0 means "inherit", any value >= 0 is an explicit override + float mx = (align_horiz_margin >= 0.0f) ? align_horiz_margin : align_margin; + float my = (align_vert_margin >= 0.0f) ? align_vert_margin : align_margin; + + float x = 0, y = 0; + switch (align_type) { + case AlignmentType::TOP_LEFT: + x = mx; + y = my; + break; + case AlignmentType::TOP_CENTER: + x = (pw - cw) / 2.0f; + y = my; + break; + case AlignmentType::TOP_RIGHT: + x = pw - cw - mx; + y = my; + break; + case AlignmentType::CENTER_LEFT: + x = mx; + y = (ph - ch) / 2.0f; + break; + case AlignmentType::CENTER: + x = (pw - cw) / 2.0f; + y = (ph - ch) / 2.0f; + break; + case AlignmentType::CENTER_RIGHT: + x = pw - cw - mx; + y = (ph - ch) / 2.0f; + break; + case AlignmentType::BOTTOM_LEFT: + x = mx; + y = ph - ch - my; + break; + case AlignmentType::BOTTOM_CENTER: + x = (pw - cw) / 2.0f; + y = ph - ch - my; + break; + case AlignmentType::BOTTOM_RIGHT: + x = pw - cw - mx; + y = ph - ch - my; + break; + default: + return; + } + + // For most drawables, position IS the bounding box top-left corner + // But for Circle and Arc, position is the center, so we need to adjust + float offset_x = 0.0f; + float offset_y = 0.0f; + + // Check if this is a Circle or Arc (where position = center) + auto dtype = derived_type(); + if (dtype == PyObjectsEnum::UICIRCLE || dtype == PyObjectsEnum::UIARC) { + // For these, position is the center, bounds.topLeft is position - radius + // So offset = position - bounds.topLeft = (radius, radius) + offset_x = position.x - self_bounds.left; + offset_y = position.y - self_bounds.top; + } + + position = sf::Vector2f(x + offset_x, y + offset_y); + onPositionChanged(); + markCompositeDirty(); +} + +void UIDrawable::setAlignment(AlignmentType align) { + align_type = align; + if (align != AlignmentType::NONE) { + applyAlignment(); + } +} + +void UIDrawable::realign() { + // Reapply alignment - useful for responsive layouts + if (align_type != AlignmentType::NONE) { + applyAlignment(); + } +} + +PyObject* UIDrawable::py_realign(PyObject* self, PyObject* args) { + PyObjectsEnum objtype = PyObjectsEnum::UIFRAME; // Default, will be set by type check + + // Determine the type from the Python object + PyObject* frame_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame"); + PyObject* caption_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption"); + PyObject* sprite_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite"); + PyObject* grid_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid"); + PyObject* line_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Line"); + PyObject* circle_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Circle"); + PyObject* arc_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Arc"); + + if (PyObject_IsInstance(self, frame_type)) objtype = PyObjectsEnum::UIFRAME; + else if (PyObject_IsInstance(self, caption_type)) objtype = PyObjectsEnum::UICAPTION; + else if (PyObject_IsInstance(self, sprite_type)) objtype = PyObjectsEnum::UISPRITE; + else if (PyObject_IsInstance(self, grid_type)) objtype = PyObjectsEnum::UIGRID; + else if (PyObject_IsInstance(self, line_type)) objtype = PyObjectsEnum::UILINE; + else if (PyObject_IsInstance(self, circle_type)) objtype = PyObjectsEnum::UICIRCLE; + else if (PyObject_IsInstance(self, arc_type)) objtype = PyObjectsEnum::UIARC; + + Py_XDECREF(frame_type); + Py_XDECREF(caption_type); + Py_XDECREF(sprite_type); + Py_XDECREF(grid_type); + Py_XDECREF(line_type); + Py_XDECREF(circle_type); + Py_XDECREF(arc_type); + + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; + + drawable->realign(); + Py_RETURN_NONE; +} + +bool UIDrawable::validateMargins(AlignmentType align, float margin, float horiz_margin, float vert_margin, bool set_error) { + // Calculate effective margins (-1 means inherit from general margin) + float eff_horiz = (horiz_margin >= 0.0f) ? horiz_margin : margin; + float eff_vert = (vert_margin >= 0.0f) ? vert_margin : margin; + + // CENTER alignment doesn't support any margins + if (align == AlignmentType::CENTER) { + if (margin != 0.0f || eff_horiz != 0.0f || eff_vert != 0.0f) { + if (set_error) { + PyErr_SetString(PyExc_ValueError, + "CENTER alignment does not support margins"); + } + return false; + } + } + + // Horizontally centered alignments don't support horiz_margin override + // (margin is applied vertically only) + if (align == AlignmentType::TOP_CENTER || align == AlignmentType::BOTTOM_CENTER) { + // If horiz_margin is explicitly set (not -1), it must be 0 or error + if (horiz_margin >= 0.0f && horiz_margin != 0.0f) { + if (set_error) { + PyErr_SetString(PyExc_ValueError, + "TOP_CENTER and BOTTOM_CENTER alignments do not support horiz_margin"); + } + return false; + } + } + + // Vertically centered alignments don't support vert_margin override + // (margin is applied horizontally only) + if (align == AlignmentType::CENTER_LEFT || align == AlignmentType::CENTER_RIGHT) { + // If vert_margin is explicitly set (not -1), it must be 0 or error + if (vert_margin >= 0.0f && vert_margin != 0.0f) { + if (set_error) { + PyErr_SetString(PyExc_ValueError, + "CENTER_LEFT and CENTER_RIGHT alignments do not support vert_margin"); + } + return false; + } + } + + return true; +} + +// Python API: get align property +PyObject* UIDrawable::get_align(PyObject* self, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; + + if (drawable->align_type == AlignmentType::NONE) { + Py_RETURN_NONE; + } + + // Return Alignment enum member + if (!PyAlignment::alignment_enum_class) { + PyErr_SetString(PyExc_RuntimeError, "Alignment enum not initialized"); + return NULL; + } + + PyObject* value = PyLong_FromLong(static_cast(drawable->align_type)); + if (!value) return NULL; + + PyObject* result = PyObject_CallFunctionObjArgs(PyAlignment::alignment_enum_class, value, NULL); + Py_DECREF(value); + return result; +} + +// Python API: set align property +int UIDrawable::set_align(PyObject* self, PyObject* value, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return -1; + + if (value == Py_None) { + drawable->align_type = AlignmentType::NONE; + return 0; + } + + AlignmentType align; + if (!PyAlignment::from_arg(value, &align)) { + return -1; + } + + // Validate margins for new alignment + if (!validateMargins(align, drawable->align_margin, drawable->align_horiz_margin, drawable->align_vert_margin)) { + return -1; + } + + drawable->setAlignment(align); + return 0; +} + +// Python API: get margin property +PyObject* UIDrawable::get_margin(PyObject* self, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; + + return PyFloat_FromDouble(drawable->align_margin); +} + +// Python API: set margin property +int UIDrawable::set_margin(PyObject* self, PyObject* value, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return -1; + + float margin = 0.0f; + if (PyFloat_Check(value)) { + margin = static_cast(PyFloat_AsDouble(value)); + } else if (PyLong_Check(value)) { + margin = static_cast(PyLong_AsLong(value)); + } else { + PyErr_SetString(PyExc_TypeError, "margin must be a number"); + return -1; + } + + // Validate margins for current alignment + if (drawable->align_type != AlignmentType::NONE) { + if (!validateMargins(drawable->align_type, margin, drawable->align_horiz_margin, drawable->align_vert_margin)) { + return -1; + } + } + + drawable->align_margin = margin; + if (drawable->align_type != AlignmentType::NONE) { + drawable->applyAlignment(); + } + return 0; +} + +// Python API: get horiz_margin property +PyObject* UIDrawable::get_horiz_margin(PyObject* self, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; + + return PyFloat_FromDouble(drawable->align_horiz_margin); +} + +// Python API: set horiz_margin property +int UIDrawable::set_horiz_margin(PyObject* self, PyObject* value, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return -1; + + float horiz_margin = 0.0f; + if (PyFloat_Check(value)) { + horiz_margin = static_cast(PyFloat_AsDouble(value)); + } else if (PyLong_Check(value)) { + horiz_margin = static_cast(PyLong_AsLong(value)); + } else { + PyErr_SetString(PyExc_TypeError, "horiz_margin must be a number"); + return -1; + } + + // Validate margins for current alignment + if (drawable->align_type != AlignmentType::NONE) { + if (!validateMargins(drawable->align_type, drawable->align_margin, horiz_margin, drawable->align_vert_margin)) { + return -1; + } + } + + drawable->align_horiz_margin = horiz_margin; + if (drawable->align_type != AlignmentType::NONE) { + drawable->applyAlignment(); + } + return 0; +} + +// Python API: get vert_margin property +PyObject* UIDrawable::get_vert_margin(PyObject* self, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return NULL; + + return PyFloat_FromDouble(drawable->align_vert_margin); +} + +// Python API: set vert_margin property +int UIDrawable::set_vert_margin(PyObject* self, PyObject* value, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = extractDrawable(self, objtype); + if (!drawable) return -1; + + float vert_margin = 0.0f; + if (PyFloat_Check(value)) { + vert_margin = static_cast(PyFloat_AsDouble(value)); + } else if (PyLong_Check(value)) { + vert_margin = static_cast(PyLong_AsLong(value)); + } else { + PyErr_SetString(PyExc_TypeError, "vert_margin must be a number"); + return -1; + } + + // Validate margins for current alignment + if (drawable->align_type != AlignmentType::NONE) { + if (!validateMargins(drawable->align_type, drawable->align_margin, drawable->align_horiz_margin, vert_margin)) { + return -1; + } + } + + drawable->align_vert_margin = vert_margin; + if (drawable->align_type != AlignmentType::NONE) { + drawable->applyAlignment(); + } + return 0; +} diff --git a/src/UIDrawable.h b/src/UIDrawable.h index 66bb691..f627697 100644 --- a/src/UIDrawable.h +++ b/src/UIDrawable.h @@ -12,6 +12,7 @@ #include "PyColor.h" #include "PyVector.h" #include "PyFont.h" +#include "PyAlignment.h" #include "Resources.h" #include "UIBase.h" @@ -133,7 +134,39 @@ public: // Python API for hit testing (#138) static PyObject* get_bounds_py(PyObject* self, void* closure); static PyObject* get_global_bounds_py(PyObject* self, void* closure); - + + // Alignment system - position children relative to parent bounds + AlignmentType align_type = AlignmentType::NONE; + float align_margin = 0.0f; // General margin for all edges + float align_horiz_margin = -1.0f; // Horizontal margin override (-1 = use align_margin) + float align_vert_margin = -1.0f; // Vertical margin override (-1 = use align_margin) + + // Apply alignment: recalculate position from parent bounds + void applyAlignment(); + + // User-callable realignment: reapply alignment to self (useful for responsive layouts) + void realign(); + + // Python API: realign method + static PyObject* py_realign(PyObject* self, PyObject* args); + + // Setters that trigger realignment + void setAlignment(AlignmentType align); + AlignmentType getAlignment() const { return align_type; } + + // Python API for alignment properties + static PyObject* get_align(PyObject* self, void* closure); + static int set_align(PyObject* self, PyObject* value, void* closure); + static PyObject* get_margin(PyObject* self, void* closure); + static int set_margin(PyObject* self, PyObject* value, void* closure); + static PyObject* get_horiz_margin(PyObject* self, void* closure); + static int set_horiz_margin(PyObject* self, PyObject* value, void* closure); + static PyObject* get_vert_margin(PyObject* self, void* closure); + static int set_vert_margin(PyObject* self, PyObject* value, void* closure); + + // Validate margin settings (raises ValueError for invalid combinations) + static bool validateMargins(AlignmentType align, float margin, float horiz_margin, float vert_margin, bool set_error = true); + // New properties for Phase 1 bool visible = true; // #87 - visibility flag float opacity = 1.0f; // #88 - opacity (0.0 = transparent, 1.0 = opaque) diff --git a/src/UIFrame.cpp b/src/UIFrame.cpp index 34aae33..4189ad0 100644 --- a/src/UIFrame.cpp +++ b/src/UIFrame.cpp @@ -7,6 +7,7 @@ #include "UIGrid.h" #include "McRFPy_API.h" #include "PythonObjectCache.h" +#include "PyAlignment.h" // UIDrawable methods now in UIBase.h UIDrawable* UIFrame::click_at(sf::Vector2f point) @@ -84,6 +85,15 @@ void UIFrame::move(float dx, float dy) void UIFrame::resize(float w, float h) { box.setSize(sf::Vector2f(w, h)); + + // Notify aligned children to recalculate their positions + if (children) { + for (auto& child : *children) { + if (child->getAlignment() != AlignmentType::NONE) { + child->applyAlignment(); + } + } + } } void UIFrame::onPositionChanged() @@ -458,6 +468,7 @@ PyGetSetDef UIFrame::getsetters[] = { {"cache_subtree", (getter)UIFrame::get_cache_subtree, (setter)UIFrame::set_cache_subtree, "#144: Cache subtree rendering to texture for performance", NULL}, UIDRAWABLE_GETSETTERS, UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIFRAME), + UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UIFRAME), {NULL} }; @@ -504,6 +515,10 @@ int UIFrame::init(PyUIFrameObject* self, PyObject* args, PyObject* kwds) float x = 0.0f, y = 0.0f, w = 0.0f, h = 0.0f; int clip_children = 0; int cache_subtree = 0; // #144: texture caching + PyObject* align_obj = nullptr; // Alignment enum or None + float margin = 0.0f; + float horiz_margin = -1.0f; + float vert_margin = -1.0f; // Keywords list matches the new spec: positional args first, then all keyword args static const char* kwlist[] = { @@ -511,14 +526,16 @@ int UIFrame::init(PyUIFrameObject* self, PyObject* args, PyObject* kwds) // Keyword-only args "fill_color", "outline_color", "outline", "children", "on_click", "visible", "opacity", "z_index", "name", "x", "y", "w", "h", "clip_children", "cache_subtree", + "align", "margin", "horiz_margin", "vert_margin", nullptr }; - + // Parse arguments with | for optional positional args - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOfOOifizffffii", const_cast(kwlist), + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOfOOifizffffiiOfff", const_cast(kwlist), &pos_obj, &size_obj, // Positional &fill_color, &outline_color, &outline, &children_arg, &click_handler, - &visible, &opacity, &z_index, &name, &x, &y, &w, &h, &clip_children, &cache_subtree)) { + &visible, &opacity, &z_index, &name, &x, &y, &w, &h, &clip_children, &cache_subtree, + &align_obj, &margin, &horiz_margin, &vert_margin)) { return -1; } @@ -617,6 +634,9 @@ int UIFrame::init(PyUIFrameObject* self, PyObject* args, PyObject* kwds) if (name) { self->data->name = std::string(name); } + + // Process alignment arguments + UIDRAWABLE_PROCESS_ALIGNMENT(self->data, align_obj, margin, horiz_margin, vert_margin); // Handle click handler if (click_handler && click_handler != Py_None) { @@ -673,6 +693,7 @@ int UIFrame::init(PyUIFrameObject* self, PyObject* args, PyObject* kwds) Py_DECREF(grid_type); if (drawable) { + drawable->setParent(self->data); // Set parent before adding (enables alignment) self->data->children->push_back(drawable); self->data->children_need_sort = true; } diff --git a/src/UIFrame.h b/src/UIFrame.h index fd85939..772a22a 100644 --- a/src/UIFrame.h +++ b/src/UIFrame.h @@ -115,7 +115,11 @@ namespace mcrfpydef { " w (float): Width override. Default: 0\n" " h (float): Height override. Default: 0\n" " clip_children (bool): Whether to clip children to frame bounds. Default: False\n" - " cache_subtree (bool): Cache rendering to texture for performance. Default: False\n\n" + " cache_subtree (bool): Cache rendering to texture for performance. Default: False\n" + " align (Alignment): Alignment relative to parent. Default: None (manual positioning)\n" + " margin (float): Margin from parent edge when aligned. Default: 0\n" + " horiz_margin (float): Horizontal margin override. Default: 0 (use margin)\n" + " vert_margin (float): Vertical margin override. Default: 0 (use margin)\n\n" "Attributes:\n" " x, y (float): Position in pixels\n" " w, h (float): Size in pixels\n" @@ -129,7 +133,11 @@ namespace mcrfpydef { " z_index (int): Rendering order\n" " name (str): Element name\n" " clip_children (bool): Whether to clip children to frame bounds\n" - " cache_subtree (bool): Cache subtree rendering to texture"), + " cache_subtree (bool): Cache subtree rendering to texture\n" + " align (Alignment): Alignment relative to parent (or None)\n" + " margin (float): General margin for alignment\n" + " horiz_margin (float): Horizontal margin override\n" + " vert_margin (float): Vertical margin override"), .tp_methods = UIFrame_methods, //.tp_members = PyUIFrame_members, .tp_getset = UIFrame::getsetters, diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index 2f4ae50..93e229d 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -3,6 +3,7 @@ #include "GameEngine.h" #include "McRFPy_API.h" #include "PythonObjectCache.h" +#include "PyAlignment.h" #include "PyTypeCache.h" // Thread-safe cached Python types #include "UIEntity.h" #include "Profiler.h" @@ -496,6 +497,15 @@ void UIGrid::resize(float w, float h) renderTexture.create(static_cast(w), static_cast(h)); output.setTexture(renderTexture.getTexture()); } + + // Notify aligned children to recalculate their positions + if (children) { + for (auto& child : *children) { + if (child->getAlignment() != AlignmentType::NONE) { + child->applyAlignment(); + } + } + } } void UIGrid::onPositionChanged() @@ -674,6 +684,10 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) { const char* name = nullptr; float x = 0.0f, y = 0.0f, w = 0.0f, h = 0.0f; int grid_w = 2, grid_h = 2; // Default to 2x2 grid + PyObject* align_obj = nullptr; // Alignment enum or None + float margin = 0.0f; + float horiz_margin = -1.0f; + float vert_margin = -1.0f; // Keywords list matches the new spec: positional args first, then all keyword args static const char* kwlist[] = { @@ -682,15 +696,17 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) { "fill_color", "on_click", "center_x", "center_y", "zoom", "visible", "opacity", "z_index", "name", "x", "y", "w", "h", "grid_w", "grid_h", "layers", // #150 - layers dict parameter + "align", "margin", "horiz_margin", "vert_margin", nullptr }; // Parse arguments with | for optional positional args - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOOOfffifizffffiiO", const_cast(kwlist), + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOOOfffifizffffiiOOfff", const_cast(kwlist), &pos_obj, &size_obj, &grid_size_obj, &textureObj, // Positional &fill_color, &click_handler, ¢er_x, ¢er_y, &zoom, &visible, &opacity, &z_index, &name, &x, &y, &w, &h, &grid_w, &grid_h, - &layers_obj)) { + &layers_obj, + &align_obj, &margin, &horiz_margin, &vert_margin)) { return -1; } @@ -813,7 +829,10 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) { if (name) { self->data->name = std::string(name); } - + + // Process alignment arguments + UIDRAWABLE_PROCESS_ALIGNMENT(self->data, align_obj, margin, horiz_margin, vert_margin); + // Handle fill_color if (fill_color && fill_color != Py_None) { PyColorObject* color_obj = PyColor::from_arg(fill_color); @@ -2188,6 +2207,7 @@ PyGetSetDef UIGrid::getsetters[] = { {"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UIGRID}, UIDRAWABLE_GETSETTERS, UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIGRID), + UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UIGRID), // #142 - Grid cell mouse events {"on_cell_enter", (getter)UIGrid::get_on_cell_enter, (setter)UIGrid::set_on_cell_enter, "Callback when mouse enters a grid cell. Called with (cell_pos: Vector).", NULL}, diff --git a/src/UIGrid.h b/src/UIGrid.h index aa1336b..9cc35ec 100644 --- a/src/UIGrid.h +++ b/src/UIGrid.h @@ -258,7 +258,11 @@ namespace mcrfpydef { " w (float): Width override. Default: auto-calculated\n" " h (float): Height override. Default: auto-calculated\n" " grid_w (int): Grid width override. Default: 2\n" - " grid_h (int): Grid height override. Default: 2\n\n" + " grid_h (int): Grid height override. Default: 2\n" + " align (Alignment): Alignment relative to parent. Default: None\n" + " margin (float): Margin from parent edge when aligned. Default: 0\n" + " horiz_margin (float): Horizontal margin override. Default: 0 (use margin)\n" + " vert_margin (float): Vertical margin override. Default: 0 (use margin)\n\n" "Attributes:\n" " x, y (float): Position in pixels\n" " w, h (float): Size in pixels\n" @@ -277,7 +281,11 @@ namespace mcrfpydef { " visible (bool): Visibility state\n" " opacity (float): Opacity value\n" " z_index (int): Rendering order\n" - " name (str): Element name"), + " name (str): Element name\n" + " align (Alignment): Alignment relative to parent (or None)\n" + " margin (float): General margin for alignment\n" + " horiz_margin (float): Horizontal margin override\n" + " vert_margin (float): Vertical margin override"), .tp_methods = UIGrid_all_methods, //.tp_members = UIGrid::members, .tp_getset = UIGrid::getsetters, diff --git a/src/UILine.cpp b/src/UILine.cpp index 993655d..fa58216 100644 --- a/src/UILine.cpp +++ b/src/UILine.cpp @@ -4,6 +4,7 @@ #include "PyVector.h" #include "PyColor.h" #include "PythonObjectCache.h" +#include "PyAlignment.h" #include UILine::UILine() @@ -465,6 +466,7 @@ PyGetSetDef UILine::getsetters[] = { (void*)PyObjectsEnum::UILINE}, UIDRAWABLE_GETSETTERS, UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UILINE), + UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UILINE), {NULL} }; @@ -497,16 +499,22 @@ int UILine::init(PyUILineObject* self, PyObject* args, PyObject* kwds) { float opacity = 1.0f; int z_index = 0; const char* name = nullptr; + PyObject* align_obj = nullptr; // Alignment enum or None + float margin = 0.0f; + float horiz_margin = -1.0f; + float vert_margin = -1.0f; static const char* kwlist[] = { "start", "end", "thickness", "color", "on_click", "visible", "opacity", "z_index", "name", + "align", "margin", "horiz_margin", "vert_margin", nullptr }; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOfOOifiz", const_cast(kwlist), + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOfOOifizOfff", const_cast(kwlist), &start_obj, &end_obj, &thickness, &color_obj, - &click_handler, &visible, &opacity, &z_index, &name)) { + &click_handler, &visible, &opacity, &z_index, &name, + &align_obj, &margin, &horiz_margin, &vert_margin)) { return -1; } @@ -565,6 +573,9 @@ int UILine::init(PyUILineObject* self, PyObject* args, PyObject* kwds) { self->data->name = std::string(name); } + // Process alignment arguments + UIDRAWABLE_PROCESS_ALIGNMENT(self->data, align_obj, margin, horiz_margin, vert_margin); + // Handle click handler if (click_handler && click_handler != Py_None) { if (!PyCallable_Check(click_handler)) { diff --git a/src/UILine.h b/src/UILine.h index 60a33c3..2912f93 100644 --- a/src/UILine.h +++ b/src/UILine.h @@ -125,7 +125,11 @@ namespace mcrfpydef { " visible (bool): Visibility state. Default: True\n" " opacity (float): Opacity (0.0-1.0). Default: 1.0\n" " z_index (int): Rendering order. Default: 0\n" - " name (str): Element name for finding. Default: None\n\n" + " name (str): Element name for finding. Default: None\n" + " align (Alignment): Alignment relative to parent. Default: None\n" + " margin (float): Margin from parent edge when aligned. Default: 0\n" + " horiz_margin (float): Horizontal margin override. Default: 0 (use margin)\n" + " vert_margin (float): Vertical margin override. Default: 0 (use margin)\n\n" "Attributes:\n" " start (Vector): Starting point\n" " end (Vector): Ending point\n" @@ -135,6 +139,10 @@ namespace mcrfpydef { " opacity (float): Opacity value\n" " z_index (int): Rendering order\n" " name (str): Element name\n" + " align (Alignment): Alignment relative to parent (or None)\n" + " margin (float): General margin for alignment\n" + " horiz_margin (float): Horizontal margin override\n" + " vert_margin (float): Vertical margin override\n" ), .tp_methods = UILine_methods, .tp_getset = UILine::getsetters, diff --git a/src/UISprite.cpp b/src/UISprite.cpp index 794748b..7461017 100644 --- a/src/UISprite.cpp +++ b/src/UISprite.cpp @@ -3,6 +3,7 @@ #include "PyVector.h" #include "PythonObjectCache.h" #include "UIFrame.h" // #144: For snapshot= parameter +#include "PyAlignment.h" // UIDrawable methods now in UIBase.h UIDrawable* UISprite::click_at(sf::Vector2f point) @@ -355,6 +356,7 @@ PyGetSetDef UISprite::getsetters[] = { {"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos, "Position as a Vector", (void*)PyObjectsEnum::UISPRITE}, UIDRAWABLE_GETSETTERS, UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UISPRITE), + UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UISPRITE), {NULL} }; @@ -388,6 +390,10 @@ int UISprite::init(PyUISpriteObject* self, PyObject* args, PyObject* kwds) const char* name = nullptr; float x = 0.0f, y = 0.0f; PyObject* snapshot = nullptr; // #144: snapshot parameter + PyObject* align_obj = nullptr; // Alignment enum or None + float margin = 0.0f; + float horiz_margin = -1.0f; + float vert_margin = -1.0f; // Keywords list matches the new spec: positional args first, then all keyword args static const char* kwlist[] = { @@ -395,14 +401,16 @@ int UISprite::init(PyUISpriteObject* self, PyObject* args, PyObject* kwds) // Keyword-only args "scale", "scale_x", "scale_y", "on_click", "visible", "opacity", "z_index", "name", "x", "y", "snapshot", + "align", "margin", "horiz_margin", "vert_margin", nullptr }; // Parse arguments with | for optional positional args - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOifffOifizffO", const_cast(kwlist), + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOifffOifizffOOfff", const_cast(kwlist), &pos_obj, &texture, &sprite_index, // Positional &scale, &scale_x, &scale_y, &click_handler, - &visible, &opacity, &z_index, &name, &x, &y, &snapshot)) { + &visible, &opacity, &z_index, &name, &x, &y, &snapshot, + &align_obj, &margin, &horiz_margin, &vert_margin)) { return -1; } @@ -512,6 +520,9 @@ int UISprite::init(PyUISpriteObject* self, PyObject* args, PyObject* kwds) self->data->name = std::string(name); } + // Process alignment arguments + UIDRAWABLE_PROCESS_ALIGNMENT(self->data, align_obj, margin, horiz_margin, vert_margin); + // Handle click handler if (click_handler && click_handler != Py_None) { if (!PyCallable_Check(click_handler)) { diff --git a/src/UISprite.h b/src/UISprite.h index 03128a8..d3ddb12 100644 --- a/src/UISprite.h +++ b/src/UISprite.h @@ -121,7 +121,11 @@ namespace mcrfpydef { " z_index (int): Rendering order. Default: 0\n" " name (str): Element name for finding. Default: None\n" " x (float): X position override. Default: 0\n" - " y (float): Y position override. Default: 0\n\n" + " y (float): Y position override. Default: 0\n" + " align (Alignment): Alignment relative to parent. Default: None\n" + " margin (float): Margin from parent edge when aligned. Default: 0\n" + " horiz_margin (float): Horizontal margin override. Default: 0 (use margin)\n" + " vert_margin (float): Vertical margin override. Default: 0 (use margin)\n\n" "Attributes:\n" " x, y (float): Position in pixels\n" " pos (Vector): Position as a Vector object\n" @@ -134,7 +138,11 @@ namespace mcrfpydef { " opacity (float): Opacity value\n" " z_index (int): Rendering order\n" " name (str): Element name\n" - " w, h (float): Read-only computed size based on texture and scale"), + " w, h (float): Read-only computed size based on texture and scale\n" + " align (Alignment): Alignment relative to parent (or None)\n" + " margin (float): General margin for alignment\n" + " horiz_margin (float): Horizontal margin override\n" + " vert_margin (float): Vertical margin override"), .tp_methods = UISprite_methods, //.tp_members = PyUIFrame_members, .tp_getset = UISprite::getsetters, diff --git a/tests/unit/alignment_constructor_test.py b/tests/unit/alignment_constructor_test.py new file mode 100644 index 0000000..5f02dab --- /dev/null +++ b/tests/unit/alignment_constructor_test.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Test alignment constructor arguments work correctly.""" + +import mcrfpy +import sys + +# Test that alignment args work in constructors + +print("Test 1: Frame with align constructor arg...") +parent = mcrfpy.Frame(pos=(0, 0), size=(400, 300)) +child = mcrfpy.Frame(size=(100, 50), align=mcrfpy.Alignment.CENTER) +parent.children.append(child) +# Expected: (400-100)/2=150, (300-50)/2=125 +if abs(child.x - 150) < 0.1 and abs(child.y - 125) < 0.1: + print(" PASS: Frame align constructor arg works") +else: + print(f" FAIL: Expected (150, 125), got ({child.x}, {child.y})") + sys.exit(1) + +print("Test 2: Frame with align and margin constructor args...") +parent2 = mcrfpy.Frame(pos=(0, 0), size=(400, 300)) +child2 = mcrfpy.Frame(size=(50, 50), align=mcrfpy.Alignment.TOP_LEFT, margin=10) +parent2.children.append(child2) +if abs(child2.x - 10) < 0.1 and abs(child2.y - 10) < 0.1: + print(" PASS: Frame margin constructor arg works") +else: + print(f" FAIL: Expected (10, 10), got ({child2.x}, {child2.y})") + sys.exit(1) + +print("Test 3: Caption with align constructor arg...") +parent3 = mcrfpy.Frame(pos=(0, 0), size=(400, 300)) +cap = mcrfpy.Caption(text="Test", align=mcrfpy.Alignment.TOP_CENTER, margin=20) +parent3.children.append(cap) +# Should be centered horizontally, 20px from top +if abs(cap.y - 20) < 0.1: + print(" PASS: Caption align constructor arg works") +else: + print(f" FAIL: Expected y=20, got y={cap.y}") + sys.exit(1) + +print("Test 4: Sprite with align constructor arg...") +parent4 = mcrfpy.Frame(pos=(0, 0), size=(400, 300)) +spr = mcrfpy.Sprite(align=mcrfpy.Alignment.BOTTOM_LEFT, margin=5) +parent4.children.append(spr) +if abs(spr.x - 5) < 0.1: + print(" PASS: Sprite align constructor arg works") +else: + print(f" FAIL: Expected x=5, got x={spr.x}") + sys.exit(1) + +print("Test 5: Grid with align constructor arg...") +parent5 = mcrfpy.Frame(pos=(0, 0), size=(400, 300)) +grid = mcrfpy.Grid(grid_size=(10, 10), size=(200, 200), align=mcrfpy.Alignment.CENTER_RIGHT, margin=15) +parent5.children.append(grid) +# Expected x: 400-200-15=185 +if abs(grid.x - 185) < 0.1: + print(" PASS: Grid align constructor arg works") +else: + print(f" FAIL: Expected x=185, got x={grid.x}") + sys.exit(1) + +print("Test 6: Line with align constructor arg...") +parent6 = mcrfpy.Frame(pos=(0, 0), size=(400, 300)) +line = mcrfpy.Line(start=(0, 0), end=(50, 0), align=mcrfpy.Alignment.TOP_LEFT, margin=25) +parent6.children.append(line) +# Line's position (pos) should be at margin +if abs(line.pos.x - 25) < 0.1 and abs(line.pos.y - 25) < 0.1: + print(" PASS: Line align constructor arg works") +else: + print(f" FAIL: Expected pos at (25, 25), got ({line.pos.x}, {line.pos.y})") + sys.exit(1) + +print("Test 7: Circle with align constructor arg...") +parent7 = mcrfpy.Frame(pos=(0, 0), size=(400, 300)) +circ = mcrfpy.Circle(radius=30, align=mcrfpy.Alignment.CENTER) +parent7.children.append(circ) +# Circle is centered, center.x should be at parent center (400/2=200), center.y at (300/2=150) +if abs(circ.center.x - 200) < 0.1 and abs(circ.center.y - 150) < 0.1: + print(" PASS: Circle align constructor arg works") +else: + print(f" FAIL: Expected center at (200, 150), got ({circ.center.x}, {circ.center.y})") + sys.exit(1) + +print("Test 8: Arc with align constructor arg...") +parent8 = mcrfpy.Frame(pos=(0, 0), size=(400, 300)) +arc = mcrfpy.Arc(radius=40, align=mcrfpy.Alignment.BOTTOM_CENTER, vert_margin=10) +parent8.children.append(arc) +# Arc is BOTTOM_CENTER aligned with 10px vert_margin +# Arc bounds: width=2*radius=80, height=2*radius=80 +# center.x should be 400/2=200 (centered) +# For bottom alignment: bottom of arc = 300-10 = 290, so center.y = 290 - 40 = 250 +if abs(arc.center.x - 200) < 1.0 and abs(arc.center.y - 250) < 1.0: + print(" PASS: Arc align constructor arg works") +else: + print(f" FAIL: Expected center at (200, 250), got ({arc.center.x}, {arc.center.y})") + sys.exit(1) + +print("Test 9: Testing horiz_margin and vert_margin separately...") +parent9 = mcrfpy.Frame(pos=(0, 0), size=(400, 300)) +frame9 = mcrfpy.Frame(size=(100, 50), align=mcrfpy.Alignment.TOP_RIGHT, horiz_margin=30, vert_margin=20) +parent9.children.append(frame9) +# Expected: x = 400-100-30=270, y = 20 +if abs(frame9.x - 270) < 0.1 and abs(frame9.y - 20) < 0.1: + print(" PASS: horiz_margin and vert_margin constructor args work") +else: + print(f" FAIL: Expected (270, 20), got ({frame9.x}, {frame9.y})") + sys.exit(1) + +print("Test 10: Nested children with alignment in constructor list...") +outer = mcrfpy.Frame( + pos=(100, 100), + size=(400, 300), + children=[ + mcrfpy.Frame(size=(200, 100), align=mcrfpy.Alignment.CENTER), + mcrfpy.Caption(text="Title", align=mcrfpy.Alignment.TOP_CENTER, margin=10), + ] +) +# Check inner frame is centered +inner = outer.children[0] +# (400-200)/2=100, (300-100)/2=100 +if abs(inner.x - 100) < 0.1 and abs(inner.y - 100) < 0.1: + print(" PASS: Nested children alignment works in constructor list") +else: + print(f" FAIL: Expected inner at (100, 100), got ({inner.x}, {inner.y})") + sys.exit(1) + +print() +print("=" * 50) +print("All alignment constructor tests PASSED!") +print("=" * 50) +sys.exit(0) diff --git a/tests/unit/alignment_test.py b/tests/unit/alignment_test.py new file mode 100644 index 0000000..35e0f00 --- /dev/null +++ b/tests/unit/alignment_test.py @@ -0,0 +1,214 @@ +"""Test the alignment system for UIDrawable elements.""" + +import mcrfpy +import sys + +# Test 1: Check Alignment enum exists and has expected values +print("Test 1: Checking Alignment enum...") +try: + assert hasattr(mcrfpy, 'Alignment'), "Alignment enum not found" + + # Check all alignment values exist + expected_alignments = [ + 'TOP_LEFT', 'TOP_CENTER', 'TOP_RIGHT', + 'CENTER_LEFT', 'CENTER', 'CENTER_RIGHT', + 'BOTTOM_LEFT', 'BOTTOM_CENTER', 'BOTTOM_RIGHT' + ] + for name in expected_alignments: + assert hasattr(mcrfpy.Alignment, name), f"Alignment.{name} not found" + print(" PASS: Alignment enum has all expected values") +except Exception as e: + print(f" FAIL: {e}") + sys.exit(1) + +# Test 2: Check that align property exists on Frame +print("Test 2: Checking align property on Frame...") +try: + frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200)) + + # Default alignment should be None + assert frame.align is None, f"Expected align=None, got {frame.align}" + + # Set alignment + frame.align = mcrfpy.Alignment.CENTER + assert frame.align == mcrfpy.Alignment.CENTER, f"Expected CENTER, got {frame.align}" + + # Set back to None + frame.align = None + assert frame.align is None, f"Expected None, got {frame.align}" + print(" PASS: align property works on Frame") +except Exception as e: + print(f" FAIL: {e}") + sys.exit(1) + +# Test 3: Check margin properties exist +print("Test 3: Checking margin properties...") +try: + frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + + # Check default margins are 0 + assert frame.margin == 0, f"Expected margin=0, got {frame.margin}" + assert frame.horiz_margin == 0, f"Expected horiz_margin=0, got {frame.horiz_margin}" + assert frame.vert_margin == 0, f"Expected vert_margin=0, got {frame.vert_margin}" + + # Set margins when no alignment + frame.margin = 10.0 + assert frame.margin == 10.0, f"Expected margin=10, got {frame.margin}" + print(" PASS: margin properties exist and can be set") +except Exception as e: + print(f" FAIL: {e}") + sys.exit(1) + +# Test 4: Check alignment auto-positioning +print("Test 4: Checking alignment auto-positioning...") +try: + # Create parent frame + parent = mcrfpy.Frame(pos=(0, 0), size=(200, 200)) + + # Create child with CENTER alignment + child = mcrfpy.Frame(pos=(0, 0), size=(50, 50)) + child.align = mcrfpy.Alignment.CENTER + + # Add to parent - should trigger alignment + parent.children.append(child) + + # Child should be centered: (200-50)/2 = 75 + expected_x = 75.0 + expected_y = 75.0 + assert abs(child.x - expected_x) < 0.1, f"Expected x={expected_x}, got {child.x}" + assert abs(child.y - expected_y) < 0.1, f"Expected y={expected_y}, got {child.y}" + print(" PASS: CENTER alignment positions child correctly") +except Exception as e: + print(f" FAIL: {e}") + sys.exit(1) + +# Test 5: Check TOP_LEFT with margin +print("Test 5: Checking TOP_LEFT alignment with margin...") +try: + parent = mcrfpy.Frame(pos=(0, 0), size=(200, 200)) + child = mcrfpy.Frame(pos=(999, 999), size=(50, 50)) # Start at wrong position + child.align = mcrfpy.Alignment.TOP_LEFT + child.margin = 10.0 + + parent.children.append(child) + + # Child should be at (10, 10) + assert abs(child.x - 10.0) < 0.1, f"Expected x=10, got {child.x}" + assert abs(child.y - 10.0) < 0.1, f"Expected y=10, got {child.y}" + print(" PASS: TOP_LEFT with margin positions correctly") +except Exception as e: + print(f" FAIL: {e}") + sys.exit(1) + +# Test 6: Check BOTTOM_RIGHT alignment +print("Test 6: Checking BOTTOM_RIGHT alignment...") +try: + parent = mcrfpy.Frame(pos=(0, 0), size=(200, 200)) + child = mcrfpy.Frame(pos=(0, 0), size=(50, 50)) + child.align = mcrfpy.Alignment.BOTTOM_RIGHT + child.margin = 5.0 + + parent.children.append(child) + + # Child should be at (200-50-5, 200-50-5) = (145, 145) + expected_x = 145.0 + expected_y = 145.0 + assert abs(child.x - expected_x) < 0.1, f"Expected x={expected_x}, got {child.x}" + assert abs(child.y - expected_y) < 0.1, f"Expected y={expected_y}, got {child.y}" + print(" PASS: BOTTOM_RIGHT with margin positions correctly") +except Exception as e: + print(f" FAIL: {e}") + sys.exit(1) + +# Test 7: Check resize propagation +print("Test 7: Checking resize propagation to children...") +try: + parent = mcrfpy.Frame(pos=(0, 0), size=(200, 200)) + child = mcrfpy.Frame(pos=(0, 0), size=(50, 50)) + child.align = mcrfpy.Alignment.CENTER + + parent.children.append(child) + + # Initial position check + assert abs(child.x - 75.0) < 0.1, f"Initial x should be 75, got {child.x}" + + # Resize parent + parent.w = 300 + parent.h = 300 + + # Child should be re-centered: (300-50)/2 = 125 + expected_x = 125.0 + expected_y = 125.0 + assert abs(child.x - expected_x) < 0.1, f"After resize, expected x={expected_x}, got {child.x}" + assert abs(child.y - expected_y) < 0.1, f"After resize, expected y={expected_y}, got {child.y}" + print(" PASS: Resize propagates to aligned children") +except Exception as e: + print(f" FAIL: {e}") + sys.exit(1) + +# Test 8: Check that align=None freezes position +print("Test 8: Checking that align=None freezes position...") +try: + parent = mcrfpy.Frame(pos=(0, 0), size=(200, 200)) + child = mcrfpy.Frame(pos=(0, 0), size=(50, 50)) + child.align = mcrfpy.Alignment.CENTER + + parent.children.append(child) + centered_x = child.x + centered_y = child.y + + # Disable alignment + child.align = None + + # Resize parent + parent.w = 400 + parent.h = 400 + + # Position should NOT change + assert abs(child.x - centered_x) < 0.1, f"Position should be frozen at {centered_x}, got {child.x}" + assert abs(child.y - centered_y) < 0.1, f"Position should be frozen at {centered_y}, got {child.y}" + print(" PASS: align=None freezes position") +except Exception as e: + print(f" FAIL: {e}") + sys.exit(1) + +# Test 9: Check CENTER alignment rejects margins +print("Test 9: Checking CENTER alignment rejects margins...") +try: + frame = mcrfpy.Frame(pos=(0, 0), size=(50, 50)) + frame.align = mcrfpy.Alignment.CENTER + + # Setting margin on CENTER should raise ValueError + try: + frame.margin = 10.0 + print(" FAIL: Expected ValueError for margin with CENTER alignment") + sys.exit(1) + except ValueError as e: + pass # Expected + + print(" PASS: CENTER alignment correctly rejects margin") +except Exception as e: + print(f" FAIL: {e}") + sys.exit(1) + +# Test 10: Check alignment on other drawable types +print("Test 10: Checking alignment on Caption...") +try: + parent = mcrfpy.Frame(pos=(0, 0), size=(200, 100)) + caption = mcrfpy.Caption(text="Test", pos=(0, 0)) + caption.align = mcrfpy.Alignment.CENTER + + parent.children.append(caption) + + # Caption should be roughly centered (exact position depends on text size) + # Just verify it was moved from (0,0) + assert caption.x > 0 or caption.y > 0, "Caption should have been repositioned" + print(" PASS: Caption supports alignment") +except Exception as e: + print(f" FAIL: {e}") + sys.exit(1) + +print("\n" + "=" * 40) +print("All alignment tests PASSED!") +print("=" * 40) +sys.exit(0)