diff --git a/HISTORY.md b/HISTORY.md
index 09452953..5b419911 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -18,6 +18,14 @@
# along with this program. If not, see .
-->
+## 0.2.289 - 2025-09-25
+
+- Rewire ``ClosableNotebook`` detachment to use the established transfer
+ pipeline, moving tabs into floating windows while preserving toolboxes and
+ lifecycle hooks.
+- Add GUI regression coverage for detachment, toolbox activation, and cleanup
+ flows to guard against regressions in the floating window pipeline.
+
## 0.2.288 - 2025-09-24
- Disable floating dockable diagram windows so tabs remain fixed within their
diff --git a/gui/utils/closable_notebook.py b/gui/utils/closable_notebook.py
index ed75e55a..3d1ff5bf 100644
--- a/gui/utils/closable_notebook.py
+++ b/gui/utils/closable_notebook.py
@@ -18,12 +18,11 @@
from __future__ import annotations
-"""Custom ttk.Notebook widget with fixed, non-detachable tabs.
+"""Custom ttk.Notebook widget with detachable, closeable tabs.
The widget behaves like a regular :class:`ttk.Notebook` but displays a close
-button on the left of each tab. Tab dragging is limited to notebook reordering;
-detachment into floating windows is intentionally disabled to keep the user
-interface constrained to a single workspace.
+button on the left of each tab. Tabs can be reordered by dragging or detached
+into floating windows that preserve toolboxes and lifecycle hooks.
"""
@@ -38,6 +37,13 @@
except Exception: # pragma: no cover - legacy path
from tk_utils import cancel_after_events
+if t.TYPE_CHECKING: # pragma: no cover - type hints only
+ from .detached_window import DetachedWindow as DetachedWindowType
+ from .widget_transfer_manager import WidgetTransferManager as WidgetTransferManagerType
+
+DetachedWindow: t.Type["DetachedWindowType"] | None = None
+WidgetTransferManager: type | None = None
+
logger = logging.getLogger(__name__)
@@ -584,6 +590,25 @@ def _reschedule_after_callbacks(
continue
setattr(clone, name, new_id)
+ def _copy_widget_config(self, src: tk.Widget, dst: tk.Widget) -> None:
+ """Copy configuration options from *src* to *dst* safely."""
+
+ try:
+ config = src.configure()
+ except Exception:
+ return
+ if not isinstance(config, dict):
+ return
+ for opt in config:
+ try:
+ value = src.cget(opt)
+ except Exception:
+ continue
+ try:
+ dst.configure({opt: value})
+ except Exception:
+ continue
+
def _copy_widget_bindings(
self,
widget: tk.Widget,
@@ -886,16 +911,90 @@ def _raise_widgets(
def _detach_tab(self, tab_id: str, x: int, y: int) -> None:
+ global DetachedWindow, WidgetTransferManager
+ if DetachedWindow is None:
+ from .detached_window import DetachedWindow as DetachedWindowImpl
+
+ DetachedWindow = DetachedWindowImpl
+ if WidgetTransferManager is None:
+ from .widget_transfer_manager import WidgetTransferManager as WTMImpl
+
+ WidgetTransferManager = WTMImpl
+
child = self.nametowidget(tab_id)
+ text = self.tab(tab_id, "text")
try:
self._cancel_after_events(child)
except Exception:
pass
+
try:
- self.select(tab_id)
- except tk.TclError:
- pass
- logger.info("Tab detachment is disabled; keeping tab docked.")
+ width = max(child.winfo_width(), self.winfo_width())
+ height = max(child.winfo_height(), self.winfo_height())
+ except Exception:
+ width = 0
+ height = 0
+ width = width or 400
+ height = height or 300
+
+ root = self._app_root or self._root()
+ window = DetachedWindow(root, width, height, x, y)
+ manager = WidgetTransferManager()
+ try:
+ moved = manager.detach_tab(self, tab_id, window.nb)
+ except Exception as exc: # pragma: no cover - defensive fallback
+ logger.info(
+ "Tab detachment failed; keeping tab docked.",
+ exc_info=exc,
+ )
+ try:
+ window.win.destroy()
+ except Exception:
+ pass
+ try:
+ self.select(tab_id)
+ except tk.TclError:
+ pass
+ return
+
+ try:
+ window.add_moved_widget(moved, text)
+ except Exception as exc: # pragma: no cover - defensive fallback
+ logger.info(
+ "Detached window setup failed; restoring tab.",
+ exc_info=exc,
+ )
+ try:
+ manager.detach_tab(window.nb, str(moved), self)
+ self.tab(moved, text=text)
+ self.select(moved)
+ except Exception:
+ pass
+ try:
+ window.win.destroy()
+ except Exception:
+ pass
+ return
+
+ if isinstance(self.master, tk.Toplevel) and not self.tabs():
+ try:
+ self._cancel_after_events(self.master)
+ except Exception:
+ pass
+ try:
+ self.master.destroy()
+ except Exception:
+ pass
+
+ self._floating_windows.append(window.win)
+
+ def _forget(_event: tk.Event, win: tk.Toplevel = window.win) -> None:
+ try:
+ self._floating_windows.remove(win)
+ except ValueError:
+ pass
+
+ window.win.bind("", _forget, add="+")
def rewrite_option_references(self, mapping: dict[tk.Widget, tk.Widget]) -> None:
"""Rewrite widget configuration options to point at cloned widgets."""
diff --git a/pytest.ini b/pytest.ini
index 37a5aff4..3b29afa7 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -25,3 +25,6 @@ markers =
hover: tests verifying hover behaviour
dockable: tests for dockable diagram windows
closable_notebook: tests for ClosableNotebook helpers
+ gui: GUI regression coverage
+ window_resizer: window resizer integration tests
+ ProjectLifecycle: project lifecycle regression tests
diff --git a/tests/gui/test_detach_window_regressions.py b/tests/gui/test_detach_window_regressions.py
new file mode 100644
index 00000000..4e95f18b
--- /dev/null
+++ b/tests/gui/test_detach_window_regressions.py
@@ -0,0 +1,145 @@
+# 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 .
+
+"""GUI regression tests for ClosableNotebook detachment pipeline."""
+
+from __future__ import annotations
+
+import tkinter as tk
+from tkinter import ttk
+
+import pytest
+
+from gui.utils.closable_notebook import ClosableNotebook
+
+
+@pytest.mark.gui
+class TestDetachmentPipeline:
+ """Grouped tests ensuring detachment uses transfer pipeline."""
+
+ def test_detach_uses_transfer_manager_and_window(self, monkeypatch):
+ try:
+ root = tk.Tk()
+ except tk.TclError:
+ pytest.skip("Tk not available")
+
+ nb = ClosableNotebook(root)
+ frame = tk.Frame(nb)
+ frame.toolbox = tk.Frame(frame)
+ nb.add(frame, text="Tab")
+
+ 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]] = []
+ self.toolboxes: list[tk.Widget] = []
+ self.hooks: list[tk.Widget] = []
+
+ def add_moved_widget(self, widget: tk.Widget, text: str) -> None:
+ self.added.append((widget, text))
+ self._ensure_toolbox(widget)
+ self._activate_hooks(widget)
+
+ def _ensure_toolbox(self, widget: tk.Widget) -> None: # noqa: ANN001 - stub API
+ tb = getattr(widget, "toolbox", None)
+ if isinstance(tb, tk.Widget):
+ self.toolboxes.append(tb)
+
+ def _activate_hooks(self, widget: tk.Widget) -> None: # noqa: ANN001 - stub API
+ self.hooks.append(widget)
+
+ 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="Tab")
+ return frame
+
+ manager = StubManager()
+ monkeypatch.setattr("gui.utils.closable_notebook.WidgetTransferManager", lambda: manager)
+
+ nb._detach_tab(nb.tabs()[0], x=10, y=10)
+
+ assert stub_windows, "DetachedWindow should have been instantiated"
+ window = stub_windows[0]
+ assert window.added and window.added[0][0] is frame
+ assert window.toolboxes and window.toolboxes[0] is frame.toolbox
+ assert window.hooks == [frame]
+ assert manager.calls and manager.calls[0][0] is nb
+ assert nb._floating_windows and nb._floating_windows[0] is window.win
+
+ window.win.destroy()
+ root.destroy()
+
+
+@pytest.mark.gui
+class TestDetachmentCloseRestore:
+ """Grouped tests covering close and restore flows for detachment."""
+
+ def test_failure_restores_tab_and_skips_window_tracking(self, monkeypatch):
+ try:
+ root = tk.Tk()
+ except tk.TclError:
+ pytest.skip("Tk not available")
+
+ nb = ClosableNotebook(root)
+ frame = tk.Frame(nb)
+ nb.add(frame, text="Tab")
+
+ class StubWindow:
+ def __init__(self, *_args, **_kwargs) -> None:
+ self.win = tk.Toplevel(root)
+ self.nb = ttk.Notebook(self.win)
+
+ def add_moved_widget(self, *_args, **_kwargs) -> None:
+ pass
+
+ monkeypatch.setattr(
+ "gui.utils.closable_notebook.DetachedWindow",
+ lambda *args, **kwargs: StubWindow(*args, **kwargs),
+ )
+
+ class FailingManager:
+ def detach_tab(self, *_args, **_kwargs) -> tk.Widget:
+ raise RuntimeError("boom")
+
+ monkeypatch.setattr(
+ "gui.utils.closable_notebook.WidgetTransferManager",
+ lambda: FailingManager(),
+ )
+
+ tab_id = nb.tabs()[0]
+ nb._detach_tab(tab_id, x=5, y=5)
+
+ assert nb.tabs() and nb.tabs()[0] == tab_id
+ assert not nb._floating_windows
+
+ root.destroy()