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
24 changes: 24 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,31 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
-->

## 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.
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
version: 0.2.235
version: 0.2.248
Author: Miguel Marina <karel.capek.robotics@gmail.com> - [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.
Expand Down
55 changes: 33 additions & 22 deletions gui/utils/closable_notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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():
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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."""
Expand Down
1 change: 1 addition & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
35 changes: 35 additions & 0 deletions tests/test_closable_notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()
34 changes: 34 additions & 0 deletions tests/test_tab_detach.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down