McRogueFace/src/PyColor.cpp
John McCardle 95463bdc78 Add Color.__eq__/__ne__ for value comparison, closes #307
Color had __hash__ but no __eq__/__ne__, violating the Python convention
that hashable objects must support equality comparison. Two Color objects
with identical RGBA values would not compare equal.

Now supports comparison with Color objects, tuples, and lists:
  Color(255, 0, 0) == Color(255, 0, 0)  # True
  Color(255, 0, 0) == (255, 0, 0)       # True
  Color(255, 0, 0) != (0, 0, 0)         # True

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 21:18:47 -04:00

458 lines
15 KiB
C++

#include "PyColor.h"
#include "McRFPy_API.h"
#include "McRFPy_Doc.h"
#include <string>
#include <cstdio>
PyGetSetDef PyColor::getsetters[] = {
{"r", (getter)PyColor::get_member, (setter)PyColor::set_member,
MCRF_PROPERTY(r, "Red component (0-255). Automatically clamped to valid range."), (void*)0},
{"g", (getter)PyColor::get_member, (setter)PyColor::set_member,
MCRF_PROPERTY(g, "Green component (0-255). Automatically clamped to valid range."), (void*)1},
{"b", (getter)PyColor::get_member, (setter)PyColor::set_member,
MCRF_PROPERTY(b, "Blue component (0-255). Automatically clamped to valid range."), (void*)2},
{"a", (getter)PyColor::get_member, (setter)PyColor::set_member,
MCRF_PROPERTY(a, "Alpha component (0-255, where 0=transparent, 255=opaque). Automatically clamped to valid range."), (void*)3},
{NULL}
};
PyMethodDef PyColor::methods[] = {
{"from_hex", (PyCFunction)PyColor::from_hex, METH_VARARGS | METH_CLASS,
MCRF_METHOD(Color, from_hex,
MCRF_SIG("(hex_string: str)", "Color"),
MCRF_DESC("Create a Color from a hexadecimal string."),
MCRF_ARGS_START
MCRF_ARG("hex_string", "Hex color string (e.g., '#FF0000', 'FF0000', '#AABBCCDD' for RGBA)")
MCRF_RETURNS("Color: New Color object with values from hex string")
MCRF_RAISES("ValueError", "If hex string is not 6 or 8 characters (RGB or RGBA)")
MCRF_NOTE("This is a class method. Call as Color.from_hex('#FF0000')")
)},
{"to_hex", (PyCFunction)PyColor::to_hex, METH_NOARGS,
MCRF_METHOD(Color, to_hex,
MCRF_SIG("()", "str"),
MCRF_DESC("Convert this Color to a hexadecimal string."),
MCRF_RETURNS("str: Hex string in format '#RRGGBB' or '#RRGGBBAA' (if alpha < 255)")
MCRF_NOTE("Alpha component is only included if not fully opaque (< 255)")
)},
{"lerp", (PyCFunction)PyColor::lerp, METH_VARARGS,
MCRF_METHOD(Color, lerp,
MCRF_SIG("(other: Color, t: float)", "Color"),
MCRF_DESC("Linearly interpolate between this color and another."),
MCRF_ARGS_START
MCRF_ARG("other", "The target Color to interpolate towards")
MCRF_ARG("t", "Interpolation factor (0.0 = this color, 1.0 = other color). Automatically clamped to [0.0, 1.0]")
MCRF_RETURNS("Color: New Color representing the interpolated value")
MCRF_NOTE("All components (r, g, b, a) are interpolated independently")
)},
{NULL}
};
PyColor::PyColor(sf::Color target)
:data(target) {}
PyObject* PyColor::pyObject()
{
PyColorObject* obj = (PyColorObject*)mcrfpydef::PyColorType.tp_alloc(&mcrfpydef::PyColorType, 0);
if (obj) {
obj->data = data;
}
return (PyObject*)obj;
}
sf::Color PyColor::fromPy(PyObject* obj)
{
// Handle None or NULL
if (!obj || obj == Py_None) {
return sf::Color::White;
}
// Check if it's already a Color object
if (PyObject_TypeCheck(obj, &mcrfpydef::PyColorType)) {
PyColorObject* self = (PyColorObject*)obj;
return self->data;
}
// Handle tuple or list input
if (PyTuple_Check(obj) || PyList_Check(obj)) {
Py_ssize_t size = PySequence_Size(obj);
if (size < 3 || size > 4) {
PyErr_SetString(PyExc_TypeError, "Color tuple/list must have 3 or 4 elements (r, g, b[, a])");
return sf::Color::White;
}
int r = 255, g = 255, b = 255, a = 255;
PyObject* item0 = PySequence_GetItem(obj, 0);
PyObject* item1 = PySequence_GetItem(obj, 1);
PyObject* item2 = PySequence_GetItem(obj, 2);
if (PyLong_Check(item0)) r = (int)PyLong_AsLong(item0);
if (PyLong_Check(item1)) g = (int)PyLong_AsLong(item1);
if (PyLong_Check(item2)) b = (int)PyLong_AsLong(item2);
Py_DECREF(item0);
Py_DECREF(item1);
Py_DECREF(item2);
if (size == 4) {
PyObject* item3 = PySequence_GetItem(obj, 3);
if (PyLong_Check(item3)) a = (int)PyLong_AsLong(item3);
Py_DECREF(item3);
}
// Clamp values
r = std::max(0, std::min(255, r));
g = std::max(0, std::min(255, g));
b = std::max(0, std::min(255, b));
a = std::max(0, std::min(255, a));
return sf::Color(r, g, b, a);
}
// Handle integer (grayscale)
if (PyLong_Check(obj)) {
int v = std::max(0, std::min(255, (int)PyLong_AsLong(obj)));
return sf::Color(v, v, v, 255);
}
// Unknown type - set error and return white
PyErr_SetString(PyExc_TypeError, "Color must be a Color object, tuple, list, or integer");
return sf::Color::White;
}
sf::Color PyColor::fromPy(PyColorObject* self)
{
return self->data;
}
void PyColor::set(sf::Color color)
{
data = color;
}
sf::Color PyColor::get()
{
return data;
}
Py_hash_t PyColor::hash(PyObject* obj)
{
auto self = (PyColorObject*)obj;
Py_hash_t value = 0;
value += self->data.r;
value <<= 8; value += self->data.g;
value <<= 8; value += self->data.b;
value <<= 8; value += self->data.a;
return value;
}
PyObject* PyColor::repr(PyObject* obj)
{
PyColorObject* self = (PyColorObject*)obj;
std::ostringstream ss;
sf::Color c = self->data;
ss << "<Color (" << int(c.r) << ", " << int(c.g) << ", " << int(c.b) << ", " << int(c.a) << ")>";
std::string repr_str = ss.str();
return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace");
}
int PyColor::init(PyColorObject* self, PyObject* args, PyObject* kwds) {
//using namespace mcrfpydef;
static const char* keywords[] = { "r", "g", "b", "a", nullptr };
PyObject* leader;
int r = -1, g = -1, b = -1, a = 255;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|iii", const_cast<char**>(keywords), &leader, &g, &b, &a)) {
PyErr_SetString(PyExc_TypeError, "mcrfpy.Color requires a 3-tuple, 4-tuple, color name, or integer values within 0-255 (r, g, b, optionally a)");
return -1;
}
//std::cout << "Arg parsing succeeded. Values: " << r << " " << g << " " << b << " " << a <<std::endl;
//std::cout << PyUnicode_AsUTF8(PyObject_Repr(leader)) << std::endl;
// Tuple cases
if (PyTuple_Check(leader)) {
Py_ssize_t tupleSize = PyTuple_Size(leader);
if (tupleSize < 3 || tupleSize > 4) {
PyErr_SetString(PyExc_TypeError, "Invalid tuple length: mcrfpy.Color requires a 3-tuple, 4-tuple, color name, or integer values within 0-255 (r, g, b, optionally a)");
return -1;
}
r = PyLong_AsLong(PyTuple_GetItem(leader, 0));
g = PyLong_AsLong(PyTuple_GetItem(leader, 1));
b = PyLong_AsLong(PyTuple_GetItem(leader, 2));
if (tupleSize == 4) {
a = PyLong_AsLong(PyTuple_GetItem(leader, 3));
}
}
// Color name (not implemented yet)
else if (PyUnicode_Check(leader)) {
PyErr_SetString(PyExc_NotImplementedError, "Color names aren't ready yet");
return -1;
}
// Check if the leader is actually an integer for the r value
else if (PyLong_Check(leader)) {
r = PyLong_AsLong(leader);
// Additional validation not shown; g, b are required to be parsed
} else {
PyErr_SetString(PyExc_TypeError, "mcrfpy.Color requires a 3-tuple, 4-tuple, color name, or integer values within 0-255 (r, g, b, optionally a)");
return -1;
}
// Validate color values
if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255 || a < 0 || a > 255) {
PyErr_SetString(PyExc_ValueError, "Color values must be between 0 and 255.");
return -1;
}
self->data = sf::Color(r, g, b, a);
return 0;
}
PyObject* PyColor::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds)
{
auto obj = (PyObject*)type->tp_alloc(type, 0);
//Py_INCREF(obj);
return obj;
}
PyObject* PyColor::get_member(PyObject* obj, void* closure)
{
PyColorObject* self = (PyColorObject*)obj;
intptr_t member = (intptr_t)closure;
switch (member) {
case 0: // r
return PyLong_FromLong(self->data.r);
case 1: // g
return PyLong_FromLong(self->data.g);
case 2: // b
return PyLong_FromLong(self->data.b);
case 3: // a
return PyLong_FromLong(self->data.a);
default:
PyErr_SetString(PyExc_AttributeError, "Invalid color member");
return NULL;
}
}
int PyColor::set_member(PyObject* obj, PyObject* value, void* closure)
{
PyColorObject* self = (PyColorObject*)obj;
intptr_t member = (intptr_t)closure;
if (!PyLong_Check(value)) {
PyErr_SetString(PyExc_TypeError, "Color values must be integers");
return -1;
}
long val = PyLong_AsLong(value);
if (val < 0 || val > 255) {
PyErr_SetString(PyExc_ValueError, "Color values must be between 0 and 255");
return -1;
}
switch (member) {
case 0: // r
self->data.r = static_cast<sf::Uint8>(val);
break;
case 1: // g
self->data.g = static_cast<sf::Uint8>(val);
break;
case 2: // b
self->data.b = static_cast<sf::Uint8>(val);
break;
case 3: // a
self->data.a = static_cast<sf::Uint8>(val);
break;
default:
PyErr_SetString(PyExc_AttributeError, "Invalid color member");
return -1;
}
return 0;
}
PyColorObject* PyColor::from_arg(PyObject* args)
{
PyTypeObject* type = &mcrfpydef::PyColorType;
// Check if args is already a Color instance
if (PyObject_IsInstance(args, (PyObject*)type)) {
Py_INCREF(args); // Return new reference so caller can safely DECREF
return (PyColorObject*)args;
}
// Create new Color object
PyObject* obj = type->tp_alloc(type, 0);
if (!obj) {
return NULL;
}
// Initialize the object
int err = init((PyColorObject*)obj, args, NULL);
if (err) {
Py_DECREF(obj);
return NULL;
}
return (PyColorObject*)obj;
}
// Color helper method implementations
PyObject* PyColor::from_hex(PyObject* cls, PyObject* args)
{
const char* hex_str;
if (!PyArg_ParseTuple(args, "s", &hex_str)) {
return NULL;
}
std::string hex(hex_str);
// Remove # if present
if (hex.length() > 0 && hex[0] == '#') {
hex = hex.substr(1);
}
// Validate hex string
if (hex.length() != 6 && hex.length() != 8) {
PyErr_SetString(PyExc_ValueError, "Hex string must be 6 or 8 characters (RGB or RGBA)");
return NULL;
}
// Parse hex values
try {
unsigned int r = std::stoul(hex.substr(0, 2), nullptr, 16);
unsigned int g = std::stoul(hex.substr(2, 2), nullptr, 16);
unsigned int b = std::stoul(hex.substr(4, 2), nullptr, 16);
unsigned int a = 255;
if (hex.length() == 8) {
a = std::stoul(hex.substr(6, 2), nullptr, 16);
}
// Create new Color object
PyTypeObject* type = (PyTypeObject*)cls;
PyColorObject* color = (PyColorObject*)type->tp_alloc(type, 0);
if (color) {
color->data = sf::Color(r, g, b, a);
}
return (PyObject*)color;
} catch (const std::exception& e) {
PyErr_SetString(PyExc_ValueError, "Invalid hex string");
return NULL;
}
}
PyObject* PyColor::to_hex(PyColorObject* self, PyObject* Py_UNUSED(ignored))
{
char hex[10]; // #RRGGBBAA + null terminator
// Include alpha only if not fully opaque
if (self->data.a < 255) {
snprintf(hex, sizeof(hex), "#%02X%02X%02X%02X",
self->data.r, self->data.g, self->data.b, self->data.a);
} else {
snprintf(hex, sizeof(hex), "#%02X%02X%02X",
self->data.r, self->data.g, self->data.b);
}
return PyUnicode_FromString(hex);
}
PyObject* PyColor::lerp(PyColorObject* self, PyObject* args)
{
PyObject* other_obj;
float t;
if (!PyArg_ParseTuple(args, "Of", &other_obj, &t)) {
return NULL;
}
// Validate other color
if (!PyObject_IsInstance(other_obj, (PyObject*)&mcrfpydef::PyColorType)) {
PyErr_SetString(PyExc_TypeError, "First argument must be a Color");
return NULL;
}
PyColorObject* other = (PyColorObject*)other_obj;
// Clamp t to [0, 1]
if (t < 0.0f) t = 0.0f;
if (t > 1.0f) t = 1.0f;
// Perform linear interpolation
sf::Uint8 r = static_cast<sf::Uint8>(self->data.r + (other->data.r - self->data.r) * t);
sf::Uint8 g = static_cast<sf::Uint8>(self->data.g + (other->data.g - self->data.g) * t);
sf::Uint8 b = static_cast<sf::Uint8>(self->data.b + (other->data.b - self->data.b) * t);
sf::Uint8 a = static_cast<sf::Uint8>(self->data.a + (other->data.a - self->data.a) * t);
// Create new Color object
auto type = &mcrfpydef::PyColorType;
PyColorObject* result = (PyColorObject*)type->tp_alloc(type, 0);
if (result) {
result->data = sf::Color(r, g, b, a);
}
return (PyObject*)result;
}
PyObject* PyColor::richcompare(PyObject* left, PyObject* right, int op)
{
// Only support == and !=
if (op != Py_EQ && op != Py_NE) {
Py_RETURN_NOTIMPLEMENTED;
}
// Extract RGBA from left operand
sf::Color left_c, right_c;
if (PyObject_IsInstance(left, (PyObject*)&mcrfpydef::PyColorType)) {
left_c = ((PyColorObject*)left)->data;
} else {
Py_RETURN_NOTIMPLEMENTED;
}
// Extract RGBA from right operand - accept Color, tuple, or list
if (PyObject_IsInstance(right, (PyObject*)&mcrfpydef::PyColorType)) {
right_c = ((PyColorObject*)right)->data;
} else if (PyTuple_Check(right) || PyList_Check(right)) {
Py_ssize_t size = PySequence_Size(right);
if (size < 3 || size > 4) {
Py_RETURN_NOTIMPLEMENTED;
}
PyObject* r = PySequence_GetItem(right, 0);
PyObject* g = PySequence_GetItem(right, 1);
PyObject* b = PySequence_GetItem(right, 2);
if (!PyLong_Check(r) || !PyLong_Check(g) || !PyLong_Check(b)) {
Py_DECREF(r); Py_DECREF(g); Py_DECREF(b);
Py_RETURN_NOTIMPLEMENTED;
}
right_c.r = (sf::Uint8)PyLong_AsLong(r);
right_c.g = (sf::Uint8)PyLong_AsLong(g);
right_c.b = (sf::Uint8)PyLong_AsLong(b);
Py_DECREF(r); Py_DECREF(g); Py_DECREF(b);
if (size == 4) {
PyObject* a = PySequence_GetItem(right, 3);
if (PyLong_Check(a)) right_c.a = (sf::Uint8)PyLong_AsLong(a);
Py_DECREF(a);
} else {
right_c.a = 255;
}
} else {
Py_RETURN_NOTIMPLEMENTED;
}
bool equal = (left_c.r == right_c.r && left_c.g == right_c.g &&
left_c.b == right_c.b && left_c.a == right_c.a);
if (op == Py_EQ) {
return PyBool_FromLong(equal);
} else {
return PyBool_FromLong(!equal);
}
}