From 24ad564a0873c56956430824692d4f62ad692aec Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 5 Oct 2026 21:27:35 +0200 Subject: [PATCH] =?UTF-8?q?Basemap=20hinzuf=C3=BCgen=20und=20Thema=20wechs?= =?UTF-8?q?eln=20im=20Basemap-Bereich=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Abschnitt „Basemap-Layer“ im Basemap-Bereich des Druck-Tabs: Knopf „Basemap hinzufügen“ und Auswahl „Thema“ (Farbe, Grau). Vorhandene Layer werden an der Kachel-URL erkannt; Rückfrage Zusätzlich laden / Ersetzen / Abbrechen. Vor dem Themenwechsel wird über einen Prüfwert der Darstellung erkannt, ob sie ungespeichert verändert wurde. Neues Modul modules/basemap_themen.py, neue Wrapper-Funktionen ask_auswahl und get_active_layer, Stilvorlagen vermerken ihr Thema. Co-Authored-By: Claude Opus 5.5 --- changelog.txt | 3 + functions/dialog_wrapper.py | 49 +++++ functions/qgisui_wrapper.py | 9 + modules/basemap_stilvorlagen.py | 11 +- modules/basemap_themen.py | 245 +++++++++++++++++++++ tests/test_basemap_themen.py | 373 ++++++++++++++++++++++++++++++++ ui/widgets/basemap_bereich.py | 164 ++++++++++++-- 7 files changed, 839 insertions(+), 15 deletions(-) create mode 100644 modules/basemap_themen.py create mode 100644 tests/test_basemap_themen.py diff --git a/changelog.txt b/changelog.txt index d795f23..461bc8c 100644 --- a/changelog.txt +++ b/changelog.txt @@ -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) diff --git a/functions/dialog_wrapper.py b/functions/dialog_wrapper.py index 4a0700f..98de018 100644 --- a/functions/dialog_wrapper.py +++ b/functions/dialog_wrapper.py @@ -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, diff --git a/functions/qgisui_wrapper.py b/functions/qgisui_wrapper.py index 9bcc85f..8dff93c 100644 --- a/functions/qgisui_wrapper.py +++ b/functions/qgisui_wrapper.py @@ -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. diff --git a/modules/basemap_stilvorlagen.py b/modules/basemap_stilvorlagen.py index a24543e..63db5f9 100644 --- a/modules/basemap_stilvorlagen.py +++ b/modules/basemap_stilvorlagen.py @@ -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: ``/sn_basis/basemap_stile/.qml`` plus ``.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. diff --git a/modules/basemap_themen.py b/modules/basemap_themen.py new file mode 100644 index 0000000..f5c3cad --- /dev/null +++ b/modules/basemap_themen.py @@ -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"]*>", "", 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) diff --git a/tests/test_basemap_themen.py b/tests/test_basemap_themen.py new file mode 100644 index 0000000..fb7e983 --- /dev/null +++ b/tests/test_basemap_themen.py @@ -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="", + 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'<{thema}/>' + 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='') + b = _Layer(darstellung='') + self.assertEqual(bt.pruefwert(a), bt.pruefwert(b)) + b.darstellung = '' + 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 += "" + 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 += "" + 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("", 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 += "" + 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 += "" + 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 += "" + 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() diff --git a/ui/widgets/basemap_bereich.py b/ui/widgets/basemap_bereich.py index a33b22f..94e7d73 100644 --- a/ui/widgets/basemap_bereich.py +++ b/ui/widgets/basemap_bereich.py @@ -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()