diff --git a/HISTORY.md b/HISTORY.md
index 43d24069..21635801 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -18,6 +18,24 @@
# along with this program. If not, see .
-->
+## 0.2.284 - 2025-09-20
+
+- Uninstall Windows resize hooks during interpreter shutdown and register a
+ global cleanup handler so callbacks are never invoked after the GIL is
+ released, avoiding Tk crashes when detached windows close.
+- Keep window resize controllers removing native hooks even while Python is
+ finalizing so floating diagram windows stop receiving Win32 messages during
+ teardown.
+
+## 0.2.283 - 2025-09-19
+
+- Ensure detached tabs register with floating window resizers so diagrams resize
+ with their new host windows instead of the original notebooks, avoiding
+ crashes during window configuration.
+- Add a regression test covering the detachment flow to verify resized diagrams
+ are tracked by the floating window resizer and remain linked to their new
+ window instances.
+
## 0.2.282 - 2025-09-18
- Harden ``cancel_after_events`` so it cancels pending Tk ``after`` jobs before
@@ -961,3 +979,4 @@
- 0.1.2 - Clarified systems safety focus in description and About dialog.
- 0.1.1 - Updated description and About dialog.
- 0.1.0 - Added Help menu and version tracking.
+- 0.2.26 - Guard detached window resize hooks during interpreter shutdown to avoid crashes.
diff --git a/gui/utils/closable_notebook.py b/gui/utils/closable_notebook.py
index cbfa4de8..0438a656 100644
--- a/gui/utils/closable_notebook.py
+++ b/gui/utils/closable_notebook.py
@@ -941,8 +941,15 @@ def _on_destroy(_e, w=dw.win) -> None:
except tk.TclError:
logger.exception("Failed to detach tab %s", tab_id)
return
- dw._ensure_toolbox(child)
- dw._activate_hooks(child)
+ try:
+ title = self.tab(child, "text")
+ except Exception:
+ title = ""
+ try:
+ dw.add_moved_widget(child, title)
+ except Exception:
+ dw._ensure_toolbox(child)
+ dw._activate_hooks(child)
def rewrite_option_references(self, mapping: dict[tk.Widget, tk.Widget]) -> None:
"""Rewrite widget configuration options to point at cloned widgets."""
diff --git a/gui/utils/win32_hooks.py b/gui/utils/win32_hooks.py
index 84f905d1..c1302887 100644
--- a/gui/utils/win32_hooks.py
+++ b/gui/utils/win32_hooks.py
@@ -19,9 +19,11 @@
from __future__ import annotations
+import atexit
import logging
import sys
import typing as t
+import weakref
LOGGER = logging.getLogger(__name__)
@@ -40,6 +42,8 @@ def _python_is_finalizing() -> bool:
import ctypes
from ctypes import wintypes
+ _HOOKS: "weakref.WeakSet[_Win32WindowProcHook]" = weakref.WeakSet()
+
LRESULT = wintypes.LPARAM
WNDPROC = ctypes.WINFUNCTYPE(
LRESULT, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM
@@ -128,6 +132,7 @@ def __init__(self, hwnd: int, callback: t.Callable[[int, int], None]) -> None:
self._original: int | None = None
self._wnd_proc = WNDPROC(self._procedure)
self._install()
+ _HOOKS.add(self)
def _install(self) -> None:
proc_pointer = ctypes.cast(self._wnd_proc, ctypes.c_void_p).value
@@ -143,6 +148,10 @@ def uninstall(self) -> None:
_set_window_proc(self.hwnd, self._original)
finally:
self._original = None
+ try:
+ _HOOKS.discard(self)
+ except Exception:
+ pass
def _procedure(self, hwnd, msg, wparam, lparam): # noqa: ANN001
if _python_is_finalizing():
@@ -190,10 +199,29 @@ def _extract_windowpos_dimensions(lparam: int) -> tuple[int, int] | None:
return int(windowpos.cx), int(windowpos.cy)
def __del__(self) -> None:
+ if _python_is_finalizing():
+ try:
+ _HOOKS.discard(self)
+ except Exception:
+ pass
+ return
try:
self.uninstall()
except Exception:
LOGGER.debug("Ignoring error while uninstalling Win32 hook", exc_info=True)
+ try:
+ _HOOKS.discard(self)
+ except Exception:
+ pass
+
+ def _uninstall_registered_hooks() -> None:
+ for hook in list(_HOOKS):
+ try:
+ hook.uninstall()
+ except Exception:
+ LOGGER.debug("Ignoring error during registered hook uninstall", exc_info=True)
+
+ atexit.register(_uninstall_registered_hooks)
else: # pragma: no cover - exercised only on non-Windows platforms
class _Win32WindowProcHook: # type: ignore[no-redef]
diff --git a/gui/utils/window_resizer.py b/gui/utils/window_resizer.py
index e7546740..91e44345 100644
--- a/gui/utils/window_resizer.py
+++ b/gui/utils/window_resizer.py
@@ -19,12 +19,22 @@
from __future__ import annotations
+import sys
import tkinter as tk
import typing as t
from gui.utils.win32_hooks import create_window_size_hook
+def _python_is_finalizing() -> bool:
+ """Return ``True`` when the Python interpreter is shutting down."""
+
+ finalizing = getattr(sys, "is_finalizing", None)
+ if finalizing is None:
+ return False
+ return bool(finalizing())
+
+
class WindowResizeController:
"""Propagate ```` events from a toplevel to tracked widgets."""
@@ -116,22 +126,7 @@ def _callback(event: tk.Event) -> None:
def shutdown(self) -> None:
"""Release bindings and native hooks held by the controller."""
- unbind = getattr(self.win, "unbind", None)
- if callable(unbind):
- if self._binding_id is not None:
- try:
- unbind("", self._binding_id)
- except Exception:
- pass
- self._binding_id = None
- if self._destroy_binding_id is not None:
- try:
- unbind("", self._destroy_binding_id)
- except Exception:
- pass
- self._destroy_binding_id = None
-
- self._callback = None
+ finalizing = _python_is_finalizing()
hook = self._win32_hook
if hook is not None:
@@ -141,6 +136,23 @@ def shutdown(self) -> None:
pass
self._win32_hook = None
+ if not finalizing:
+ unbind = getattr(self.win, "unbind", None)
+ if callable(unbind):
+ if self._binding_id is not None:
+ try:
+ unbind("", self._binding_id)
+ except Exception:
+ pass
+ self._binding_id = None
+ if self._destroy_binding_id is not None:
+ try:
+ unbind("", self._destroy_binding_id)
+ except Exception:
+ pass
+ self._destroy_binding_id = None
+
+ self._callback = None
self._targets.clear()
self._primary = None
self._last_size = None
@@ -319,6 +331,8 @@ def _notify(widget: tk.Widget, width: int, height: int) -> None:
pass
def __del__(self) -> None: # pragma: no cover - defensive cleanup
+ if _python_is_finalizing():
+ return
try:
self.shutdown()
except Exception:
diff --git a/tests/detachment/window/test_closable_notebook_detach_resize.py b/tests/detachment/window/test_closable_notebook_detach_resize.py
new file mode 100644
index 00000000..d1343576
--- /dev/null
+++ b/tests/detachment/window/test_closable_notebook_detach_resize.py
@@ -0,0 +1,95 @@
+# Author: Miguel Marina
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Copyright (C) 2025 Capek System Safety & Robotic Solutions
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+"""Regression tests for detaching tabs into floating windows."""
+
+from __future__ import annotations
+
+import tkinter as tk
+from tkinter import ttk
+
+import pytest
+
+from gui.utils.closable_notebook import ClosableNotebook
+
+
+@pytest.mark.detachment
+@pytest.mark.window_resizer
+class TestClosableNotebookDetachment:
+ """Ensure detached tabs register with the floating window resizer."""
+
+ def test_detach_tab_registers_resizer(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ try:
+ root = tk.Tk()
+ except tk.TclError:
+ pytest.skip("Tk not available")
+
+ notebook = ClosableNotebook(root)
+ frame = tk.Frame(notebook)
+ notebook.add(frame, text="Diagram")
+
+ class StubWindow:
+ def __init__(self, *_args, **_kwargs) -> None:
+ self.win = tk.Toplevel(root)
+ self.nb = ttk.Notebook(self.win)
+ self.added: list[tuple[tk.Widget, str]] = []
+
+ def add_moved_widget(self, widget: tk.Widget, text: str) -> None:
+ self.added.append((widget, text))
+
+ def _ensure_toolbox(self, widget: tk.Widget) -> None: # noqa: ANN001 - stub API
+ self.added.append((widget, "toolbox"))
+
+ def _activate_hooks(self, widget: tk.Widget) -> None: # noqa: ANN001 - stub API
+ self.added.append((widget, "hooks"))
+
+ stub_windows: list[StubWindow] = []
+ monkeypatch.setattr(
+ "gui.utils.closable_notebook.DetachedWindow",
+ lambda *args, **kwargs: stub_windows.append(StubWindow(*args, **kwargs))
+ or stub_windows[-1],
+ )
+
+ class StubManager:
+ def __init__(self) -> None:
+ self.calls: list[tuple[tk.Widget, str, tk.Widget]] = []
+
+ def detach_tab(
+ self, source: tk.Widget, tab_id: str, target: tk.Widget
+ ) -> tk.Widget:
+ self.calls.append((source, tab_id, target))
+ source.forget(tab_id)
+ target.add(frame, text="Diagram")
+ return frame
+
+ manager = StubManager()
+ monkeypatch.setattr(
+ "gui.utils.closable_notebook.WidgetTransferManager", lambda: manager
+ )
+
+ notebook._detach_tab(str(frame), x=10, y=10)
+
+ assert stub_windows, "DetachedWindow should have been instantiated"
+ window = stub_windows[0]
+ assert window.added[0] == (frame, "Diagram")
+ assert manager.calls and manager.calls[0][0] is notebook
+
+ window.win.destroy()
+ root.destroy()
diff --git a/tests/detachment/window/test_window_resizer_finalization.py b/tests/detachment/window/test_window_resizer_finalization.py
new file mode 100644
index 00000000..fc6a12ee
--- /dev/null
+++ b/tests/detachment/window/test_window_resizer_finalization.py
@@ -0,0 +1,54 @@
+# Author: Miguel Marina
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Copyright (C) 2025 Capek System Safety & Robotic Solutions
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+"""Finalization safety for the window resize controller."""
+
+from __future__ import annotations
+
+import gui.utils.window_resizer as window_resizer
+
+
+class _StubWindow:
+ def __init__(self) -> None:
+ self.unbind_calls: list[tuple[tuple[str, str], dict[str, str]]] = []
+
+ def wm_resizable(self, *_args, **_kwargs) -> None: # pragma: no cover - noop
+ return None
+
+ def bind(self, *args, **_kwargs) -> str: # pragma: no cover - basic stub
+ return f"bind-{args[0]}"
+
+ def unbind(self, *args, **kwargs) -> None: # pragma: no cover - tracking only
+ self.unbind_calls.append((args, kwargs))
+
+ def winfo_id(self) -> None: # pragma: no cover - disable Win32 hook install
+ return None
+
+
+def test_shutdown_skips_finalizing(monkeypatch) -> None:
+ """Ensure shutdown avoids Tk/Win32 work during interpreter finalization."""
+
+ win = _StubWindow()
+ monkeypatch.setattr(window_resizer, "_python_is_finalizing", lambda: False)
+ controller = window_resizer.WindowResizeController(win)
+ monkeypatch.setattr(window_resizer, "_python_is_finalizing", lambda: True)
+
+ controller.shutdown()
+
+ assert win.unbind_calls == []
+