From 894b695ecf73865392ceced64dc91d7c16a7b1e3 Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 5 Oct 2026 21:53:50 +0200 Subject: [PATCH] Tabs der LNO-Docks in Scroll-Bereich packen (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein langer Tab (Druck-Tab mit aufgeklapptem Basemap-Bereich) schob den unteren Rand des QGIS-Fensters aus dem Bildschirm, weil Qt für das Dock die Höhe des höchsten Tabs verlangt. BaseDockWidget.setWidget packt jetzt jeden Tab eines QTabWidget in einen QScrollArea; das wirkt auch für Fachplugins, die ihre Tabs selbst aufbauen. QScrollArea/QFrame im qt_wrapper ergänzt. Co-Authored-By: Claude Opus 5.5 --- changelog.txt | 1 + functions/qt_wrapper.py | 43 ++++++++++++++++++++- tests/test_base_dockwidget.py | 71 ++++++++++++++++++++++++++++++++++- ui/base_dockwidget.py | 49 +++++++++++++++++++++++- 4 files changed, 161 insertions(+), 3 deletions(-) diff --git a/changelog.txt b/changelog.txt index 461bc8c..ff68a4b 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,4 @@ +- Bugfix: Ein langer Tab (z. B. der Druck-Tab mit aufgeklapptem Basemap-Bereich) schob den unteren Rand des QGIS-Fensters über den Bildschirm hinaus, statt einen Scrollbalken zu zeigen. Jeder Tab der LNO-Docks liegt jetzt in einem Scroll-Bereich; das gilt auch für Fachplugins, die ihre Tabs selbst aufbauen (Plan41, Verfahrensgebiet), ohne dass sie geändert werden mussten (#104) - Basemap-Bereich im Druck-Tab: neuer Abschnitt „Basemap-Layer“ mit Knopf „Basemap hinzufügen“ und Auswahl „Thema“ (Farbe, Grau; Standard Farbe). Liegt schon ein Layer mit derselben Quell-URL im Projekt, nennt eine Rückfrage ihn und bietet „Zusätzlich laden“, „Ersetzen“ (vorhandenen Layer im gewählten Thema neu laden, Position und Bezüge in Layouts bleiben) und „Abbrechen“. Die Themenauswahl stellt den im Layerbaum ausgewählten Basemap-Layer um (sonst den ersten). Vor dem Überschreiben der Darstellung wird geprüft, ob sie seit dem Laden des Themas oder Stils verändert wurde (Prüfwert als Layer-Eigenschaft); dann „Als Stil speichern …“, „Verwerfen“ oder „Abbrechen“ (#100) - Stilvorlagen des Basemap-Layers vermerken ihr Thema; Laden, Speichern und „Punktlinien korrigieren“ wirken auf den im Layerbaum ausgewählten Basemap-Layer, sonst wie bisher auf den ersten (#100) - Neue allgemeine Funktionen `dialog_wrapper.ask_auswahl` (Rückfrage mit frei benannten Knöpfen) und `qgisui_wrapper.get_active_layer` (#100) diff --git a/functions/qt_wrapper.py b/functions/qt_wrapper.py index ce17f35..a602e41 100644 --- a/functions/qt_wrapper.py +++ b/functions/qt_wrapper.py @@ -56,6 +56,9 @@ QTableWidgetItem: Type[Any] = object QTableWidgetSelectionRange: Type[Any] = object QIcon: Type[Any] = object QSize: Type[Any] = object +QScrollArea: Type[Any] = object +QFrame: Type[Any] = object +FrameNoFrame: Any = 0 def exec_dialog(dialog: Any) -> Any: @@ -116,6 +119,8 @@ try: QTableWidget as _QTableWidget, QTableWidgetItem as _QTableWidgetItem, QTableWidgetSelectionRange as _QTableWidgetSelectionRange, + QScrollArea as _QScrollArea, + QFrame as _QFrame, ) from qgis.PyQt.QtGui import QFont as _QFont, QIcon as _QIcon from qgis.PyQt.QtCore import ( @@ -176,6 +181,8 @@ try: QTableWidget = _QTableWidget QTableWidgetItem = _QTableWidgetItem QTableWidgetSelectionRange = _QTableWidgetSelectionRange + QScrollArea = _QScrollArea + QFrame = _QFrame QIcon = _QIcon QSize = _QSize # ✅ QT6 ENUMS @@ -194,6 +201,7 @@ try: SizePolicyPreferred = QSizePolicy.Policy.Preferred SizePolicyMaximum = QSizePolicy.Policy.Maximum DockWidgetMovable = QDockWidget.DockWidgetFeature.DockWidgetMovable + FrameNoFrame = QFrame.Shape.NoFrame DockWidgetFloatable = QDockWidget.DockWidgetFeature.DockWidgetFloatable DockWidgetClosable = QDockWidget.DockWidgetFeature.DockWidgetClosable DockAreaLeft = Qt.DockWidgetArea.LeftDockWidgetArea @@ -247,6 +255,8 @@ except (ImportError, AttributeError): QTableWidget as _QTableWidget, QTableWidgetItem as _QTableWidgetItem, QTableWidgetSelectionRange as _QTableWidgetSelectionRange, + QScrollArea as _QScrollArea, + QFrame as _QFrame, ) from PyQt5.QtGui import QFont as _QFont, QIcon as _QIcon from PyQt5.QtCore import ( @@ -305,6 +315,8 @@ except (ImportError, AttributeError): QTableWidget = _QTableWidget QTableWidgetItem = _QTableWidgetItem QTableWidgetSelectionRange = _QTableWidgetSelectionRange + QScrollArea = _QScrollArea + QFrame = _QFrame QIcon = _QIcon QSize = _QSize @@ -325,6 +337,7 @@ except (ImportError, AttributeError): SizePolicyPreferred = QSizePolicy.Preferred SizePolicyMaximum = QSizePolicy.Maximum DockWidgetMovable = QDockWidget.DockWidgetMovable + FrameNoFrame = QFrame.NoFrame DockWidgetFloatable = QDockWidget.DockWidgetFloatable DockWidgetClosable = QDockWidget.DockWidgetClosable DockAreaLeft = Qt.LeftDockWidgetArea @@ -492,9 +505,28 @@ except (ImportError, AttributeError): def show(self) -> None: pass def deleteLater(self) -> None: pass def closeEvent(self, event) -> None: pass + def setWidget(self, widget) -> None: self._widget = widget + def widget(self): return getattr(self, "_widget", None) + def setFeatures(self, *args, **kwargs) -> None: pass + def setAllowedAreas(self, *args, **kwargs) -> None: pass + def setWindowTitle(self, title: str) -> None: self._title = title QDockWidget = _MockQDockWidget + class _MockQScrollArea(_MockWidget): + def __init__(self, *args, **kwargs): + self._widget = None + self._resizable = False + def setWidget(self, widget) -> None: self._widget = widget + def widget(self): return self._widget + def setWidgetResizable(self, value: bool) -> None: self._resizable = bool(value) + def widgetResizable(self) -> bool: return self._resizable + def setFrameShape(self, *args, **kwargs) -> None: pass + + QScrollArea = _MockQScrollArea + QFrame = _MockWidget + FrameNoFrame = 0 + class _MockAction: def __init__(self, *args, **kwargs): self._checked = False @@ -556,8 +588,17 @@ except (ImportError, AttributeError): DockWidgetClosable = 4 class _MockTabWidget: - def __init__(self, *args, **kwargs): self._tabs = [] + def __init__(self, *args, **kwargs): + self._tabs = [] + self._aktuell = 0 def addTab(self, widget, title: str): self._tabs.append((widget, title)) + def insertTab(self, index: int, widget, title: str): self._tabs.insert(index, (widget, title)) + def removeTab(self, index: int): self._tabs.pop(index) + def count(self) -> int: return len(self._tabs) + def widget(self, index: int): return self._tabs[index][0] if 0 <= index < len(self._tabs) else None + def tabText(self, index: int) -> str: return self._tabs[index][1] + def currentIndex(self) -> int: return self._aktuell + def setCurrentIndex(self, index: int) -> None: self._aktuell = index QTabWidget = _MockTabWidget diff --git a/tests/test_base_dockwidget.py b/tests/test_base_dockwidget.py index 8cc342e..d61da8d 100644 --- a/tests/test_base_dockwidget.py +++ b/tests/test_base_dockwidget.py @@ -1,7 +1,8 @@ -"""Tests für sn_basis/ui/base_dockwidget.py::closeEvent (Issue #65).""" +"""Tests für sn_basis/ui/base_dockwidget.py: closeEvent (Issue #65) und Scroll-Bereich je Tab (Basis #104).""" import unittest +from sn_basis.functions.qt_wrapper import QScrollArea, QTabWidget from sn_basis.ui.base_dockwidget import BaseDockWidget @@ -95,5 +96,73 @@ class CloseEventTests(unittest.TestCase): dock.closeEvent(None) # darf keine Ausnahme werfen +class _TabA: + tab_title = "Daten" + + +class _TabB: + tab_title = "Druck" + + +class _Dock(BaseDockWidget): + tabs = [_TabA, _TabB] + + +class ScrollbereichTests(unittest.TestCase): + """Lange Tabs dürfen das QGIS-Fenster nicht über den Bildschirmrand schieben (Basis #104).""" + + def test_jeder_tab_liegt_in_einem_scrollbereich(self): + """Prüft, dass BaseDockWidget jeden Tab in einen mitwachsenden Scroll-Bereich packt, damit ein langer Tab scrollt, statt das Fenster zu verlängern.""" + reiter = _Dock().widget() + self.assertEqual(reiter.count(), 2) + for index, klasse in enumerate((_TabA, _TabB)): + bereich = reiter.widget(index) + self.assertIsInstance(bereich, QScrollArea) + self.assertTrue(bereich.widgetResizable()) + self.assertIsInstance(bereich.widget(), klasse) + self.assertEqual(reiter.tabText(index), klasse.tab_title) + + def test_fachplugin_mit_eigenem_tabaufbau_bekommt_scrollbereich(self): + """Prüft, dass auch ein Fachplugin, das seine Tabs selbst aufbaut und per setWidget einsetzt (wie Plan41, Verfahrensgebiet), Scroll-Bereiche bekommt.""" + dock = _Dock() + eigene = QTabWidget() + tab = _TabA() + eigene.addTab(tab, "Eigener Tab") + eigene.setCurrentIndex(0) + dock.setWidget(eigene) + self.assertIs(dock.widget(), eigene) + self.assertIsInstance(eigene.widget(0), QScrollArea) + self.assertIs(eigene.widget(0).widget(), tab) + self.assertEqual(eigene.tabText(0), "Eigener Tab") + self.assertEqual(eigene.currentIndex(), 0) + + def test_scrollbereich_wird_nicht_doppelt_angelegt(self): + """Prüft, dass ein Tab, der schon in einem Scroll-Bereich liegt, beim erneuten Einsetzen nicht ein zweites Mal verpackt wird.""" + dock = _Dock() + reiter = dock.widget() + bereich = reiter.widget(0) + dock.setWidget(reiter) + self.assertIs(reiter.widget(0), bereich) + + def test_setwidget_none_und_andere_widgets_bleiben_unveraendert(self): + """Prüft, dass setWidget(None) und Widgets ohne Tabs ohne Fehler und unverändert eingesetzt werden.""" + dock = _Dock() + dock.setWidget(None) + self.assertIsNone(dock.widget()) + einfach = _TabA() + dock.setWidget(einfach) + self.assertIs(dock.widget(), einfach) + + def test_cleanup_erreicht_tabs_im_scrollbereich(self): + """Prüft, dass closeEvent cleanup() auch für Tabs aufruft, die in einem Scroll-Bereich liegen.""" + dock = _neues_dock() + tab = _FakeTab() + bereich = QScrollArea() + bereich.setWidget(tab) + dock.widget = lambda: _FakeTabWidget([bereich]) + dock.closeEvent(None) + self.assertTrue(tab.cleanup_aufgerufen) + + if __name__ == "__main__": unittest.main() diff --git a/ui/base_dockwidget.py b/ui/base_dockwidget.py index acc5a91..c8b70c1 100644 --- a/ui/base_dockwidget.py +++ b/ui/base_dockwidget.py @@ -9,6 +9,8 @@ from sn_basis.functions.message_wrapper import warning, error from sn_basis.functions.qt_wrapper import ( QDockWidget, QTabWidget, + QScrollArea, + FrameNoFrame, Qt, DockWidgetMovable, DockWidgetFloatable, @@ -18,6 +20,36 @@ from sn_basis.functions.qt_wrapper import ( ) +def _inhalt(seite): + """Tab-Instanz einer Reiterseite; liegt sie in einem Scroll-Bereich, dessen Inhalt.""" + if isinstance(seite, QScrollArea): + return seite.widget() + return seite + + +def tabs_mit_scrollbereich(tab_widget) -> None: + """Packt jeden Tab eines QTabWidget in einen mitwachsenden Scroll-Bereich (Basis #104). + + Ohne Scroll-Bereich verlangt Qt für das Dock mindestens die Höhe des höchsten Tabs; ist + der Tab höher als der Bildschirm, schiebt QGIS seinen unteren Fensterrand aus dem Bild. + Tabs, die schon in einem Scroll-Bereich liegen, bleiben unverändert. + """ + if not callable(getattr(tab_widget, "count", None)) or not callable(getattr(tab_widget, "insertTab", None)): + return + aktuell = tab_widget.currentIndex() + for index in range(tab_widget.count()): + seite = tab_widget.widget(index) + if seite is None or isinstance(seite, QScrollArea): + continue + titel = tab_widget.tabText(index) + bereich = QScrollArea() + bereich.setWidgetResizable(True) + bereich.setFrameShape(FrameNoFrame) + tab_widget.removeTab(index) + bereich.setWidget(seite) + tab_widget.insertTab(index, bereich, titel) + tab_widget.setCurrentIndex(aktuell) + class BaseDockWidget(QDockWidget): """ @@ -26,6 +58,8 @@ class BaseDockWidget(QDockWidget): - Titel wird automatisch aus base_title + subtitle erzeugt - Tabs werden dynamisch aus der Klassenvariable 'tabs' erzeugt - Die zugehörige Toolbar-Action wird beim Schließen zurückgesetzt + - Jeder Tab liegt in einem Scroll-Bereich (``setWidget``), auch wenn ein Fachplugin + seine Tabs selbst aufbaut (Basis #104) """ base_title = "LNO Sachsen" @@ -90,6 +124,19 @@ class BaseDockWidget(QDockWidget): str(e), ) + # --------------------------------------------------------- + # Inhalt setzen + # --------------------------------------------------------- + + def setWidget(self, widget): + """Wie ``QDockWidget.setWidget``; die Tabs eines QTabWidget bekommen je einen Scroll-Bereich.""" + if widget is not None: + try: + tabs_mit_scrollbereich(widget) + except Exception as e: + warning("Scroll-Bereich konnte nicht angelegt werden", str(e)) + super().setWidget(widget) + # --------------------------------------------------------- # Dock schließen # --------------------------------------------------------- @@ -103,7 +150,7 @@ class BaseDockWidget(QDockWidget): tab_widget = self.widget() if tab_widget and hasattr(tab_widget, "count") and hasattr(tab_widget, "widget"): for index in range(tab_widget.count()): - tab = tab_widget.widget(index) + tab = _inhalt(tab_widget.widget(index)) cleanup = getattr(tab, "cleanup", None) if callable(cleanup): try: -- 2.54.0