Add read-only Caption.font getter; addresses #320

The Caption class docstring listed `font` under its Attributes, but no getter
existed -- the PyUICaptionObject.font slot was GC-managed yet never populated by
init(). Wire it up:
  - init() now stores the supplied Font (incref) or, when none/None was given, a
    wrapper around the engine default font, so the getter reflects what is
    actually rendered rather than returning None.
  - New read-only `font` getset (consistent with Sprite.texture being read-only).

Regenerated API docs/stubs/man page and rebaselined the api-surface snapshot
(one added line: Caption `prop font: Font (ro)`). Frozen docstring gate 100%,
suite 297/297.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnywUddaFRhkxo5kijxJnv
This commit is contained in:
John McCardle 2026-06-21 12:18:53 -04:00
commit ab6aecd0b0
9 changed files with 47 additions and 10 deletions

View file

@ -57,7 +57,7 @@ The active tier1 queue is empty. The last three findings (#309 Caption float→u
- **Phase 5.3** -- documentation regenerated; `tools/generate_stubs_v2.py` rewritten as introspection-based so it can no longer drift from the C++ source.
### Recently Shipped (June 2026)
- **#320** -- `Caption` constructor positional signature now matches its frozen docstring. The docstring advertised `Caption(pos, font, text, ...)` (parallel to `Sprite`/`Entity`, whose 2nd positional is the resource), but the implementation laid its two positional slots out as `(pos, text)` with `font` keyword-only, so `Caption((x,y), None, "text")` raised `TypeError`. Fixed `UICaption::init` to `(pos, font, text)` positional-or-keyword. Audited zero live callers of the old `(pos, text)` 2-positional form. Also rewrote two stale unit tests (`test_animation_raii`, `test_animation_property_locking`) that called the removed `mcrfpy.Animation(...)` constructor to use `drawable.animate(...)` -- preserving the suite's only `conflict_mode` (#120) coverage and the weak-target RAII checks. Suite now 297/297.
- **#320** -- `Caption` constructor positional signature now matches its frozen docstring. The docstring advertised `Caption(pos, font, text, ...)` (parallel to `Sprite`/`Entity`, whose 2nd positional is the resource), but the implementation laid its two positional slots out as `(pos, text)` with `font` keyword-only, so `Caption((x,y), None, "text")` raised `TypeError`. Fixed `UICaption::init` to `(pos, font, text)` positional-or-keyword. Audited zero live callers of the old `(pos, text)` 2-positional form. Also added the matching read-only `Caption.font` getter (the class docstring listed `font` as an attribute but no getter existed; it now reflects the supplied or engine-default font). Also rewrote two stale unit tests (`test_animation_raii`, `test_animation_property_locking`) that called the removed `mcrfpy.Animation(...)` constructor to use `drawable.animate(...)` -- preserving the suite's only `conflict_mode` (#120) coverage and the weak-target RAII checks. Suite now 297/297.
- **#317 / #318 / #319** -- The three code-level bugs surfaced by the #314 docstring-accuracy verify pass, fixed together. #317: `automation.scroll()` dropped the x of its position argument (the scroll delta now has its own `injectMouseEvent` parameter, so the real x/y is forwarded). #318: `GridView.texture` always returned `None` (a TODO stub) -- it now returns a `Texture` wrapper (and since `mcrfpy.Grid`/`mcrfpy.GridView` are one type post-#252, both names benefit). #319: `Entity.visible_entities(radius=None)` raised `TypeError` (the `i` format code rejects `None`) -- radius is now parsed as an object so `None`/omitted/`-1` mean "grid default". Regression tests for each; api-surface snapshot re-baselined and docs/stubs regenerated.
- **#316** -- Sparse (windowed) perspective writeback in `UIEntity::updateVisibility`. The demote+promote passes are now clipped to an AABB sized to `fov_radius` (with a `prev_fov` window cache so a moving entity leaves no trailing "ghost vision"), replacing two full-`W*H` walks per entity. The Phase 5.2 benchmark's flat ~25-36 ms/entity writeback overhead on a 1000x1000 grid collapses to single-digit microseconds (384x-6577x on the cheap algorithms; lost in timing noise on the rest). Adversarial verify caught a regression the happy-path test missed -- externally-assigned maps (the documented `from_bytes` load/resume path) need a one-shot full demote (`perspective_full_demote_pending`) since `prev_fov` only bounds engine-promoted cells; fixed and locked with a 7-section regression test.
- **#313** -- `UIEntity::grid` migrated from `shared_ptr<UIGrid>` to `shared_ptr<GridData>` (post-#252 refactor cleanup), adding a new public `entity.texture` read/write property. Merged to master.

View file

@ -1,6 +1,6 @@
# McRogueFace API Reference
*Generated on 2026-06-21 10:11:50*
*Generated on 2026-06-21 12:15:59*
*This documentation was dynamically generated from the compiled module.*
@ -871,6 +871,7 @@ Attributes:
- `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.
- `bounds` *(read-only)*: Axis-aligned bounding box (tuple, read-only) as a (pos, size) pair of Vectors: (Vector(x, y), Vector(width, height)).
- `fill_color`: Fill color of the text (Color). Returns a copy; modifying components requires reassignment. For animation, use 'fill_color.r', 'fill_color.g', etc.
- `font` *(read-only)*: Font used for text rendering (Font, read-only). Reflects the engine default font when none was provided at construction.
- `font_size`: Font size in points (int). Clamped to the range [0, 65535].
- `global_bounds` *(read-only)*: Axis-aligned bounding box in screen coordinates (tuple, read-only) as a (pos, size) pair of Vectors: (Vector(x, y), Vector(width, height)).
- `global_position` *(read-only)*: Global screen position (read-only). Calculates absolute position by walking up the parent chain.

View file

@ -108,7 +108,7 @@
<body>
<div class="container">
<h1>McRogueFace API Reference</h1>
<p><em>Generated on 2026-06-21 10:11:50</em></p>
<p><em>Generated on 2026-06-21 12:15:59</em></p>
<p><em>This documentation was dynamically generated from the compiled module.</em></p>
<div class="toc">
@ -1006,6 +1006,7 @@ Attributes:
<li><span class='property-name'>align</span>: 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.</li>
<li><span class='property-name'>bounds</span> (read-only): Axis-aligned bounding box (tuple, read-only) as a (pos, size) pair of Vectors: (Vector(x, y), Vector(width, height)).</li>
<li><span class='property-name'>fill_color</span>: Fill color of the text (Color). Returns a copy; modifying components requires reassignment. For animation, use &#x27;fill_color.r&#x27;, &#x27;fill_color.g&#x27;, etc.</li>
<li><span class='property-name'>font</span> (read-only): Font used for text rendering (Font, read-only). Reflects the engine default font when none was provided at construction.</li>
<li><span class='property-name'>font_size</span>: Font size in points (int). Clamped to the range [0, 65535].</li>
<li><span class='property-name'>global_bounds</span> (read-only): Axis-aligned bounding box in screen coordinates (tuple, read-only) as a (pos, size) pair of Vectors: (Vector(x, y), Vector(width, height)).</li>
<li><span class='property-name'>global_position</span> (read-only): Global screen position (read-only). Calculates absolute position by walking up the parent chain.</li>

View file

@ -14,11 +14,11 @@
. ftr VB CB
. ftr VBI CBI
.\}
.TH "MCRFPY" "3" "2026-06-21" "McRogueFace 0.2.7-prerelease-7drl2026-108-g2654253" ""
.TH "MCRFPY" "3" "2026-06-21" "McRogueFace 0.2.7-prerelease-7drl2026-112-g5eecb2b" ""
.hy
.SH McRogueFace API Reference
.PP
\f[I]Generated on 2026-06-21 10:11:50\f[R]
\f[I]Generated on 2026-06-21 12:15:59\f[R]
.PP
\f[I]This documentation was dynamically generated from the compiled
module.\f[R]
@ -982,6 +982,9 @@ Vector(width, height)).
- \f[V]fill_color\f[R]: Fill color of the text (Color).
Returns a copy; modifying components requires reassignment.
For animation, use `fill_color.r', `fill_color.g', etc.
- \f[V]font\f[R] \f[I](read-only)\f[R]: Font used for text rendering
(Font, read-only).
Reflects the engine default font when none was provided at construction.
- \f[V]font_size\f[R]: Font size in points (int).
Clamped to the range [0, 65535].
- \f[V]global_bounds\f[R] \f[I](read-only)\f[R]: Axis-aligned bounding

View file

@ -355,6 +355,17 @@ int UICaption::set_text(PyUICaptionObject* self, PyObject* value, void* closure)
return 0;
}
PyObject* UICaption::get_font(PyUICaptionObject* self, void* closure)
{
// #320: read-only Font getter. self->font is populated at construction with
// either the supplied Font or a wrapper around the engine default font.
if (self->font) {
Py_INCREF(self->font);
return self->font;
}
Py_RETURN_NONE;
}
PyObject* UICaption::get_size(PyUICaptionObject* self, void* closure)
{
auto bounds = self->data->text.getGlobalBounds();
@ -415,6 +426,11 @@ PyGetSetDef UICaption::getsetters[] = {
{"text", (getter)UICaption::get_text, (setter)UICaption::set_text,
MCRF_PROPERTY(text, "The text string displayed by this Caption (str)."),
NULL},
{"font", (getter)UICaption::get_font, NULL,
MCRF_PROPERTY(font,
"Font used for text rendering (Font, read-only). Reflects the engine "
"default font when none was provided at construction."
), NULL},
{"font_size", (getter)UICaption::get_float_member, (setter)UICaption::set_float_member,
MCRF_PROPERTY(font_size, "Font size in points (int). Clamped to the range [0, 65535]."),
(void*)5},
@ -551,13 +567,18 @@ int UICaption::init(PyUICaptionObject* self, PyObject* args, PyObject* kwds)
if (outline < 0.0f) outline = 0.0f;
self->data->text.setOutlineThickness(outline);
// Set the font
// Set the font, and remember the Python Font object for the read-only
// `font` getter (#320). When no font was provided, expose the engine default
// font rather than None so the getter reflects what is actually rendered.
if (pyfont) {
self->data->text.setFont(pyfont->font);
Py_INCREF(font);
Py_XSETREF(self->font, font);
} else {
// Use default font when None or not provided
if (McRFPy_API::default_font) {
self->data->text.setFont(McRFPy_API::default_font->font);
Py_XSETREF(self->font, McRFPy_API::default_font->pyObject());
}
}

View file

@ -40,6 +40,7 @@ public:
static int set_color_member(PyUICaptionObject* self, PyObject* value, void* closure);
static PyObject* get_text(PyUICaptionObject* self, void* closure);
static int set_text(PyUICaptionObject* self, PyObject* value, void* closure);
static PyObject* get_font(PyUICaptionObject* self, void* closure);
static PyObject* get_size(PyUICaptionObject* self, void* closure);
static PyObject* get_w(PyUICaptionObject* self, void* closure);
static PyObject* get_h(PyUICaptionObject* self, void* closure);

View file

@ -386,6 +386,7 @@ class Caption:
align: Any # Alignment relative to parent bounds (Alignment enum or None). When set, position is automatically calculated when parent is assigned or resized. Set to None ...
bounds: tuple # Axis-aligned bounding box (tuple, read-only) as a (pos, size) pair of Vectors: (Vector(x, y), Vector(width, height)).
fill_color: Color # Fill color of the text (Color). Returns a copy; modifying components requires reassignment. For animation, use 'fill_color.r', 'fill_color.g', etc.
font: Font # Font used for text rendering (Font, read-only). Reflects the engine default font when none was provided at construction.
font_size: int # Font size in points (int). Clamped to the range [0, 65535].
global_bounds: tuple # Axis-aligned bounding box in screen coordinates (tuple, read-only) as a (pos, size) pair of Vectors: (Vector(x, y), Vector(width, height)).
global_position: Any # Global screen position (read-only). Calculates absolute position by walking up the parent chain.

View file

@ -27,20 +27,28 @@ def check(label, cond):
font = mcrfpy.Font("assets/JetbrainsMono.ttf")
# Note: Caption has no readable 'font' getter (font is consumed at construction),
# so the positional order is proven by *acceptance* of a real Font in slot 2 vs.
# *rejection* of a string there, plus text landing in slot 3.
# --- the bug: 3 positional args (pos, font, text) must work -----------------
c = mcrfpy.Caption((50, 100), font, "Hello World")
check("Caption((pos), font, text) does not raise", True)
check(" -> text bound to 3rd positional", c.text == "Hello World")
check(" -> x from pos", c.x == 50)
check(" -> y from pos", c.y == 100)
check(" -> font getter returns the passed Font (identity)", c.font is font)
# --- font=None positionally still selects the default font -----------------
c2 = mcrfpy.Caption((10, 20), None, "Defaulted")
check("Caption((pos), None, text) works (None font -> default)", c2.text == "Defaulted")
check(" -> font getter reflects default font (not None)", c2.font is not None)
check(" -> default font is a Font instance", isinstance(c2.font, mcrfpy.Font))
# --- font getter is read-only ----------------------------------------------
try:
c2.font = font
check("Caption.font is read-only", False)
except AttributeError:
check("Caption.font is read-only", True)
except Exception as e:
check("Caption.font is read-only (got %s)" % type(e).__name__, False)
# --- font is the 2nd positional (matches Sprite/Entity resource-2nd order) --
# A real Font in slot 2 must be accepted as the font, NOT misread as text.

View file

@ -303,6 +303,7 @@ submodule automation
prop align: Any (rw)
prop bounds: tuple (ro)
prop fill_color: Color (rw)
prop font: Font (ro)
prop font_size: int (rw)
prop global_bounds: tuple (ro)
prop global_position: Any (ro)