Merge pull request 'Basemap hinzufügen und Thema wechseln im Basemap-Bereich (#100)' (#103) from feature/100-basemap-themen into unstable

Reviewed-on: #103
This commit was merged in pull request #103.
This commit is contained in:
2026-10-05 19:44:35 +00:00
7 changed files with 839 additions and 15 deletions
+3
View File
@@ -1,3 +1,6 @@
- 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)
- Bugfix: Mehrspaltige Legenden waren sehr breit, weil alle Spalten so breit wie der längste Text waren. Legendentext bricht jetzt nach 60 mm um, Spalten sind ungleich breit, der Titel der Basemap-Legende ist zweizeilig – im Test wurde die dreispaltige Basemap-Legende 226 statt 431 mm breit. Standard- und Mindestbreite der Legenden ist die Breite des Kartenschilds (180 mm); sie wird am Layout vermerkt und beim Laden des Projekts wieder gesetzt, weil QGIS sie nicht speichert (#96)
- Bugfix: Bei kleinen Verfahren oder großem Maßstab überlappten sich Legenden, Quellenangabe und Kartenschild, weil sie zusammen höher als die Karte waren. Beim Anlegen einer §38-/§41-Vorlage werden die Legenden jetzt bei Bedarf zwei- oder dreispaltig gesetzt (Kartenrand wird dafür breiter); reicht auch das nicht, wird die Seite höher und Quellenangabe und Kartenschild rutschen nach unten. Größe und Lage der Karte bleiben gleich (#93)
- Bugfix: Weiße Basemap-Flächen ohne Kontur (z. B. „Platz“) waren in der Basemap-Legende auf weißem Grund unsichtbar; ihr Legendensymbol bekommt jetzt einen dünnen grauen Rand (0,15 mm), die Karte bleibt unverändert (#92)
+49
View File
@@ -133,6 +133,55 @@ def ask_overwrite_append_cancel_custom(
return "cancel"
def ask_auswahl(
title: str,
message: str,
optionen: list[tuple[str, str]],
default: str,
abbrechen: Optional[str] = None,
parent: Any = None,
) -> str:
"""Stellt eine Frage mit frei benannten Knöpfen und gibt den Schlüssel der Wahl zurück.
Parameters
----------
optionen :
``(schluessel, beschriftung)`` in Anzeigereihenfolge.
default :
Vorbelegter Knopf; im Mock-Modus die Antwort.
abbrechen :
Schlüssel, der beim Schließen des Fensters (Esc, X) und bei einem Fehler gilt;
ohne Angabe ``default``.
"""
rueckfall = abbrechen if abbrechen is not None else default
if QT_VERSION == 0: # Mock-Modus
return default
try:
from sn_basis.functions import qt_wrapper
msg = QMessageBox(parent)
msg.setIcon(ICON_QUESTION)
msg.setWindowTitle(title)
msg.setText(message)
knoepfe = {}
for schluessel, beschriftung in optionen:
rolle = qt_wrapper.RejectRole if schluessel == abbrechen else qt_wrapper.ActionRole
knoepfe[schluessel] = msg.addButton(beschriftung, rolle)
if default in knoepfe:
msg.setDefaultButton(knoepfe[default])
if abbrechen in knoepfe:
msg.setEscapeButton(knoepfe[abbrechen])
exec_dialog(msg)
geklickt = msg.clickedButton()
for schluessel, knopf in knoepfe.items():
if geklickt == knopf:
return schluessel
return rueckfall
except Exception as e:
print(f"⚠️ ask_auswahl Fehler: {e}")
return rueckfall
def ask_ergaenzen_entfernen_ersetzen_abbrechen(
parent: Any,
title: str,
+9
View File
@@ -219,6 +219,15 @@ def remove_toolbar(toolbar: Any) -> None:
# Layer zum Projekt hinzufügen
# ---------------------------------------------------------
def get_active_layer() -> Any:
"""Im Layerbaum ausgewählter Layer oder ``None`` (Mock-Modus, kein Layer gewählt)."""
try:
aktiv = getattr(iface, "activeLayer", None)
return aktiv() if callable(aktiv) else None
except Exception:
return None
def add_layer_to_project(layer: Any) -> bool:
"""
Fügt einen Layer dem aktuellen QGIS-Projekt hinzu.
+9 -2
View File
@@ -6,7 +6,8 @@ abgeschaltete Styles wie Hausnummern) als benannte Stilvorlage im QGIS-Profil un
wieder. Mehrere Varianten sind möglich; eine davon kann als Standard markiert werden.
Ablage: ``<QGIS-Profil>/sn_basis/basemap_stile/<name>.qml`` plus ``<name>.json`` mit
Anzeigename, Erstellungszeit, Style-Namen der Vorlage und – optional – der Legendenauswahl.
Anzeigename, Erstellungszeit, Thema des Layers (Basis #100), Style-Namen der Vorlage und –
optional – der Legendenauswahl.
Der Standard steht in ``QSettings`` unter ``sn_basis/basemap_standardstil``.
Die Vorlagen sind lokal (je Rechner und Profil) und werden nicht geteilt.
"""
@@ -19,6 +20,7 @@ from datetime import datetime
from typing import Any
from sn_basis.functions.qgiscore_wrapper import QgsApplication, STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG
from sn_basis.modules.basemap_themen import thema_des_layers
from sn_basis.functions.qt_wrapper import QSettings
from sn_basis.functions.text_wrapper import sanitize_bezeichner
@@ -32,6 +34,7 @@ class Stilvorlage:
pfad: str # QML-Datei
erstellt: str = ""
standard: bool = False
thema: str = "" # Schlüssel aus basemap_themen.THEMEN; leer bei älteren Vorlagen
def standard_ordner() -> str:
@@ -98,6 +101,7 @@ class BasemapStilvorlagen:
pfad=os.path.join(self.ordner, datei),
erstellt=str(meta.get("erstellt", "")),
standard=(name == standard),
thema=str(meta.get("thema") or ""),
))
return sorted(vorlagen, key=lambda v: v.anzeigename.lower())
@@ -143,11 +147,14 @@ class BasemapStilvorlagen:
"layer": layer.name(),
"stile": _stilnamen(layer),
}
thema = thema_des_layers(layer)
if thema:
meta["thema"] = thema
if legende_auswahl:
meta["legende_auswahl"] = legende_auswahl
with open(json_pfad, "w", encoding="utf-8") as datei:
json.dump(meta, datei, ensure_ascii=False, indent=1)
return Stilvorlage(name, anzeigename.strip(), qml_pfad, erstellt, name == self.standard())
return Stilvorlage(name, anzeigename.strip(), qml_pfad, erstellt, name == self.standard(), thema or "")
def laden(self, layer: Any, name: str) -> None:
"""Ersetzt Symbologie und Beschriftung des Layers durch die Vorlage.
+245
View File
@@ -0,0 +1,245 @@
"""
sn_basis/modules/basemap_themen.py – Basemap-Layer hinzufügen und Themen wechseln (Basis #100)
Begriffe: Ein **Thema** ist eine vom Dienst basemap.de als Style-JSON bereitgestellte Darstellung
(Farbe, Grau). Ein **Stil** ist eine lokale Anpassung des Nutzers (``basemap_stilvorlagen``).
Alle Themen nutzen dieselben Vektorkacheln; das Thema steht im Parameter ``styleUrl`` der
Layerquelle. Ein Basemap-Layer gilt als schon vorhanden, wenn ein Layer mit identischer
Kachel-URL im Projekt liegt.
Ungespeicherte Änderungen: QGIS führt dafür kein Kennzeichen. Direkt nach dem Laden eines
Themas oder Stils wird deshalb ein Prüfwert der Darstellung (Symbologie und Beschriftung) als
Layer-Eigenschaft gespeichert; vor einem Themenwechsel wird er neu berechnet und verglichen.
Die Eigenschaften wandern mit in die Projektdatei.
"""
from __future__ import annotations
import hashlib
import os
import re
import tempfile
from dataclasses import dataclass
from typing import Any, Callable, Iterable
from urllib.parse import parse_qsl
from sn_basis.functions.qgiscore_wrapper import (
QgsVectorTileLayer,
STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG,
VECTOR_TILES_AVAILABLE,
)
KACHEL_URL = "https://sgx.geodatenzentrum.de/gdz_basemapde_vektor/tiles/v2/bm_web_de_3857/{z}/{x}/{y}.pbf"
STYLE_BASIS_URL = "https://sgx.geodatenzentrum.de/gdz_basemapde_vektor/styles/"
ZOOM_MIN = 0
ZOOM_MAX = 15 # Kacheln enden bei Stufe 15 (bm_web_de_3857.json)
LAYERNAME = "basemap.de"
THEMA_EIGENSCHAFT = "sn_basemap_thema"
PRUEFWERT_EIGENSCHAFT = "sn_basemap_pruefwert"
UNVERAENDERT = "unveraendert"
GEAENDERT = "geaendert"
UNBEKANNT = "unbekannt"
@dataclass(frozen=True)
class Thema:
schluessel: str
bezeichnung: str
style_url: str
# Relief (bm_web_top) bewusst nicht: braucht Höhenlinien und Schummerung aus weiteren Quellen,
# die ein einzelner Vektorkachel-Layer nicht darstellt (siehe fallstricke.md).
THEMEN: dict[str, Thema] = {
"farbe": Thema("farbe", "Farbe", STYLE_BASIS_URL + "bm_web_col.json"),
"grau": Thema("grau", "Grau", STYLE_BASIS_URL + "bm_web_gry.json"),
}
STANDARD_THEMA = "farbe"
def _aufruf(objekt: Any, methode: str, standard: Any = None, *args: Any) -> Any:
funktion = getattr(objekt, methode, None)
if not callable(funktion):
return standard
try:
return funktion(*args)
except Exception:
return standard
def _erfolg(ergebnis: Any) -> tuple[bool, str]:
"""Deutet die je nach QGIS-Version unterschiedlichen Rückgaben von ``loadDefaultStyle`` & Co."""
if isinstance(ergebnis, tuple):
wahrheitswerte = [w for w in ergebnis if isinstance(w, bool)]
texte = [t for t in ergebnis if isinstance(t, str) and t]
return (wahrheitswerte[0] if wahrheitswerte else True), (texte[0] if texte else "")
if isinstance(ergebnis, bool):
return ergebnis, ""
return True, ""
def thema(schluessel: str | None) -> Thema:
"""Thema zum Schlüssel; unbekannte Schlüssel fallen auf das Standardthema zurück."""
return THEMEN.get(schluessel or "", THEMEN[STANDARD_THEMA])
def layername(thema_: Thema) -> str:
return f"{LAYERNAME} - {thema_.bezeichnung}"
def quelle(thema_: Thema) -> str:
"""Datenquelle eines Vektorkachel-Layers für das Thema."""
return f"type=xyz&url={KACHEL_URL}&zmin={ZOOM_MIN}&zmax={ZOOM_MAX}&styleUrl={thema_.style_url}"
# ---------------------------------------------------------
# Quelle und Thema eines Layers
# ---------------------------------------------------------
def _parameter(layer: Any) -> dict[str, str]:
text = str(_aufruf(layer, "source", "") or "")
return {k: v for k, v in parse_qsl(text, keep_blank_values=True)}
def _normiert(url: str) -> str:
return url.strip().rstrip("/").lower()
def quell_url(layer: Any) -> str:
"""Kachel-URL aus der Layerquelle (Parameter ``url``, URL-dekodiert), sonst leer."""
return _parameter(layer).get("url", "")
def layer_mit_quelle(layer: Iterable[Any], url: str = KACHEL_URL) -> list[Any]:
"""Layer, deren Kachel-URL mit ``url`` übereinstimmt – unabhängig von Name und Thema."""
ziel = _normiert(url)
return [l for l in layer if ziel and _normiert(quell_url(l)) == ziel]
def thema_des_layers(layer: Any) -> str | None:
"""Schlüssel des Themas, dessen Style-JSON in der Layerquelle steht, sonst ``None``."""
style_url = _normiert(_parameter(layer).get("styleUrl", ""))
for schluessel, eintrag in THEMEN.items():
if style_url and style_url == _normiert(eintrag.style_url):
return schluessel
return None
# ---------------------------------------------------------
# Prüfwert der Darstellung
# ---------------------------------------------------------
def pruefwert(layer: Any) -> str | None:
"""Prüfwert von Symbologie und Beschriftung; ``None``, wenn QGIS die Darstellung nicht ausgibt.
Die QGIS-Version im Wurzelelement der QML bleibt unberücksichtigt, damit ein Update von QGIS
keine Änderung vortäuscht.
"""
if not callable(getattr(layer, "saveNamedStyle", None)):
return None
deskriptor, pfad = tempfile.mkstemp(suffix=".qml", prefix="sn_basemap_")
os.close(deskriptor)
try:
ok, _ = _erfolg(_aufruf(layer, "saveNamedStyle", None, pfad, STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG))
if not ok or not os.path.getsize(pfad):
return None
with open(pfad, encoding="utf-8", errors="replace") as datei:
inhalt = datei.read()
except OSError:
return None
finally:
try:
os.remove(pfad)
except OSError:
pass
inhalt = re.sub(r"<qgis\b[^>]*>", "<qgis>", inhalt, count=1)
return hashlib.sha256(inhalt.encode("utf-8")).hexdigest()
def merke_stand(layer: Any, schluessel: str | None = None) -> None:
"""Merkt Thema und Prüfwert der aktuellen Darstellung als Ausgangsstand (nach Laden oder Speichern)."""
schluessel = schluessel or thema_des_layers(layer) or ""
_aufruf(layer, "setCustomProperty", None, THEMA_EIGENSCHAFT, schluessel)
_aufruf(layer, "setCustomProperty", None, PRUEFWERT_EIGENSCHAFT, pruefwert(layer) or "")
def aenderungsstand(layer: Any) -> str:
"""``UNVERAENDERT``, ``GEAENDERT`` oder ``UNBEKANNT`` (kein gemerkter Stand, Prüfwert nicht ermittelbar)."""
gemerkt = str(_aufruf(layer, "customProperty", "", PRUEFWERT_EIGENSCHAFT, "") or "")
if not gemerkt:
return UNBEKANNT
aktuell = pruefwert(layer)
if aktuell is None:
return UNBEKANNT
return UNVERAENDERT if aktuell == gemerkt else GEAENDERT
# ---------------------------------------------------------
# Layer anlegen und Thema wechseln
# ---------------------------------------------------------
def _lade_themenstil(layer: Any) -> None:
ok, meldung = _erfolg(_aufruf(layer, "loadDefaultStyle", None))
if not ok:
raise ValueError(f"Das Thema konnte nicht geladen werden: {meldung or 'unbekannter Fehler'}")
_aufruf(layer, "triggerRepaint")
def erzeuge_layer(schluessel: str | None = None,
fabrik: Callable[[str, str], Any] | None = None) -> Any:
"""Neuer Basemap-Layer im gewählten Thema, noch nicht im Projekt.
Raises
------
ValueError
Wenn Vektorkacheln nicht verfügbar sind oder der Layer ungültig ist.
"""
eintrag = thema(schluessel)
if fabrik is None:
if not VECTOR_TILES_AVAILABLE:
raise ValueError("Vektorkachel-Layer sind in dieser Umgebung nicht verfügbar.")
fabrik = QgsVectorTileLayer
layer = fabrik(quelle(eintrag), layername(eintrag))
if not _aufruf(layer, "isValid", True):
raise ValueError("Der Basemap-Dienst ist nicht erreichbar oder liefert keine gültigen Kacheln.")
_lade_themenstil(layer)
merke_stand(layer, eintrag.schluessel)
return layer
def fuege_hinzu(project: Any, layer: Any) -> None:
"""Fügt den Layer ganz unten im Layerbaum ein (Hintergrundkarte)."""
wurzel = _aufruf(project, "layerTreeRoot")
if wurzel is not None and callable(getattr(wurzel, "addLayer", None)):
project.addMapLayer(layer, False)
wurzel.addLayer(layer)
else:
project.addMapLayer(layer)
def wechsle_thema(layer: Any, schluessel: str) -> None:
"""Stellt den Layer auf ein anderes Thema um; Layer-ID, Position und Layout-Bezüge bleiben.
Der Layername wird nur angepasst, wenn er noch der automatisch vergebene ist.
Raises
------
ValueError
Wenn die Quelle nicht umgestellt oder das Thema nicht geladen werden konnte.
"""
eintrag = thema(schluessel)
alter_name = str(_aufruf(layer, "name", "") or "")
automatisch = alter_name in {layername(t) for t in THEMEN.values()}
neuer_name = layername(eintrag) if automatisch else alter_name
umstellen = getattr(layer, "setDataSource", None)
if not callable(umstellen):
raise ValueError("Die Quelle des Basemap-Layers lässt sich nicht umstellen.")
anbieter = _aufruf(layer, "providerType", "") or ""
try:
umstellen(quelle(eintrag), neuer_name, anbieter, False)
except TypeError:
umstellen(quelle(eintrag), neuer_name, anbieter)
_lade_themenstil(layer)
merke_stand(layer, eintrag.schluessel)
+373
View File
@@ -0,0 +1,373 @@
"""
Tests für modules/basemap_themen.py und den Abschnitt „Basemap-Layer“ im Basemap-Bereich (Basis #100):
Basemap-Layer hinzufügen, vorhandenen Layer an der Quell-URL erkennen, Thema wechseln und
ungespeicherte Änderungen am Thema über den Prüfwert erkennen.
Der Vektorkachel-Layer ist ein einfacher Ersatz: ``saveNamedStyle`` schreibt seinen Darstellungstext
in die Datei, ``setDataSource`` merkt sich die Quelle. Laden über ``styleUrl`` am echten Layer ist
ein QGIS-Test.
"""
import json
import os
import shutil
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from sn_basis.functions.dialog_wrapper import ask_auswahl
from sn_basis.modules import basemap_themen as bt
from sn_basis.modules.basemap_stilvorlagen import BasemapStilvorlagen
from sn_basis.ui.widgets.basemap_bereich import BasemapBereich
class _Layer:
"""Ersatz für QgsVectorTileLayer mit Quelle, Darstellung und Layer-Eigenschaften."""
def __init__(self, quelle="", name="basemap.de - Farbe", darstellung="<qgis version=\"3.44.0\"><farbe/></qgis>",
gueltig=True, laden_ok=True):
self._quelle = quelle
self._name = name
self.darstellung = darstellung
self._gueltig = gueltig
self._laden_ok = laden_ok
self.eigenschaften = {}
self.geladen = 0
self.anbieter = None
def source(self): return self._quelle
def name(self): return self._name
def isValid(self): return self._gueltig
def providerType(self): return "xyzvectortiles"
def tileMatrixSet(self): return None
def triggerRepaint(self): pass
def setCustomProperty(self, k, v): self.eigenschaften[k] = v
def customProperty(self, k, standard=None): return self.eigenschaften.get(k, standard)
def renderer(self): return type("R", (), {"styles": lambda s: []})()
def saveNamedStyle(self, pfad, kategorien):
with open(pfad, "w", encoding="utf-8") as datei:
datei.write(self.darstellung)
return "", True
def loadDefaultStyle(self):
self.geladen += 1
thema = bt.thema_des_layers(self) or "unbekannt"
self.darstellung = f'<qgis version="3.44.0"><{thema}/></qgis>'
return ("", True) if self._laden_ok else ("Style-JSON nicht erreichbar", False)
def setDataSource(self, quelle, name, anbieter, standardstil=False):
self._quelle, self._name, self.anbieter = quelle, name, anbieter
def _fabrik(**optionen):
return lambda quelle, name: _Layer(quelle, name, **optionen)
class TestQuelleUndThema(unittest.TestCase):
"""Layerquelle der Themen, Thema eines Layers und Erkennung über die Kachel-URL."""
def test_quelle_enthaelt_kachel_url_und_style_des_themas(self):
"""Prüft, dass die Layerquelle eines Themas die gemeinsame Kachel-URL und die Style-JSON des Themas enthält."""
layer = _Layer(bt.quelle(bt.THEMEN["grau"]))
self.assertEqual(bt.quell_url(layer), bt.KACHEL_URL)
self.assertEqual(bt.thema_des_layers(layer), "grau")
self.assertIn("zmax=15", layer.source())
def test_unbekannte_style_json_ergibt_kein_thema(self):
"""Prüft, dass ein Layer mit fremder oder fehlender Style-JSON keinem Thema zugeordnet wird."""
self.assertIsNone(bt.thema_des_layers(_Layer(f"type=xyz&url={bt.KACHEL_URL}&styleUrl=https://x/eigen.json")))
self.assertIsNone(bt.thema_des_layers(_Layer(f"type=xyz&url={bt.KACHEL_URL}")))
self.assertIsNone(bt.thema_des_layers(_Layer("")))
def test_unbekannter_schluessel_faellt_auf_farbe_zurueck(self):
"""Prüft, dass ein unbekannter Themenschlüssel auf das Standardthema „Farbe“ zurückfällt."""
self.assertEqual(bt.thema("relief").schluessel, "farbe")
self.assertEqual(bt.STANDARD_THEMA, "farbe")
def test_vorhandener_layer_wird_an_der_quell_url_erkannt(self):
"""Prüft, dass ein vorhandener Basemap-Layer an der Kachel-URL erkannt wird – unabhängig von Name, Thema und URL-Kodierung."""
farbe = _Layer(bt.quelle(bt.THEMEN["farbe"]), name="Hintergrund")
grau_kodiert = _Layer(bt.quelle(bt.THEMEN["grau"]).replace("{z}/{x}/{y}", "%7Bz%7D/%7Bx%7D/%7By%7D"))
fremd = _Layer("type=xyz&url=https://example.org/{z}/{x}/{y}.pbf", name="basemap.de")
ohne_quelle = MagicMock(spec=["name"])
self.assertEqual(bt.layer_mit_quelle([farbe, grau_kodiert, fremd, ohne_quelle]), [farbe, grau_kodiert])
class TestPruefwert(unittest.TestCase):
"""Prüfwert der Darstellung und Änderungsstand für ungespeicherte Änderungen."""
def test_gleiche_darstellung_gleicher_pruefwert_auch_bei_anderer_qgis_version(self):
"""Prüft, dass dieselbe Darstellung denselben Prüfwert ergibt, auch wenn die QGIS-Version in der QML abweicht."""
a = _Layer(darstellung='<qgis version="3.44.0" styleCategories="Symbology"><x/></qgis>')
b = _Layer(darstellung='<qgis version="4.0.1" styleCategories="Symbology"><x/></qgis>')
self.assertEqual(bt.pruefwert(a), bt.pruefwert(b))
b.darstellung = '<qgis version="4.0.1"><y/></qgis>'
self.assertNotEqual(bt.pruefwert(a), bt.pruefwert(b))
def test_ohne_ausgabe_der_darstellung_kein_pruefwert(self):
"""Prüft, dass kein Prüfwert entsteht, wenn der Layer seine Darstellung nicht ausgeben kann."""
self.assertIsNone(bt.pruefwert(MagicMock(spec=["name"])))
layer = _Layer()
layer.saveNamedStyle = lambda pfad, kategorien: ("kein Schreibrecht", False)
self.assertIsNone(bt.pruefwert(layer))
def test_aenderungsstand_unbekannt_unveraendert_geaendert(self):
"""Prüft, dass der Änderungsstand ohne gemerkten Prüfwert „unbekannt“ ist, nach dem Merken „unverändert“ und nach einer Änderung „geändert“."""
layer = _Layer(bt.quelle(bt.THEMEN["farbe"]))
self.assertEqual(bt.aenderungsstand(layer), bt.UNBEKANNT)
bt.merke_stand(layer)
self.assertEqual(layer.eigenschaften[bt.THEMA_EIGENSCHAFT], "farbe")
self.assertEqual(bt.aenderungsstand(layer), bt.UNVERAENDERT)
layer.darstellung += "<hausnummern_aus/>"
self.assertEqual(bt.aenderungsstand(layer), bt.GEAENDERT)
class TestLayerUndThemenwechsel(unittest.TestCase):
"""Basemap-Layer anlegen, in den Layerbaum einfügen und Thema wechseln."""
def test_neuer_layer_hat_thema_namen_und_gemerkten_stand(self):
"""Prüft, dass ein neuer Basemap-Layer im gewählten Thema entsteht, den Themenstil lädt und als unverändert gilt."""
layer = bt.erzeuge_layer("grau", _fabrik())
self.assertEqual(layer.name(), "basemap.de - Grau")
self.assertEqual(bt.thema_des_layers(layer), "grau")
self.assertEqual(layer.geladen, 1)
self.assertEqual(bt.aenderungsstand(layer), bt.UNVERAENDERT)
def test_ungueltiger_layer_oder_fehlender_themenstil_meldet_fehler(self):
"""Prüft, dass ein ungültiger Layer oder ein nicht ladbarer Themenstil als ValueError gemeldet wird."""
with self.assertRaises(ValueError):
bt.erzeuge_layer("farbe", _fabrik(gueltig=False))
with self.assertRaisesRegex(ValueError, "Style-JSON nicht erreichbar"):
bt.erzeuge_layer("farbe", _fabrik(laden_ok=False))
def test_ohne_vektorkacheln_kein_layer(self):
"""Prüft, dass ohne Vektorkachel-Unterstützung (Mock-Modus) kein Layer erzeugt, sondern ein Fehler gemeldet wird."""
with patch.object(bt, "VECTOR_TILES_AVAILABLE", False):
with self.assertRaises(ValueError):
bt.erzeuge_layer("farbe")
def test_layer_kommt_ganz_unten_in_den_layerbaum(self):
"""Prüft, dass der Basemap-Layer ohne automatische Einordnung ins Projekt kommt und unten an den Layerbaum angehängt wird."""
projekt, layer = MagicMock(), _Layer()
bt.fuege_hinzu(projekt, layer)
projekt.addMapLayer.assert_called_once_with(layer, False)
projekt.layerTreeRoot.return_value.addLayer.assert_called_once_with(layer)
def test_themenwechsel_stellt_quelle_um_und_merkt_neuen_stand(self):
"""Prüft, dass der Themenwechsel die Style-JSON der Quelle umstellt, den Themenstil lädt, den automatischen Namen anpasst und den neuen Stand merkt."""
layer = bt.erzeuge_layer("farbe", _fabrik())
bt.wechsle_thema(layer, "grau")
self.assertEqual(bt.thema_des_layers(layer), "grau")
self.assertEqual(layer.name(), "basemap.de - Grau")
self.assertEqual(layer.anbieter, "xyzvectortiles")
self.assertEqual(layer.geladen, 2)
self.assertEqual(layer.eigenschaften[bt.THEMA_EIGENSCHAFT], "grau")
self.assertEqual(bt.aenderungsstand(layer), bt.UNVERAENDERT)
def test_themenwechsel_behaelt_eigenen_layernamen(self):
"""Prüft, dass ein vom Nutzer vergebener Layername beim Themenwechsel erhalten bleibt."""
layer = _Layer(bt.quelle(bt.THEMEN["farbe"]), name="Hintergrundkarte")
bt.wechsle_thema(layer, "grau")
self.assertEqual(layer.name(), "Hintergrundkarte")
def test_themenwechsel_ohne_umstellbare_quelle_meldet_fehler(self):
"""Prüft, dass ein Layer ohne setDataSource den Themenwechsel mit einem ValueError ablehnt."""
with self.assertRaises(ValueError):
bt.wechsle_thema(MagicMock(spec=["name", "source"]), "grau")
class TestStilvorlageMitThema(unittest.TestCase):
"""Stilvorlagen vermerken das Thema des Layers."""
def setUp(self):
self.ordner = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.ordner, ignore_errors=True)
def test_stilvorlage_vermerkt_das_thema(self):
"""Prüft, dass eine gespeicherte Stilvorlage das Thema des Layers in ihrer Begleitdatei und in der Liste vermerkt."""
einstellungen = MagicMock()
einstellungen.value.return_value = ""
vorlagen = BasemapStilvorlagen(self.ordner, einstellungen)
vorlagen.speichern(_Layer(bt.quelle(bt.THEMEN["grau"])), "Grau ohne Hausnummern")
with open(os.path.join(self.ordner, "Grau_ohne_Hausnummern.json"), encoding="utf-8") as datei:
self.assertEqual(json.load(datei)["thema"], "grau")
self.assertEqual(vorlagen.liste()[0].thema, "grau")
class TestAuswahlDialog(unittest.TestCase):
"""Rückfrage mit frei benannten Knöpfen."""
def test_mock_modus_liefert_vorbelegung(self):
"""Prüft, dass die Rückfrage mit frei benannten Knöpfen im Mock-Modus den vorbelegten Schlüssel liefert."""
self.assertEqual(ask_auswahl("T", "F", [("a", "A"), ("b", "B")], default="b", abbrechen="a"), "b")
def _bereich(layer_im_projekt=(), thema="farbe", ziel=None):
bereich = BasemapBereich.__new__(BasemapBereich)
bereich._project = MagicMock()
bereich._project.mapLayers.return_value = {str(i): l for i, l in enumerate(layer_im_projekt)}
bereich._stilvorlagen = MagicMock()
bereich._layer_fabrik = _fabrik()
bereich._thema_combo = MagicMock()
bereich._thema_combo.currentData.return_value = thema
bereich._thema_combo.findData.return_value = 0
bereich._ziel_layer = MagicMock(return_value=ziel)
bereich.aktualisieren = MagicMock()
return bereich
@patch("sn_basis.ui.widgets.basemap_bereich.success")
@patch("sn_basis.ui.widgets.basemap_bereich.warning")
class TestBasemapHinzufuegen(unittest.TestCase):
"""Knopf „Basemap hinzufügen“ mit Rückfrage bei vorhandenem Layer gleicher Quelle."""
def test_ohne_vorhandenen_layer_wird_ohne_rueckfrage_hinzugefuegt(self, _warnung, erfolg):
"""Prüft, dass ohne Basemap-Layer gleicher Quelle sofort ein Layer im gewählten Thema hinzugefügt wird."""
bereich = _bereich(thema="grau")
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl") as frage:
bereich._on_basemap_hinzufuegen()
frage.assert_not_called()
layer = bereich._project.addMapLayer.call_args[0][0]
self.assertEqual(bt.thema_des_layers(layer), "grau")
erfolg.assert_called_once()
def test_vorhandener_layer_wird_in_der_rueckfrage_genannt_abbrechen(self, _warnung, _erfolg):
"""Prüft, dass ein vorhandener Layer gleicher Quelle in der Rückfrage genannt wird und „Abbrechen“ nichts verändert."""
vorhanden = _Layer(bt.quelle(bt.THEMEN["farbe"]), name="Meine Basemap")
bereich = _bereich([vorhanden])
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl", return_value="abbrechen") as frage:
bereich._on_basemap_hinzufuegen()
self.assertIn("„Meine Basemap“", frage.call_args[0][1])
self.assertEqual([o[0] for o in frage.call_args[0][2]], ["zusaetzlich", "ersetzen", "abbrechen"])
bereich._project.addMapLayer.assert_not_called()
self.assertEqual(vorhanden.geladen, 0)
def test_zusaetzlich_laden_fuegt_zweiten_layer_hinzu(self, _warnung, _erfolg):
"""Prüft, dass „Zusätzlich laden“ einen weiteren Basemap-Layer neben dem vorhandenen hinzufügt."""
vorhanden = _Layer(bt.quelle(bt.THEMEN["farbe"]))
bereich = _bereich([vorhanden], thema="grau")
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl", return_value="zusaetzlich"):
bereich._on_basemap_hinzufuegen()
neu = bereich._project.addMapLayer.call_args[0][0]
self.assertIsNot(neu, vorhanden)
self.assertEqual(bt.thema_des_layers(neu), "grau")
def test_ersetzen_laedt_vorhandenen_layer_im_gewaehlten_thema(self, _warnung, _erfolg):
"""Prüft, dass „Ersetzen“ keinen neuen Layer anlegt, sondern den vorhandenen im gewählten Thema neu lädt."""
vorhanden = bt.erzeuge_layer("farbe", _fabrik())
bereich = _bereich([vorhanden], thema="grau")
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl", return_value="ersetzen"):
bereich._on_basemap_hinzufuegen()
bereich._project.addMapLayer.assert_not_called()
self.assertEqual(bt.thema_des_layers(vorhanden), "grau")
def test_fehler_beim_anlegen_wird_gemeldet(self, warnung, erfolg):
"""Prüft, dass ein nicht erreichbarer Dienst als Warnung gemeldet und kein Layer hinzugefügt wird."""
bereich = _bereich()
bereich._layer_fabrik = _fabrik(gueltig=False)
bereich._on_basemap_hinzufuegen()
warnung.assert_called_once()
erfolg.assert_not_called()
bereich._project.addMapLayer.assert_not_called()
@patch("sn_basis.ui.widgets.basemap_bereich.success")
@patch("sn_basis.ui.widgets.basemap_bereich.warning")
class TestThemaWechseln(unittest.TestCase):
"""Themenauswahl mit Rückfrage bei geänderter oder unbekannter Darstellung."""
def test_unveraenderter_layer_wechselt_ohne_rueckfrage(self, _warnung, erfolg):
"""Prüft, dass ein unveränderter Basemap-Layer ohne Rückfrage auf das gewählte Thema wechselt."""
layer = bt.erzeuge_layer("farbe", _fabrik())
bereich = _bereich(thema="grau", ziel=layer)
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl") as frage:
bereich._on_thema_gewaehlt(1)
frage.assert_not_called()
self.assertEqual(bt.thema_des_layers(layer), "grau")
erfolg.assert_called_once()
def test_gleiches_thema_aendert_nichts(self, _warnung, _erfolg):
"""Prüft, dass die Wahl des Themas, das der Layer schon hat, nichts neu lädt."""
layer = bt.erzeuge_layer("farbe", _fabrik())
_bereich(thema="farbe", ziel=layer)._on_thema_gewaehlt(0)
self.assertEqual(layer.geladen, 1)
def test_geaenderte_darstellung_abbrechen_behaelt_thema(self, _warnung, _erfolg):
"""Prüft, dass bei geänderter Darstellung nachgefragt wird und „Abbrechen“ Thema und Darstellung unverändert lässt."""
layer = bt.erzeuge_layer("farbe", _fabrik())
layer.darstellung += "<eigene_aenderung/>"
bereich = _bereich(thema="grau", ziel=layer)
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl", return_value="abbrechen") as frage:
bereich._on_thema_gewaehlt(1)
self.assertIn("verändert", frage.call_args[0][1])
self.assertEqual(bt.thema_des_layers(layer), "farbe")
self.assertIn("<eigene_aenderung/>", layer.darstellung)
bereich._thema_combo.setCurrentIndex.assert_called() # Dropdown zurück auf „Farbe“
def test_geaenderte_darstellung_verwerfen_wechselt(self, _warnung, _erfolg):
"""Prüft, dass „Verwerfen“ die geänderte Darstellung aufgibt und das Thema wechselt."""
layer = bt.erzeuge_layer("farbe", _fabrik())
layer.darstellung += "<eigene_aenderung/>"
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl", return_value="verwerfen"):
_bereich(thema="grau", ziel=layer)._on_thema_gewaehlt(1)
self.assertEqual(bt.thema_des_layers(layer), "grau")
def test_als_stil_speichern_sichert_vor_dem_wechsel(self, _warnung, _erfolg):
"""Prüft, dass „Als Stil speichern“ die Darstellung vor dem Themenwechsel als Stilvorlage sichert."""
layer = bt.erzeuge_layer("farbe", _fabrik())
layer.darstellung += "<eigene_aenderung/>"
bereich = _bereich(thema="grau", ziel=layer)
bereich._stil_combo = MagicMock()
bereich._stil_combo.currentText.return_value = ""
bereich._stilvorlagen.vorhanden.return_value = False
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl", return_value="speichern"), \
patch("sn_basis.ui.widgets.basemap_bereich.ask_text", return_value=("Farbe angepasst", True)):
bereich._on_thema_gewaehlt(1)
self.assertEqual(bereich._stilvorlagen.speichern.call_args[0][:2], (layer, "Farbe angepasst"))
self.assertEqual(bt.thema_des_layers(layer), "grau")
def test_abgebrochenes_speichern_verhindert_den_wechsel(self, _warnung, _erfolg):
"""Prüft, dass das Thema nicht wechselt, wenn „Als Stil speichern“ gewählt, die Namensabfrage aber abgebrochen wird."""
layer = bt.erzeuge_layer("farbe", _fabrik())
layer.darstellung += "<eigene_aenderung/>"
bereich = _bereich(thema="grau", ziel=layer)
bereich._stil_combo = MagicMock()
bereich._stil_combo.currentText.return_value = ""
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl", return_value="speichern"), \
patch("sn_basis.ui.widgets.basemap_bereich.ask_text", return_value=("", False)):
bereich._on_thema_gewaehlt(1)
bereich._stilvorlagen.speichern.assert_not_called()
self.assertEqual(bt.thema_des_layers(layer), "farbe")
def test_unbekannter_stand_fragt_nach(self, _warnung, _erfolg):
"""Prüft, dass bei einem Layer ohne gemerkten Prüfwert (vor dieser Funktion geladen) vorsichtshalber nachgefragt wird."""
layer = _Layer(bt.quelle(bt.THEMEN["farbe"]))
with patch("sn_basis.ui.widgets.basemap_bereich.ask_auswahl", return_value="abbrechen") as frage:
_bereich(thema="grau", ziel=layer)._on_thema_gewaehlt(1)
self.assertIn("nicht bekannt", frage.call_args[0][1])
self.assertEqual(bt.thema_des_layers(layer), "farbe")
def test_ohne_basemap_layer_nur_vorauswahl(self, warnung, _erfolg):
"""Prüft, dass die Themenwahl ohne Basemap-Layer nur als Vorauswahl für „Basemap hinzufügen“ dient und nichts meldet."""
_bereich(thema="grau", ziel=None)._on_thema_gewaehlt(1)
warnung.assert_not_called()
class TestZielLayer(unittest.TestCase):
"""Auswahl des Basemap-Layers, auf den Thema und Stil wirken."""
def test_ausgewaehlter_basemap_layer_hat_vorrang(self):
"""Prüft, dass der im Layerbaum ausgewählte Basemap-Layer Ziel des Themenwechsels ist, sonst der erste Basemap-Layer."""
bereich = BasemapBereich.__new__(BasemapBereich)
erster, ausgewaehlt = _Layer(bt.quelle(bt.THEMEN["farbe"])), _Layer(bt.quelle(bt.THEMEN["grau"]))
bereich.basemap_layer = MagicMock(return_value=erster)
with patch("sn_basis.ui.widgets.basemap_bereich.get_active_layer", return_value=ausgewaehlt):
self.assertIs(bereich._ziel_layer(), ausgewaehlt)
with patch("sn_basis.ui.widgets.basemap_bereich.get_active_layer", return_value=MagicMock(spec=["name"])):
self.assertIs(bereich._ziel_layer(), erster)
if __name__ == "__main__":
unittest.main()
+151 -13
View File
@@ -1,21 +1,23 @@
"""
sn_basis/ui/widgets/basemap_bereich.py – einklappbarer Bereich „Basemap“ im Druck-Tab
Einstellungen der Basemap-Legende (Auswertebereich, Erzeugen beim Anlegen der Vorlage),
Basemap-Layer hinzufügen und sein Thema wechseln (Basis #100), Einstellungen der
Basemap-Legende (Auswertebereich, Erzeugen beim Anlegen der Vorlage),
„Basemap-Legende aktualisieren“ für bestehende Vorlagen, lokale Stilvorlagen des
Basemap-Layers (laden, speichern, als Standard markieren, löschen) und „Punktlinien
korrigieren“ (Basis #90). Basis #88.
Die Handler enthalten nur Ablauf und Rückfragen; die Logik liegt in
``modules/basemap_legende`` und ``modules/basemap_stilvorlagen``.
``modules/basemap_themen``, ``modules/basemap_legende`` und ``modules/basemap_stilvorlagen``.
"""
from __future__ import annotations
from typing import Any, Optional
from sn_basis.functions.dialog_wrapper import ask_text, ask_yes_no
from sn_basis.functions.dialog_wrapper import ask_auswahl, ask_text, ask_yes_no
from sn_basis.functions.message_wrapper import info, success, warning
from sn_basis.functions.qgiscore_wrapper import QgsProject
from sn_basis.functions.qgisui_wrapper import get_active_layer
from sn_basis.functions.qt_wrapper import (
ArrowDown,
ArrowRight,
@@ -30,6 +32,7 @@ from sn_basis.functions.qt_wrapper import (
ToolButtonTextBesideIcon,
)
from sn_basis.modules import basemap_legende as legende
from sn_basis.modules import basemap_themen as themen
from sn_basis.modules.basemap_legende_layout import layouts_mit_basemap_legende
from sn_basis.modules.basemap_stilkorrektur import korrigiere_punktlinien
from sn_basis.modules.basemap_stilvorlagen import BasemapStilvorlagen
@@ -42,10 +45,12 @@ class BasemapBereich(QWidget):
"""Einklappbarer Basemap-Bereich; standardmäßig eingeklappt."""
def __init__(self, parent: Optional[Any] = None, project: Any | None = None,
stilvorlagen: BasemapStilvorlagen | None = None) -> None:
stilvorlagen: BasemapStilvorlagen | None = None,
layer_fabrik: Any | None = None) -> None:
super().__init__(parent)
self._project = project
self._stilvorlagen = stilvorlagen
self._layer_fabrik = layer_fabrik
self._build_ui()
self.aktualisieren()
@@ -77,6 +82,25 @@ class BasemapBereich(QWidget):
self._status.setWordWrap(True)
spalte.addWidget(self._status)
spalte.addWidget(self._ueberschrift("Basemap-Layer"))
self._hinzufuegen_btn = QPushButton("Basemap hinzufügen", self)
self._hinzufuegen_btn.setToolTip(
"Lädt basemap.de (Vektorkacheln) im gewählten Thema ganz unten in den Layerbaum."
)
self._hinzufuegen_btn.clicked.connect(lambda *_: self._on_basemap_hinzufuegen())
spalte.addWidget(self._hinzufuegen_btn)
spalte.addWidget(QLabel("Thema"))
self._thema_combo = QComboBox(self)
self._thema_combo.setToolTip(
"Darstellung des Dienstes für den ausgewählten Basemap-Layer. "
"Ohne Basemap-Layer gilt die Wahl für „Basemap hinzufügen“."
)
for schluessel, eintrag in themen.THEMEN.items():
self._thema_combo.addItem(eintrag.bezeichnung, schluessel)
self._thema_combo.setCurrentIndex(max(0, self._thema_combo.findData(themen.STANDARD_THEMA)))
self._thema_combo.currentIndexChanged.connect(self._on_thema_gewaehlt)
spalte.addWidget(self._thema_combo)
spalte.addWidget(self._ueberschrift("Legende"))
spalte.addWidget(QLabel("Auswertebereich"))
self._bereich_combo = QComboBox(self)
@@ -150,6 +174,25 @@ class BasemapBereich(QWidget):
gefunden = legende.finde_basemap_layer(layer)
return gefunden[0] if gefunden else None
def _ziel_layer(self) -> Any | None:
"""Der im Layerbaum ausgewählte Basemap-Layer, sonst der erste des Projekts."""
aktiv = get_active_layer()
if aktiv is not None and legende.ist_basemap_layer(aktiv):
return aktiv
return self.basemap_layer()
def _gewaehltes_thema(self) -> str:
return str(self._thema_combo.currentData() or themen.STANDARD_THEMA)
def _zeige_thema(self, layer: Any | None) -> None:
"""Stellt das Thema-Dropdown ohne Themenwechsel auf das Thema des Layers."""
schluessel = themen.thema_des_layers(layer) if layer is not None else None
if schluessel is None:
return
self._thema_combo.blockSignals(True)
self._thema_combo.setCurrentIndex(max(0, self._thema_combo.findData(schluessel)))
self._thema_combo.blockSignals(False)
def _umschalten(self, offen: bool) -> None:
self._knopf.setArrowType(ArrowDown if offen else ArrowRight)
self._inhalt.setVisible(offen)
@@ -158,8 +201,9 @@ class BasemapBereich(QWidget):
def aktualisieren(self) -> None:
"""Liest Projektvariablen, Layouts und Stilvorlagen neu ein (z. B. nach Projektwechsel)."""
layer = self.basemap_layer()
layer = self._ziel_layer()
self._status.setText(f"Basemap-Layer: {layer.name()}" if layer is not None else KEIN_LAYER_TEXT)
self._zeige_thema(layer)
bereich = legende.lies_auswertebereich()
self._bereich_combo.blockSignals(True)
@@ -181,6 +225,96 @@ class BasemapBereich(QWidget):
if standard:
self._stil_combo.setCurrentIndex(max(0, self._stil_combo.findData(standard)))
# --- Handler: Basemap-Layer und Thema ------------------------------
def _aenderungen_klaeren(self, layer: Any) -> bool:
"""Fragt vor dem Überschreiben der Darstellung nach, ob Änderungen als Stil gesichert werden sollen.
``True``: weitermachen (gespeichert, verworfen oder nichts geändert); ``False``: abbrechen.
"""
stand = themen.aenderungsstand(layer)
if stand == themen.UNVERAENDERT:
return True
if stand == themen.GEAENDERT:
grund = f"Die Darstellung von „{layer.name()}“ wurde seit dem Laden des Themas oder Stils verändert."
else:
grund = (f"Für „{layer.name()}“ ist nicht bekannt, ob die Darstellung verändert wurde "
"(Layer wurde ohne diese Funktion geladen).")
wahl = ask_auswahl(
TITEL,
f"{grund} Beim Themenwechsel gehen Änderungen verloren.\n\nVorher als Stil speichern?",
[("speichern", "Als Stil speichern …"), ("verwerfen", "Verwerfen"), ("abbrechen", "Abbrechen")],
default="speichern",
abbrechen="abbrechen",
)
if wahl == "abbrechen":
return False
if wahl == "speichern":
return self._on_stil_speichern(layer)
return True
def _on_basemap_hinzufuegen(self) -> None:
schluessel = self._gewaehltes_thema()
try:
vorhanden = themen.layer_mit_quelle(list(self._projekt().mapLayers().values()))
except Exception:
vorhanden = []
if vorhanden:
namen = ", ".join(f"„{l.name()}“" for l in vorhanden)
wahl = ask_auswahl(
TITEL,
f"Im Projekt gibt es schon einen Basemap-Layer mit derselben Quelle: {namen}.\n\n"
"Einen weiteren Basemap-Layer zusätzlich laden, den vorhandenen ersetzen oder abbrechen?",
[("zusaetzlich", "Zusätzlich laden"), ("ersetzen", "Ersetzen"), ("abbrechen", "Abbrechen")],
default="abbrechen",
abbrechen="abbrechen",
)
if wahl == "abbrechen":
return
if wahl == "ersetzen":
ziel = self._ziel_layer()
self._ersetzen(ziel if ziel in vorhanden else vorhanden[0], schluessel)
return
try:
layer = themen.erzeuge_layer(schluessel, getattr(self, "_layer_fabrik", None))
except ValueError as fehler:
warning(TITEL, str(fehler))
return
themen.fuege_hinzu(self._projekt(), layer)
self.aktualisieren()
success(TITEL, f"„{layer.name()}“ hinzugefügt.")
def _ersetzen(self, layer: Any, schluessel: str) -> None:
"""Lädt den vorhandenen Layer im gewählten Thema neu; Position und Bezüge in Layouts bleiben."""
if not self._aenderungen_klaeren(layer):
return
try:
themen.wechsle_thema(layer, schluessel)
except ValueError as fehler:
warning(TITEL, str(fehler))
return
self.aktualisieren()
success(TITEL, f"„{layer.name()}“ im Thema {themen.thema(schluessel).bezeichnung} neu geladen.")
def _on_thema_gewaehlt(self, *_: Any) -> None:
layer = self._ziel_layer()
if layer is None:
return # Wahl gilt für „Basemap hinzufügen“
schluessel = self._gewaehltes_thema()
if themen.thema_des_layers(layer) == schluessel:
return
if not self._aenderungen_klaeren(layer):
self._zeige_thema(layer)
return
try:
themen.wechsle_thema(layer, schluessel)
except ValueError as fehler:
warning(TITEL, str(fehler))
self._zeige_thema(layer)
return
self.aktualisieren()
success(TITEL, f"Thema von „{layer.name()}“: {themen.thema(schluessel).bezeichnung}.")
# --- Handler: Legende -----------------------------------------------
def _on_bereich_geaendert(self, *_: Any) -> None:
@@ -210,7 +344,7 @@ class BasemapBereich(QWidget):
# --- Handler: Darstellung korrigieren ------------------------------
def _on_punktlinien_korrigieren(self) -> None:
layer = self.basemap_layer()
layer = self._ziel_layer()
if layer is None:
warning(TITEL, KEIN_LAYER_TEXT)
return
@@ -226,7 +360,7 @@ class BasemapBereich(QWidget):
return str(self._stil_combo.currentData() or "")
def _on_stil_laden(self) -> None:
layer = self.basemap_layer()
layer = self._ziel_layer()
name = self._gewaehlte_vorlage()
if layer is None or not name:
warning(TITEL, "Kein Basemap-Layer oder keine Stilvorlage gewählt.")
@@ -252,28 +386,32 @@ class BasemapBereich(QWidget):
import json
from sn_basis.functions.variable_wrapper import set_variable
set_variable(legende.AUSWAHL_VAR, json.dumps(auswahl), scope="project")
themen.merke_stand(layer)
success(TITEL, "Stilvorlage geladen.")
def _on_stil_speichern(self) -> None:
layer = self.basemap_layer()
def _on_stil_speichern(self, layer: Any | None = None) -> bool:
"""Speichert die Darstellung als Stilvorlage; ``True``, wenn gespeichert wurde."""
layer = layer if layer is not None else self._ziel_layer()
if layer is None:
warning(TITEL, KEIN_LAYER_TEXT)
return
return False
anzeigename, ok = ask_text("Stilvorlage speichern", "Name der Stilvorlage:",
default_text=self._stil_combo.currentText().replace(" ★", ""))
if not ok or not anzeigename.strip():
return
return False
if self._vorlagen().vorhanden(anzeigename) and not ask_yes_no(
"Stilvorlage speichern", f"Die Stilvorlage „{anzeigename}“ gibt es schon. Ersetzen?", default=False
):
return
return False
try:
self._vorlagen().speichern(layer, anzeigename, legende.lies_auswahl())
except ValueError as fehler:
warning(TITEL, str(fehler))
return
return False
themen.merke_stand(layer)
self.aktualisieren()
success(TITEL, f"Stilvorlage „{anzeigename.strip()}“ gespeichert.")
return True
def _on_stil_standard(self) -> None:
name = self._gewaehlte_vorlage()