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: