Add subscript protocol to ColorLayer, TileLayer, and Grid

Implements __getitem__/__setitem__ on ColorLayer, TileLayer, and Grid
(GridView) for ergonomic cell access, mirroring the existing pattern on
HeightMap and DiscreteMap. Part of the 1.0 API freeze ergonomic pass --
no existing .at() / .set() methods are removed or changed.

* ColorLayer[x, y] returns mcrfpy.Color; assignment accepts a Color or a
  3/4-tuple via PyColor::fromPy.
* TileLayer[x, y] returns / accepts an int (sprite index, -1 transparent).
* Grid[x, y] returns the same GridPoint as Grid.at(x, y); assignment raises
  TypeError because GridPoints are views, not assignable values.
* Internal _GridData (PyUIGridType) gets the same TypeError-raising setitem
  for consistency.

Keys are 2-tuples (x, y); anything else raises TypeError. Out-of-bounds
coordinates raise IndexError. Subscript on Grid (the user-facing GridView,
#252) delegates to its underlying GridData via aliasing shared_ptr, the
same way UIGridView::get_grid wraps the data.
This commit is contained in:
John McCardle 2026-04-18 13:02:44 -04:00
commit c52a6a0db6
5 changed files with 235 additions and 1 deletions

View file

@ -945,6 +945,90 @@ PyObject* PyGridLayerAPI::ColorLayer_set(PyColorLayerObject* self, PyObject* arg
Py_RETURN_NONE;
}
// =============================================================================
// Subscript protocol: layer[x, y] / layer[x, y] = value
// =============================================================================
// Helper: parse a 2-tuple key into (x, y) ints. Sets TypeError on bad keys.
static bool parse_subscript_key(PyObject* key, int* x, int* y) {
if (!PyTuple_Check(key) || PyTuple_Size(key) != 2) {
PyErr_SetString(PyExc_TypeError,
"Layer indices must be a 2-tuple (x, y)");
return false;
}
PyObject* x_obj = PyTuple_GetItem(key, 0);
PyObject* y_obj = PyTuple_GetItem(key, 1);
if (!PyLong_Check(x_obj) || !PyLong_Check(y_obj)) {
PyErr_SetString(PyExc_TypeError, "Layer indices must be integers");
return false;
}
*x = (int)PyLong_AsLong(x_obj);
*y = (int)PyLong_AsLong(y_obj);
return true;
}
PyObject* PyGridLayerAPI::ColorLayer_subscript(PyColorLayerObject* self, PyObject* key) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return NULL;
}
int x, y;
if (!parse_subscript_key(key, &x, &y)) return NULL;
if (x < 0 || x >= self->data->grid_x || y < 0 || y >= self->data->grid_y) {
PyErr_Format(PyExc_IndexError,
"Position (%d, %d) out of bounds for ColorLayer of size (%d, %d)",
x, y, self->data->grid_x, self->data->grid_y);
return NULL;
}
const sf::Color& color = self->data->at(x, y);
// Wrap as mcrfpy.Color
auto* color_type = (PyTypeObject*)PyObject_GetAttrString(
PyImport_ImportModule("mcrfpy"), "Color");
if (!color_type) return NULL;
PyColorObject* color_obj = (PyColorObject*)color_type->tp_alloc(color_type, 0);
Py_DECREF(color_type);
if (!color_obj) return NULL;
color_obj->data = color;
return (PyObject*)color_obj;
}
int PyGridLayerAPI::ColorLayer_subscript_assign(PyColorLayerObject* self, PyObject* key, PyObject* value) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return -1;
}
if (value == nullptr) {
PyErr_SetString(PyExc_TypeError, "cannot delete ColorLayer cells");
return -1;
}
int x, y;
if (!parse_subscript_key(key, &x, &y)) return -1;
if (x < 0 || x >= self->data->grid_x || y < 0 || y >= self->data->grid_y) {
PyErr_Format(PyExc_IndexError,
"Position (%d, %d) out of bounds for ColorLayer of size (%d, %d)",
x, y, self->data->grid_x, self->data->grid_y);
return -1;
}
// PyColor::fromPy accepts Color objects, tuples, lists, ints; sets PyErr on failure.
sf::Color color = PyColor::fromPy(value);
if (PyErr_Occurred()) return -1;
self->data->at(x, y) = color;
self->data->markDirty(x, y);
return 0;
}
PyMappingMethods PyGridLayerAPI::ColorLayer_mapping_methods = {
.mp_length = nullptr,
.mp_subscript = (binaryfunc)PyGridLayerAPI::ColorLayer_subscript,
.mp_ass_subscript = (objobjargproc)PyGridLayerAPI::ColorLayer_subscript_assign,
};
PyObject* PyGridLayerAPI::ColorLayer_fill(PyColorLayerObject* self, PyObject* args) {
PyObject* color_obj;
if (!PyArg_ParseTuple(args, "O", &color_obj)) {
@ -1952,6 +2036,64 @@ PyObject* PyGridLayerAPI::TileLayer_set(PyTileLayerObject* self, PyObject* args)
Py_RETURN_NONE;
}
// =============================================================================
// TileLayer subscript: tl[x, y] / tl[x, y] = index
// =============================================================================
PyObject* PyGridLayerAPI::TileLayer_subscript(PyTileLayerObject* self, PyObject* key) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return NULL;
}
int x, y;
if (!parse_subscript_key(key, &x, &y)) return NULL;
if (x < 0 || x >= self->data->grid_x || y < 0 || y >= self->data->grid_y) {
PyErr_Format(PyExc_IndexError,
"Position (%d, %d) out of bounds for TileLayer of size (%d, %d)",
x, y, self->data->grid_x, self->data->grid_y);
return NULL;
}
return PyLong_FromLong(self->data->at(x, y));
}
int PyGridLayerAPI::TileLayer_subscript_assign(PyTileLayerObject* self, PyObject* key, PyObject* value) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return -1;
}
if (value == nullptr) {
PyErr_SetString(PyExc_TypeError, "cannot delete TileLayer cells");
return -1;
}
int x, y;
if (!parse_subscript_key(key, &x, &y)) return -1;
if (x < 0 || x >= self->data->grid_x || y < 0 || y >= self->data->grid_y) {
PyErr_Format(PyExc_IndexError,
"Position (%d, %d) out of bounds for TileLayer of size (%d, %d)",
x, y, self->data->grid_x, self->data->grid_y);
return -1;
}
if (!PyLong_Check(value)) {
PyErr_SetString(PyExc_TypeError, "tile index must be an int");
return -1;
}
int index = (int)PyLong_AsLong(value);
self->data->at(x, y) = index;
self->data->markDirty(x, y);
return 0;
}
PyMappingMethods PyGridLayerAPI::TileLayer_mapping_methods = {
.mp_length = nullptr,
.mp_subscript = (binaryfunc)PyGridLayerAPI::TileLayer_subscript,
.mp_ass_subscript = (objobjargproc)PyGridLayerAPI::TileLayer_subscript_assign,
};
PyObject* PyGridLayerAPI::TileLayer_fill(PyTileLayerObject* self, PyObject* args) {
int index;
if (!PyArg_ParseTuple(args, "i", &index)) {

View file

@ -218,6 +218,11 @@ public:
static int ColorLayer_set_grid(PyColorLayerObject* self, PyObject* value, void* closure);
static PyObject* ColorLayer_repr(PyColorLayerObject* self);
// Subscript protocol: layer[x, y] / layer[x, y] = value
static PyObject* ColorLayer_subscript(PyColorLayerObject* self, PyObject* key);
static int ColorLayer_subscript_assign(PyColorLayerObject* self, PyObject* key, PyObject* value);
static PyMappingMethods ColorLayer_mapping_methods;
// TileLayer methods
static int TileLayer_init(PyTileLayerObject* self, PyObject* args, PyObject* kwds);
static PyObject* TileLayer_at(PyTileLayerObject* self, PyObject* args, PyObject* kwds);
@ -238,6 +243,11 @@ public:
static int TileLayer_set_grid(PyTileLayerObject* self, PyObject* value, void* closure);
static PyObject* TileLayer_repr(PyTileLayerObject* self);
// Subscript protocol: layer[x, y] / layer[x, y] = value
static PyObject* TileLayer_subscript(PyTileLayerObject* self, PyObject* key);
static int TileLayer_subscript_assign(PyTileLayerObject* self, PyObject* key, PyObject* value);
static PyMappingMethods TileLayer_mapping_methods;
// Method and getset arrays
static PyMethodDef ColorLayer_methods[];
static PyGetSetDef ColorLayer_getsetters[];
@ -259,6 +269,7 @@ namespace mcrfpydef {
Py_TYPE(self)->tp_free(self);
},
.tp_repr = (reprfunc)PyGridLayerAPI::ColorLayer_repr,
.tp_as_mapping = &PyGridLayerAPI::ColorLayer_mapping_methods, // layer[x, y]
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR("ColorLayer(z_index=-1, name=None, grid_size=None)\n\n"
"A grid layer that stores RGBA colors per cell for background/overlay effects.\n\n"
@ -312,6 +323,7 @@ namespace mcrfpydef {
Py_TYPE(self)->tp_free(self);
},
.tp_repr = (reprfunc)PyGridLayerAPI::TileLayer_repr,
.tp_as_mapping = &PyGridLayerAPI::TileLayer_mapping_methods, // layer[x, y]
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR("TileLayer(z_index=-1, name=None, texture=None, grid_size=None)\n\n"
"A grid layer that stores sprite indices per cell for tile-based rendering.\n\n"

View file

@ -82,10 +82,19 @@ PyObject* UIGrid::subscript(PyUIGridObject* self, PyObject* key)
return (PyObject*)obj;
}
// Setitem on _GridData / Grid: GridPoints are views, not assignable.
static int UIGrid_subscript_assign(PyUIGridObject* self, PyObject* key, PyObject* value)
{
(void)self; (void)key; (void)value;
PyErr_SetString(PyExc_TypeError,
"Grid points are not assignable; modify properties on the returned point");
return -1;
}
PyMappingMethods UIGrid::mpmethods = {
.mp_length = NULL,
.mp_subscript = (binaryfunc)UIGrid::subscript,
.mp_ass_subscript = NULL
.mp_ass_subscript = (objobjargproc)UIGrid_subscript_assign,
};
// =========================================================================

View file

@ -1,6 +1,7 @@
// UIGridView.cpp - Rendering view for GridData (#252)
#include "UIGridView.h"
#include "UIGrid.h"
#include "UIGridPoint.h"
#include "UIEntity.h"
#include "GameEngine.h"
#include "McRFPy_API.h"
@ -780,6 +781,69 @@ int UIGridView::set_float_member_gv(PyUIGridViewObject* self, PyObject* value, v
return 0;
}
// =========================================================================
// Subscript protocol: grid[x, y] -> GridPoint (delegates to GridData).
// Setitem raises TypeError (GridPoints are views, not assignable).
// =========================================================================
PyObject* UIGridView::subscript(PyUIGridViewObject* self, PyObject* key)
{
if (!self->data || !self->data->grid_data) {
PyErr_SetString(PyExc_RuntimeError, "Grid has no underlying data");
return NULL;
}
if (!PyTuple_Check(key) || PyTuple_Size(key) != 2) {
PyErr_SetString(PyExc_TypeError, "Grid indices must be a 2-tuple (x, y)");
return NULL;
}
PyObject* x_obj = PyTuple_GetItem(key, 0);
PyObject* y_obj = PyTuple_GetItem(key, 1);
if (!PyLong_Check(x_obj) || !PyLong_Check(y_obj)) {
PyErr_SetString(PyExc_TypeError, "Grid indices must be integers");
return NULL;
}
int x = (int)PyLong_AsLong(x_obj);
int y = (int)PyLong_AsLong(y_obj);
auto& grid_data = self->data->grid_data;
if (x < 0 || x >= grid_data->grid_w) {
PyErr_Format(PyExc_IndexError, "x index %d is out of range [0, %d)",
x, grid_data->grid_w);
return NULL;
}
if (y < 0 || y >= grid_data->grid_h) {
PyErr_Format(PyExc_IndexError, "y index %d is out of range [0, %d)",
y, grid_data->grid_h);
return NULL;
}
// Reconstruct shared_ptr<UIGrid> from GridData via aliasing constructor
// (mirrors UIGridView::get_grid).
auto grid_ptr = static_cast<UIGrid*>(grid_data.get());
auto grid_as_uigrid = std::shared_ptr<UIGrid>(grid_data, grid_ptr);
auto type = &mcrfpydef::PyUIGridPointType;
auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0);
if (!obj) return NULL;
obj->grid = grid_as_uigrid;
obj->x = x;
obj->y = y;
return (PyObject*)obj;
}
int UIGridView::subscript_assign(PyUIGridViewObject* self, PyObject* key, PyObject* value)
{
(void)self; (void)key; (void)value;
PyErr_SetString(PyExc_TypeError,
"Grid points are not assignable; modify properties on the returned point");
return -1;
}
PyMappingMethods UIGridView::mpmethods = {
.mp_length = NULL,
.mp_subscript = (binaryfunc)UIGridView::subscript,
.mp_ass_subscript = (objobjargproc)UIGridView::subscript_assign,
};
// #252: PyObjectType typedef for UIDRAWABLE_* macros
typedef PyUIGridViewObject PyObjectType;

View file

@ -110,6 +110,12 @@ public:
static PyMethodDef methods[];
static PyGetSetDef getsetters[];
// Subscript protocol: grid[x, y] (delegates to underlying GridData).
// Setitem raises TypeError (GridPoints are views).
static PyObject* subscript(PyUIGridViewObject* self, PyObject* key);
static int subscript_assign(PyUIGridViewObject* self, PyObject* key, PyObject* value);
static PyMappingMethods mpmethods;
};
// Forward declaration of methods array
@ -139,6 +145,7 @@ namespace mcrfpydef {
Py_TYPE(self)->tp_free(self);
},
.tp_repr = (reprfunc)UIGridView::repr,
.tp_as_mapping = &UIGridView::mpmethods, // grid[x, y] (delegates to GridData)
.tp_getattro = UIGridView::getattro, // #252: attribute delegation to Grid
.tp_setattro = UIGridView::setattro, // #252: attribute delegation to Grid
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC,