fix(render): content invalidation propagates again; clear hover on focus loss
closes #368 closes #367 #368 -- Frame(cache_subtree=True) silently froze its subtree's CONTENT. markContentDirty() gated its walk up the parent chain on (!was_dirty || !p->render_dirty). That is sound only if render_dirty is reliably cleared once a drawable has been drawn. It is not: clearDirty() had exactly one call site in the whole engine (UIGridView, added by #364 on this branch). UIFrame's non-render-texture path, UICaption, UISprite, UILine, UICircle and UIArc never cleared it, so render_dirty was a write-once latch -- true from an object's first mutation until it died. With the flag stuck true, was_dirty was always true AND the parent's render_dirty was always true, so the guard was always false and content invalidation propagated nowhere. A caching ancestor went on re-blitting a stale composite: text never updated, colours never changed, sprite indices never changed. Only movement survived, because x/y route through markCompositeDirty, whose walk was already unconditional. That asymmetry is why the bug read as intermittent rather than total, and it is why my original issue writeup got the shape wrong -- its repro's third step was a move, so it looked like the latch cleared. It does not. Measured on master: 4/4 recolours and 3/3 caption edits under a cache were ALL dropped, including the first. The fix makes markContentDirty's walk unconditional, matching markCompositeDirty. The alternative -- repair the guard by having every drawable clear the flag honestly -- buys a shorter walk at the price of an invariant that silently breaks the day someone adds a drawable and forgets. Not worth it here: parent chains are shallow, and this is the exact cost markCompositeDirty has always paid on every position change without anyone noticing. A/B on the changed path (200k content mutations, best of 3): depth 1 4 8 16 before 82ns 86ns 82ns 87ns <- flat because it did nothing after 76ns 114ns 175ns 295ns The old build's flatness IS the defect. Entity movement -- the actual hot path -- is unchanged (164ns/move either way), since it was already on the unconditional composite path. Crucible shows no regression (step_swarm 12.9 -> 10.7ms, entity_churn 36.3 -> 31.5ms, within noise). Regression test asserts every consecutive content mutation repaints through a cache at depths 1 and 5, AND that idle frames stay byte-identical -- without that last assertion, "mark everything dirty always" would pass and the cache would be worthless. #367 -- Losing window focus stranded hover exactly as a window-leave did (#363). An unfocused window is delivered no MouseMoved, so the hover walk never runs and whatever was hovered when focus left stays lit while the game sits in the background: on_exit/on_cell_exit never fire and hovered_cell keeps naming a cell the user is no longer pointing at. sf::Event::LostFocus was already produced by every backend -- SDL2Renderer even translates SDL_WINDOWEVENT_FOCUS_LOST into it -- and, like MouseLeft before #363, had nobody listening. Routed to the same PyScene::do_mouse_leave() that #363 added. Once unfocused the engine genuinely does not know where the pointer is, so "hovering nothing" is the only truthful state to hold; the next MouseMoved after focus returns restores it. automation.loseFocus() injects the event, via a new injectWindowEvent() rather than injectMouseEvent -- a focus event has no coordinates and no button, and should not borrow a signature that implies it does. api_surface golden re-baselined: +1 line. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c696b4ef41
commit
9d67e33370
8 changed files with 379 additions and 5 deletions
File diff suppressed because one or more lines are too long
|
|
@ -623,7 +623,14 @@ void GameEngine::processEvent(const sf::Event& event)
|
|||
}
|
||||
// #363 - The cursor left the window. No further MouseMoved will arrive, so
|
||||
// whatever is hovered must be exited now or it stays hovered forever.
|
||||
else if (event.type == sf::Event::MouseLeft)
|
||||
//
|
||||
// #367 - Losing focus strands hover identically. The cursor may still be physically
|
||||
// over the window, but an unfocused window is delivered no MouseMoved, so the hover
|
||||
// walk never runs and the last hovered drawable stays lit while the game sits in the
|
||||
// background. Once unfocused we genuinely do not know where the pointer is, and
|
||||
// "hovering nothing" is the only truthful state to hold; the next MouseMoved after
|
||||
// focus returns restores hover.
|
||||
else if (event.type == sf::Event::MouseLeft || event.type == sf::Event::LostFocus)
|
||||
{
|
||||
if (auto* pyscene = dynamic_cast<PyScene*>(currentScene())) {
|
||||
pyscene->do_mouse_leave();
|
||||
|
|
|
|||
|
|
@ -113,6 +113,17 @@ sf::Keyboard::Key McRFPy_Automation::stringToKey(const std::string& keyName) {
|
|||
return sf::Keyboard::Unknown;
|
||||
}
|
||||
|
||||
// #367 - Inject a window-level event. These carry no coordinates and no button, which
|
||||
// is why they get their own entry point rather than riding injectMouseEvent.
|
||||
void McRFPy_Automation::injectWindowEvent(sf::Event::EventType type) {
|
||||
auto engine = getGameEngine();
|
||||
if (!engine) return;
|
||||
|
||||
sf::Event event;
|
||||
event.type = type;
|
||||
engine->processEvent(event);
|
||||
}
|
||||
|
||||
// Inject mouse event into the game engine
|
||||
void McRFPy_Automation::injectMouseEvent(sf::Event::EventType type, int x, int y, sf::Mouse::Button button, float scrollDelta) {
|
||||
auto engine = getGameEngine();
|
||||
|
|
@ -335,6 +346,15 @@ PyObject* McRFPy_Automation::_mouseLeave(PyObject* self, PyObject* args) {
|
|||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
// #367 - Simulate the window losing focus (alt-tab). Like a window-leave, this clears
|
||||
// all hover state: an unfocused window receives no MouseMoved, so any drawable still
|
||||
// marked hovered would stay lit indefinitely. The simulated cursor position is left
|
||||
// alone -- focus and pointer location are independent, and the pointer has not moved.
|
||||
PyObject* McRFPy_Automation::_loseFocus(PyObject* self, PyObject* args) {
|
||||
injectWindowEvent(sf::Event::LostFocus);
|
||||
Py_RETURN_NONE;
|
||||
}
|
||||
|
||||
// Move mouse relative - accepts moveRel(offset, duration)
|
||||
PyObject* McRFPy_Automation::_moveRel(PyObject* self, PyObject* args, PyObject* kwargs) {
|
||||
static const char* kwlist[] = {"offset", "duration", NULL};
|
||||
|
|
@ -990,6 +1010,16 @@ static PyMethodDef automationMethods[] = {
|
|||
MCRF_NOTE("Takes no position: the cursor is outside the window, so there is none. "
|
||||
"Exit callbacks receive the last position passed to moveTo/click.")
|
||||
)},
|
||||
{"loseFocus", (PyCFunction)McRFPy_Automation::_loseFocus, METH_NOARGS,
|
||||
MCRF_METHOD(automation, loseFocus,
|
||||
MCRF_SIG("()", "None"),
|
||||
MCRF_DESC("Simulate the window losing focus (alt-tab), clearing all hover state. "
|
||||
"Every hovered drawable fires its on_exit, every hovered grid fires "
|
||||
"on_cell_exit, and Grid.hovered_cell becomes None."),
|
||||
MCRF_NOTE("An unfocused window is delivered no mouse motion, so the engine cannot "
|
||||
"know where the pointer is; hovering nothing is the only truthful state. "
|
||||
"Hover is restored by the next mouse movement after focus returns.")
|
||||
)},
|
||||
|
||||
{"typewrite", (PyCFunction)McRFPy_Automation::_typewrite, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(automation, typewrite,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ public:
|
|||
static PyObject* _mouseDown(PyObject* self, PyObject* args, PyObject* kwargs);
|
||||
static PyObject* _mouseUp(PyObject* self, PyObject* args, PyObject* kwargs);
|
||||
static PyObject* _mouseLeave(PyObject* self, PyObject* args); // #363
|
||||
static PyObject* _loseFocus(PyObject* self, PyObject* args); // #367
|
||||
|
||||
// Keyboard
|
||||
static PyObject* _typewrite(PyObject* self, PyObject* args, PyObject* kwargs);
|
||||
|
|
@ -45,6 +46,9 @@ public:
|
|||
|
||||
// Helper functions
|
||||
static void injectMouseEvent(sf::Event::EventType type, int x, int y, sf::Mouse::Button button = sf::Mouse::Left, float scrollDelta = 0.0f);
|
||||
// #367 - Window-level events (LostFocus, GainedFocus) carry no payload at all, so
|
||||
// they do not belong in injectMouseEvent's coordinate-bearing signature.
|
||||
static void injectWindowEvent(sf::Event::EventType type);
|
||||
static void injectKeyEvent(sf::Event::EventType type, sf::Keyboard::Key key);
|
||||
static void injectTextEvent(sf::Uint32 unicode);
|
||||
static sf::Keyboard::Key stringToKey(const std::string& keyName);
|
||||
|
|
|
|||
|
|
@ -1046,15 +1046,32 @@ bool UIDrawable::contains_point(float x, float y) const {
|
|||
}
|
||||
|
||||
// #144: Content dirty - texture needs rebuild
|
||||
//
|
||||
// #368 - The walk up the parent chain is UNCONDITIONAL. It used to be gated on
|
||||
// `(!was_dirty || !p->render_dirty)`, which is sound only if render_dirty is reliably
|
||||
// cleared once a drawable has been drawn. It is not: clearDirty() is called by the two
|
||||
// classes that actually *cache* a raster (UIFrame's render-texture path, UIGridView),
|
||||
// and by nobody else. For every ordinary Frame/Caption/Sprite/Line/Circle/Arc,
|
||||
// render_dirty is a write-once latch -- true from the first mutation until death.
|
||||
//
|
||||
// With the flag stuck true, `was_dirty` was always true and the parent's render_dirty
|
||||
// was always true, so the guard was always false and content invalidation propagated
|
||||
// NOWHERE. A caching ancestor (Frame(cache_subtree=True), or a GridView) went on
|
||||
// re-blitting a stale composite: text never updated, colors never changed. Only
|
||||
// position changes survived, because markCompositeDirty (below) already walked
|
||||
// unconditionally.
|
||||
//
|
||||
// Restoring the guard would mean making every drawable clear the flag honestly, and
|
||||
// then trusting that every future drawable remembers to. The invariant here is worth
|
||||
// more than the walk it saves: parent chains are shallow, and this is exactly the cost
|
||||
// markCompositeDirty has always paid on every move without anyone noticing.
|
||||
void UIDrawable::markContentDirty() {
|
||||
bool was_dirty = render_dirty;
|
||||
render_dirty = true;
|
||||
composite_dirty = true; // If content changed, composite also needs update
|
||||
|
||||
// Propagate to parent - parent's composite is dirty (child content changed)
|
||||
// Propagate if: we weren't already dirty, OR parent was cleared (rendered) since last propagation
|
||||
auto p = parent.lock();
|
||||
if (p && (!was_dirty || !p->render_dirty)) {
|
||||
if (p) {
|
||||
p->markContentDirty(); // Parent also needs to rebuild to include our changes
|
||||
}
|
||||
}
|
||||
|
|
|
|||
139
tests/regression/issue_367_hover_exit_on_focus_loss_test.py
Normal file
139
tests/regression/issue_367_hover_exit_on_focus_loss_test.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test for #367: losing window focus stranded hover state (#363's sibling).
|
||||
|
||||
#363 fixed the case where the cursor physically LEAVES the window. But alt-tabbing
|
||||
away strands hover in exactly the same way and for exactly the same reason: while the
|
||||
window is unfocused no MouseMoved arrives, so the hover walk never runs, so whatever
|
||||
was hovered when focus was lost stays hovered. on_exit / on_cell_exit never fire and
|
||||
grid.hovered_cell keeps reporting a cell the user is no longer pointing at -- while
|
||||
the game sits in the background.
|
||||
|
||||
sf::Event::LostFocus was already being produced by every backend (SDL2Renderer even
|
||||
translates SDL_WINDOWEVENT_FOCUS_LOST into it) and, like MouseLeft before #363, had
|
||||
NOBODY LISTENING.
|
||||
|
||||
The fix routes LostFocus to the same PyScene::do_mouse_leave() that #363 added. That
|
||||
is the honest response: once unfocused, the engine genuinely does not know where the
|
||||
pointer is, and "hovering nothing" is the only truthful state. Hover is restored by
|
||||
the next MouseMoved after focus returns.
|
||||
|
||||
automation.loseFocus() injects the event.
|
||||
"""
|
||||
import sys
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
|
||||
CELL = 16
|
||||
FAILURES = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
if not cond:
|
||||
print(f"FAIL: {msg}", file=sys.stderr)
|
||||
FAILURES.append(msg)
|
||||
|
||||
|
||||
def screen_of_cell(grid, cx, cy):
|
||||
"""Screen coords of the center of cell (cx, cy) under the view's camera."""
|
||||
left_sp = int(grid.center_x - (grid.w / 2.0))
|
||||
top_sp = int(grid.center_y - (grid.h / 2.0))
|
||||
return (grid.x + ((cx + 0.5) * CELL - left_sp),
|
||||
grid.y + ((cy + 0.5) * CELL - top_sp))
|
||||
|
||||
|
||||
def case_grid_cell_exit_fires_on_focus_loss():
|
||||
scene = mcrfpy.Scene("blur_grid")
|
||||
scene.activate()
|
||||
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
|
||||
scene.children.append(grid)
|
||||
|
||||
enters, exits = [], []
|
||||
grid.on_cell_enter = lambda pos: enters.append((pos.x, pos.y))
|
||||
grid.on_cell_exit = lambda pos: exits.append((pos.x, pos.y))
|
||||
|
||||
automation.moveTo(screen_of_cell(grid, 2, 2))
|
||||
check(enters == [(2, 2)], f"moving onto cell (2,2) should fire on_cell_enter; got {enters}")
|
||||
check(grid.hovered_cell == (2, 2),
|
||||
f"hovered_cell should be (2, 2) while hovering; got {grid.hovered_cell}")
|
||||
|
||||
# The player alt-tabs away. The cursor may still be physically over the window,
|
||||
# but no further MouseMoved will be delivered to us.
|
||||
automation.loseFocus()
|
||||
|
||||
check(exits == [(2, 2)],
|
||||
f"losing focus must fire on_cell_exit for the hovered cell; got {exits}")
|
||||
check(grid.hovered_cell is None,
|
||||
f"hovered_cell must be None while the window is unfocused; got {grid.hovered_cell}")
|
||||
check(enters == [(2, 2)],
|
||||
f"losing focus must not fire any on_cell_enter; got {enters}")
|
||||
|
||||
|
||||
def case_frame_on_exit_fires_on_focus_loss():
|
||||
"""Like #363, this is an input-layer gap, not a grid one: Frame.on_exit too."""
|
||||
scene = mcrfpy.Scene("blur_frame")
|
||||
scene.activate()
|
||||
frame = mcrfpy.Frame(pos=(50, 50), size=(100, 100))
|
||||
scene.children.append(frame)
|
||||
|
||||
entered, exited = [], []
|
||||
frame.on_enter = lambda pos: entered.append((pos.x, pos.y))
|
||||
frame.on_exit = lambda pos: exited.append((pos.x, pos.y))
|
||||
|
||||
automation.moveTo((100, 100))
|
||||
check(len(entered) == 1, f"moving into the frame should fire on_enter; got {entered}")
|
||||
check(exited == [], f"nothing should have exited yet; got {exited}")
|
||||
|
||||
automation.loseFocus()
|
||||
check(len(exited) == 1, f"losing focus must fire the frame's on_exit; got {exited}")
|
||||
|
||||
# Already unfocused: a second blur has nothing left to exit.
|
||||
automation.loseFocus()
|
||||
check(len(exited) == 1, f"a redundant focus loss must not fire on_exit twice; got {exited}")
|
||||
|
||||
|
||||
def case_reenter_after_focus_loss():
|
||||
"""Hover must be genuinely cleared, not merely reported clear.
|
||||
|
||||
Re-entering the SAME cell after focus returns has to fire on_cell_enter again. If
|
||||
loseFocus had only nulled the reported value while leaving the internal hovered
|
||||
cell set, the incoming cell would compare equal and the enter would be swallowed.
|
||||
"""
|
||||
scene = mcrfpy.Scene("blur_reenter")
|
||||
scene.activate()
|
||||
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
|
||||
scene.children.append(grid)
|
||||
|
||||
enters = []
|
||||
grid.on_cell_enter = lambda pos: enters.append((pos.x, pos.y))
|
||||
|
||||
target = screen_of_cell(grid, 3, 3)
|
||||
automation.moveTo(target)
|
||||
automation.loseFocus()
|
||||
|
||||
automation.moveTo(target)
|
||||
check(enters == [(3, 3), (3, 3)],
|
||||
f"pointing at the same cell after focus returns must fire on_cell_enter again; "
|
||||
f"got {enters}")
|
||||
|
||||
|
||||
def main():
|
||||
case_grid_cell_exit_fires_on_focus_loss()
|
||||
case_frame_on_exit_fires_on_focus_loss()
|
||||
case_reenter_after_focus_loss()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
if FAILURES:
|
||||
print(f"\n{len(FAILURES)} FAILURE(S)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"\nTEST CRASHED: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
176
tests/regression/issue_368_cache_subtree_content_dirty_test.py
Normal file
176
tests/regression/issue_368_cache_subtree_content_dirty_test.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""Regression test for #368 - Frame(cache_subtree=True) froze its subtree's content.
|
||||
|
||||
`UIDrawable::markContentDirty()` guarded its walk up the parent chain with
|
||||
|
||||
bool was_dirty = render_dirty;
|
||||
...
|
||||
if (p && (!was_dirty || !p->render_dirty)) p->markContentDirty();
|
||||
|
||||
That guard is sound only if `render_dirty` is reliably cleared once a drawable has
|
||||
been drawn. It was not: `clearDirty()` had exactly ONE call site in the entire
|
||||
engine (UIGridView, added by #364). UIFrame/UICaption/UISprite/UILine/UICircle/UIArc
|
||||
never cleared it, so `render_dirty` was a WRITE-ONCE LATCH -- true from the first
|
||||
mutation until the object died.
|
||||
|
||||
With the flag permanently true, `was_dirty` was permanently true, the parent's
|
||||
`render_dirty` was permanently true, and the guard was permanently false. Content
|
||||
invalidation stopped propagating entirely, so a caching ancestor kept re-blitting a
|
||||
stale composite: text never updated, colors never changed, sprites never changed.
|
||||
|
||||
Only MOVEMENT survived, because `x`/`y` route through `markCompositeDirty()` -- a
|
||||
different function whose walk was already unconditional. That asymmetry is what made
|
||||
the bug look intermittent rather than total, and it is why every case below mutates
|
||||
CONTENT (color, text, sprite index) rather than position.
|
||||
|
||||
The fix makes markContentDirty's walk unconditional too, matching markCompositeDirty.
|
||||
Correctness then does not depend on any drawable remembering to clear a flag.
|
||||
"""
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
import sys, os, tempfile
|
||||
|
||||
TMPDIR = tempfile.mkdtemp(prefix="mcrf368_")
|
||||
_counter = 0
|
||||
_results = []
|
||||
|
||||
|
||||
def check(label, ok):
|
||||
_results.append((label, bool(ok)))
|
||||
print(" [%s] %s" % ("PASS" if ok else "FAIL", label))
|
||||
|
||||
|
||||
def shot():
|
||||
global _counter
|
||||
path = os.path.join(TMPDIR, "s%d.png" % _counter)
|
||||
_counter += 1
|
||||
automation.screenshot(path)
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def case_recolor_under_cache():
|
||||
"""Every recolor of a nested child must reach the caching ancestor.
|
||||
|
||||
Not just the first: on master ZERO of these propagated, because render_dirty had
|
||||
already latched by the time the first one ran.
|
||||
"""
|
||||
scene = mcrfpy.Scene("t368_recolor")
|
||||
scene.activate()
|
||||
|
||||
cache = mcrfpy.Frame(pos=(10, 10), size=(300, 300), cache_subtree=True)
|
||||
scene.children.append(cache)
|
||||
inner = mcrfpy.Frame(pos=(0, 0), size=(280, 280))
|
||||
cache.children.append(inner)
|
||||
kid = mcrfpy.Frame(pos=(32, 32), size=(48, 48), fill_color=mcrfpy.Color(255, 0, 0))
|
||||
inner.children.append(kid)
|
||||
|
||||
prev = shot()
|
||||
for i, rgb in enumerate([(0, 255, 0), (0, 0, 255), (255, 255, 0), (255, 0, 255)]):
|
||||
kid.fill_color = mcrfpy.Color(*rgb)
|
||||
cur = shot()
|
||||
check("1.%d: recolor -> %s repaints through the cache" % (i + 1, rgb), cur != prev)
|
||||
prev = cur
|
||||
|
||||
|
||||
def case_caption_text_under_cache():
|
||||
"""The most visible form of this bug: a label inside a cached panel never updates."""
|
||||
scene = mcrfpy.Scene("t368_text")
|
||||
scene.activate()
|
||||
|
||||
cache = mcrfpy.Frame(pos=(10, 10), size=(300, 300), cache_subtree=True)
|
||||
scene.children.append(cache)
|
||||
inner = mcrfpy.Frame(pos=(0, 0), size=(280, 280))
|
||||
cache.children.append(inner)
|
||||
cap = mcrfpy.Caption(text="one", pos=(20, 20))
|
||||
inner.children.append(cap)
|
||||
|
||||
prev = shot()
|
||||
for i, txt in enumerate(["two", "three", "four"]):
|
||||
cap.text = txt
|
||||
cur = shot()
|
||||
check("2.%d: caption text -> %r repaints through the cache" % (i + 1, txt), cur != prev)
|
||||
prev = cur
|
||||
|
||||
|
||||
def case_deep_nesting():
|
||||
"""The walk must reach the cache from arbitrary depth, not just one level down."""
|
||||
scene = mcrfpy.Scene("t368_deep")
|
||||
scene.activate()
|
||||
|
||||
cache = mcrfpy.Frame(pos=(10, 10), size=(300, 300), cache_subtree=True)
|
||||
scene.children.append(cache)
|
||||
|
||||
node = cache
|
||||
for d in range(4):
|
||||
nxt = mcrfpy.Frame(pos=(5, 5), size=(260 - d * 10, 260 - d * 10))
|
||||
node.children.append(nxt)
|
||||
node = nxt
|
||||
|
||||
leaf = mcrfpy.Frame(pos=(10, 10), size=(40, 40), fill_color=mcrfpy.Color(255, 0, 0))
|
||||
node.children.append(leaf)
|
||||
|
||||
prev = shot()
|
||||
for i, rgb in enumerate([(0, 255, 0), (0, 0, 255), (255, 255, 0)]):
|
||||
leaf.fill_color = mcrfpy.Color(*rgb)
|
||||
cur = shot()
|
||||
check("3.%d: depth-5 leaf recolor reaches the cache" % (i + 1), cur != prev)
|
||||
prev = cur
|
||||
|
||||
|
||||
def case_idle_is_still_cheap():
|
||||
"""The fix must not defeat the cache: an untouched frame stays byte-identical.
|
||||
|
||||
If markContentDirty had been "fixed" by marking everything dirty every frame, the
|
||||
tests above would pass and the cache would be worthless. This is the counterweight.
|
||||
"""
|
||||
scene = mcrfpy.Scene("t368_idle")
|
||||
scene.activate()
|
||||
|
||||
cache = mcrfpy.Frame(pos=(10, 10), size=(300, 300), cache_subtree=True)
|
||||
scene.children.append(cache)
|
||||
kid = mcrfpy.Frame(pos=(32, 32), size=(48, 48), fill_color=mcrfpy.Color(255, 0, 0))
|
||||
cache.children.append(kid)
|
||||
|
||||
a = shot()
|
||||
b = shot()
|
||||
c = shot()
|
||||
check("4a: idle frames under a cache are byte-identical", a == b == c)
|
||||
|
||||
|
||||
def case_uncached_still_works():
|
||||
"""Sanity: the ordinary (uncached) path must be unaffected."""
|
||||
scene = mcrfpy.Scene("t368_plain")
|
||||
scene.activate()
|
||||
|
||||
holder = mcrfpy.Frame(pos=(10, 10), size=(300, 300))
|
||||
scene.children.append(holder)
|
||||
kid = mcrfpy.Frame(pos=(32, 32), size=(48, 48), fill_color=mcrfpy.Color(255, 0, 0))
|
||||
holder.children.append(kid)
|
||||
|
||||
prev = shot()
|
||||
for i, rgb in enumerate([(0, 255, 0), (0, 0, 255)]):
|
||||
kid.fill_color = mcrfpy.Color(*rgb)
|
||||
cur = shot()
|
||||
check("5.%d: recolor repaints without a cache" % (i + 1), cur != prev)
|
||||
prev = cur
|
||||
|
||||
|
||||
def run_tests():
|
||||
case_recolor_under_cache()
|
||||
case_caption_text_under_cache()
|
||||
case_deep_nesting()
|
||||
case_idle_is_still_cheap()
|
||||
case_uncached_still_works()
|
||||
return all(ok for _, ok in _results)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
ok = run_tests()
|
||||
print("PASS" if ok else "FAIL")
|
||||
sys.exit(0 if ok else 1)
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print("FAIL")
|
||||
sys.exit(1)
|
||||
|
|
@ -1205,6 +1205,7 @@ func dragTo :: dragTo(pos: tuple | list | Vector, duration: float = 0.0, button:
|
|||
func hotkey :: hotkey(*keys: str) -> None
|
||||
func keyDown :: keyDown(key: str) -> None
|
||||
func keyUp :: keyUp(key: str) -> None
|
||||
func loseFocus :: loseFocus() -> None
|
||||
func middleClick :: middleClick(pos: tuple | list | Vector | None = None) -> None
|
||||
func mouseDown :: mouseDown(pos: tuple | list | Vector | None = None, button: str = 'left') -> None
|
||||
func mouseLeave :: mouseLeave() -> None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue