Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
-->

## 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
Expand Down
91 changes: 75 additions & 16 deletions gui/utils/widget_transfer_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

"""Utilities for moving widgets between notebooks without cloning."""
"""Utilities for moving widgets between notebooks by cloning."""

from __future__ import annotations

import tkinter as tk
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:
Expand Down Expand Up @@ -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 = {}
Comment on lines +81 to +85

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Cloned tabs discard widget runtime state

The new _clone_widget instantiates fresh widgets and only replays configuration options and event bindings. Stateful content (text inserted into a Text/Entry without a textvariable, listbox items, canvas drawings, etc.) is stored inside the widget rather than in configure() data, so these values are dropped when the original tab is destroyed and the clone is added. The previous implementation reparented the existing widget and preserved this state. Detaching a tab that users have already interacted with will now silently lose their data.

Useful? React with 👍 / 👎.

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
16 changes: 7 additions & 9 deletions tests/detachment/window/test_reparent_across_toplevel.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

"""Regression test for reparenting tabs across toplevel windows."""
"""Regression test for cloning tabs across toplevel windows."""

from __future__ import annotations

Expand All @@ -25,17 +25,14 @@

import pytest

import os
from gui.utils.closable_notebook import ClosableNotebook
from gui.utils.widget_transfer_manager import WidgetTransferManager


@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:
Expand All @@ -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()