Daniel Helbig (LK Meissen) am 14.09.2026: ist die Seitenleiste hoeher als der Bildschirm, schiebt sie den unteren Rand des QGIS-Fensters ins Unsichtbare. Grund: ein QDockWidget reicht die Mindesthoehe seines Inhalts an das Hauptfenster weiter. Der Inhalt haengt jetzt in einer QScrollArea (widgetResizable, kein Rahmen, waagerechter Balken aus). Damit meldet das Dock nur noch 88 px Mindesthoehe statt 931; der Inhalt bleibt ueber einen senkrechten Rollbalken erreichbar. Die Mindestbreite wandert auf die Rollflaeche und bekommt die Balkenbreite aufgeschlagen (278 px), damit nichts angeschnitten wird. Die Diensteliste gibt sich mit 90 statt 140 px zufrieden -- ihre Mindesthoehe zaehlt in die des Docks hinein. Geprueft mit tools/headless_panel_test.py (QGIS 4.2 offscreen, 0 Fehler): Rollflaeche da, Dock verlangt 88 px bei 931 px Inhalt, Rollbalken wird gebraucht, kein waagerechter Balken. Der Test laeuft neuerdings auch ohne VLN_TOKEN -- der Online-Teil wird dann uebersprungen, die Layout-Pruefung nicht. Closes #1 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
351 lines
16 KiB
Python
351 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Seitenleiste (andockbares Panel) des Plugins — rechts in QGIS.
|
|
|
|
Ersetzt die frühere Werkzeugleiste (Erik, 08.09.2026: „keine Toolbar, sondern eine
|
|
Seitenleiste auf der rechten Seite"). Das Panel ist reine Oberfläche: es hält keinen
|
|
Zustand der Fachlogik, sondern meldet Klicks über Rückrufe (``callbacks``) an
|
|
``VlnKartenPlugin`` und wird von dort über ``set_angemeldet`` / ``set_verfahren``
|
|
umgeschaltet.
|
|
|
|
Aufbau von oben nach unten:
|
|
1. Anmeldung — Status + Knopf (Anmelden/Abmelden)
|
|
2. Verfahren — Suchfeld (filtert) + Auswahlliste
|
|
3. Layer — Auswahl der Gruppen, laden, Änderungen speichern
|
|
4. Geodienste — die Linkliste des Verfahrens: auswählen und als Layer laden
|
|
5. Darstellung — Stil speichern (mit Häkchen „für alle Verfahren")
|
|
6. Statuszeile
|
|
|
|
Der Inhalt sitzt in einer Rollfläche: ist die Leiste höher als der Bildschirm, rollt sie,
|
|
statt das QGIS-Fenster zu strecken (Issue #1).
|
|
|
|
Der frühere GeoJSON-Weg (Kopie laden, später als Ganzes hochladen) ist am 08.09.2026
|
|
entfallen (Erik: „das Speichern via OGC einbauen, GeoJSON nicht mehr verwenden"). Über
|
|
die OGC API schreibt QGIS jede Änderung einzeln zurück; „Änderungen speichern" schließt
|
|
nur die offenen Bearbeitungssitzungen.
|
|
"""
|
|
|
|
from qgis.PyQt.QtCore import Qt
|
|
from qgis.PyQt.QtWidgets import (QCheckBox, QComboBox, QDockWidget, QFrame, QGroupBox,
|
|
QHBoxLayout, QLabel, QLineEdit, QListWidget,
|
|
QListWidgetItem, QPushButton, QScrollArea, QSizePolicy,
|
|
QVBoxLayout, QWidget)
|
|
|
|
PANEL_TITEL = "VLN Karten"
|
|
PANEL_OBJEKT = "VlnKartenPanel"
|
|
|
|
|
|
def _klein(widget):
|
|
"""Hinweistexte eine Stufe kleiner und gedämpft — wie in QGIS-Panels üblich.
|
|
|
|
Die Farbe kommt aus der Palette (``palette(mid)``), damit der Text in hellen wie
|
|
dunklen QGIS-Themen lesbar bleibt; ``setEnabled(False)`` wäre dafür der falsche Weg,
|
|
weil es das Widget zugleich unbedienbar macht.
|
|
"""
|
|
schrift = widget.font()
|
|
schrift.setPointSizeF(max(8.0, schrift.pointSizeF() - 1.0))
|
|
widget.setFont(schrift)
|
|
widget.setWordWrap(True)
|
|
widget.setStyleSheet("color: palette(mid);")
|
|
return widget
|
|
|
|
|
|
class VlnPanel(QDockWidget):
|
|
"""Die Seitenleiste. ``callbacks`` bündelt die Rückrufe ins Plugin."""
|
|
|
|
def __init__(self, parent=None, callbacks=None):
|
|
super().__init__(PANEL_TITEL, parent)
|
|
self.setObjectName(PANEL_OBJEKT)
|
|
self.setAllowedAreas(Qt.DockWidgetArea.RightDockWidgetArea
|
|
| Qt.DockWidgetArea.LeftDockWidgetArea)
|
|
self._cb = callbacks or {}
|
|
self._verfahren = [] # [(beschriftung, vkz)] — ungefiltert
|
|
self._stumm = False # unterdrückt Signale beim programmatischen Füllen
|
|
|
|
inhalt = QWidget()
|
|
aussen = QVBoxLayout(inhalt)
|
|
aussen.setContentsMargins(8, 8, 8, 8)
|
|
aussen.setSpacing(10)
|
|
|
|
aussen.addWidget(self._bau_anmeldung())
|
|
aussen.addWidget(self._bau_verfahren())
|
|
aussen.addWidget(self._bau_layer())
|
|
aussen.addWidget(self._bau_dienste())
|
|
aussen.addWidget(self._bau_darstellung())
|
|
aussen.addStretch(1)
|
|
aussen.addWidget(self._bau_status())
|
|
|
|
inhalt.setMinimumWidth(260)
|
|
|
|
# Die Seitenleiste haengt in einer Rollflaeche, nicht direkt im Dock (Daniel Helbig,
|
|
# LK Meissen, 14.09.2026 — Issue #1 „UI-Ueberlauf"): ein QDockWidget gibt die
|
|
# Mindesthoehe seines Inhalts an das Hauptfenster weiter. Ist der Inhalt hoeher als
|
|
# der Bildschirm, waechst das QGIS-Fenster mit und sein unterer Rand rutscht ins
|
|
# Unsichtbare — samt Statusleiste. Die Rollflaeche kappt diese Weitergabe und blendet
|
|
# stattdessen einen senkrechten Rollbalken ein.
|
|
rolle = QScrollArea()
|
|
rolle.setWidget(inhalt)
|
|
rolle.setWidgetResizable(True) # Inhalt folgt der Breite des Docks
|
|
rolle.setFrameShape(QFrame.Shape.NoFrame)
|
|
rolle.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
rolle.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
# Ohne das bleibt die Mindesthoehe des Inhalts am Dock haengen und der Rollbalken
|
|
# aendert nichts an der gemeldeten Groesse. Uebrig bleibt die eigene Mindesthoehe der
|
|
# Rollflaeche (gemessen 88 px) — klein genug fuer jeden Bildschirm, gross genug,
|
|
# dass sich das Dock nicht auf einen Strich zusammenziehen laesst.
|
|
rolle.setMinimumHeight(0)
|
|
# Breite des Rollbalkens draufschlagen, sonst schneidet er die Auswahllisten an.
|
|
balken = rolle.verticalScrollBar().sizeHint().width() or 16
|
|
rolle.setMinimumWidth(260 + balken)
|
|
|
|
self.setWidget(rolle)
|
|
self.set_angemeldet(False)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Aufbau
|
|
# ------------------------------------------------------------------
|
|
|
|
def _bau_anmeldung(self):
|
|
kasten = QGroupBox("Anmeldung")
|
|
lage = QVBoxLayout(kasten)
|
|
lage.setSpacing(6)
|
|
self.status_label = QLabel("Nicht angemeldet")
|
|
self.status_label.setWordWrap(True)
|
|
self.login_knopf = QPushButton("Anmelden …")
|
|
self.login_knopf.clicked.connect(lambda: self._ruf("login"))
|
|
self.logout_knopf = QPushButton("Abmelden")
|
|
self.logout_knopf.clicked.connect(lambda: self._ruf("logout"))
|
|
zeile = QHBoxLayout()
|
|
zeile.addWidget(self.login_knopf)
|
|
zeile.addWidget(self.logout_knopf)
|
|
lage.addWidget(self.status_label)
|
|
lage.addLayout(zeile)
|
|
return kasten
|
|
|
|
def _bau_verfahren(self):
|
|
kasten = QGroupBox("Verfahren")
|
|
lage = QVBoxLayout(kasten)
|
|
lage.setSpacing(6)
|
|
self.filter_feld = QLineEdit()
|
|
self.filter_feld.setPlaceholderText("filtern (Name oder Kennzeichen) …")
|
|
self.filter_feld.setClearButtonEnabled(True)
|
|
self.filter_feld.textChanged.connect(self._filter_geaendert)
|
|
self.combo = QComboBox()
|
|
self.combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
|
self.combo.setToolTip("Teilnehmergemeinschaft, für die geladen und gespeichert wird")
|
|
self.combo.currentIndexChanged.connect(self._auswahl_geaendert)
|
|
lage.addWidget(self.filter_feld)
|
|
lage.addWidget(self.combo)
|
|
return kasten
|
|
|
|
def _bau_layer(self):
|
|
kasten = QGroupBox("Layer")
|
|
lage = QVBoxLayout(kasten)
|
|
lage.setSpacing(6)
|
|
# Jede Sammlung kostet beim Anlegen rund eine halbe Sekunde (der Provider fragt
|
|
# Sammlung, Schema, Stil, OPTIONS und die erste Seite ab). Wer nur einen Teil braucht,
|
|
# ist mit der Auswahl deutlich schneller: 27 Layer ~14 s, die Vorerhebung allein ~2 s.
|
|
lage.addWidget(_klein(QLabel("Was geladen wird:")))
|
|
self.gruppen_haken = {}
|
|
for schluessel, beschriftung in (("Verfahren", "Verfahren (Umring, Plan 41, KAS …)"),
|
|
("Vorerhebung", "Vorerhebung"),
|
|
("Maßnahmenblätter", "Maßnahmenblätter")):
|
|
haken = QCheckBox(beschriftung)
|
|
haken.setChecked(True)
|
|
lage.addWidget(haken)
|
|
self.gruppen_haken[schluessel] = haken
|
|
self.ogc_knopf = QPushButton("Layer laden")
|
|
self.ogc_knopf.setToolTip("Die angehakten Sammlungen als editierbare Layer der OGC API")
|
|
self.ogc_knopf.clicked.connect(lambda: self._ruf("load_ogc"))
|
|
linie = QFrame()
|
|
linie.setFrameShape(QFrame.Shape.HLine)
|
|
linie.setFrameShadow(QFrame.Shadow.Sunken)
|
|
self.speichern_knopf = QPushButton("Änderungen speichern")
|
|
self.speichern_knopf.setToolTip("Schließt die offenen Bearbeitungssitzungen aller "
|
|
"geladenen VLN-Layer; jede Änderung geht einzeln an "
|
|
"die API")
|
|
self.speichern_knopf.clicked.connect(lambda: self._ruf("save_edits"))
|
|
lage.addWidget(self.ogc_knopf)
|
|
lage.addWidget(linie)
|
|
lage.addWidget(self.speichern_knopf)
|
|
lage.addWidget(_klein(QLabel("Bearbeiten wie gewohnt über den Stift; Speichern schreibt "
|
|
"jede Änderung sofort zurück.")))
|
|
return kasten
|
|
|
|
def _bau_dienste(self):
|
|
"""Die Linkliste des gewählten Verfahrens — vierstufig gepflegt, hier nur geladen.
|
|
|
|
Die Liste kommt fertig aufgelöst vom Server (landesweit, Landkreis, Team, Verfahren);
|
|
je Zeile steht die Herkunft dabei, damit man sieht, wo ein Dienst herkommt.
|
|
"""
|
|
kasten = QGroupBox("Geodienste (Linkliste)")
|
|
lage = QVBoxLayout(kasten)
|
|
lage.setSpacing(6)
|
|
lage.addWidget(_klein(QLabel("WMS-, WFS- und ArcGIS-Dienste für dieses Verfahren.")))
|
|
self.dienste_liste = QListWidget()
|
|
# Bewusst niedrig: die Mindesthoehe aller Kaesten zusammen ist die Mindesthoehe des
|
|
# Docks (s. Rollflaeche im Konstruktor). 140 px hier machten die Seitenleiste auf
|
|
# einem 13-Zoll-Bildschirm hoeher als das Fenster.
|
|
self.dienste_liste.setMinimumHeight(90)
|
|
self.dienste_liste.setToolTip("Angehakte Dienste werden geladen")
|
|
lage.addWidget(self.dienste_liste)
|
|
zeile = QHBoxLayout()
|
|
self.dienste_holen_knopf = QPushButton("Liste holen")
|
|
self.dienste_holen_knopf.clicked.connect(lambda: self._ruf("dienste_holen"))
|
|
self.dienste_laden_knopf = QPushButton("Ausgewählte laden")
|
|
self.dienste_laden_knopf.clicked.connect(lambda: self._ruf("dienste_laden"))
|
|
zeile.addWidget(self.dienste_holen_knopf)
|
|
zeile.addWidget(self.dienste_laden_knopf)
|
|
lage.addLayout(zeile)
|
|
self.dienste_alle = QPushButton("alle an- oder abwählen")
|
|
self.dienste_alle.setFlat(True)
|
|
self.dienste_alle.clicked.connect(self._dienste_umschalten)
|
|
lage.addWidget(self.dienste_alle)
|
|
return kasten
|
|
|
|
def _dienste_umschalten(self):
|
|
"""Alles an, wenn noch etwas aus ist; sonst alles aus."""
|
|
etwas_aus = any(self.dienste_liste.item(i).checkState() != Qt.CheckState.Checked
|
|
for i in range(self.dienste_liste.count()))
|
|
ziel = Qt.CheckState.Checked if etwas_aus else Qt.CheckState.Unchecked
|
|
for i in range(self.dienste_liste.count()):
|
|
self.dienste_liste.item(i).setCheckState(ziel)
|
|
|
|
def set_dienste(self, dienste):
|
|
"""Die geholte Liste anzeigen: Name, Gruppe und Herkunft, alle zunächst angehakt."""
|
|
self.dienste_liste.clear()
|
|
for d in dienste or []:
|
|
text = "%s · %s · %s" % (d.get("name"), d.get("gruppe_name") or "",
|
|
d.get("herkunft_text") or "")
|
|
if d.get("stil"):
|
|
text += " · mit Stil"
|
|
eintrag = QListWidgetItem(text)
|
|
eintrag.setFlags(eintrag.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
|
eintrag.setCheckState(Qt.CheckState.Checked)
|
|
eintrag.setData(Qt.ItemDataRole.UserRole, d)
|
|
eintrag.setToolTip("%s\n%s" % (d.get("url"), d.get("beschreibung") or ""))
|
|
self.dienste_liste.addItem(eintrag)
|
|
|
|
def gewaehlte_dienste(self):
|
|
"""Die angehakten Einträge als Liste der Server-Datensätze."""
|
|
aus = []
|
|
for i in range(self.dienste_liste.count()):
|
|
e = self.dienste_liste.item(i)
|
|
if e.checkState() == Qt.CheckState.Checked:
|
|
aus.append(e.data(Qt.ItemDataRole.UserRole))
|
|
return aus
|
|
|
|
def _bau_darstellung(self):
|
|
kasten = QGroupBox("Darstellung")
|
|
lage = QVBoxLayout(kasten)
|
|
lage.setSpacing(6)
|
|
self.stil_knopf = QPushButton("Stil des aktiven Layers speichern")
|
|
self.stil_knopf.setToolTip("Den Stil dieses Verfahrens darf jeder speichern, der das "
|
|
"Verfahren sieht")
|
|
self.stil_knopf.clicked.connect(lambda: self._ruf("save_style"))
|
|
self.vorlage_haken = QCheckBox("als Vorlage für alle Verfahren")
|
|
self.vorlage_haken.setToolTip("Gilt dann für diese Art in jedem Verfahren, z. B. für "
|
|
"jede Karte alter Stand — nur für die Gruppe "
|
|
"MODUL_KARTENSTIL")
|
|
lage.addWidget(self.stil_knopf)
|
|
lage.addWidget(self.vorlage_haken)
|
|
return kasten
|
|
|
|
def _bau_status(self):
|
|
self.status_zeile = _klein(QLabel(""))
|
|
self.status_zeile.setToolTip("letzte Aktion")
|
|
return self.status_zeile
|
|
|
|
# ------------------------------------------------------------------
|
|
# Zustand (vom Plugin gesetzt)
|
|
# ------------------------------------------------------------------
|
|
|
|
def set_angemeldet(self, angemeldet, wer=None):
|
|
self.status_label.setText(("Angemeldet: %s" % wer) if angemeldet and wer
|
|
else ("Angemeldet" if angemeldet else "Nicht angemeldet"))
|
|
self.login_knopf.setVisible(not angemeldet)
|
|
self.logout_knopf.setVisible(angemeldet)
|
|
for widget in (self.filter_feld, self.combo, self.ogc_knopf, self.speichern_knopf,
|
|
self.stil_knopf, self.vorlage_haken, self.dienste_holen_knopf,
|
|
self.dienste_laden_knopf, self.dienste_alle, self.dienste_liste,
|
|
*self.gruppen_haken.values()):
|
|
widget.setEnabled(angemeldet)
|
|
if not angemeldet:
|
|
self._stumm = True
|
|
self._verfahren = []
|
|
self.combo.clear()
|
|
self.combo.addItem("— bitte anmelden —", None)
|
|
self.filter_feld.clear()
|
|
self.dienste_liste.clear()
|
|
self._stumm = False
|
|
|
|
def set_verfahren(self, verfahren):
|
|
"""``verfahren``: Liste von ``{"vkz":…, "name":…}`` aus der API."""
|
|
self._verfahren = [
|
|
("%s — %s" % (v["vkz"], v["name"]) if v.get("name") else str(v["vkz"]), v["vkz"])
|
|
for v in verfahren
|
|
]
|
|
self._liste_fuellen()
|
|
|
|
def set_status(self, text):
|
|
self.status_zeile.setText(text or "")
|
|
|
|
def aktuelles_vkz(self):
|
|
return self.combo.currentData()
|
|
|
|
def aktueller_text(self):
|
|
return self.combo.currentText()
|
|
|
|
def als_vorlage(self):
|
|
return self.vorlage_haken.isChecked()
|
|
|
|
def gewaehlte_gruppen(self):
|
|
"""Namen der angehakten Layer-Gruppen (wie in ``ogc_layers.GRUPPEN``)."""
|
|
return {name for name, haken in self.gruppen_haken.items() if haken.isChecked()}
|
|
|
|
def waehle_vkz(self, vkz):
|
|
"""Setzt die Auswahl ohne Rückruf (Wiederherstellen aus der Projektdatei)."""
|
|
self._stumm = True
|
|
index = self.combo.findData(vkz) if vkz else -1
|
|
self.combo.setCurrentIndex(index if index >= 0 else 0)
|
|
self._stumm = False
|
|
|
|
def anzahl_eintraege(self):
|
|
return self.combo.count()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Innenleben
|
|
# ------------------------------------------------------------------
|
|
|
|
def _liste_fuellen(self, muster=""):
|
|
"""Füllt die Auswahlliste, gefiltert; die bisherige Auswahl bleibt wenn möglich."""
|
|
vorher = self.combo.currentData()
|
|
muster = (muster or "").strip().lower()
|
|
treffer = [(t, v) for t, v in self._verfahren if muster in t.lower()]
|
|
self._stumm = True
|
|
self.combo.clear()
|
|
if not self._verfahren:
|
|
self.combo.addItem("— keine Verfahren —", None)
|
|
elif not treffer:
|
|
self.combo.addItem("— kein Treffer —", None)
|
|
else:
|
|
self.combo.addItem("— Verfahren wählen —", None)
|
|
for beschriftung, vkz in treffer:
|
|
self.combo.addItem(beschriftung, vkz)
|
|
index = self.combo.findData(vorher) if vorher else -1
|
|
self.combo.setCurrentIndex(index if index >= 0 else 0)
|
|
self._stumm = False
|
|
if index < 0 and vorher:
|
|
self._auswahl_geaendert(0)
|
|
|
|
def _filter_geaendert(self, text):
|
|
self._liste_fuellen(text)
|
|
|
|
def _auswahl_geaendert(self, _index):
|
|
if not self._stumm:
|
|
self._ruf("verfahren_gewaehlt", self.combo.currentData())
|
|
|
|
def _ruf(self, name, *args):
|
|
rueckruf = self._cb.get(name)
|
|
if rueckruf:
|
|
rueckruf(*args)
|