From 438d29166b9f44898efc430d5af33ab85d43c0bc Mon Sep 17 00:00:00 2001 From: Karel Capek Robotics <96583804+MelkorBalrog@users.noreply.github.com> Date: Fri, 5 Sep 2025 23:27:22 -0400 Subject: [PATCH] fix: clone tabs instead of reparent --- HISTORY.md | 5 + gui/utils/widget_transfer_manager.py | 91 +++++++++++++++---- .../window/test_reparent_across_toplevel.py | 16 ++-- 3 files changed, 87 insertions(+), 25 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index b9bc30d7..9e382a74 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -18,6 +18,11 @@ # along with this program. If not, see . --> +## 0.2.252 - 2025-09-04 + +- Rebuild detached tabs by cloning widgets instead of reparenting to avoid + cross-toplevel ``TclError`` + ## 0.2.251 - 2025-09-04 - Reparent detached tabs across toplevel windows using native OS APIs so diff --git a/gui/utils/widget_transfer_manager.py b/gui/utils/widget_transfer_manager.py index 590cc7c9..36d93576 100644 --- a/gui/utils/widget_transfer_manager.py +++ b/gui/utils/widget_transfer_manager.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Utilities for moving widgets between notebooks without cloning.""" +"""Utilities for moving widgets between notebooks by cloning.""" from __future__ import annotations @@ -24,9 +24,9 @@ import typing as t try: # pragma: no cover - support direct module execution - from .tk_utils import cancel_after_events, reparent_widget + from .tk_utils import cancel_after_events except Exception: # pragma: no cover - legacy path - from tk_utils import cancel_after_events, reparent_widget + from tk_utils import cancel_after_events class WidgetTransferManager: @@ -58,19 +58,78 @@ def detach_tab( orig = source.nametowidget(tab_id) text = source.tab(tab_id, "text") cancel_after_events(orig) + clone = self._clone_widget(orig, target) source.forget(orig) try: - reparent_widget(orig, target) - target.add(orig, text=text) - target.select(orig) - except tk.TclError as exc: - # Roll back to the source notebook if re-parenting fails + orig.destroy() + except Exception: + pass + target.add(clone, text=text) + target.select(clone) + return clone + + # ------------------------------------------------------------------ + # Cloning helpers + # ------------------------------------------------------------------ + + def _clone_widget(self, widget: tk.Widget, parent: tk.Widget) -> tk.Widget: + """Recursively clone *widget* under *parent*.""" + + cls: t.Type[tk.Widget] = widget.__class__ + clone = cls(parent) + + # Copy configuration options + try: + config = widget.configure() + except tk.TclError: + config = {} + for opt, opts in config.items(): + if isinstance(opts, (tuple, list)) and len(opts) >= 2: + value = opts[-1] + try: + clone.configure({opt: value}) + except tk.TclError: + pass + + # Copy event bindings + try: + sequences = widget.tk.call("bind", widget._w).split() + except Exception: + sequences = [] + for seq in sequences: try: - target.forget(orig) - except tk.TclError: - pass - source.add(orig, text=text) - source.select(orig) - raise exc - - return orig + cmd = widget.bind(seq) + if cmd: + clone.bind(seq, cmd) + except Exception: + continue + + # Recreate children + for child in widget.winfo_children(): + child_clone = self._clone_widget(child, clone) + self._apply_layout(child, child_clone) + + return clone + + def _apply_layout(self, orig: tk.Widget, clone: tk.Widget) -> None: + """Apply geometry management of *orig* to *clone*.""" + + manager = orig.winfo_manager() + try: + if manager == "pack": + info = orig.pack_info() + for key in ("in", "in_"): + info.pop(key, None) + clone.pack(**info) + elif manager == "grid": + info = orig.grid_info() + for key in ("in", "in_"): + info.pop(key, None) + clone.grid(**info) + elif manager == "place": + info = orig.place_info() + for key in ("in", "in_"): + info.pop(key, None) + clone.place(**info) + except Exception: + pass diff --git a/tests/detachment/window/test_reparent_across_toplevel.py b/tests/detachment/window/test_reparent_across_toplevel.py index af8a3c2a..8b6c6129 100644 --- a/tests/detachment/window/test_reparent_across_toplevel.py +++ b/tests/detachment/window/test_reparent_across_toplevel.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Regression test for reparenting tabs across toplevel windows.""" +"""Regression test for cloning tabs across toplevel windows.""" from __future__ import annotations @@ -25,7 +25,6 @@ import pytest -import os from gui.utils.closable_notebook import ClosableNotebook from gui.utils.widget_transfer_manager import WidgetTransferManager @@ -33,9 +32,7 @@ @pytest.mark.detachment @pytest.mark.reparenting class TestReparentAcrossToplevel: - def test_widget_reparented_between_toplevels(self) -> None: - if os.name != "nt": - pytest.skip("OS-level reparenting implemented only on Windows") + def test_widget_cloned_between_toplevels(self) -> None: try: root = tk.Tk() except tk.TclError: @@ -49,8 +46,9 @@ def test_widget_reparented_between_toplevels(self) -> None: nb2.pack() tab_id = nb1.tabs()[0] manager = WidgetTransferManager() - moved = manager.detach_tab(nb1, tab_id, nb2) - assert moved is frame - assert nb2.nametowidget(nb2.tabs()[0]) is frame - assert frame.master is nb2 + clone = manager.detach_tab(nb1, tab_id, nb2) + assert clone is not frame + assert isinstance(clone, ttk.Frame) + assert not frame.winfo_exists() + assert nb2.nametowidget(nb2.tabs()[0]) is clone root.destroy()