From c3ac8d5dd8a0bcb9c12c8d7227a29843be394e68 Mon Sep 17 00:00:00 2001 From: Karel Capek Robotics <96583804+MelkorBalrog@users.noreply.github.com> Date: Fri, 28 Nov 2025 09:37:51 -0500 Subject: [PATCH] Restore closable notebook detachment pipeline --- HISTORY.md | 8 ++ gui/utils/closable_notebook.py | 115 ++++++++++++++-- pytest.ini | 3 + tests/gui/test_detach_window_regressions.py | 145 ++++++++++++++++++++ 4 files changed, 263 insertions(+), 8 deletions(-) create mode 100644 tests/gui/test_detach_window_regressions.py diff --git a/HISTORY.md b/HISTORY.md index 09452953..5b419911 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -18,6 +18,14 @@ # along with this program. If not, see . --> +## 0.2.289 - 2025-09-25 + +- Rewire ``ClosableNotebook`` detachment to use the established transfer + pipeline, moving tabs into floating windows while preserving toolboxes and + lifecycle hooks. +- Add GUI regression coverage for detachment, toolbox activation, and cleanup + flows to guard against regressions in the floating window pipeline. + ## 0.2.288 - 2025-09-24 - Disable floating dockable diagram windows so tabs remain fixed within their diff --git a/gui/utils/closable_notebook.py b/gui/utils/closable_notebook.py index ed75e55a..3d1ff5bf 100644 --- a/gui/utils/closable_notebook.py +++ b/gui/utils/closable_notebook.py @@ -18,12 +18,11 @@ from __future__ import annotations -"""Custom ttk.Notebook widget with fixed, non-detachable tabs. +"""Custom ttk.Notebook widget with detachable, closeable tabs. The widget behaves like a regular :class:`ttk.Notebook` but displays a close -button on the left of each tab. Tab dragging is limited to notebook reordering; -detachment into floating windows is intentionally disabled to keep the user -interface constrained to a single workspace. +button on the left of each tab. Tabs can be reordered by dragging or detached +into floating windows that preserve toolboxes and lifecycle hooks. """ @@ -38,6 +37,13 @@ except Exception: # pragma: no cover - legacy path from tk_utils import cancel_after_events +if t.TYPE_CHECKING: # pragma: no cover - type hints only + from .detached_window import DetachedWindow as DetachedWindowType + from .widget_transfer_manager import WidgetTransferManager as WidgetTransferManagerType + +DetachedWindow: t.Type["DetachedWindowType"] | None = None +WidgetTransferManager: type | None = None + logger = logging.getLogger(__name__) @@ -584,6 +590,25 @@ def _reschedule_after_callbacks( continue setattr(clone, name, new_id) + def _copy_widget_config(self, src: tk.Widget, dst: tk.Widget) -> None: + """Copy configuration options from *src* to *dst* safely.""" + + try: + config = src.configure() + except Exception: + return + if not isinstance(config, dict): + return + for opt in config: + try: + value = src.cget(opt) + except Exception: + continue + try: + dst.configure({opt: value}) + except Exception: + continue + def _copy_widget_bindings( self, widget: tk.Widget, @@ -886,16 +911,90 @@ def _raise_widgets( def _detach_tab(self, tab_id: str, x: int, y: int) -> None: + global DetachedWindow, WidgetTransferManager + if DetachedWindow is None: + from .detached_window import DetachedWindow as DetachedWindowImpl + + DetachedWindow = DetachedWindowImpl + if WidgetTransferManager is None: + from .widget_transfer_manager import WidgetTransferManager as WTMImpl + + WidgetTransferManager = WTMImpl + child = self.nametowidget(tab_id) + text = self.tab(tab_id, "text") try: self._cancel_after_events(child) except Exception: pass + try: - self.select(tab_id) - except tk.TclError: - pass - logger.info("Tab detachment is disabled; keeping tab docked.") + width = max(child.winfo_width(), self.winfo_width()) + height = max(child.winfo_height(), self.winfo_height()) + except Exception: + width = 0 + height = 0 + width = width or 400 + height = height or 300 + + root = self._app_root or self._root() + window = DetachedWindow(root, width, height, x, y) + manager = WidgetTransferManager() + try: + moved = manager.detach_tab(self, tab_id, window.nb) + except Exception as exc: # pragma: no cover - defensive fallback + logger.info( + "Tab detachment failed; keeping tab docked.", + exc_info=exc, + ) + try: + window.win.destroy() + except Exception: + pass + try: + self.select(tab_id) + except tk.TclError: + pass + return + + try: + window.add_moved_widget(moved, text) + except Exception as exc: # pragma: no cover - defensive fallback + logger.info( + "Detached window setup failed; restoring tab.", + exc_info=exc, + ) + try: + manager.detach_tab(window.nb, str(moved), self) + self.tab(moved, text=text) + self.select(moved) + except Exception: + pass + try: + window.win.destroy() + except Exception: + pass + return + + if isinstance(self.master, tk.Toplevel) and not self.tabs(): + try: + self._cancel_after_events(self.master) + except Exception: + pass + try: + self.master.destroy() + except Exception: + pass + + self._floating_windows.append(window.win) + + def _forget(_event: tk.Event, win: tk.Toplevel = window.win) -> None: + try: + self._floating_windows.remove(win) + except ValueError: + pass + + window.win.bind("", _forget, add="+") def rewrite_option_references(self, mapping: dict[tk.Widget, tk.Widget]) -> None: """Rewrite widget configuration options to point at cloned widgets.""" diff --git a/pytest.ini b/pytest.ini index 37a5aff4..3b29afa7 100644 --- a/pytest.ini +++ b/pytest.ini @@ -25,3 +25,6 @@ markers = hover: tests verifying hover behaviour dockable: tests for dockable diagram windows closable_notebook: tests for ClosableNotebook helpers + gui: GUI regression coverage + window_resizer: window resizer integration tests + ProjectLifecycle: project lifecycle regression tests diff --git a/tests/gui/test_detach_window_regressions.py b/tests/gui/test_detach_window_regressions.py new file mode 100644 index 00000000..4e95f18b --- /dev/null +++ b/tests/gui/test_detach_window_regressions.py @@ -0,0 +1,145 @@ +# Author: Miguel Marina +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Copyright (C) 2025 Capek System Safety & Robotic Solutions +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""GUI regression tests for ClosableNotebook detachment pipeline.""" + +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk + +import pytest + +from gui.utils.closable_notebook import ClosableNotebook + + +@pytest.mark.gui +class TestDetachmentPipeline: + """Grouped tests ensuring detachment uses transfer pipeline.""" + + def test_detach_uses_transfer_manager_and_window(self, monkeypatch): + try: + root = tk.Tk() + except tk.TclError: + pytest.skip("Tk not available") + + nb = ClosableNotebook(root) + frame = tk.Frame(nb) + frame.toolbox = tk.Frame(frame) + nb.add(frame, text="Tab") + + class StubWindow: + def __init__(self, *_args, **_kwargs) -> None: + self.win = tk.Toplevel(root) + self.nb = ttk.Notebook(self.win) + self.added: list[tuple[tk.Widget, str]] = [] + self.toolboxes: list[tk.Widget] = [] + self.hooks: list[tk.Widget] = [] + + def add_moved_widget(self, widget: tk.Widget, text: str) -> None: + self.added.append((widget, text)) + self._ensure_toolbox(widget) + self._activate_hooks(widget) + + def _ensure_toolbox(self, widget: tk.Widget) -> None: # noqa: ANN001 - stub API + tb = getattr(widget, "toolbox", None) + if isinstance(tb, tk.Widget): + self.toolboxes.append(tb) + + def _activate_hooks(self, widget: tk.Widget) -> None: # noqa: ANN001 - stub API + self.hooks.append(widget) + + stub_windows: list[StubWindow] = [] + monkeypatch.setattr( + "gui.utils.closable_notebook.DetachedWindow", + lambda *args, **kwargs: stub_windows.append(StubWindow(*args, **kwargs)) + or stub_windows[-1], + ) + + class StubManager: + def __init__(self) -> None: + self.calls: list[tuple[tk.Widget, str, tk.Widget]] = [] + + def detach_tab( + self, source: tk.Widget, tab_id: str, target: tk.Widget + ) -> tk.Widget: + self.calls.append((source, tab_id, target)) + source.forget(tab_id) + target.add(frame, text="Tab") + return frame + + manager = StubManager() + monkeypatch.setattr("gui.utils.closable_notebook.WidgetTransferManager", lambda: manager) + + nb._detach_tab(nb.tabs()[0], x=10, y=10) + + assert stub_windows, "DetachedWindow should have been instantiated" + window = stub_windows[0] + assert window.added and window.added[0][0] is frame + assert window.toolboxes and window.toolboxes[0] is frame.toolbox + assert window.hooks == [frame] + assert manager.calls and manager.calls[0][0] is nb + assert nb._floating_windows and nb._floating_windows[0] is window.win + + window.win.destroy() + root.destroy() + + +@pytest.mark.gui +class TestDetachmentCloseRestore: + """Grouped tests covering close and restore flows for detachment.""" + + def test_failure_restores_tab_and_skips_window_tracking(self, monkeypatch): + try: + root = tk.Tk() + except tk.TclError: + pytest.skip("Tk not available") + + nb = ClosableNotebook(root) + frame = tk.Frame(nb) + nb.add(frame, text="Tab") + + class StubWindow: + def __init__(self, *_args, **_kwargs) -> None: + self.win = tk.Toplevel(root) + self.nb = ttk.Notebook(self.win) + + def add_moved_widget(self, *_args, **_kwargs) -> None: + pass + + monkeypatch.setattr( + "gui.utils.closable_notebook.DetachedWindow", + lambda *args, **kwargs: StubWindow(*args, **kwargs), + ) + + class FailingManager: + def detach_tab(self, *_args, **_kwargs) -> tk.Widget: + raise RuntimeError("boom") + + monkeypatch.setattr( + "gui.utils.closable_notebook.WidgetTransferManager", + lambda: FailingManager(), + ) + + tab_id = nb.tabs()[0] + nb._detach_tab(tab_id, x=5, y=5) + + assert nb.tabs() and nb.tabs()[0] == tab_id + assert not nb._floating_windows + + root.destroy()