feat: Implement comprehensive mouse event system

Implements multiple mouse event improvements for UI elements:

- Mouse enter/exit events (#140): on_enter, on_exit callbacks and
  hovered property for all UIDrawable types (Frame, Caption, Sprite, Grid)
- Headless click events (#111): Track simulated mouse position for
  automation testing in headless mode
- Mouse move events (#141): on_move callback fires continuously while
  mouse is within element bounds
- Grid cell events (#142): on_cell_enter, on_cell_exit, on_cell_click
  callbacks with cell coordinates (x, y), plus hovered_cell property

Includes comprehensive tests for all new functionality.

Closes #140, closes #111, closes #141, closes #142

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-11-27 23:08:31 -05:00
commit 6c496b8732
14 changed files with 1353 additions and 27 deletions

View file

@ -5,6 +5,7 @@
#include "UIEntity.h"
#include "Profiler.h"
#include <algorithm>
#include <cmath> // #142 - for std::floor
// UIDrawable methods now in UIBase.h
UIGrid::UIGrid()
@ -619,9 +620,51 @@ UIDrawable* UIGrid::click_at(sf::Vector2f point)
// No entity handled it, check if grid itself has handler
if (click_callable) {
// #142 - Fire on_cell_click if we have the callback and clicked on a valid cell
if (on_cell_click_callable) {
int cell_x = static_cast<int>(std::floor(grid_x));
int cell_y = static_cast<int>(std::floor(grid_y));
// Only fire if within valid grid bounds
if (cell_x >= 0 && cell_x < this->grid_x && cell_y >= 0 && cell_y < this->grid_y) {
PyObject* args = Py_BuildValue("(ii)", cell_x, cell_y);
PyObject* result = PyObject_CallObject(on_cell_click_callable->borrow(), args);
Py_DECREF(args);
if (!result) {
std::cerr << "Cell click callback raised an exception:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else {
Py_DECREF(result);
}
}
}
return this;
}
// #142 - Even without click_callable, fire on_cell_click if present
// Note: We fire the callback but DON'T return this, because PyScene::do_mouse_input
// would try to call click_callable which doesn't exist
if (on_cell_click_callable) {
int cell_x = static_cast<int>(std::floor(grid_x));
int cell_y = static_cast<int>(std::floor(grid_y));
// Only fire if within valid grid bounds
if (cell_x >= 0 && cell_x < this->grid_x && cell_y >= 0 && cell_y < this->grid_y) {
PyObject* args = Py_BuildValue("(ii)", cell_x, cell_y);
PyObject* result = PyObject_CallObject(on_cell_click_callable->borrow(), args);
Py_DECREF(args);
if (!result) {
std::cerr << "Cell click callback raised an exception:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else {
Py_DECREF(result);
}
// Don't return this - no click_callable to call
}
}
return nullptr;
}
@ -1500,6 +1543,15 @@ 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),
// #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_x, cell_y).", NULL},
{"on_cell_exit", (getter)UIGrid::get_on_cell_exit, (setter)UIGrid::set_on_cell_exit,
"Callback when mouse exits a grid cell. Called with (cell_x, cell_y).", NULL},
{"on_cell_click", (getter)UIGrid::get_on_cell_click, (setter)UIGrid::set_on_cell_click,
"Callback when a grid cell is clicked. Called with (cell_x, cell_y).", NULL},
{"hovered_cell", (getter)UIGrid::get_hovered_cell, NULL,
"Currently hovered cell as (x, y) tuple, or None if not hovering.", NULL},
{NULL} /* Sentinel */
};
@ -1550,6 +1602,145 @@ void PyUIGrid_dealloc(PyUIGridObject* self) {
}
*/
// #142 - Grid cell mouse event getters/setters
PyObject* UIGrid::get_on_cell_enter(PyUIGridObject* self, void* closure) {
if (self->data->on_cell_enter_callable) {
PyObject* cb = self->data->on_cell_enter_callable->borrow();
Py_INCREF(cb); // Return new reference, not borrowed
return cb;
}
Py_RETURN_NONE;
}
int UIGrid::set_on_cell_enter(PyUIGridObject* self, PyObject* value, void* closure) {
if (value == Py_None) {
self->data->on_cell_enter_callable.reset();
} else {
self->data->on_cell_enter_callable = std::make_unique<PyClickCallable>(value);
}
return 0;
}
PyObject* UIGrid::get_on_cell_exit(PyUIGridObject* self, void* closure) {
if (self->data->on_cell_exit_callable) {
PyObject* cb = self->data->on_cell_exit_callable->borrow();
Py_INCREF(cb); // Return new reference, not borrowed
return cb;
}
Py_RETURN_NONE;
}
int UIGrid::set_on_cell_exit(PyUIGridObject* self, PyObject* value, void* closure) {
if (value == Py_None) {
self->data->on_cell_exit_callable.reset();
} else {
self->data->on_cell_exit_callable = std::make_unique<PyClickCallable>(value);
}
return 0;
}
PyObject* UIGrid::get_on_cell_click(PyUIGridObject* self, void* closure) {
if (self->data->on_cell_click_callable) {
PyObject* cb = self->data->on_cell_click_callable->borrow();
Py_INCREF(cb); // Return new reference, not borrowed
return cb;
}
Py_RETURN_NONE;
}
int UIGrid::set_on_cell_click(PyUIGridObject* self, PyObject* value, void* closure) {
if (value == Py_None) {
self->data->on_cell_click_callable.reset();
} else {
self->data->on_cell_click_callable = std::make_unique<PyClickCallable>(value);
}
return 0;
}
PyObject* UIGrid::get_hovered_cell(PyUIGridObject* self, void* closure) {
if (self->data->hovered_cell.has_value()) {
return Py_BuildValue("(ii)", self->data->hovered_cell->x, self->data->hovered_cell->y);
}
Py_RETURN_NONE;
}
// #142 - Convert screen coordinates to cell coordinates
std::optional<sf::Vector2i> UIGrid::screenToCell(sf::Vector2f screen_pos) const {
// Get grid's global position
sf::Vector2f global_pos = get_global_position();
sf::Vector2f local_pos = screen_pos - global_pos;
// Check if within grid bounds
sf::FloatRect bounds = box.getGlobalBounds();
if (local_pos.x < 0 || local_pos.y < 0 ||
local_pos.x >= bounds.width || local_pos.y >= bounds.height) {
return std::nullopt;
}
// Get cell size from texture or default
float cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH;
float cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT;
// Apply zoom
cell_width *= zoom;
cell_height *= zoom;
// Calculate grid space position (account for center/pan)
float half_width = bounds.width / 2.0f;
float half_height = bounds.height / 2.0f;
float grid_space_x = (local_pos.x - half_width) / zoom + center_x;
float grid_space_y = (local_pos.y - half_height) / zoom + center_y;
// Convert to cell coordinates
int cell_x = static_cast<int>(std::floor(grid_space_x / (ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH)));
int cell_y = static_cast<int>(std::floor(grid_space_y / (ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT)));
// Check if within valid cell range
if (cell_x < 0 || cell_x >= grid_x || cell_y < 0 || cell_y >= grid_y) {
return std::nullopt;
}
return sf::Vector2i(cell_x, cell_y);
}
// #142 - Update cell hover state and fire callbacks
void UIGrid::updateCellHover(sf::Vector2f mousepos) {
auto new_cell = screenToCell(mousepos);
// Check if cell changed
if (new_cell != hovered_cell) {
// Fire exit callback for old cell
if (hovered_cell.has_value() && on_cell_exit_callable) {
PyObject* args = Py_BuildValue("(ii)", hovered_cell->x, hovered_cell->y);
PyObject* result = PyObject_CallObject(on_cell_exit_callable->borrow(), args);
Py_DECREF(args);
if (!result) {
std::cerr << "Cell exit callback raised an exception:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else {
Py_DECREF(result);
}
}
// Fire enter callback for new cell
if (new_cell.has_value() && on_cell_enter_callable) {
PyObject* args = Py_BuildValue("(ii)", new_cell->x, new_cell->y);
PyObject* result = PyObject_CallObject(on_cell_enter_callable->borrow(), args);
Py_DECREF(args);
if (!result) {
std::cerr << "Cell enter callback raised an exception:" << std::endl;
PyErr_Print();
PyErr_Clear();
} else {
Py_DECREF(result);
}
}
hovered_cell = new_cell;
}
}
int UIEntityCollectionIter::init(PyUIEntityCollectionIterObject* self, PyObject* args, PyObject* kwds)
{
PyErr_SetString(PyExc_TypeError, "UICollection cannot be instantiated: a C++ data source is required.");