Alignment: reactive or automatically calculated repositioning of UIDrawables on their parent

This commit is contained in:
John McCardle 2026-01-13 20:40:34 -05:00
commit 4bf590749c
23 changed files with 1350 additions and 397 deletions

View file

@ -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) {

232
src/PyAlignment.cpp Normal file
View file

@ -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<int>(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;
}

42
src/PyAlignment.h Normal file
View file

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

View file

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

View file

@ -1,6 +1,7 @@
#include "UIArc.h"
#include "McRFPy_API.h"
#include "PythonObjectCache.h"
#include "PyAlignment.h"
#include <cmath>
#include <sstream>
@ -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<char**>(kwlist),
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OfffOfOifizOfff", const_cast<char**>(kwlist),
&center_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();

View file

@ -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,

View file

@ -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<typename T>
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

View file

@ -4,6 +4,7 @@
#include "PyVector.h"
#include "PyFont.h"
#include "PythonObjectCache.h"
#include "PyAlignment.h"
// UIDrawable methods now in UIBase.h
#include <algorithm>
@ -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,6 +352,10 @@ 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[] = {
@ -357,14 +363,16 @@ int UICaption::init(PyUICaptionObject* self, PyObject* args, PyObject* kwds)
// 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<char**>(kwlist),
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOzOOffOifizffOfff", const_cast<char**>(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;
}
@ -464,6 +472,9 @@ int UICaption::init(PyUICaptionObject* 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)) {

View file

@ -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,

View file

@ -4,6 +4,7 @@
#include "PyVector.h"
#include "PyColor.h"
#include "PythonObjectCache.h"
#include "PyAlignment.h"
#include <cmath>
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, &center_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();

View file

@ -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,

View file

@ -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<PyObjectsEnum>(reinterpret_cast<intptr_t>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(closure) >> 8);
int member = reinterpret_cast<intptr_t>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(closure) >> 8);
int member = reinterpret_cast<intptr_t>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(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<UIDrawable> 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> 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<PyObjectsEnum>(reinterpret_cast<intptr_t>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(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<float>(resolution.x);
ph = static_cast<float>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(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<int>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(closure));
UIDrawable* drawable = extractDrawable(self, objtype);
if (!drawable) return -1;
float margin = 0.0f;
if (PyFloat_Check(value)) {
margin = static_cast<float>(PyFloat_AsDouble(value));
} else if (PyLong_Check(value)) {
margin = static_cast<float>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(closure));
UIDrawable* drawable = extractDrawable(self, objtype);
if (!drawable) return -1;
float horiz_margin = 0.0f;
if (PyFloat_Check(value)) {
horiz_margin = static_cast<float>(PyFloat_AsDouble(value));
} else if (PyLong_Check(value)) {
horiz_margin = static_cast<float>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(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<PyObjectsEnum>(reinterpret_cast<intptr_t>(closure));
UIDrawable* drawable = extractDrawable(self, objtype);
if (!drawable) return -1;
float vert_margin = 0.0f;
if (PyFloat_Check(value)) {
vert_margin = static_cast<float>(PyFloat_AsDouble(value));
} else if (PyLong_Check(value)) {
vert_margin = static_cast<float>(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;
}

View file

@ -12,6 +12,7 @@
#include "PyColor.h"
#include "PyVector.h"
#include "PyFont.h"
#include "PyAlignment.h"
#include "Resources.h"
#include "UIBase.h"
@ -134,6 +135,38 @@ public:
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)

View file

@ -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<char**>(kwlist),
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOfOOifizffffiiOfff", const_cast<char**>(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;
}
@ -618,6 +635,9 @@ int UIFrame::init(PyUIFrameObject* 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)) {
@ -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;
}

View file

@ -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,

View file

@ -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<unsigned int>(w), static_cast<unsigned int>(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<char**>(kwlist),
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOOOfffifizffffiiOOfff", const_cast<char**>(kwlist),
&pos_obj, &size_obj, &grid_size_obj, &textureObj, // Positional
&fill_color, &click_handler, &center_x, &center_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;
}
@ -814,6 +830,9 @@ int UIGrid::init(PyUIGridObject* 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 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},

View file

@ -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,

View file

@ -4,6 +4,7 @@
#include "PyVector.h"
#include "PyColor.h"
#include "PythonObjectCache.h"
#include "PyAlignment.h"
#include <cmath>
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<char**>(kwlist),
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOfOOifizOfff", const_cast<char**>(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)) {

View file

@ -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,

View file

@ -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<char**>(kwlist),
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOifffOifizffOOfff", const_cast<char**>(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)) {

View file

@ -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,

View file

@ -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)

View file

@ -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)