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

## 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
Expand Down
115 changes: 107 additions & 8 deletions gui/utils/closable_notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""


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


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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("<Destroy>", _forget, add="+")

def rewrite_option_references(self, mapping: dict[tk.Widget, tk.Widget]) -> None:
"""Rewrite widget configuration options to point at cloned widgets."""
Expand Down
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
145 changes: 145 additions & 0 deletions tests/gui/test_detach_window_regressions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Author: Miguel Marina <karel.capek.robotics@gmail.com>
# 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 <https://www.gnu.org/licenses/>.

"""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()