From babf08a80de802e0f6e42b4542efa893dcfb44a6 Mon Sep 17 00:00:00 2001
From: Karel Capek Robotics <96583804+MelkorBalrog@users.noreply.github.com>
Date: Thu, 4 Sep 2025 10:32:33 -0400
Subject: [PATCH] Restore clone-based tab detachment
---
HISTORY.md | 24 ++++++++++++++
README.md | 5 ++-
gui/utils/closable_notebook.py | 55 ++++++++++++++++++++-------------
pytest.ini | 1 +
tests/test_closable_notebook.py | 35 +++++++++++++++++++++
tests/test_tab_detach.py | 34 ++++++++++++++++++++
6 files changed, 131 insertions(+), 23 deletions(-)
diff --git a/HISTORY.md b/HISTORY.md
index 04048b76..e36b1917 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -18,7 +18,31 @@
# along with this program. If not, see .
-->
+## 0.2.248 - 2025-09-04
+
+- Restore clone-based tab detachment to avoid Tk reparenting errors
+- Prune inanimate duplicates so detached windows keep one toolbox and diagram
+
# Version History
+- 0.2.247 - Fix widget transfer to use keyword arguments when adding tabs,
+ preventing detachment errors.
+- 0.2.246 - Track expected children by widget identity when hiding unexpected
+ widgets during tab detachment so tabs keep their toolbox and diagram
+ while only inert duplicates are hidden.
+- 0.2.245 - Hide unexpected widgets during tab detachment so only the edge
+ toolbox and diagram remain visible. Replace destructive pruning with
+ geometry unmapping and expand detached-tab regression tests.
+- 0.2.244 - Destroy unexpected widgets during tab detachment so only the
+ edge toolbox and diagram persist while stray duplicates are removed.
+- 0.2.243 - Guard widget pruning against missing expected children so
+ detached windows never drop toolbox and diagram widgets.
+- 0.2.242 - Restore duplicate pruning during tab detachment so only the
+ edge toolbox and diagram remain visible in detached windows.
+- 0.2.241 - Fix widget pruning logic so detached tabs keep the first
+ toolbox and last diagram while unmapping any stray duplicates.
+- 0.2.240 - Unmap unexpected widgets during tab detachment and drop
+ destructive heuristics so detached tabs retain a single toolbox
+ and diagram. Add regression tests for detached tab content.
- 0.2.237 - Enforce keyword-only layouts and cancelled parameters in
``_clone_widget`` and add regression test confirming detached
tabs render with and without a layouts mapping.
diff --git a/README.md b/README.md
index 579a7d6c..3def7a5a 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,8 @@
-version: 0.2.235
+version: 0.2.248
Author: Miguel Marina - [LinkedIn](https://www.linkedin.com/in/progman32/)
+
+Restore clone-based tab detachment and prune inanimate duplicates so
+detached windows display a single functional toolbox and diagram.
# AutoML
AutoML is an automotive modeling and analysis tool built around a SysML-based metamodel. It lets you describe items, operating scenarios, functions, structure and interfaces in a single environment.
diff --git a/gui/utils/closable_notebook.py b/gui/utils/closable_notebook.py
index b4f84aec..387aec25 100644
--- a/gui/utils/closable_notebook.py
+++ b/gui/utils/closable_notebook.py
@@ -1369,6 +1369,8 @@ def _on_destroy(_e, w=dw.win) -> None:
cancelled=cancelled,
)
self._reassign_widget_references(mapping)
+ self._prune_duplicates(dw.win, mapping, {child})
+ self._safe_destroy(orig)
dw.add(child, text)
def rewrite_option_references(self, mapping: dict[tk.Widget, tk.Widget]) -> None:
@@ -1544,9 +1546,9 @@ def _find_toolbar_frame(self, widget: tk.Widget) -> tk.Widget | None:
def _collect_expected_children(
self, mapping: dict[tk.Widget, tk.Widget]
- ) -> tuple[dict[tk.Widget, set[str]], set[tk.Widget]]:
- """Return expected child names for each cloned parent."""
- expected: dict[tk.Widget, set[str]] = {}
+ ) -> tuple[dict[tk.Widget, set[tk.Widget]], set[tk.Widget]]:
+ """Return expected child widgets for each cloned parent."""
+ expected: dict[tk.Widget, set[tk.Widget]] = {}
reparented: set[tk.Widget] = set()
for orig, clone in mapping.items():
if isinstance(orig, tk.Canvas) and not orig.winfo_exists():
@@ -1556,7 +1558,9 @@ def _collect_expected_children(
continue
parent_clone = mapping.get(orig.master)
if parent_clone is not None:
- expected.setdefault(parent_clone, set()).add(clone.winfo_name())
+ # Track clone widgets directly so original widgets with the
+ # same name can still be identified and removed.
+ expected.setdefault(parent_clone, set()).add(clone)
else:
reparented.add(clone)
return expected, reparented
@@ -1565,10 +1569,19 @@ def _prune_widget_tree(
self,
parent: tk.Widget,
keep: set[tk.Widget],
- expected: dict[tk.Widget, set[str]],
+ expected: dict[tk.Widget, set[tk.Widget]],
reparented: set[tk.Widget],
) -> None:
- """Recursively destroy duplicate widgets under *parent*."""
+ """Recursively clean duplicate widgets under *parent*.
+
+ Any child absent from the ``expected`` mapping is merely unmapped via
+ the appropriate geometry manager. Relying on widget identity rather
+ than names keeps the intended toolbox and diagram clones visible while
+ hiding inert originals that would otherwise pile up in the detached
+ window. Destroying these widgets outright previously broke external
+ references and could leave the window empty; this workaround can be
+ revisited once the detachment process is more robust.
+ """
if not parent.winfo_exists():
return
@@ -1578,22 +1591,20 @@ def _prune_widget_tree(
self._prune_widget_tree(child, keep, expected, reparented)
if child in keep or child in reparented:
continue
- names = expected.get(parent, set())
- if child.winfo_name() in names or (
- isinstance(child, (tk.Frame, ttk.Frame, ttk.Treeview))
- and not any(
- isinstance(gc, (tk.Button, ttk.Button))
- for gc in child.winfo_children()
- )
- ):
- try:
- self._cancel_after_events(child)
- except Exception:
- pass
- try:
- child.destroy()
- except Exception:
- pass
+ expected_children = expected.get(parent)
+ if expected_children and child not in expected_children:
+ # Unmap unexpected widgets so only the desired toolbox and
+ # diagram remain visible.
+ for mgr in ("pack", "grid", "place"):
+ info = getattr(child, f"{mgr}_info", None)
+ forget = getattr(child, f"{mgr}_forget", None)
+ if info and forget:
+ try:
+ if info():
+ forget()
+ break
+ except Exception:
+ pass
def _traverse_widgets(self, widget: tk.Widget) -> list[tk.Widget]:
"""Return a list of *widget* and all its descendants."""
diff --git a/pytest.ini b/pytest.ini
index 9492159e..4aac0812 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -19,6 +19,7 @@
[pytest]
markers =
detachment: tests covering tab detachment
+ detached_tab: detached tab regression tests
reparenting: tests verifying widget reparenting
controls: tests covering GUI controls
hover: tests verifying hover behaviour
diff --git a/tests/test_closable_notebook.py b/tests/test_closable_notebook.py
index e4ccd8aa..5bc04d98 100644
--- a/tests/test_closable_notebook.py
+++ b/tests/test_closable_notebook.py
@@ -24,6 +24,7 @@
import pytest
from gui.utils.closable_notebook import ClosableNotebook
+from tkinter import ttk
@pytest.mark.skipif("DISPLAY" not in os.environ, reason="Tk display not available")
@@ -57,3 +58,37 @@ def test_update_canvas_windows():
clone_win = clone.nametowidget(win_path)
assert isinstance(clone_win, tk.Frame)
root.destroy()
+
+
+@pytest.mark.detached_tab
+@pytest.mark.skipif("DISPLAY" not in os.environ, reason="Tk display not available")
+class TestDetachedTab:
+ """Detached tab regression tests."""
+
+ def test_detached_tab_has_single_toolbox_and_diagram(self):
+ root = tk.Tk()
+ nb = ClosableNotebook(root)
+ frame = ttk.Frame(nb)
+ ttk.Frame(frame, name="toolbox").pack(side="left")
+ tk.Canvas(frame, name="diagram").pack(side="right")
+ nb.add(frame, text="Tab1")
+ nb.update_idletasks()
+
+ class Event: ...
+
+ press = Event(); press.x = 5; press.y = 5
+ nb._on_tab_press(press)
+ nb._dragging = True
+ release = Event()
+ release.x_root = nb.winfo_rootx() + nb.winfo_width() + 40
+ release.y_root = nb.winfo_rooty() + nb.winfo_height() + 40
+ nb._on_tab_release(release)
+
+ win = nb._floating_windows[0]
+ new_nb = next(w for w in win.winfo_children() if isinstance(w, ClosableNotebook))
+ new_frame = new_nb.nametowidget(new_nb.tabs()[0])
+ toolboxes = [w for w in new_frame.winfo_children() if w.winfo_name() == "toolbox"]
+ diagrams = [w for w in new_frame.winfo_children() if w.winfo_name() == "diagram"]
+ assert len(toolboxes) == 1
+ assert len(diagrams) == 1
+ root.destroy()
diff --git a/tests/test_tab_detach.py b/tests/test_tab_detach.py
index 770ab076..d1bb69f0 100644
--- a/tests/test_tab_detach.py
+++ b/tests/test_tab_detach.py
@@ -849,6 +849,40 @@ class Event: ...
root.destroy()
+@pytest.mark.detached_tab
+@pytest.mark.skipif("DISPLAY" not in os.environ, reason="Tk display not available")
+class TestDetachedTabRegression:
+ """Detached tab regression tests."""
+
+ def test_detached_tab_has_single_toolbox_and_diagram(self):
+ root = tk.Tk()
+ nb = ClosableNotebook(root)
+ frame = ttk.Frame(nb)
+ ttk.Frame(frame, name="toolbox").pack(side="left")
+ tk.Canvas(frame, name="diagram").pack(side="right")
+ nb.add(frame, text="Tab1")
+ nb.update_idletasks()
+
+ class Event: ...
+
+ press = Event(); press.x = 5; press.y = 5
+ nb._on_tab_press(press)
+ nb._dragging = True
+ release = Event()
+ release.x_root = nb.winfo_rootx() + nb.winfo_width() + 40
+ release.y_root = nb.winfo_rooty() + nb.winfo_height() + 40
+ nb._on_tab_release(release)
+
+ win = nb._floating_windows[0]
+ new_nb = next(w for w in win.winfo_children() if isinstance(w, ClosableNotebook))
+ new_frame = new_nb.nametowidget(new_nb.tabs()[0])
+ toolboxes = [w for w in new_frame.winfo_children() if w.winfo_name() == "toolbox"]
+ diagrams = [w for w in new_frame.winfo_children() if w.winfo_name() == "diagram"]
+ assert len(toolboxes) == 1
+ assert len(diagrams) == 1
+ root.destroy()
+
+
class TestTabDetachCallbacks:
def test_detach_tab_with_after_callback(self):
try: