Merge pull request 'Tabs der LNO-Docks in Scroll-Bereich packen (#104)' (#105) from bugfix/104-dock-scrollbereich into unstable

Reviewed-on: #105
This commit was merged in pull request #105.
This commit is contained in:
2026-10-05 19:57:13 +00:00
4 changed files with 161 additions and 3 deletions
+1
View File
@@ -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)
+42 -1
View File
@@ -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
+70 -1
View File
@@ -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()
+48 -1
View File
@@ -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: