diff --git a/HISTORY.md b/HISTORY.md index 86bf22567..7e4c78780 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -19,6 +19,10 @@ --> # Version History +- 0.2.221 - Inspect registered Tcl commands when cancelling ``after`` callbacks + so root-level events without widget paths are removed. + - Add regression test ensuring unstored root callbacks referencing + detached widgets produce no ``invalid command name`` logs. - 0.2.220 - Include script-bearing options when rewriting widget configuration references so commands target cloned widgets. - Add grouped tests verifying button and menu commands operate on diff --git a/gui/utils/closable_notebook.py b/gui/utils/closable_notebook.py index 0c6e7e9f4..f7e8ebd62 100644 --- a/gui/utils/closable_notebook.py +++ b/gui/utils/closable_notebook.py @@ -45,6 +45,17 @@ def cancel_after_events(widget: tk.Widget, cancelled: set[str] | None = None) -> if cancelled is None: cancelled = set() + def _callback_references_widget(func: t.Any) -> bool: + if getattr(func, "__self__", None) is widget: + return True + try: + closure = inspect.getclosurevars(func) + if widget in closure.nonlocals.values() or widget in closure.globals.values(): + return True + except Exception: + pass + return False + def _cancel_ident(ident: str) -> None: tkapp_local = getattr(widget, "tk", None) if tkapp_local is None: @@ -83,6 +94,16 @@ def _cancel_ident(ident: str) -> None: continue if str(widget) in script: _cancel_ident(ident) + continue + try: + root = widget._root() + except Exception: + root = None + if root is not None: + tcl_cmds = getattr(root, "_tclCommands", {}) or {} + cb = tcl_cmds.get(script) + if cb is not None and _callback_references_widget(cb): + _cancel_ident(ident) try: for name in dir(widget): diff --git a/tests/detachment/after_callbacks/test_cancel_root_level.py b/tests/detachment/after_callbacks/test_cancel_root_level.py index e48b7188b..5ae9956ac 100644 --- a/tests/detachment/after_callbacks/test_cancel_root_level.py +++ b/tests/detachment/after_callbacks/test_cancel_root_level.py @@ -43,3 +43,13 @@ def test_detach_and_close_no_invalid_command(self, capsys): root.update() assert "invalid command name" not in capsys.readouterr().err root.destroy() + + def test_unstored_command_references_widget(self, capsys): + root = tk.Tk(); root.withdraw() + btn = tk.Button(root, text="Go") + btn.pack() + cmd = btn.register(lambda: btn.config(text="Foo")) + root.tk.call("after", "1", cmd) + cancel_after_events(btn) + root.destroy() + assert "invalid command name" not in capsys.readouterr().err