From a97cdfca54ca6c955365279cd08e4e9ae8faa0af Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 27 Jan 2025 17:14:22 -0800 Subject: [PATCH 01/43] Fix icons, refactor, add toolbar --- ee_plugin/ee_plugin.py | 121 ++++++++++++++++++++++++++++++----------- 1 file changed, 89 insertions(+), 32 deletions(-) diff --git a/ee_plugin/ee_plugin.py b/ee_plugin/ee_plugin.py index 0248364..becdc34 100644 --- a/ee_plugin/ee_plugin.py +++ b/ee_plugin/ee_plugin.py @@ -10,24 +10,42 @@ import os.path import webbrowser from builtins import object +from typing import Callable, cast, Optional import requests # type: ignore from qgis.core import QgsProject -from qgis.PyQt.QtCore import QCoreApplication, QSettings, QTranslator, qVersion +from qgis.gui import QgisInterface +from qgis.PyQt.QtCore import ( + QCoreApplication, + QSettings, + QTranslator, + qVersion, + QObject, + Qt, +) from qgis.PyQt.QtGui import QIcon -from qgis.PyQt.QtWidgets import QAction +from qgis.PyQt.QtWidgets import QAction, QMenu, QToolButton + +PLUGIN_DIR = os.path.dirname(__file__) # read the plugin version from metadata cfg = configparser.ConfigParser() -cfg.read(os.path.join(os.path.dirname(__file__), "metadata.txt")) +cfg.read(os.path.join(PLUGIN_DIR, "metadata.txt")) VERSION = cfg.get("general", "version") version_checked = False +def icon(icon_name: str) -> QIcon: + """Helper function to return an icon from the plugin directory.""" + return QIcon(os.path.join(PLUGIN_DIR, "icons", icon_name)) + + class GoogleEarthEnginePlugin(object): """QGIS Plugin Implementation.""" - def __init__(self, iface): + # iface: QgisInterface + + def __init__(self, iface: QgisInterface): """Constructor. :param iface: An interface instance that will be passed to this class @@ -40,15 +58,13 @@ def __init__(self, iface): # Save reference to the QGIS interface self.iface = iface - # initialize plugin directory - self.plugin_dir = os.path.dirname(__file__) + self.menu = None # initialize locale locale = QSettings().value("locale/userLocale")[0:2] locale_path = os.path.join( - self.plugin_dir, "i18n", "GoogleEarthEnginePlugin_{}.qm".format(locale) + PLUGIN_DIR, "i18n", "GoogleEarthEnginePlugin_{}.qm".format(locale) ) - if os.path.exists(locale_path): self.translator = QTranslator() self.translator.load(locale_path) @@ -56,8 +72,6 @@ def __init__(self, iface): if qVersion() > "4.3.3": QCoreApplication.installTranslator(self.translator) - self.menu_name_plugin = self.tr("Google Earth Engine") - # Create and register the EE data providers provider.register_data_provider() @@ -77,34 +91,74 @@ def tr(self, message): return QCoreApplication.translate("GoogleEarthEngine", message) def initGui(self): - ### Main dockwidget menu - # Create action that will start plugin configuration - ee_icon_path = ":/plugins/ee_plugin/icons/earth-engine.svg" - self.cmd_ee_user_guide = QAction( - QIcon(ee_icon_path), "User Guide", self.iface.mainWindow() + """Initialize the plugin GUI.""" + # Build actions + self.ee_user_guide_action = self._build_action( + text="User Guide", + icon_name="earth-engine.svg", + callback=self.run_cmd_ee_user_guide, ) - self.cmd_ee_user_guide.triggered.connect(self.run_cmd_ee_user_guide) - - gcp_icon_path = ":/plugins/ee_plugin/icons/google-cloud.svg" - self.cmd_sign_in = QAction( - QIcon(gcp_icon_path), "Sign-in", self.iface.mainWindow() + self.sign_in_action = self._build_action( + text="Sign-in", + icon_name="google-cloud.svg", + callback=self.run_cmd_sign_in, + ) + self.set_cloud_project_action = self._build_action( + text="Set Project", + icon_name="google-cloud-project.svg", + callback=self.run_cmd_set_cloud_project, ) - self.cmd_sign_in.triggered.connect(self.run_cmd_sign_in) - gcp_project_icon_path = ":/plugins/ee_plugin/icons/google-cloud-project.svg" - self.cmd_set_cloud_project = QAction( - QIcon(gcp_project_icon_path), "Set Project", self.iface.mainWindow() + # Build plugin menu + self.menu = cast( + QMenu, + self.iface.pluginMenu().addMenu( + icon("earth-engine.svg"), + self.tr("&Google Earth Engine"), + ), + ) + self.menu.setDefaultAction(self.ee_user_guide_action) + self.menu.addAction(self.ee_user_guide_action) + self.menu.addSeparator() + self.menu.addAction(self.sign_in_action) + self.menu.addAction(self.set_cloud_project_action) + + # Build toolbar + self.toolButton = QToolButton() + self.toolButton.setMenu(QMenu()) + self.toolButton.setToolButtonStyle( + Qt.ToolButtonStyle.ToolButtonTextBesideIcon + if False + else Qt.ToolButtonStyle.ToolButtonIconOnly ) - self.cmd_set_cloud_project.triggered.connect(self.run_cmd_set_cloud_project) + self.toolButton.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) + toolButtonMenu = self.toolButton.menu() - # Add menu item - self.iface.addPluginToMenu(self.menu_name_plugin, self.cmd_ee_user_guide) - self.iface.addPluginToMenu(self.menu_name_plugin, self.cmd_sign_in) - self.iface.addPluginToMenu(self.menu_name_plugin, self.cmd_set_cloud_project) + self.toolButton.setDefaultAction(self.ee_user_guide_action) + toolButtonMenu.addAction(self.ee_user_guide_action) + toolButtonMenu.addSeparator() + toolButtonMenu.addAction(self.sign_in_action) + toolButtonMenu.addAction(self.set_cloud_project_action) + self.iface.addToolBarWidget(self.toolButton) # Register signal to initialize EE layers on project load self.iface.projectRead.connect(self.updateLayers) + def _build_action( + self, + *, + text: str, + icon_name: str, + parent: Optional[QObject] = None, + callback: Callable, + ) -> QAction: + """Helper to add a menu item and connect it to a handler.""" + action = QAction( + icon(icon_name), self.tr(text), parent or self.iface.mainWindow() + ) + action.triggered.connect(callback) + return action + def run_cmd_ee_user_guide(self): # open user guide in external web browser webbrowser.open_new("http://qgis-ee-plugin.appspot.com/user-guide") @@ -158,9 +212,12 @@ def check_version(self): def unload(self): # Remove the plugin menu item and icon - self.iface.removePluginMenu(self.menu_name_plugin, self.cmd_ee_user_guide) - self.iface.removePluginMenu(self.menu_name_plugin, self.cmd_sign_in) - self.iface.removePluginMenu(self.menu_name_plugin, self.cmd_set_cloud_project) + if not self.menu: + # The initGui() method was never called + return + + self.iface.pluginMenu().removeAction(self.menu.menuAction()) + self.toolButton.deleteLater() def updateLayers(self): import ee From de2392978e38e95f5236ff90953002b5d5c61efb Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 6 Feb 2025 19:37:17 -0800 Subject: [PATCH 02/43] In progress --- ee_plugin/dialog.py | 205 +++++++++++++++++++++++ ee_plugin/ee_plugin.py | 159 ++++++++++++++---- ee_plugin/ui/add_gee_image_collection.ui | 104 ++++++++++++ ee_plugin/ui/utils.py | 154 +++++++++++++++++ 4 files changed, 593 insertions(+), 29 deletions(-) create mode 100644 ee_plugin/dialog.py create mode 100644 ee_plugin/ui/add_gee_image_collection.ui create mode 100644 ee_plugin/ui/utils.py diff --git a/ee_plugin/dialog.py b/ee_plugin/dialog.py new file mode 100644 index 0000000..747b2f7 --- /dev/null +++ b/ee_plugin/dialog.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'ee_plugin/ui/dailog2.ui' +# +# Created by: PyQt5 UI code generator 5.15.4 +# +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt5 import QtCore, QtGui, QtWidgets + +margins = [10] * 4 + + +class Ui_Dialog(object): + def setupUi(self, Dialog): + Dialog.setObjectName("Dialog") + Dialog.resize(554, 653) + self.verticalLayoutWidget = QtWidgets.QWidget(Dialog) + self.verticalLayoutWidget.setGeometry(QtCore.QRect(0, 0, 551, 651)) + self.verticalLayoutWidget.setObjectName("verticalLayoutWidget") + self.verticalLayout = QtWidgets.QVBoxLayout(self.verticalLayoutWidget) + self.verticalLayout.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) + self.verticalLayout.setContentsMargins(*margins) + self.verticalLayout.setObjectName("verticalLayout") + self.groupBox = QtWidgets.QGroupBox(self.verticalLayoutWidget) + self.groupBox.setObjectName("groupBox") + self.formLayoutWidget = QtWidgets.QWidget(self.groupBox) + self.formLayoutWidget.setGeometry(QtCore.QRect(0, 19, 541, 41)) + self.formLayoutWidget.setObjectName("formLayoutWidget") + self.formLayout = QtWidgets.QFormLayout(self.formLayoutWidget) + self.formLayout.setFieldGrowthPolicy(QtWidgets.QFormLayout.ExpandingFieldsGrow) + self.formLayout.setContentsMargins(*margins) + self.formLayout.setObjectName("formLayout") + self.addGEEFeatureCollectionToMapLabel = QtWidgets.QLabel(self.formLayoutWidget) + self.addGEEFeatureCollectionToMapLabel.setObjectName( + "addGEEFeatureCollectionToMapLabel" + ) + self.formLayout.setWidget( + 0, QtWidgets.QFormLayout.LabelRole, self.addGEEFeatureCollectionToMapLabel + ) + self.addGEEFeatureCollectionToMapLineEdit = QtWidgets.QLineEdit( + self.formLayoutWidget + ) + self.addGEEFeatureCollectionToMapLineEdit.setObjectName( + "addGEEFeatureCollectionToMapLineEdit" + ) + self.formLayout.setWidget( + 0, + QtWidgets.QFormLayout.FieldRole, + self.addGEEFeatureCollectionToMapLineEdit, + ) + self.verticalLayout.addWidget(self.groupBox) + + # + self.groupBox_2 = QtWidgets.QGroupBox(self.verticalLayoutWidget) + self.groupBox_2.setLayoutDirection(QtCore.Qt.LeftToRight) + self.groupBox_2.setObjectName("groupBox_2") + self.formLayoutWidget_2 = QtWidgets.QWidget(self.groupBox_2) + self.formLayoutWidget_2.setGeometry(QtCore.QRect(0, 20, 541, 101)) + self.formLayoutWidget_2.setObjectName("formLayoutWidget_2") + self.formLayout_2 = QtWidgets.QFormLayout(self.formLayoutWidget_2) + self.formLayout_2.setFieldGrowthPolicy( + QtWidgets.QFormLayout.ExpandingFieldsGrow + ) + self.formLayout_2.setContentsMargins(*margins) + self.formLayout_2.setObjectName("formLayout_2") + self.nameLabel = QtWidgets.QLabel(self.formLayoutWidget_2) + self.nameLabel.setObjectName("nameLabel") + self.formLayout_2.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.nameLabel) + self.nameLineEdit = QtWidgets.QLineEdit(self.formLayoutWidget_2) + self.nameLineEdit.setObjectName("nameLineEdit") + self.formLayout_2.setWidget( + 0, QtWidgets.QFormLayout.FieldRole, self.nameLineEdit + ) + self.valueLabel = QtWidgets.QLabel(self.formLayoutWidget_2) + self.valueLabel.setObjectName("valueLabel") + self.formLayout_2.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.valueLabel) + self.valueLineEdit = QtWidgets.QLineEdit(self.formLayoutWidget_2) + self.valueLineEdit.setObjectName("valueLineEdit") + self.formLayout_2.setWidget( + 1, QtWidgets.QFormLayout.FieldRole, self.valueLineEdit + ) + self.verticalLayout.addWidget(self.groupBox_2) + + # + self.groupBox_3 = QtWidgets.QGroupBox(self.verticalLayoutWidget) + self.groupBox_3.setLayoutDirection(QtCore.Qt.LeftToRight) + self.groupBox_3.setObjectName("groupBox_3") + self.formLayoutWidget_3 = QtWidgets.QWidget(self.groupBox_3) + self.formLayoutWidget_3.setGeometry(QtCore.QRect(0, 20, 541, 101)) + self.formLayoutWidget_3.setObjectName("formLayoutWidget_3") + self.formLayout_3 = QtWidgets.QFormLayout(self.formLayoutWidget_3) + self.formLayout_3.setFieldGrowthPolicy( + QtWidgets.QFormLayout.AllNonFixedFieldsGrow + ) + self.formLayout_3.setContentsMargins(*margins) + self.formLayout_3.setObjectName("formLayout_3") + self.nameLabel_2 = QtWidgets.QLabel(self.formLayoutWidget_3) + self.nameLabel_2.setObjectName("nameLabel_2") + self.formLayout_3.setWidget( + 0, QtWidgets.QFormLayout.LabelRole, self.nameLabel_2 + ) + self.dateEdit = QtWidgets.QDateEdit(self.formLayoutWidget_3) + self.dateEdit.setObjectName("dateEdit") + self.formLayout_3.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.dateEdit) + self.valueLabel_2 = QtWidgets.QLabel(self.formLayoutWidget_3) + self.valueLabel_2.setObjectName("valueLabel_2") + self.formLayout_3.setWidget( + 1, QtWidgets.QFormLayout.LabelRole, self.valueLabel_2 + ) + self.dateEdit_2 = QtWidgets.QDateEdit(self.formLayoutWidget_3) + self.dateEdit_2.setObjectName("dateEdit_2") + self.formLayout_3.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.dateEdit_2) + self.verticalLayout.addWidget(self.groupBox_3) + + # + self.groupBox_4 = QtWidgets.QGroupBox(self.verticalLayoutWidget) + self.groupBox_4.setLayoutDirection(QtCore.Qt.LeftToRight) + self.groupBox_4.setObjectName("groupBox_4") + self.mExtentGroupBox = gui.QgsExtentGroupBox(self.groupBox_4) + self.mExtentGroupBox.setGeometry(QtCore.QRect(0, 10, 541, 171)) + self.mExtentGroupBox.setObjectName("mExtentGroupBox") + self.verticalLayout.addWidget(self.groupBox_4) + + # + self.groupBox_5 = QtWidgets.QGroupBox(self.verticalLayoutWidget) + self.groupBox_5.setObjectName("groupBox_5") + self.formLayoutWidget_4 = QtWidgets.QWidget(self.groupBox_5) + self.formLayoutWidget_4.setGeometry(QtCore.QRect(0, 20, 541, 54)) + self.formLayoutWidget_4.setObjectName("formLayoutWidget_4") + self.formLayout_4 = QtWidgets.QFormLayout(self.formLayoutWidget_4) + self.formLayout_4.setFormAlignment( + QtCore.Qt.AlignLeading | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop + ) + self.formLayout_4.setContentsMargins(*margins) + self.formLayout_4.setObjectName("formLayout_4") + self.label = QtWidgets.QLabel(self.formLayoutWidget_4) + self.label.setObjectName("label") + self.formLayout_4.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label) + self.mColorButton = gui.QgsColorButton(self.formLayoutWidget_4) + self.mColorButton.setObjectName("mColorButton") + self.formLayout_4.setWidget( + 0, QtWidgets.QFormLayout.FieldRole, self.mColorButton + ) + self.verticalLayout.addWidget(self.groupBox_5) + + # + self.buttonBox = QtWidgets.QDialogButtonBox(self.verticalLayoutWidget) + self.buttonBox.setOrientation(QtCore.Qt.Horizontal) + self.buttonBox.setStandardButtons( + QtWidgets.QDialogButtonBox.Cancel | QtWidgets.QDialogButtonBox.Ok + ) + self.buttonBox.setObjectName("buttonBox") + self.verticalLayout.addWidget(self.buttonBox) + + # + + for i, size in enumerate((1, 2, 2, 3, 1, 1)): + self.verticalLayout.setStretch(i, size) + + self.retranslateUi(Dialog) + self.buttonBox.accepted.connect(Dialog.accept) + self.buttonBox.rejected.connect(Dialog.reject) + QtCore.QMetaObject.connectSlotsByName(Dialog) + + def retranslateUi(self, Dialog): + _translate = QtCore.QCoreApplication.translate + Dialog.setWindowTitle(_translate("Dialog", "Dialog")) + self.groupBox.setTitle(_translate("Dialog", "Source")) + self.addGEEFeatureCollectionToMapLabel.setToolTip( + _translate("Dialog", "This is a tooltip!") + ) + self.addGEEFeatureCollectionToMapLabel.setWhatsThis( + _translate( + "Dialog", 'This is "WhatsThis"! Link' + ) + ) + self.addGEEFeatureCollectionToMapLabel.setText( + _translate("Dialog", "Add GEE Feature Collection to Map") + ) + self.groupBox_2.setTitle(_translate("Dialog", "Filter by Properties")) + self.nameLabel.setText(_translate("Dialog", "Name")) + self.valueLabel.setText(_translate("Dialog", "Value")) + self.groupBox_3.setTitle(_translate("Dialog", "Filter by Dates")) + self.nameLabel_2.setText(_translate("Dialog", "Start")) + self.valueLabel_2.setText(_translate("Dialog", "End")) + self.groupBox_4.setTitle(_translate("Dialog", "Filter by Coordinates")) + self.groupBox_5.setTitle(_translate("Dialog", "Visualization")) + self.label.setText(_translate("Dialog", "Color")) + + +from qgis import gui + + +if __name__ == "__main__": + import sys + + app = QtWidgets.QApplication(sys.argv) + Dialog = QtWidgets.QDialog() + ui = Ui_Dialog() + ui.setupUi(Dialog) + Dialog.show() + sys.exit(app.exec_()) diff --git a/ee_plugin/ee_plugin.py b/ee_plugin/ee_plugin.py index becdc34..c87a2c6 100644 --- a/ee_plugin/ee_plugin.py +++ b/ee_plugin/ee_plugin.py @@ -14,7 +14,7 @@ import requests # type: ignore from qgis.core import QgsProject -from qgis.gui import QgisInterface +from qgis import gui from qgis.PyQt.QtCore import ( QCoreApplication, QSettings, @@ -23,8 +23,12 @@ QObject, Qt, ) + from qgis.PyQt.QtGui import QIcon -from qgis.PyQt.QtWidgets import QAction, QMenu, QToolButton +from qgis.PyQt import QtWidgets + +from . import dialog +from .ui.utils import GroupBox, Row, Label, Widget, Dialog PLUGIN_DIR = os.path.dirname(__file__) @@ -43,9 +47,7 @@ def icon(icon_name: str) -> QIcon: class GoogleEarthEnginePlugin(object): """QGIS Plugin Implementation.""" - # iface: QgisInterface - - def __init__(self, iface: QgisInterface): + def __init__(self, iface: gui.QgisInterface): """Constructor. :param iface: An interface instance that will be passed to this class @@ -96,22 +98,27 @@ def initGui(self): self.ee_user_guide_action = self._build_action( text="User Guide", icon_name="earth-engine.svg", - callback=self.run_cmd_ee_user_guide, + callback=self._run_cmd_ee_user_guide, ) self.sign_in_action = self._build_action( text="Sign-in", icon_name="google-cloud.svg", - callback=self.run_cmd_sign_in, + callback=self._run_cmd_sign_in, ) self.set_cloud_project_action = self._build_action( text="Set Project", icon_name="google-cloud-project.svg", - callback=self.run_cmd_set_cloud_project, + callback=self._run_cmd_set_cloud_project, + ) + self.open_test_widget = self._build_action( + text="Test Dialog", + icon_name="earth-engine.svg", + callback=self._test_dock_widget, ) # Build plugin menu self.menu = cast( - QMenu, + QtWidgets.QMenu, self.iface.pluginMenu().addMenu( icon("earth-engine.svg"), self.tr("&Google Earth Engine"), @@ -124,25 +131,29 @@ def initGui(self): self.menu.addAction(self.set_cloud_project_action) # Build toolbar - self.toolButton = QToolButton() - self.toolButton.setMenu(QMenu()) + self.toolButton = QtWidgets.QToolButton() self.toolButton.setToolButtonStyle( - Qt.ToolButtonStyle.ToolButtonTextBesideIcon - if False - else Qt.ToolButtonStyle.ToolButtonIconOnly + Qt.ToolButtonStyle.ToolButtonIconOnly + # Qt.ToolButtonStyle.ToolButtonTextBesideIcon ) - self.toolButton.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) - toolButtonMenu = self.toolButton.menu() - - self.toolButton.setDefaultAction(self.ee_user_guide_action) - toolButtonMenu.addAction(self.ee_user_guide_action) - toolButtonMenu.addSeparator() - toolButtonMenu.addAction(self.sign_in_action) - toolButtonMenu.addAction(self.set_cloud_project_action) + self.toolButton.setPopupMode( + QtWidgets.QToolButton.ToolButtonPopupMode.MenuButtonPopup + ) + self.toolButton.setDefaultAction(self.open_test_widget) + self.toolButton.setMenu(QtWidgets.QMenu()) + self.toolButton.menu().addAction(self.open_test_widget) + self.toolButton.menu().addAction(self.ee_user_guide_action) + self.toolButton.menu().addSeparator() + self.toolButton.menu().addAction(self.sign_in_action) + self.toolButton.menu().addAction(self.set_cloud_project_action) self.iface.addToolBarWidget(self.toolButton) + self.iface.addPluginToVectorMenu("My Test", self.ee_user_guide_action) + + # TODO: How to add to Processing Toolbox? + # Register signal to initialize EE layers on project load - self.iface.projectRead.connect(self.updateLayers) + self.iface.projectRead.connect(self._updateLayers) def _build_action( self, @@ -151,19 +162,19 @@ def _build_action( icon_name: str, parent: Optional[QObject] = None, callback: Callable, - ) -> QAction: + ) -> QtWidgets.QAction: """Helper to add a menu item and connect it to a handler.""" - action = QAction( + action = QtWidgets.QAction( icon(icon_name), self.tr(text), parent or self.iface.mainWindow() ) action.triggered.connect(callback) return action - def run_cmd_ee_user_guide(self): + def _run_cmd_ee_user_guide(self): # open user guide in external web browser webbrowser.open_new("http://qgis-ee-plugin.appspot.com/user-guide") - def run_cmd_sign_in(self): + def _run_cmd_sign_in(self): import ee from ee_plugin import ee_auth # type: ignore @@ -174,7 +185,7 @@ def run_cmd_sign_in(self): # after resetting authentication, select Google Cloud project again ee_auth.select_project() - def run_cmd_set_cloud_project(self): + def _run_cmd_set_cloud_project(self): from ee_plugin import ee_auth # type: ignore ee_auth.select_project() @@ -219,7 +230,7 @@ def unload(self): self.iface.pluginMenu().removeAction(self.menu.menuAction()) self.toolButton.deleteLater() - def updateLayers(self): + def _updateLayers(self): import ee from .utils import add_or_update_ee_layer @@ -257,3 +268,93 @@ def updateLayers(self): opacity = layer.renderer().opacity() add_or_update_ee_layer(ee_object, ee_object_vis, name, shown, opacity) + + def _test_dock_widget(self): + dialog = Dialog( + object_name="Dialog", + title="Dialog", + margins=[10] * 4, + group_boxes=[ + GroupBox( + object_name="groupBox_1", + title="Source", + rows=[ + Row( + label=Label( + object_name="addGEEFeatureCollectionToMapLabel", + text="Add GEE Feature Collection to Map", + tooltip="This is a tooltip!", + whatsthis='This is "WhatsThis"! Link', + ), + widget=Widget( + cls=QtWidgets.QLineEdit, + object_name="addGEEFeatureCollectionToMapLineEdit", + ), + ) + ], + ), + GroupBox( + object_name="groupBox_2", + title="Filter by Properties", + rows=[ + Row( + label=Label(object_name="nameLabel", text="Name"), + widget=Widget( + cls=QtWidgets.QLineEdit, object_name="nameLineEdit" + ), + ), + Row( + label=Label(object_name="valueLabel", text="Value"), + widget=Widget( + cls=QtWidgets.QLineEdit, object_name="valueLineEdit" + ), + ), + ], + ), + GroupBox( + object_name="groupBox_3", + title="Filter by Dates", + rows=[ + Row( + label=Label(object_name="nameLabel_2", text="Start"), + widget=Widget( + cls=QtWidgets.QDateEdit, object_name="dateEdit" + ), + ), + Row( + label=Label(object_name="valueLabel_2", text="End"), + widget=Widget( + cls=QtWidgets.QDateEdit, object_name="dateEdit_2" + ), + ), + ], + ), + GroupBox( + object_name="groupBox_4", + title="Filter by Coordinates", + rows=[ + # Place a QgsExtentGroupBox in one row (with an empty label) + Row( + label=Label(object_name="extentLabel", text=""), + widget=Widget( + cls=gui.QgsExtentGroupBox, + object_name="mExtentGroupBox", + ), + ) + ], + ), + GroupBox( + object_name="groupBox_5", + title="Visualization", + rows=[ + Row( + label=Label(object_name="label", text="Color"), + widget=Widget( + cls=gui.QgsColorButton, object_name="mColorButton" + ), + ) + ], + ), + ], + ).build(self.iface.mainWindow()) + dialog.show() diff --git a/ee_plugin/ui/add_gee_image_collection.ui b/ee_plugin/ui/add_gee_image_collection.ui new file mode 100644 index 0000000..16caf14 --- /dev/null +++ b/ee_plugin/ui/add_gee_image_collection.ui @@ -0,0 +1,104 @@ + + + gsDockWidget + + + + 0 + 0 + 400 + 300 + + + + Add GEE Feature Collection to Map + + + + + + 0 + 0 + 391 + 271 + + + + + + + QLayout::SetMaximumSize + + + QFormLayout::ExpandingFieldsGrow + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + 12 + + + + + Foo + + + + + + + + + + Xyz + + + + + + + + + + Bar + + + + + + + + + + asdf + + + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + QgsDockWidget + QDockWidget +
qgsdockwidget.h
+ 1 +
+
+ + +
diff --git a/ee_plugin/ui/utils.py b/ee_plugin/ui/utils.py new file mode 100644 index 0000000..eebbcc5 --- /dev/null +++ b/ee_plugin/ui/utils.py @@ -0,0 +1,154 @@ +from dataclasses import dataclass, field +from typing import List, Optional, Type + +from qgis.PyQt.QtWidgets import ( + QWidget, + QLabel, + QGroupBox, + QFormLayout, + QVBoxLayout, + QDialog, + QDialogButtonBox, +) + + +@dataclass +class Label: + """ + Defines a QLabel with optional tooltip/whatsThis. + """ + + object_name: str + text: str + tooltip: Optional[str] = None + whatsthis: Optional[str] = None + cls: Type[QLabel] = QLabel + + def build(self, parent: QWidget) -> QLabel: + """Instantiate and return a QLabel.""" + label = self.cls(parent) + label.setObjectName(self.object_name) + label.setText(self.text) + if self.tooltip: + label.setToolTip(self.tooltip) + if self.whatsthis: + label.setWhatsThis(self.whatsthis) + return label + + +@dataclass +class Widget: + """ + Describes the widget to be created (e.g., QLineEdit, QDateEdit, QgsColorButton). + Storing the actual widget class instead of a string. + """ + + cls: Type + object_name: str + + def build(self, parent: QWidget) -> QWidget: + """Instantiate and return a widget of the given class.""" + w = self.cls(parent) + w.setObjectName(self.object_name) + return w + + +@dataclass +class Row: + """ + A row in a QFormLayout: label + widget side by side. + """ + + label: Label + widget: Widget + + def build(self, parent: QWidget, form_layout: QFormLayout): + """ + Create a label and widget, then add them to the form layout. + Returns (label_instance, widget_instance). + """ + lbl = self.label.build(parent) + wdg = self.widget.build(parent) + form_layout.addRow(lbl, wdg) + return lbl, wdg + + +@dataclass +class GroupBox: + """ + A group box in the UI. If `stretch_factor` is provided, that determines + how tall this block grows relative to other siblings in the parent layout. + """ + + object_name: str + title: str + rows: List[Row] = field(default_factory=list) + stretch_factor: Optional[int] = None + + def build(self, parent: QWidget, parent_layout: QVBoxLayout) -> QGroupBox: + """ + Create a QGroupBox, give it a QFormLayout, build each row, + then add this group box to the parent_layout (with optional stretch). + Returns the newly created QGroupBox instance. + """ + gb = QGroupBox(parent) + gb.setObjectName(self.object_name) + gb.setTitle(self.title) + + form_layout = QFormLayout(gb) + for row in self.rows: + row.build(gb, form_layout) + + if self.stretch_factor is not None: + parent_layout.addWidget(gb, self.stretch_factor) + else: + parent_layout.addWidget(gb) + + return gb + + +@dataclass +class Dialog: + """ + The top-level definition of our dialog window. + It holds the dialog's dimensions, title, margins, and the group boxes to be displayed. + + The build() method creates and returns a QDialog, setting up the layout, + adding each group box in turn, and finally adding a standard button box (OK/Cancel). + """ + + object_name: str + title: str + width: int = 600 + height: int = 400 + margins: tuple = (10, 10, 10, 10) + group_boxes: List[GroupBox] = field(default_factory=list) + + def build(self, parent) -> QDialog: + """ + Create the QDialog, set its layout and geometry, then build the group boxes + and the button box at the bottom. Returns the fully constructed dialog. + """ + dialog = QDialog(parent) + dialog.setObjectName(self.object_name) + dialog.setWindowTitle(self.title) + dialog.resize(self.width, self.height) + + main_layout = QVBoxLayout(dialog) + main_layout.setContentsMargins(*self.margins) + dialog.setLayout(main_layout) + + # Build each group box in order + for gb_def in self.group_boxes: + gb_def.build(dialog, main_layout) + + # Add OK/Cancel buttons + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.setObjectName("buttonBox") + main_layout.addWidget(button_box) + + # Hook up signals + button_box.accepted.connect(dialog.accept) + button_box.rejected.connect(dialog.reject) + + return dialog From 758fe6770765ac848348f77e0ebc899393acb7e5 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 7 Feb 2025 09:11:57 -0800 Subject: [PATCH 03/43] Refactor to prefer functions over dataclasses --- ee_plugin/ee_plugin.py | 109 ++++++++++-------- ee_plugin/ui/utils.py | 249 +++++++++++++++++++++++------------------ 2 files changed, 201 insertions(+), 157 deletions(-) diff --git a/ee_plugin/ee_plugin.py b/ee_plugin/ee_plugin.py index c87a2c6..e4500e8 100644 --- a/ee_plugin/ee_plugin.py +++ b/ee_plugin/ee_plugin.py @@ -28,7 +28,14 @@ from qgis.PyQt import QtWidgets from . import dialog -from .ui.utils import GroupBox, Row, Label, Widget, Dialog +from .ui.utils import ( + build_group_box, + Row, + build_label, + build_widget, + build_dialog, + get_values, +) PLUGIN_DIR = os.path.dirname(__file__) @@ -111,7 +118,7 @@ def initGui(self): callback=self._run_cmd_set_cloud_project, ) self.open_test_widget = self._build_action( - text="Test Dialog", + text="Test dialog", icon_name="earth-engine.svg", callback=self._test_dock_widget, ) @@ -270,91 +277,101 @@ def _updateLayers(self): add_or_update_ee_layer(ee_object, ee_object_vis, name, shown, opacity) def _test_dock_widget(self): - dialog = Dialog( - object_name="Dialog", - title="Dialog", + dialog = build_dialog( + object_name="dialog", + title="dialog", margins=[10] * 4, - group_boxes=[ - GroupBox( + children=[ + build_group_box( object_name="groupBox_1", title="Source", rows=[ - Row( - label=Label( + ( + build_label( object_name="addGEEFeatureCollectionToMapLabel", text="Add GEE Feature Collection to Map", tooltip="This is a tooltip!", whatsthis='This is "WhatsThis"! Link', ), - widget=Widget( + build_widget( cls=QtWidgets.QLineEdit, object_name="addGEEFeatureCollectionToMapLineEdit", ), ) ], ), - GroupBox( + build_group_box( object_name="groupBox_2", title="Filter by Properties", rows=[ - Row( - label=Label(object_name="nameLabel", text="Name"), - widget=Widget( - cls=QtWidgets.QLineEdit, object_name="nameLineEdit" + ( + build_label(object_name="nameLabel", text="Name"), + build_widget( + cls=QtWidgets.QLineEdit, + object_name="nameLineEdit", ), ), - Row( - label=Label(object_name="valueLabel", text="Value"), - widget=Widget( - cls=QtWidgets.QLineEdit, object_name="valueLineEdit" + ( + build_label(object_name="valueLabel", text="Value"), + build_widget( + cls=QtWidgets.QLineEdit, + object_name="valueLineEdit", ), ), ], ), - GroupBox( + build_group_box( object_name="groupBox_3", title="Filter by Dates", rows=[ - Row( - label=Label(object_name="nameLabel_2", text="Start"), - widget=Widget( - cls=QtWidgets.QDateEdit, object_name="dateEdit" + ( + build_label(object_name="nameLabel_2", text="Start"), + build_widget( + cls=QtWidgets.QDateEdit, + object_name="dateEdit", ), ), - Row( - label=Label(object_name="valueLabel_2", text="End"), - widget=Widget( - cls=QtWidgets.QDateEdit, object_name="dateEdit_2" + ( + build_label(object_name="valueLabel_2", text="End"), + build_widget( + cls=QtWidgets.QDateEdit, + object_name="dateEdit_2", ), ), ], ), - GroupBox( - object_name="groupBox_4", + build_widget( + cls=gui.QgsExtentGroupBox, + object_name="mExtentGroupBox", title="Filter by Coordinates", - rows=[ - # Place a QgsExtentGroupBox in one row (with an empty label) - Row( - label=Label(object_name="extentLabel", text=""), - widget=Widget( - cls=gui.QgsExtentGroupBox, - object_name="mExtentGroupBox", - ), - ) - ], ), - GroupBox( + build_group_box( object_name="groupBox_5", title="Visualization", rows=[ - Row( - label=Label(object_name="label", text="Color"), - widget=Widget( - cls=gui.QgsColorButton, object_name="mColorButton" + ( + build_label(object_name="label", text="Color"), + build_widget( + cls=gui.QgsColorButton, + object_name="mColorButton", ), ) ], ), ], - ).build(self.iface.mainWindow()) + ) + + # qdialog = dialog.build(self.iface.mainWindow()) + dialog.accepted.connect( + lambda: self.iface.messageBar().pushMessage( + f"Accepted {get_values(dialog)=}" + ) + ) + dialog.rejected.connect( + lambda: self.iface.messageBar().pushMessage("Cancelled") + ) dialog.show() + + +def retrieve_values(): + print("handle") diff --git a/ee_plugin/ui/utils.py b/ee_plugin/ui/utils.py index eebbcc5..c242efa 100644 --- a/ee_plugin/ui/utils.py +++ b/ee_plugin/ui/utils.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, field -from typing import List, Optional, Type +from typing import List, Optional, Type, Callable, Tuple -from qgis.PyQt.QtWidgets import ( +from PyQt5.QtWidgets import ( QWidget, QLabel, QGroupBox, @@ -9,48 +9,51 @@ QVBoxLayout, QDialog, QDialogButtonBox, + QLineEdit, + QDateEdit, + QCheckBox, ) -@dataclass -class Label: - """ - Defines a QLabel with optional tooltip/whatsThis. - """ - - object_name: str - text: str - tooltip: Optional[str] = None - whatsthis: Optional[str] = None - cls: Type[QLabel] = QLabel +IGNORED_TYPES = (QLabel, QGroupBox, QDialogButtonBox, QVBoxLayout, QFormLayout) - def build(self, parent: QWidget) -> QLabel: - """Instantiate and return a QLabel.""" - label = self.cls(parent) - label.setObjectName(self.object_name) - label.setText(self.text) - if self.tooltip: - label.setToolTip(self.tooltip) - if self.whatsthis: - label.setWhatsThis(self.whatsthis) - return label - -@dataclass -class Widget: +def build_label( + *, + object_name: str, + text: str, + tooltip: Optional[str] = None, + whatsthis: Optional[str] = None, + cls: Type[QLabel] = QLabel, + parent: Optional[QWidget] = None, +) -> QLabel: """ - Describes the widget to be created (e.g., QLineEdit, QDateEdit, QgsColorButton). - Storing the actual widget class instead of a string. + Defines a QLabel with optional tooltip/WhatsThis. """ - - cls: Type - object_name: str - - def build(self, parent: QWidget) -> QWidget: - """Instantiate and return a widget of the given class.""" - w = self.cls(parent) - w.setObjectName(self.object_name) - return w + lbl = cls(parent) + lbl.setObjectName(object_name) + lbl.setText(text) + if tooltip: + lbl.setToolTip(tooltip) + if whatsthis: + lbl.setWhatsThis(whatsthis) + + return lbl + + +def build_widget( + *, + object_name: str, + cls: Type, + **kwargs, +) -> QWidget: + """ + Describes the widget to be created (e.g., QLineEdit, QDateEdit, QgsColorButton), + storing the actual widget class instead of a string. + """ + widget = cls(**kwargs) + widget.setObjectName(object_name) + return widget @dataclass @@ -59,56 +62,69 @@ class Row: A row in a QFormLayout: label + widget side by side. """ - label: Label - widget: Widget + label: QLabel + widget: QWidget - def build(self, parent: QWidget, form_layout: QFormLayout): - """ - Create a label and widget, then add them to the form layout. - Returns (label_instance, widget_instance). - """ - lbl = self.label.build(parent) - wdg = self.widget.build(parent) - form_layout.addRow(lbl, wdg) - return lbl, wdg - -@dataclass -class GroupBox: +def build_group_box( + *, + object_name: str, + title: str, + rows: List[Tuple[QWidget, QWidget]] = field(default_factory=list), +) -> QGroupBox: """ - A group box in the UI. If `stretch_factor` is provided, that determines - how tall this block grows relative to other siblings in the parent layout. + A group box in the UI. + + :param object_name: The object name of the group box. + :param title: The title of the group box. + :param rows: A list of tuples, each containing a label and a widget. """ + gb = QGroupBox() + gb.setObjectName(object_name) + gb.setTitle(title) + + form_layout = QFormLayout(gb) + gb.setLayout(form_layout) + + for row in rows: + form_layout.addRow(*row) - object_name: str - title: str - rows: List[Row] = field(default_factory=list) - stretch_factor: Optional[int] = None + return gb - def build(self, parent: QWidget, parent_layout: QVBoxLayout) -> QGroupBox: - """ - Create a QGroupBox, give it a QFormLayout, build each row, - then add this group box to the parent_layout (with optional stretch). - Returns the newly created QGroupBox instance. - """ - gb = QGroupBox(parent) - gb.setObjectName(self.object_name) - gb.setTitle(self.title) - form_layout = QFormLayout(gb) - for row in self.rows: - row.build(gb, form_layout) +def build_button_box( + *, + object_name: str, + buttons: List[QDialogButtonBox.StandardButton] = field(default_factory=list), + accepted: Optional[Callable] = None, + rejected: Optional[Callable] = None, +) -> QDialogButtonBox: + """ + A button box in the UI. + """ + btn_box = QDialogButtonBox() + btn_box.setObjectName(object_name) - if self.stretch_factor is not None: - parent_layout.addWidget(gb, self.stretch_factor) - else: - parent_layout.addWidget(gb) + for button in buttons: + btn_box.addButton(button) - return gb + if accepted: + btn_box.accepted.connect(accepted) + if rejected: + btn_box.rejected.connect(rejected) + return btn_box -@dataclass -class Dialog: + +def build_dialog( + object_name: str, + title: str, + width: int = 600, + height: int = 400, + margins: tuple = (10, 10, 10, 10), + children: List[QWidget] = field(default_factory=list), + parent: Optional[QWidget] = None, +) -> QDialog: """ The top-level definition of our dialog window. It holds the dialog's dimensions, title, margins, and the group boxes to be displayed. @@ -117,38 +133,49 @@ class Dialog: adding each group box in turn, and finally adding a standard button box (OK/Cancel). """ - object_name: str - title: str - width: int = 600 - height: int = 400 - margins: tuple = (10, 10, 10, 10) - group_boxes: List[GroupBox] = field(default_factory=list) - - def build(self, parent) -> QDialog: - """ - Create the QDialog, set its layout and geometry, then build the group boxes - and the button box at the bottom. Returns the fully constructed dialog. - """ - dialog = QDialog(parent) - dialog.setObjectName(self.object_name) - dialog.setWindowTitle(self.title) - dialog.resize(self.width, self.height) - - main_layout = QVBoxLayout(dialog) - main_layout.setContentsMargins(*self.margins) - dialog.setLayout(main_layout) - - # Build each group box in order - for gb_def in self.group_boxes: - gb_def.build(dialog, main_layout) - - # Add OK/Cancel buttons - button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - button_box.setObjectName("buttonBox") - main_layout.addWidget(button_box) - - # Hook up signals - button_box.accepted.connect(dialog.accept) - button_box.rejected.connect(dialog.reject) - - return dialog + """ + Create the QDialog, set its layout and geometry, build group boxes, + add the button box, and return the dialog widget. + + :return: A tuple: (QDialog instance, Dict of widget references). + """ + dialog = QDialog(parent) + dialog.setObjectName(object_name) + dialog.setWindowTitle(title) + dialog.resize(width, height) + + main_layout = QVBoxLayout(dialog) + main_layout.setContentsMargins(*margins) + dialog.setLayout(main_layout) + + # Build each group box + for widget in children: + main_layout.addWidget(widget) + + # Add OK/Cancel buttons + main_layout.addWidget( + build_button_box( + object_name="button_box", + buttons=[QDialogButtonBox.Ok, QDialogButtonBox.Cancel], + accepted=dialog.accept, + rejected=dialog.reject, + ) + ) + + return dialog + + +def get_values(dialog): + """ + Return a dictionary of all widget values from dialog. + """ + parsers = { + QLineEdit: lambda w: w.text(), + QDateEdit: lambda w: w.date().toString("yyyy-MM-dd"), + QCheckBox: lambda w: w.isChecked(), + } + return { + w.objectName(): parsers[type(w)](w) + for w in dialog.findChildren(QWidget) + if type(w) in parsers + } From da0b867b1d9b904aa28c5ef7b03b876b4e4e2503 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 7 Feb 2025 12:14:55 -0800 Subject: [PATCH 04/43] Trim down code --- ee_plugin/ee_plugin.py | 106 ++++++++++----------------- ee_plugin/ui/utils.py | 159 +++++++++-------------------------------- 2 files changed, 70 insertions(+), 195 deletions(-) diff --git a/ee_plugin/ee_plugin.py b/ee_plugin/ee_plugin.py index e4500e8..e52e350 100644 --- a/ee_plugin/ee_plugin.py +++ b/ee_plugin/ee_plugin.py @@ -27,13 +27,9 @@ from qgis.PyQt.QtGui import QIcon from qgis.PyQt import QtWidgets -from . import dialog from .ui.utils import ( - build_group_box, - Row, - build_label, - build_widget, - build_dialog, + build_form_group_box, + build_vbox_dialog, get_values, ) @@ -277,101 +273,71 @@ def _updateLayers(self): add_or_update_ee_layer(ee_object, ee_object_vis, name, shown, opacity) def _test_dock_widget(self): - dialog = build_dialog( - object_name="dialog", - title="dialog", - margins=[10] * 4, - children=[ - build_group_box( - object_name="groupBox_1", + dialog = build_vbox_dialog( + windowTitle="Add Feature Collection", + widgets=[ + build_form_group_box( title="Source", rows=[ ( - build_label( - object_name="addGEEFeatureCollectionToMapLabel", + QtWidgets.QLabel( + objectName="addGEEFeatureCollectionToMapLabel", text="Add GEE Feature Collection to Map", - tooltip="This is a tooltip!", - whatsthis='This is "WhatsThis"! Link', + toolTip="This is a tooltip!", + whatsThis='This is "WhatsThis"! Link', ), - build_widget( - cls=QtWidgets.QLineEdit, - object_name="addGEEFeatureCollectionToMapLineEdit", + QtWidgets.QLineEdit( + objectName="addGEEFeatureCollectionToMapLineEdit" ), ) ], ), - build_group_box( - object_name="groupBox_2", + build_form_group_box( title="Filter by Properties", + collapsable=True, + collapsed=True, rows=[ ( - build_label(object_name="nameLabel", text="Name"), - build_widget( - cls=QtWidgets.QLineEdit, - object_name="nameLineEdit", - ), + "Name", + QtWidgets.QLineEdit(objectName="nameLineEdit"), ), ( - build_label(object_name="valueLabel", text="Value"), - build_widget( - cls=QtWidgets.QLineEdit, - object_name="valueLineEdit", - ), + "Value", + QtWidgets.QLineEdit(objectName="valueLineEdit"), ), ], ), - build_group_box( - object_name="groupBox_3", + build_form_group_box( title="Filter by Dates", + collapsable=True, + collapsed=True, rows=[ ( - build_label(object_name="nameLabel_2", text="Start"), - build_widget( - cls=QtWidgets.QDateEdit, - object_name="dateEdit", - ), + "Start", + QtWidgets.QDateEdit(objectName="dateEdit"), ), ( - build_label(object_name="valueLabel_2", text="End"), - build_widget( - cls=QtWidgets.QDateEdit, - object_name="dateEdit_2", - ), + "End", + QtWidgets.QDateEdit(objectName="dateEdit_2"), ), ], ), - build_widget( - cls=gui.QgsExtentGroupBox, - object_name="mExtentGroupBox", + gui.QgsExtentGroupBox( + objectName="mExtentGroupBox", title="Filter by Coordinates", + collapsed=True, ), - build_group_box( - object_name="groupBox_5", + build_form_group_box( title="Visualization", + collapsable=True, + collapsed=True, rows=[ - ( - build_label(object_name="label", text="Color"), - build_widget( - cls=gui.QgsColorButton, - object_name="mColorButton", - ), - ) + ("Color", gui.QgsColorButton(objectName="mColorButton")), ], ), ], - ) - - # qdialog = dialog.build(self.iface.mainWindow()) - dialog.accepted.connect( - lambda: self.iface.messageBar().pushMessage( + accepted=lambda: self.iface.messageBar().pushMessage( f"Accepted {get_values(dialog)=}" - ) - ) - dialog.rejected.connect( - lambda: self.iface.messageBar().pushMessage("Cancelled") + ), + rejected=lambda: self.iface.messageBar().pushMessage("Cancelled"), ) - dialog.show() - - -def retrieve_values(): - print("handle") diff --git a/ee_plugin/ui/utils.py b/ee_plugin/ui/utils.py index c242efa..fe558c3 100644 --- a/ee_plugin/ui/utils.py +++ b/ee_plugin/ui/utils.py @@ -1,9 +1,8 @@ -from dataclasses import dataclass, field -from typing import List, Optional, Type, Callable, Tuple +from dataclasses import field +from typing import List, Tuple, Union -from PyQt5.QtWidgets import ( +from qgis.PyQt.QtWidgets import ( QWidget, - QLabel, QGroupBox, QFormLayout, QVBoxLayout, @@ -12,156 +11,65 @@ QLineEdit, QDateEdit, QCheckBox, + QLayout, ) +from qgis.gui import QgsColorButton, QgsCollapsibleGroupBox -IGNORED_TYPES = (QLabel, QGroupBox, QDialogButtonBox, QVBoxLayout, QFormLayout) - - -def build_label( +def build_form_group_box( *, - object_name: str, - text: str, - tooltip: Optional[str] = None, - whatsthis: Optional[str] = None, - cls: Type[QLabel] = QLabel, - parent: Optional[QWidget] = None, -) -> QLabel: - """ - Defines a QLabel with optional tooltip/WhatsThis. - """ - lbl = cls(parent) - lbl.setObjectName(object_name) - lbl.setText(text) - if tooltip: - lbl.setToolTip(tooltip) - if whatsthis: - lbl.setWhatsThis(whatsthis) - - return lbl - - -def build_widget( - *, - object_name: str, - cls: Type, + rows: List[ + Union[Tuple[Union[QWidget, str], Union[QWidget, QLayout]], QWidget, QLayout] + ] = field(default_factory=list), + collapsable: bool = False, **kwargs, -) -> QWidget: - """ - Describes the widget to be created (e.g., QLineEdit, QDateEdit, QgsColorButton), - storing the actual widget class instead of a string. +) -> Union[QGroupBox, QgsCollapsibleGroupBox]: """ - widget = cls(**kwargs) - widget.setObjectName(object_name) - return widget - - -@dataclass -class Row: - """ - A row in a QFormLayout: label + widget side by side. + A group box with a form layout. """ - - label: QLabel - widget: QWidget - - -def build_group_box( - *, - object_name: str, - title: str, - rows: List[Tuple[QWidget, QWidget]] = field(default_factory=list), -) -> QGroupBox: - """ - A group box in the UI. - - :param object_name: The object name of the group box. - :param title: The title of the group box. - :param rows: A list of tuples, each containing a label and a widget. - """ - gb = QGroupBox() - gb.setObjectName(object_name) - gb.setTitle(title) - - form_layout = QFormLayout(gb) - gb.setLayout(form_layout) + gb = QGroupBox(**kwargs) if not collapsable else QgsCollapsibleGroupBox(**kwargs) + layout = QFormLayout() + gb.setLayout(layout) for row in rows: - form_layout.addRow(*row) + if isinstance(row, (QWidget, QLayout)): + row = [row] + layout.addRow(*row) return gb -def build_button_box( - *, - object_name: str, - buttons: List[QDialogButtonBox.StandardButton] = field(default_factory=list), - accepted: Optional[Callable] = None, - rejected: Optional[Callable] = None, -) -> QDialogButtonBox: - """ - A button box in the UI. - """ - btn_box = QDialogButtonBox() - btn_box.setObjectName(object_name) - - for button in buttons: - btn_box.addButton(button) - - if accepted: - btn_box.accepted.connect(accepted) - if rejected: - btn_box.rejected.connect(rejected) - - return btn_box - - -def build_dialog( - object_name: str, - title: str, - width: int = 600, - height: int = 400, - margins: tuple = (10, 10, 10, 10), - children: List[QWidget] = field(default_factory=list), - parent: Optional[QWidget] = None, +def build_vbox_dialog( + widgets: List[QWidget] = field(default_factory=list), + show: bool = True, + **kwargs, ) -> QDialog: """ - The top-level definition of our dialog window. - It holds the dialog's dimensions, title, margins, and the group boxes to be displayed. - - The build() method creates and returns a QDialog, setting up the layout, - adding each group box in turn, and finally adding a standard button box (OK/Cancel). - """ - - """ - Create the QDialog, set its layout and geometry, build group boxes, - add the button box, and return the dialog widget. - - :return: A tuple: (QDialog instance, Dict of widget references). + Build a dialog with a vertical layout and configured standard buttons. """ - dialog = QDialog(parent) - dialog.setObjectName(object_name) - dialog.setWindowTitle(title) - dialog.resize(width, height) + dialog = QDialog(**kwargs) + # Configure dialog layout main_layout = QVBoxLayout(dialog) - main_layout.setContentsMargins(*margins) dialog.setLayout(main_layout) - # Build each group box - for widget in children: + # Add widgets to the dialog + for widget in widgets: main_layout.addWidget(widget) # Add OK/Cancel buttons main_layout.addWidget( - build_button_box( - object_name="button_box", - buttons=[QDialogButtonBox.Ok, QDialogButtonBox.Cancel], + QDialogButtonBox( + standardButtons=QDialogButtonBox.Cancel | QDialogButtonBox.Ok, accepted=dialog.accept, rejected=dialog.reject, ) ) + # Show the dialog on screen + if show: + dialog.show() + return dialog @@ -173,6 +81,7 @@ def get_values(dialog): QLineEdit: lambda w: w.text(), QDateEdit: lambda w: w.date().toString("yyyy-MM-dd"), QCheckBox: lambda w: w.isChecked(), + QgsColorButton: lambda w: w.color().name(), } return { w.objectName(): parsers[type(w)](w) From 02e1acc9e4621bf67915fd1b19653707bc23b0a4 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 7 Feb 2025 14:20:48 -0800 Subject: [PATCH 05/43] Rename inputs --- ee_plugin/ee_plugin.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/ee_plugin/ee_plugin.py b/ee_plugin/ee_plugin.py index e52e350..fe0a4b7 100644 --- a/ee_plugin/ee_plugin.py +++ b/ee_plugin/ee_plugin.py @@ -281,14 +281,11 @@ def _test_dock_widget(self): rows=[ ( QtWidgets.QLabel( - objectName="addGEEFeatureCollectionToMapLabel", text="Add GEE Feature Collection to Map", toolTip="This is a tooltip!", whatsThis='This is "WhatsThis"! Link', ), - QtWidgets.QLineEdit( - objectName="addGEEFeatureCollectionToMapLineEdit" - ), + QtWidgets.QLineEdit(objectName="featureCollectionId"), ) ], ), @@ -299,11 +296,11 @@ def _test_dock_widget(self): rows=[ ( "Name", - QtWidgets.QLineEdit(objectName="nameLineEdit"), + QtWidgets.QLineEdit(objectName="filterName"), ), ( "Value", - QtWidgets.QLineEdit(objectName="valueLineEdit"), + QtWidgets.QLineEdit(objectName="filterValue"), ), ], ), @@ -314,16 +311,16 @@ def _test_dock_widget(self): rows=[ ( "Start", - QtWidgets.QDateEdit(objectName="dateEdit"), + QtWidgets.QDateEdit(objectName="startDate"), ), ( "End", - QtWidgets.QDateEdit(objectName="dateEdit_2"), + QtWidgets.QDateEdit(objectName="endDate"), ), ], ), gui.QgsExtentGroupBox( - objectName="mExtentGroupBox", + objectName="extent", title="Filter by Coordinates", collapsed=True, ), @@ -332,7 +329,7 @@ def _test_dock_widget(self): collapsable=True, collapsed=True, rows=[ - ("Color", gui.QgsColorButton(objectName="mColorButton")), + ("Color", gui.QgsColorButton(objectName="vizColorHex")), ], ), ], From c02e38563059717126e4d215417a9c5e82ba42a6 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 7 Feb 2025 14:22:09 -0800 Subject: [PATCH 06/43] Add type --- ee_plugin/ui/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee_plugin/ui/utils.py b/ee_plugin/ui/utils.py index fe558c3..61a0037 100644 --- a/ee_plugin/ui/utils.py +++ b/ee_plugin/ui/utils.py @@ -73,7 +73,7 @@ def build_vbox_dialog( return dialog -def get_values(dialog): +def get_values(dialog: QDialog) -> dict: """ Return a dictionary of all widget values from dialog. """ From ee75ce3cce71baaeafba5010bbeff45fc2a32eb1 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 11 Feb 2025 13:21:50 -0800 Subject: [PATCH 07/43] In progress --- ee_plugin/ee_plugin.py | 80 +++------------------ ee_plugin/ui/forms.py | 155 +++++++++++++++++++++++++++++++++++++++++ ee_plugin/utils.py | 1 + 3 files changed, 164 insertions(+), 72 deletions(-) create mode 100644 ee_plugin/ui/forms.py diff --git a/ee_plugin/ee_plugin.py b/ee_plugin/ee_plugin.py index 43f7beb..7d8afe0 100644 --- a/ee_plugin/ee_plugin.py +++ b/ee_plugin/ee_plugin.py @@ -20,11 +20,7 @@ from qgis.PyQt.QtGui import QIcon from .config import EarthEngineConfig -from .ui.utils import ( - build_form_group_box, - build_vbox_dialog, - get_values, -) +from .ui.forms import add_feature_collection_form PLUGIN_DIR = os.path.dirname(__file__) @@ -117,6 +113,12 @@ def initGui(self): parent=self.iface.mainWindow(), triggered=self._run_cmd_set_cloud_project, ) + add_fc_button = QtWidgets.QAction( + icon=icon("google-cloud.svg"), + text=self.tr("Add Feature Collection"), + parent=self.iface.mainWindow(), + triggered=lambda: add_feature_collection_form(self.iface), + ) # Build plugin menu plugin_menu = cast(QtWidgets.QMenu, self.iface.pluginMenu()) @@ -129,6 +131,7 @@ def initGui(self): ee_menu.addSeparator() ee_menu.addAction(sign_in_action) ee_menu.addAction(self.set_cloud_project_action) + ee_menu.addAction(add_fc_button) # Build toolbar toolButton = QtWidgets.QToolButton() @@ -254,70 +257,3 @@ def _updateLayers(self): opacity = layer.renderer().opacity() add_or_update_ee_layer(ee_object, ee_object_vis, name, shown, opacity) - - def _test_dock_widget(self): - dialog = build_vbox_dialog( - windowTitle="Add Feature Collection", - widgets=[ - build_form_group_box( - title="Source", - rows=[ - ( - QtWidgets.QLabel( - text="Add GEE Feature Collection to Map", - toolTip="This is a tooltip!", - whatsThis='This is "WhatsThis"! Link', - ), - QtWidgets.QLineEdit(objectName="featureCollectionId"), - ) - ], - ), - build_form_group_box( - title="Filter by Properties", - collapsable=True, - collapsed=True, - rows=[ - ( - "Name", - QtWidgets.QLineEdit(objectName="filterName"), - ), - ( - "Value", - QtWidgets.QLineEdit(objectName="filterValue"), - ), - ], - ), - build_form_group_box( - title="Filter by Dates", - collapsable=True, - collapsed=True, - rows=[ - ( - "Start", - QtWidgets.QDateEdit(objectName="startDate"), - ), - ( - "End", - QtWidgets.QDateEdit(objectName="endDate"), - ), - ], - ), - gui.QgsExtentGroupBox( - objectName="extent", - title="Filter by Coordinates", - collapsed=True, - ), - build_form_group_box( - title="Visualization", - collapsable=True, - collapsed=True, - rows=[ - ("Color", gui.QgsColorButton(objectName="vizColorHex")), - ], - ), - ], - accepted=lambda: self.iface.messageBar().pushMessage( - f"Accepted {get_values(dialog)=}" - ), - rejected=lambda: self.iface.messageBar().pushMessage("Cancelled"), - ) diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py new file mode 100644 index 0000000..85acf6c --- /dev/null +++ b/ee_plugin/ui/forms.py @@ -0,0 +1,155 @@ +from qgis import gui +from qgis.PyQt import QtWidgets, QtCore + +from .utils import ( + build_form_group_box, + build_vbox_dialog, + get_values, +) + +from .. import Map, utils +import ee + + +def add_feature_collection_form(iface: gui.QgisInterface, _debug=True, **kwargs): + """Add a GEE Feature Collection to the map.""" + dialog = build_vbox_dialog( + windowTitle="Add Feature Collection", + widgets=[ + build_form_group_box( + title="Source", + rows=[ + ( + QtWidgets.QLabel( + text="Add GEE Feature Collection to Map", + toolTip="This is a tooltip!", + whatsThis='This is "WhatsThis"! Link', + ), + QtWidgets.QLineEdit(objectName="feature_collection_id"), + ) + ], + ), + build_form_group_box( + title="Filter by Properties", + collapsable=True, + collapsed=True, + rows=[ + ( + "Name", + QtWidgets.QLineEdit(objectName="filter_name"), + ), + ( + "Value", + QtWidgets.QLineEdit(objectName="filter_value"), + ), + ], + ), + build_form_group_box( + title="Filter by Dates", + collapsable=True, + collapsed=True, + rows=[ + ( + "Start", + QtWidgets.QDateEdit( + objectName="start_date", + specialValueText="", + date=QtCore.QDate(0, 0, 0), + ), + ), + ( + "End", + QtWidgets.QDateEdit(objectName="end_date"), + ), + ], + ), + gui.QgsExtentGroupBox( + objectName="extent", + title="Filter by Coordinates", + collapsed=True, + ), + build_form_group_box( + title="Visualization", + collapsable=True, + collapsed=True, + rows=[ + ("Color", gui.QgsColorButton(objectName="viz_color_hex")), + ], + ), + ], + parent=iface.mainWindow(), + **kwargs, + ) + + if _debug: + dialog.accepted.connect( + lambda: iface.messageBar().pushMessage(f"Accepted {get_values(dialog)=}") + ) + + dialog.accepted.connect(lambda: load(**get_values(dialog))) + + return + + +def load( + feature_collection_id: str, + filter_name: str, + filter_value: str, + start_date: str, + end_date: str, + mYMaxLineEdit: str, + mYMinLineEdit: str, + mXMaxLineEdit: str, + mXMinLineEdit: str, + viz_color_hex: str, + **kwargs, +): + """ + Loads and optionally filters a FeatureCollection, then adds it to the map. + + Args: + feature_collection_id (str): The Earth Engine FeatureCollection ID. + filter_name (str, optional): Name of the attribute to filter on. + filter_value (str, optional): Value of the attribute to match. + start_date (str, optional): Start date (YYYY-MM-DD) for filtering (must have a date property in your FC). + end_date (str, optional): End date (YYYY-MM-DD) for filtering (must have a date property in your FC). + extent (ee.Geometry, optional): Geometry to filter (or clip) the FeatureCollection. + viz_color_hex (str, optional): Hex color code for styling the features. + + Returns: + ee.FeatureCollection: The filtered FeatureCollection. + """ + + # 1. Load the FeatureCollection + fc = ee.FeatureCollection(feature_collection_id) + + # 2. Filter by property name and value if provided + if filter_name and filter_value: + fc = fc.filter(ee.Filter.eq(filter_name, filter_value)) + + # 3. Filter by date if both start_date and end_date are provided + # This step only makes sense if your FeatureCollection has a date property + # (e.g. "date" or "system:time_start"). Change "date" below to the relevant property. + # if start_date and end_date: + # fc = fc.filter( + # ee.Filter.date(ee.Date(start_date), ee.Date(end_date)) + # # If your features store date in a property named "date", you might need: + # # ee.Filter.calendarRange( + # # start_date, end_date, 'year' (or 'day', etc.) if using calendarRange + # ) + + # 4. Filter by geometry if provided + extent_src = [mXMinLineEdit, mYMinLineEdit, mXMaxLineEdit, mYMaxLineEdit] + if all(extent_src): + extent = ee.Geometry.Rectangle([float(val) for val in extent_src]) + # fc = fc.filterBounds(extent) + # Alternatively, if you want to clip features to the extent rather than just filter: + fc = fc.map(lambda f: f.intersection(extent)) + + # 6. Add to map + layer_name = f"FC: {feature_collection_id}" + utils.add_or_update_ee_vector_layer(fc, layer_name) + return fc + + +# 'USGS/WBD/2017/HUC06' diff --git a/ee_plugin/utils.py b/ee_plugin/utils.py index 0dc678c..474656d 100644 --- a/ee_plugin/utils.py +++ b/ee_plugin/utils.py @@ -57,6 +57,7 @@ def add_or_update_ee_layer(eeObject, vis_params, name, shown, opacity): """ Entry point to add/update an EE layer. Routes between raster, vector layers, and vector tile layers. """ + vis_params = vis_params or {} if isinstance(eeObject, ee.Image): add_or_update_ee_raster_layer(eeObject, name, vis_params, shown, opacity) elif isinstance(eeObject, ee.FeatureCollection): From aff56a7c6abe961c0b1537f3f7303118fb12884e Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 11 Feb 2025 14:16:45 -0800 Subject: [PATCH 08/43] Buildout types for utils --- ee_plugin/utils.py | 129 ++++++++++++++++++++++++++++++++------------- 1 file changed, 93 insertions(+), 36 deletions(-) diff --git a/ee_plugin/utils.py b/ee_plugin/utils.py index 612285c..076b56a 100644 --- a/ee_plugin/utils.py +++ b/ee_plugin/utils.py @@ -5,13 +5,26 @@ import json import tempfile +from typing import Optional, TypedDict, Any import ee import qgis -from qgis.core import QgsProject, QgsRasterLayer, QgsVectorLayer +from qgis.core import QgsProject, QgsRasterLayer, QgsVectorLayer, QgsMapLayer -def is_named_dataset(eeObject): +class VisualizeParams(TypedDict, total=False): + bands: Optional[Any] + gain: Optional[Any] + bias: Optional[Any] + min: Optional[Any] + max: Optional[Any] + gamma: Optional[Any] + opacity: Optional[float] + palette: Optional[Any] + forceRgbOutput: Optional[bool] + + +def is_named_dataset(eeObject: ee.Element) -> bool: """ Checks if the FeatureCollection is a named dataset that should be handled as a vector tiled layer. """ @@ -22,52 +35,70 @@ def is_named_dataset(eeObject): return False -def get_layer_by_name(name): +def get_layer_by_name(name: str) -> Optional[QgsMapLayer]: for layer in QgsProject.instance().mapLayersByName(name): return layer - return None - -def get_ee_image_url(image): +def get_ee_image_url(image: ee.Image) -> str: map_id = ee.data.getMapId({"image": image}) url = map_id["tile_fetcher"].url_format + "&zmax=25" return url -def add_or_update_ee_layer(eeObject, vis_params, name, shown, opacity): +def add_or_update_ee_layer( + eeObject: ee.Element, + vis_params: VisualizeParams, + name: str, + shown: bool, + opacity: float, +) -> QgsMapLayer: """ Entry point to add/update an EE layer. Routes between raster, vector layers, and vector tile layers. """ vis_params = vis_params or {} + if isinstance(eeObject, ee.Image): - add_or_update_ee_raster_layer(eeObject, name, vis_params, shown, opacity) - elif isinstance(eeObject, ee.FeatureCollection): + return add_or_update_ee_raster_layer(eeObject, name, vis_params, shown, opacity) + + if isinstance(eeObject, ee.FeatureCollection): if is_named_dataset(eeObject): - add_or_update_named_vector_layer(eeObject, name, vis_params, shown, opacity) - else: - add_or_update_ee_vector_layer(eeObject, name, shown, opacity) - elif isinstance(eeObject, ee.Geometry): - add_or_update_ee_vector_layer(eeObject, name, shown, opacity) - else: - raise TypeError("Unsupported EE object type") + return add_or_update_named_vector_layer( + eeObject, name, vis_params, shown, opacity + ) + return add_or_update_ee_vector_layer(eeObject, name, shown, opacity) + + if isinstance(eeObject, ee.Geometry): + return add_or_update_ee_vector_layer(eeObject, name, shown, opacity) + raise TypeError("Unsupported EE object type") -def add_or_update_ee_raster_layer(image, name, vis_params, shown=True, opacity=1.0): + +def add_or_update_ee_raster_layer( + image: ee.Image, + name: str, + vis_params: VisualizeParams, + shown: bool = True, + opacity: float = 1.0, +) -> QgsRasterLayer: """ Adds or updates a raster EE layer. """ layer = get_layer_by_name(name) if layer and layer.customProperty("ee-layer"): - layer = update_ee_image_layer(image, layer, vis_params, shown, opacity) - else: - layer = add_ee_image_layer(image, name, vis_params, shown, opacity) + return update_ee_image_layer(image, layer, vis_params, shown, opacity) - return layer + return add_ee_image_layer(image, name, vis_params, shown, opacity) -def add_ee_image_layer(image, name, vis_params, shown, opacity): +def add_ee_image_layer( + image: ee.Image, + name: str, + vis_params: VisualizeParams, + shown: bool, + opacity: float, +) -> QgsRasterLayer: """ Adds a raster layer using the 'EE' provider. """ @@ -96,7 +127,13 @@ def add_ee_image_layer(image, name, vis_params, shown, opacity): return layer -def update_ee_image_layer(image, layer, vis_params, shown=True, opacity=1.0): +def update_ee_image_layer( + image: ee.Image, + layer: QgsMapLayer, + vis_params: VisualizeParams, + shown: bool = True, + opacity: float = 1.0, +) -> QgsRasterLayer: """ Updates an existing EE raster layer. """ @@ -126,8 +163,12 @@ def update_ee_image_layer(image, layer, vis_params, shown=True, opacity=1.0): def add_or_update_named_vector_layer( - eeObject, name, vis_params, shown=True, opacity=1.0 -): + eeObject: ee.Element, + name: str, + vis_params: VisualizeParams, + shown: bool = True, + opacity: float = 1.0, +) -> QgsRasterLayer: """ Adds or updates a vector tiled layer from an Earth Engine named dataset. """ @@ -137,12 +178,16 @@ def add_or_update_named_vector_layer( # Given the potential large-size of named datasets, we render FeatureCollections as WMS raster layers image = ee.Image().paint(eeObject, 0, 2) - layer = add_or_update_ee_raster_layer(image, name, vis_params, shown, opacity) - return layer + return add_or_update_ee_raster_layer(image, name, vis_params, shown, opacity) -def add_or_update_ee_vector_layer(eeObject, name, shown=True, opacity=1.0): +def add_or_update_ee_vector_layer( + eeObject: ee.Element, + name: str, + shown: bool = True, + opacity: float = 1.0, +) -> QgsVectorLayer: """ Handles vector layers by converting them to a properly styled GeoJSON vector layer. """ @@ -151,14 +196,17 @@ def add_or_update_ee_vector_layer(eeObject, name, shown=True, opacity=1.0): if layer: if not layer.customProperty("ee-layer"): raise Exception(f"Layer is not an EE layer: {name}") - layer = update_ee_vector_layer(eeObject, layer, shown, opacity) - else: - layer = add_ee_vector_layer(eeObject, name, shown, opacity) + return update_ee_vector_layer(eeObject, layer, shown, opacity) - return layer + return add_ee_vector_layer(eeObject, name, shown, opacity) -def add_ee_vector_layer(eeObject, name, shown=True, opacity=1.0): +def add_ee_vector_layer( + eeObject: ee.Element, + name: str, + shown: bool = True, + opacity: float = 1.0, +) -> QgsVectorLayer: """ Adds a vector layer properly by converting EE Geometry to a valid GeoJSON FeatureCollection. """ @@ -202,7 +250,12 @@ def add_ee_vector_layer(eeObject, name, shown=True, opacity=1.0): return layer -def update_ee_vector_layer(eeObject, layer, shown, opacity): +def update_ee_vector_layer( + eeObject: ee.Element, + layer: QgsMapLayer, + shown: bool, + opacity: float, +) -> QgsVectorLayer: """ Updates an existing vector layer with new features from EE. """ @@ -225,7 +278,11 @@ def update_ee_vector_layer(eeObject, layer, shown, opacity): return new_layer -def add_ee_catalog_image(name, asset_name, vis_params): +def add_ee_catalog_image( + name: str, + asset_name: str, + vis_params: VisualizeParams, +) -> QgsRasterLayer: """ Adds an EE image from a catalog. """ @@ -233,7 +290,7 @@ def add_ee_catalog_image(name, asset_name, vis_params): add_or_update_ee_raster_layer(image, name) -def check_version(): +def check_version() -> None: """ Check if we have the latest plugin version. """ From ec1b30c05ebcc7a2d04fdf6a1097ec18dfefa82b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 11 Feb 2025 14:16:58 -0800 Subject: [PATCH 09/43] In progress --- ee_plugin/ui/forms.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py index 85acf6c..8b5bb89 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/forms.py @@ -26,7 +26,11 @@ def add_feature_collection_form(iface: gui.QgisInterface, _debug=True, **kwargs) whatsThis='This is "WhatsThis"! Link', ), QtWidgets.QLineEdit(objectName="feature_collection_id"), - ) + ), + ( + "Use
add_or_update_ee_vector_layer
", + QtWidgets.QCheckBox(objectName="use_util"), + ), ], ), build_form_group_box( @@ -102,6 +106,7 @@ def load( mXMaxLineEdit: str, mXMinLineEdit: str, viz_color_hex: str, + use_util: bool, **kwargs, ): """ @@ -148,7 +153,20 @@ def load( # 6. Add to map layer_name = f"FC: {feature_collection_id}" - utils.add_or_update_ee_vector_layer(fc, layer_name) + if use_util: + utils.add_ee_vector_layer(fc, layer_name) + else: + Map.addLayer( + fc, + { + # "color": viz_color_hex, + # If you'd like transparency or a custom fill color, you can add: + # "fillColor": viz_color_hex, + # "opacity": 0.6, + "palette": viz_color_hex, + }, + layer_name, + ) return fc From e8b18cedaba085001959bacb1ac0ac64b0716b6a Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 11:34:32 -0800 Subject: [PATCH 10/43] Refactor menu setup --- ee_plugin/ee_plugin.py | 57 +++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/ee_plugin/ee_plugin.py b/ee_plugin/ee_plugin.py index 4aafbd7..e5bd043 100644 --- a/ee_plugin/ee_plugin.py +++ b/ee_plugin/ee_plugin.py @@ -114,42 +114,53 @@ def initGui(self): triggered=self._run_cmd_set_cloud_project, ) add_fc_button = QtWidgets.QAction( - icon=icon("google-cloud.svg"), text=self.tr("Add Feature Collection"), parent=self.iface.mainWindow(), triggered=lambda: forms.add_feature_collection_form(self.iface), ) - # Build plugin menu + # Initialize plugin menu plugin_menu = cast(QtWidgets.QMenu, self.iface.pluginMenu()) - ee_menu = plugin_menu.addMenu( + self.menu = plugin_menu.addMenu( icon("earth-engine.svg"), self.tr("&Google Earth Engine"), ) - self.menu = ee_menu - ee_menu.addAction(ee_user_guide_action) - ee_menu.addSeparator() - ee_menu.addAction(sign_in_action) - ee_menu.addAction(self.set_cloud_project_action) - ee_menu.addAction(add_fc_button) - - # Build toolbar - toolButton = QtWidgets.QToolButton() - toolButton.setToolButtonStyle( + + # Initialize toolbar menu + self.toolButton = QtWidgets.QToolButton() + self.toolButton.setToolButtonStyle( Qt.ToolButtonStyle.ToolButtonIconOnly # Qt.ToolButtonStyle.ToolButtonTextBesideIcon ) - toolButton.setPopupMode( - QtWidgets.QToolButton.ToolButtonPopupMode.MenuButtonPopup + self.toolButton.setPopupMode( + # QtWidgets.QToolButton.ToolButtonPopupMode.DelayedPopup # Button is only for triggering action + QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup # Button is only for opening dropdown menu + # QtWidgets.QToolButton.ToolButtonPopupMode.MenuButtonPopup # Button is split into action and dropdown menu + ) + self.toolButton.setMenu(QtWidgets.QMenu()) + self.toolButton.setDefaultAction( + QtWidgets.QAction( + icon=icon("earth-engine.svg"), + text=f'{self.tr("Google Earth Engine")}', + parent=self.iface.mainWindow(), + ) ) - toolButton.setMenu(QtWidgets.QMenu()) - toolButton.setDefaultAction(ee_user_guide_action) - toolButton.menu().addAction(ee_user_guide_action) - toolButton.menu().addSeparator() - toolButton.menu().addAction(sign_in_action) - toolButton.menu().addAction(self.set_cloud_project_action) - self.iface.pluginToolBar().addWidget(toolButton) - self.toolButton = toolButton + self.iface.pluginToolBar().addWidget(self.toolButton) + + # Add actions to the menu + for action in [ + ee_user_guide_action, + None, + sign_in_action, + self.set_cloud_project_action, + None, + add_fc_button, + ]: + for menu in [self.menu, self.toolButton.menu()]: + if action: + menu.addAction(action) + else: + menu.addSeparator() # Register signal to initialize EE layers on project load self.iface.projectRead.connect(self._updateLayers) From 6d1ed60eaf338734f78c2ca0f491704d89e805d8 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 13:00:12 -0800 Subject: [PATCH 11/43] Support nullable date widget --- ee_plugin/ui/forms.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py index 8b5bb89..75aca55 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/forms.py @@ -1,5 +1,7 @@ +from typing import Optional from qgis import gui from qgis.PyQt import QtWidgets, QtCore +import ee from .utils import ( build_form_group_box, @@ -8,7 +10,21 @@ ) from .. import Map, utils -import ee + + +def DefaultNullQgsDateEdit( + *, date: Optional[QtCore.QDate] = None, **kwargs +) -> gui.QgsDateEdit: + """Build a QgsDateEdit widget, with null default capability.""" + d = gui.QgsDateEdit(**kwargs) + # It would be great to remove this helper and just use the built-in QgsDateEdit class + # but at this time it's not clear how to make a DateEdit widget that initializes with + # a null value. This is a workaround. + if date is None: + d.clear() + else: + d.setDate(date) + return d def add_feature_collection_form(iface: gui.QgisInterface, _debug=True, **kwargs): @@ -55,15 +71,11 @@ def add_feature_collection_form(iface: gui.QgisInterface, _debug=True, **kwargs) rows=[ ( "Start", - QtWidgets.QDateEdit( - objectName="start_date", - specialValueText="", - date=QtCore.QDate(0, 0, 0), - ), + DefaultNullQgsDateEdit(objectName="start_date"), ), ( "End", - QtWidgets.QDateEdit(objectName="end_date"), + DefaultNullQgsDateEdit(objectName="end_date"), ), ], ), From 7672e08f30f21638dd7bcbb3291f0edee3a84912 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 13:03:08 -0800 Subject: [PATCH 12/43] Display error to user --- ee_plugin/ui/forms.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py index 75aca55..a887ac9 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/forms.py @@ -166,7 +166,14 @@ def load( # 6. Add to map layer_name = f"FC: {feature_collection_id}" if use_util: - utils.add_ee_vector_layer(fc, layer_name) + try: + utils.add_ee_vector_layer(fc, layer_name) + except ee.ee_exception.EEException as e: + Map.get_iface().messageBar().pushMessage( + "Error", + f"Failed to load the Feature Collection: {e}", + level=gui.Qgis.Critical, + ) else: Map.addLayer( fc, From ae2f80b9bdc1a94785f1d8fd509be9ed7514cccb Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 13:03:27 -0800 Subject: [PATCH 13/43] Improve help text --- ee_plugin/ui/forms.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py index a887ac9..c474867 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/forms.py @@ -37,7 +37,12 @@ def add_feature_collection_form(iface: gui.QgisInterface, _debug=True, **kwargs) rows=[ ( QtWidgets.QLabel( - text="Add GEE Feature Collection to Map", + text="
".join( + [ + "Add GEE Feature Collection to Map", + "e.g. USGS/WBD/2017/HUC06", + ] + ), toolTip="This is a tooltip!", whatsThis='This is "WhatsThis"! Link', ), From 46beec786d0d3688c94994995b4435ac999a8e84 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 13:04:05 -0800 Subject: [PATCH 14/43] Return dialog --- ee_plugin/ui/forms.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py index c474867..00d25ba 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/forms.py @@ -27,7 +27,9 @@ def DefaultNullQgsDateEdit( return d -def add_feature_collection_form(iface: gui.QgisInterface, _debug=True, **kwargs): +def add_feature_collection_form( + iface: gui.QgisInterface, _debug=True, **kwargs +) -> QtWidgets.QDialog: """Add a GEE Feature Collection to the map.""" dialog = build_vbox_dialog( windowTitle="Add Feature Collection", @@ -109,7 +111,7 @@ def add_feature_collection_form(iface: gui.QgisInterface, _debug=True, **kwargs) dialog.accepted.connect(lambda: load(**get_values(dialog))) - return + return dialog def load( From b53e90554d088f850009bf0c35a176366bef9ec7 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 13:08:44 -0800 Subject: [PATCH 15/43] Rename func --- ee_plugin/ui/forms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py index 00d25ba..c3d30ca 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/forms.py @@ -109,12 +109,12 @@ def add_feature_collection_form( lambda: iface.messageBar().pushMessage(f"Accepted {get_values(dialog)=}") ) - dialog.accepted.connect(lambda: load(**get_values(dialog))) + dialog.accepted.connect(lambda: add_feature_collection(**get_values(dialog))) return dialog -def load( +def add_feature_collection( feature_collection_id: str, filter_name: str, filter_value: str, From 0ab93c23b63ceb66c93181651183fb1f27d4aea3 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 13:09:07 -0800 Subject: [PATCH 16/43] Cleanup comments --- ee_plugin/ui/forms.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py index c3d30ca..642de4e 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/forms.py @@ -144,16 +144,11 @@ def add_feature_collection( ee.FeatureCollection: The filtered FeatureCollection. """ - # 1. Load the FeatureCollection fc = ee.FeatureCollection(feature_collection_id) - # 2. Filter by property name and value if provided if filter_name and filter_value: fc = fc.filter(ee.Filter.eq(filter_name, filter_value)) - # 3. Filter by date if both start_date and end_date are provided - # This step only makes sense if your FeatureCollection has a date property - # (e.g. "date" or "system:time_start"). Change "date" below to the relevant property. # if start_date and end_date: # fc = fc.filter( # ee.Filter.date(ee.Date(start_date), ee.Date(end_date)) @@ -162,13 +157,12 @@ def add_feature_collection( # # start_date, end_date, 'year' (or 'day', etc.) if using calendarRange # ) - # 4. Filter by geometry if provided extent_src = [mXMinLineEdit, mYMinLineEdit, mXMaxLineEdit, mYMaxLineEdit] if all(extent_src): extent = ee.Geometry.Rectangle([float(val) for val in extent_src]) - # fc = fc.filterBounds(extent) + # Alternatively, if you want to clip features to the extent rather than just filter: - fc = fc.map(lambda f: f.intersection(extent)) + fc = fc.filterBounds(extent) # 6. Add to map layer_name = f"FC: {feature_collection_id}" @@ -194,6 +188,3 @@ def add_feature_collection( layer_name, ) return fc - - -# 'USGS/WBD/2017/HUC06' From de2476162b554da998fb97a1fcaf24f161ecf2f5 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 13:09:16 -0800 Subject: [PATCH 17/43] Simplify flag --- ee_plugin/ui/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py index 642de4e..4330187 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/forms.py @@ -51,7 +51,7 @@ def add_feature_collection_form( QtWidgets.QLineEdit(objectName="feature_collection_id"), ), ( - "Use
add_or_update_ee_vector_layer
", + "Retain as a vector layer", QtWidgets.QCheckBox(objectName="use_util"), ), ], From e2ccb155314f5dfb669d71a5745635632f366a6f Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 13:22:51 -0800 Subject: [PATCH 18/43] Add beginning of tests --- test/test_ui.py | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 test/test_ui.py diff --git a/test/test_ui.py b/test/test_ui.py new file mode 100644 index 0000000..ad40617 --- /dev/null +++ b/test/test_ui.py @@ -0,0 +1,56 @@ +from qgis.PyQt import QtWidgets +from ee_plugin.ui import utils, forms + + +def test_get_values(): + dialog = forms.build_vbox_dialog( + widgets=[ + forms.build_form_group_box( + rows=[ + ( + "Label", + QtWidgets.QLineEdit(objectName="line_edit"), + ), + ( + "Check", + QtWidgets.QCheckBox(objectName="check_box"), + ), + ], + ), + ], + ) + dialog.show() + + dialog.findChild(QtWidgets.QLineEdit, "line_edit").setText("test") + dialog.findChild(QtWidgets.QCheckBox, "check_box").setChecked(True) + + assert utils.get_values(dialog) == { + "line_edit": "test", + "check_box": True, + } + + +def test_add_feature_collection_form(qgis_iface): + dialog = forms.add_feature_collection_form(iface=qgis_iface) + + dialog.findChild(QtWidgets.QLineEdit, "feature_collection_id").setText( + "USGS/WBD/2017/HUC06" + ) + + assert utils.get_values(dialog) == { + "feature_collection_id": "USGS/WBD/2017/HUC06", + "filter_name": "", + "filter_value": "", + # "start_date": "", # TODO: This is missing + # "end_date": "", # TODO: This is missing + "mYMaxLineEdit": "", + "mYMinLineEdit": "", + "mXMaxLineEdit": "", + "mXMinLineEdit": "", + "viz_color_hex": "#000000", + "use_util": False, + **{ # TODO: this is unexpected + "mCondensedLineEdit": "", + "qt_spinbox_lineedit": "2025-02-12", + }, + } From 6e80ff6dbedaac66665148336370f23034f6cab6 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 13:25:38 -0800 Subject: [PATCH 19/43] Refactor get_values to support subclassed types --- ee_plugin/ui/utils.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/ee_plugin/ui/utils.py b/ee_plugin/ui/utils.py index 61a0037..c8debef 100644 --- a/ee_plugin/ui/utils.py +++ b/ee_plugin/ui/utils.py @@ -83,8 +83,9 @@ def get_values(dialog: QDialog) -> dict: QCheckBox: lambda w: w.isChecked(), QgsColorButton: lambda w: w.color().name(), } - return { - w.objectName(): parsers[type(w)](w) - for w in dialog.findChildren(QWidget) - if type(w) in parsers - } + values = {} + for cls, formatter in parsers.items(): + for widget in dialog.findChildren(cls): + values[widget.objectName()] = formatter(widget) + + return values From 73bed0ac945d34e022880ec7c2afb502a4309627 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 13:32:19 -0800 Subject: [PATCH 20/43] add TODO --- ee_plugin/ui/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ee_plugin/ui/utils.py b/ee_plugin/ui/utils.py index c8debef..fa7bde6 100644 --- a/ee_plugin/ui/utils.py +++ b/ee_plugin/ui/utils.py @@ -84,6 +84,7 @@ def get_values(dialog: QDialog) -> dict: QgsColorButton: lambda w: w.color().name(), } values = {} + # TODO: Some widgets have subwidgets with different names, need to reasonbly handle this for cls, formatter in parsers.items(): for widget in dialog.findChildren(cls): values[widget.objectName()] = formatter(widget) From 2904eb17ce3401989a013e0ee169292b6555677a Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 15:04:44 -0800 Subject: [PATCH 21/43] Build parser for QgsDateEdit --- ee_plugin/ui/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ee_plugin/ui/utils.py b/ee_plugin/ui/utils.py index fa7bde6..8cbcf92 100644 --- a/ee_plugin/ui/utils.py +++ b/ee_plugin/ui/utils.py @@ -80,6 +80,7 @@ def get_values(dialog: QDialog) -> dict: parsers = { QLineEdit: lambda w: w.text(), QDateEdit: lambda w: w.date().toString("yyyy-MM-dd"), + QgsDateEdit: lambda w: None if w.isNull() else w.findChild(QLineEdit).text(), QCheckBox: lambda w: w.isChecked(), QgsColorButton: lambda w: w.color().name(), } From 802986d5c02fbca9d117240014b4807dbdb9920c Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 15:06:09 -0800 Subject: [PATCH 22/43] Add docs --- ee_plugin/ui/utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ee_plugin/ui/utils.py b/ee_plugin/ui/utils.py index 8cbcf92..cdc338e 100644 --- a/ee_plugin/ui/utils.py +++ b/ee_plugin/ui/utils.py @@ -76,7 +76,15 @@ def build_vbox_dialog( def get_values(dialog: QDialog) -> dict: """ Return a dictionary of all widget values from dialog. + + Note that the response dictionary may contain keys that were not explicitely set as + object names in the widgets. This is due to the fact that some widgets are composites + of multiple child widgets. The child widgets are parsed and stored in the response + but it is the value of the parent widget that should be used in the application. """ + # NOTE: To support more widgets, register the widget class with a parser here. These + # parsers are read in order, so more specific widgets should be listed last as their + # results will overwrite more general widgets. parsers = { QLineEdit: lambda w: w.text(), QDateEdit: lambda w: w.date().toString("yyyy-MM-dd"), From 3b6119a3f6e622232e2a3245123eb083243d03df Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 15:06:38 -0800 Subject: [PATCH 23/43] Cleanup --- ee_plugin/ui/forms.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py index 4330187..15b5e6c 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/forms.py @@ -28,7 +28,7 @@ def DefaultNullQgsDateEdit( def add_feature_collection_form( - iface: gui.QgisInterface, _debug=True, **kwargs + iface: gui.QgisInterface, **dialog_kwargs ) -> QtWidgets.QDialog: """Add a GEE Feature Collection to the map.""" dialog = build_vbox_dialog( @@ -101,16 +101,10 @@ def add_feature_collection_form( ), ], parent=iface.mainWindow(), - **kwargs, + **dialog_kwargs, ) - if _debug: - dialog.accepted.connect( - lambda: iface.messageBar().pushMessage(f"Accepted {get_values(dialog)=}") - ) - dialog.accepted.connect(lambda: add_feature_collection(**get_values(dialog))) - return dialog From 9624c3fde7bd69b59cbd9929dc462995d5117dda Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 16:01:31 -0800 Subject: [PATCH 24/43] Simplify --- ee_plugin/ui/forms.py | 38 ++++++++------------------------------ 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py index 15b5e6c..d5efc82 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/forms.py @@ -112,12 +112,9 @@ def add_feature_collection( feature_collection_id: str, filter_name: str, filter_value: str, - start_date: str, - end_date: str, - mYMaxLineEdit: str, - mYMinLineEdit: str, - mXMaxLineEdit: str, - mXMinLineEdit: str, + start_date: Optional[str], + end_date: Optional[str], + extent: Optional[tuple[float, float, float, float]], viz_color_hex: str, use_util: bool, **kwargs, @@ -143,20 +140,11 @@ def add_feature_collection( if filter_name and filter_value: fc = fc.filter(ee.Filter.eq(filter_name, filter_value)) - # if start_date and end_date: - # fc = fc.filter( - # ee.Filter.date(ee.Date(start_date), ee.Date(end_date)) - # # If your features store date in a property named "date", you might need: - # # ee.Filter.calendarRange( - # # start_date, end_date, 'year' (or 'day', etc.) if using calendarRange - # ) + if start_date and end_date: + fc = fc.filter(ee.Filter.date(ee.Date(start_date), ee.Date(end_date))) - extent_src = [mXMinLineEdit, mYMinLineEdit, mXMaxLineEdit, mYMaxLineEdit] - if all(extent_src): - extent = ee.Geometry.Rectangle([float(val) for val in extent_src]) - - # Alternatively, if you want to clip features to the extent rather than just filter: - fc = fc.filterBounds(extent) + if extent: + fc = fc.filterBounds(ee.Geometry.Rectangle(extent)) # 6. Add to map layer_name = f"FC: {feature_collection_id}" @@ -170,15 +158,5 @@ def add_feature_collection( level=gui.Qgis.Critical, ) else: - Map.addLayer( - fc, - { - # "color": viz_color_hex, - # If you'd like transparency or a custom fill color, you can add: - # "fillColor": viz_color_hex, - # "opacity": 0.6, - "palette": viz_color_hex, - }, - layer_name, - ) + Map.addLayer(fc, {"palette": viz_color_hex}, layer_name) return fc From 03d5758b8b76c704bf7cab3501e1b83c12ed570b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 16:02:05 -0800 Subject: [PATCH 25/43] Support QgsExtentGroupBox --- ee_plugin/ui/utils.py | 11 +++++++++-- ee_plugin/ui/widget_parsers.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 ee_plugin/ui/widget_parsers.py diff --git a/ee_plugin/ui/utils.py b/ee_plugin/ui/utils.py index cdc338e..8d328df 100644 --- a/ee_plugin/ui/utils.py +++ b/ee_plugin/ui/utils.py @@ -13,7 +13,14 @@ QCheckBox, QLayout, ) -from qgis.gui import QgsColorButton, QgsCollapsibleGroupBox +from qgis.gui import ( + QgsColorButton, + QgsCollapsibleGroupBox, + QgsDateEdit, + QgsExtentGroupBox, +) + +from . import widget_parsers def build_form_group_box( @@ -91,9 +98,9 @@ def get_values(dialog: QDialog) -> dict: QgsDateEdit: lambda w: None if w.isNull() else w.findChild(QLineEdit).text(), QCheckBox: lambda w: w.isChecked(), QgsColorButton: lambda w: w.color().name(), + QgsExtentGroupBox: widget_parsers.qgs_extent_to_bbox, } values = {} - # TODO: Some widgets have subwidgets with different names, need to reasonbly handle this for cls, formatter in parsers.items(): for widget in dialog.findChildren(cls): values[widget.objectName()] = formatter(widget) diff --git a/ee_plugin/ui/widget_parsers.py b/ee_plugin/ui/widget_parsers.py new file mode 100644 index 0000000..4193e94 --- /dev/null +++ b/ee_plugin/ui/widget_parsers.py @@ -0,0 +1,34 @@ +from typing import Optional + +from qgis.gui import QgsExtentGroupBox +from qgis.core import QgsCoordinateReferenceSystem, QgsCoordinateTransform, QgsProject + + +def qgs_extent_to_bbox( + w: QgsExtentGroupBox, +) -> Optional[tuple[float, float, float, float]]: + """ + Convert a QgsRectangle in a given CRS to an Earth Engine ee.Geometry.Rectangle. + + :param rect: A QgsRectangle representing the bounding box. + :param source_crs: The CRS in which rect is defined (e.g., QgsCoordinateReferenceSystem("EPSG:XXXX")). + :param target_crs: The CRS to transform to. Defaults to EPSG:4326 (WGS84 lat/lon). + :return: An ee.Geometry.Rectangle in the target CRS (default: EPSG:4326). + """ + extent = w.outputExtent() + if extent.area() == float("inf"): + return None + + source_crs = w.outputCrs() + target_crs = QgsCoordinateReferenceSystem("EPSG:4326") + + extent_transformed = QgsCoordinateTransform( + source_crs, target_crs, QgsProject.instance() + ).transformBoundingBox(extent) + + return ( + extent_transformed.xMinimum(), + extent_transformed.yMinimum(), + extent_transformed.xMaximum(), + extent_transformed.yMaximum(), + ) From 728a0828b5a9bf2f72f92fffdd898317689a39ab Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 12 Feb 2025 16:02:14 -0800 Subject: [PATCH 26/43] Update tests --- test/test_ui.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/test/test_ui.py b/test/test_ui.py index ad40617..d09e3e2 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -37,20 +37,18 @@ def test_add_feature_collection_form(qgis_iface): "USGS/WBD/2017/HUC06" ) - assert utils.get_values(dialog) == { - "feature_collection_id": "USGS/WBD/2017/HUC06", - "filter_name": "", - "filter_value": "", - # "start_date": "", # TODO: This is missing - # "end_date": "", # TODO: This is missing - "mYMaxLineEdit": "", - "mYMinLineEdit": "", - "mXMaxLineEdit": "", - "mXMinLineEdit": "", - "viz_color_hex": "#000000", - "use_util": False, - **{ # TODO: this is unexpected - "mCondensedLineEdit": "", - "qt_spinbox_lineedit": "2025-02-12", - }, - } + dialog_values = utils.get_values(dialog) + + assert ( + dialog_values.items() + >= { + "feature_collection_id": "USGS/WBD/2017/HUC06", + "filter_name": "", + "filter_value": "", + "start_date": None, + "end_date": None, + "extent": None, + "viz_color_hex": "#000000", + "use_util": False, + }.items() + ) From 8fe6a4a47a0f9fa6c9e3d43a5f2c146962fc2d9d Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 13 Feb 2025 09:22:59 -0800 Subject: [PATCH 27/43] Rework form calls with helper to filter args --- ee_plugin/ui/forms.py | 12 +++++++----- ee_plugin/ui/utils.py | 18 ++++++++++++++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py index d5efc82..aa18c0a 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/forms.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Callable from qgis import gui from qgis.PyQt import QtWidgets, QtCore import ee @@ -6,7 +6,7 @@ from .utils import ( build_form_group_box, build_vbox_dialog, - get_values, + call_func_with_values, ) from .. import Map, utils @@ -28,7 +28,9 @@ def DefaultNullQgsDateEdit( def add_feature_collection_form( - iface: gui.QgisInterface, **dialog_kwargs + iface: gui.QgisInterface, + accepted: Optional[Callable] = None, + **dialog_kwargs, ) -> QtWidgets.QDialog: """Add a GEE Feature Collection to the map.""" dialog = build_vbox_dialog( @@ -104,7 +106,8 @@ def add_feature_collection_form( **dialog_kwargs, ) - dialog.accepted.connect(lambda: add_feature_collection(**get_values(dialog))) + if accepted: + dialog.accepted.connect(lambda: call_func_with_values(accepted, dialog)) return dialog @@ -117,7 +120,6 @@ def add_feature_collection( extent: Optional[tuple[float, float, float, float]], viz_color_hex: str, use_util: bool, - **kwargs, ): """ Loads and optionally filters a FeatureCollection, then adds it to the map. diff --git a/ee_plugin/ui/utils.py b/ee_plugin/ui/utils.py index 8d328df..e24152a 100644 --- a/ee_plugin/ui/utils.py +++ b/ee_plugin/ui/utils.py @@ -1,5 +1,6 @@ +import inspect from dataclasses import field -from typing import List, Tuple, Union +from typing import Callable, List, Tuple, Union from qgis.PyQt.QtWidgets import ( QWidget, @@ -80,7 +81,7 @@ def build_vbox_dialog( return dialog -def get_values(dialog: QDialog) -> dict: +def get_dialog_values(dialog: QDialog) -> dict: """ Return a dictionary of all widget values from dialog. @@ -106,3 +107,16 @@ def get_values(dialog: QDialog) -> dict: values[widget.objectName()] = formatter(widget) return values + + +def call_func_with_values(func: Callable, dialog: QDialog): + """ + Call a function with values from a dialog. Prior to the call, the function signature + is inspected and used to filter out any values from the dialog that are not expected + by the function. + """ + func_signature = inspect.signature(func) + func_kwargs = set(func_signature.parameters.keys()) + dialog_values = get_dialog_values(dialog) + kwargs = {k: v for k, v in dialog_values.items() if k in func_kwargs} + return func(**kwargs) From d40bc9e344ba40748e6e14bbc21aada9755be11f Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 13 Feb 2025 09:23:05 -0800 Subject: [PATCH 28/43] Expand tests --- test/test_ui.py | 107 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 85 insertions(+), 22 deletions(-) diff --git a/test/test_ui.py b/test/test_ui.py index d09e3e2..f537227 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -1,4 +1,9 @@ -from qgis.PyQt import QtWidgets +import datetime +from unittest.mock import create_autospec + +import pytest +from qgis import gui +from qgis.PyQt import QtWidgets, QtGui from ee_plugin.ui import utils, forms @@ -24,31 +29,89 @@ def test_get_values(): dialog.findChild(QtWidgets.QLineEdit, "line_edit").setText("test") dialog.findChild(QtWidgets.QCheckBox, "check_box").setChecked(True) - assert utils.get_values(dialog) == { + assert utils.get_dialog_values(dialog) == { "line_edit": "test", "check_box": True, } -def test_add_feature_collection_form(qgis_iface): - dialog = forms.add_feature_collection_form(iface=qgis_iface) +@pytest.mark.parametrize( + "form_input,expected_form_output", + [ + ( + { + "feature_collection_id": "USGS/WBD/2017/HUC06", + }, + { + "feature_collection_id": "USGS/WBD/2017/HUC06", + }, + ), + ( + { + "feature_collection_id": "USGS/WBD/2017/HUC06", + "start_date": (gui.QgsDateEdit, "2020-01-01"), + "end_date": (gui.QgsDateEdit, "2020-12-31"), + }, + { + "feature_collection_id": "USGS/WBD/2017/HUC06", + "start_date": "2020-01-01", + "end_date": "2020-12-31", + }, + ), + ( + { + "feature_collection_id": "USGS/WBD/2017/HUC06", + "start_date": (gui.QgsDateEdit, "2020-01-01"), + "end_date": (gui.QgsDateEdit, "2020-12-31"), + }, + { + "feature_collection_id": "USGS/WBD/2017/HUC06", + "start_date": "2020-01-01", + "end_date": "2020-12-31", + }, + ), + ( + { + "feature_collection_id": "USGS/WBD/2017/HUC06", + "viz_color_hex": (gui.QgsColorButton, "#F00"), + }, + { + "feature_collection_id": "USGS/WBD/2017/HUC06", + "viz_color_hex": "#ff0000", + }, + ), + ], +) +def test_add_feature_collection_form(qgis_iface, form_input, expected_form_output): + callback = create_autospec(forms.add_feature_collection) + dialog = forms.add_feature_collection_form(iface=qgis_iface, accepted=callback) - dialog.findChild(QtWidgets.QLineEdit, "feature_collection_id").setText( - "USGS/WBD/2017/HUC06" - ) + # Populate dialog with form_input + for key, value in form_input.items(): + if isinstance(value, tuple): + widget_cls, value = value + else: + widget_cls = QtWidgets.QWidget - dialog_values = utils.get_values(dialog) - - assert ( - dialog_values.items() - >= { - "feature_collection_id": "USGS/WBD/2017/HUC06", - "filter_name": "", - "filter_value": "", - "start_date": None, - "end_date": None, - "extent": None, - "viz_color_hex": "#000000", - "use_util": False, - }.items() - ) + widget = dialog.findChild(widget_cls, key) + + if isinstance(widget, gui.QgsDateEdit): + widget.setDate(datetime.datetime.strptime(value, "%Y-%m-%d").date()) + elif isinstance(widget, gui.QgsColorButton): + widget.setColor(QtGui.QColor(value)) + else: + widget.setText(value) + + dialog.accept() + + default_outputs = { + "filter_name": "", + "filter_value": "", + "start_date": None, + "end_date": None, + "extent": None, + "viz_color_hex": "#000000", + "use_util": False, + } + + callback.assert_called_once_with(**{**default_outputs, **expected_form_output}) From f5b74b926ead45f41fa83ee0ae8138a4e3ab4e25 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 13 Feb 2025 09:33:00 -0800 Subject: [PATCH 29/43] Set default display format on custom date edit widget --- ee_plugin/ui/forms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py index aa18c0a..e886f74 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/forms.py @@ -13,10 +13,10 @@ def DefaultNullQgsDateEdit( - *, date: Optional[QtCore.QDate] = None, **kwargs + *, date: Optional[QtCore.QDate] = None, displayFormat="yyyy-MM-dd", **kwargs ) -> gui.QgsDateEdit: """Build a QgsDateEdit widget, with null default capability.""" - d = gui.QgsDateEdit(**kwargs) + d = gui.QgsDateEdit(**kwargs, displayFormat=displayFormat) # It would be great to remove this helper and just use the built-in QgsDateEdit class # but at this time it's not clear how to make a DateEdit widget that initializes with # a null value. This is a workaround. From ef857c71d5620ce09c9ecbe5b210ad1aec117fb5 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 13 Feb 2025 09:38:51 -0800 Subject: [PATCH 30/43] Add types for utils (#228) * Buildout types for utils * Update ee_plugin/utils.py --- ee_plugin/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee_plugin/utils.py b/ee_plugin/utils.py index 076b56a..9ba79cc 100644 --- a/ee_plugin/utils.py +++ b/ee_plugin/utils.py @@ -286,7 +286,7 @@ def add_ee_catalog_image( """ Adds an EE image from a catalog. """ - image = ee.Image(asset_name).visualize(vis_params) + image = ee.Image(asset_name).visualize(**vis_params) add_or_update_ee_raster_layer(image, name) From 4a334b89d8b056152a8a9fbce43b9c409fdc4505 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 13 Feb 2025 09:40:28 -0800 Subject: [PATCH 31/43] Rm unnecessary change --- ee_plugin/utils.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ee_plugin/utils.py b/ee_plugin/utils.py index 9ba79cc..1b9650c 100644 --- a/ee_plugin/utils.py +++ b/ee_plugin/utils.py @@ -56,8 +56,6 @@ def add_or_update_ee_layer( """ Entry point to add/update an EE layer. Routes between raster, vector layers, and vector tile layers. """ - vis_params = vis_params or {} - if isinstance(eeObject, ee.Image): return add_or_update_ee_raster_layer(eeObject, name, vis_params, shown, opacity) From 57d688182fae1e171aefafb586fafcdd3c027395 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 13 Feb 2025 09:49:11 -0800 Subject: [PATCH 32/43] Cleanup docstring --- ee_plugin/ui/widget_parsers.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/ee_plugin/ui/widget_parsers.py b/ee_plugin/ui/widget_parsers.py index 4193e94..22ab868 100644 --- a/ee_plugin/ui/widget_parsers.py +++ b/ee_plugin/ui/widget_parsers.py @@ -8,12 +8,8 @@ def qgs_extent_to_bbox( w: QgsExtentGroupBox, ) -> Optional[tuple[float, float, float, float]]: """ - Convert a QgsRectangle in a given CRS to an Earth Engine ee.Geometry.Rectangle. - - :param rect: A QgsRectangle representing the bounding box. - :param source_crs: The CRS in which rect is defined (e.g., QgsCoordinateReferenceSystem("EPSG:XXXX")). - :param target_crs: The CRS to transform to. Defaults to EPSG:4326 (WGS84 lat/lon). - :return: An ee.Geometry.Rectangle in the target CRS (default: EPSG:4326). + Convert a QgsRectangle in a given CRS to an EPSG:4326 bounding box, formatted as + (xmin, ymin, xmax, ymax). """ extent = w.outputExtent() if extent.area() == float("inf"): From bb046afb54f51f05994ac2dd5bd27463bdef4c75 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 13 Feb 2025 09:50:58 -0800 Subject: [PATCH 33/43] Add notes --- ee_plugin/ui/forms.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py index e886f74..896ea51 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/forms.py @@ -16,10 +16,12 @@ def DefaultNullQgsDateEdit( *, date: Optional[QtCore.QDate] = None, displayFormat="yyyy-MM-dd", **kwargs ) -> gui.QgsDateEdit: """Build a QgsDateEdit widget, with null default capability.""" + # NOTE: Specifying a displayFormat guarantees that the date will be formatted as + # expected across different runtime environments. d = gui.QgsDateEdit(**kwargs, displayFormat=displayFormat) - # It would be great to remove this helper and just use the built-in QgsDateEdit class - # but at this time it's not clear how to make a DateEdit widget that initializes with - # a null value. This is a workaround. + # NOTE: It would be great to remove this helper and just use the built-in QgsDateEdit + # class but at this time it's not clear how to make a DateEdit widget that initializes + # with a null value. This is a workaround. if date is None: d.clear() else: From ca7d52c62924b8d7dfaf38afa0c2243ba0fc5b1e Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 13 Feb 2025 10:05:18 -0800 Subject: [PATCH 34/43] Rename 'use_util' to 'as_vector' and update tooltip for feature collection form --- ee_plugin/ui/forms.py | 18 ++++++++++++------ test/test_ui.py | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py index 896ea51..4289046 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/forms.py @@ -49,14 +49,20 @@ def add_feature_collection_form( "e.g. USGS/WBD/2017/HUC06", ] ), - toolTip="This is a tooltip!", - whatsThis='This is "WhatsThis"! Link', ), - QtWidgets.QLineEdit(objectName="feature_collection_id"), + QtWidgets.QLineEdit( + objectName="feature_collection_id", + whatsThis=( + "Attempt to retain the layer as a vector layer, running " + "the risk of encountering Earth Engine API limitations if " + "the layer is large. Otherwise, the layer will be added as " + "a WMS raster layer." + ), + ), ), ( "Retain as a vector layer", - QtWidgets.QCheckBox(objectName="use_util"), + QtWidgets.QCheckBox(objectName="as_vector"), ), ], ), @@ -121,7 +127,7 @@ def add_feature_collection( end_date: Optional[str], extent: Optional[tuple[float, float, float, float]], viz_color_hex: str, - use_util: bool, + as_vector: bool, ): """ Loads and optionally filters a FeatureCollection, then adds it to the map. @@ -152,7 +158,7 @@ def add_feature_collection( # 6. Add to map layer_name = f"FC: {feature_collection_id}" - if use_util: + if as_vector: try: utils.add_ee_vector_layer(fc, layer_name) except ee.ee_exception.EEException as e: diff --git a/test/test_ui.py b/test/test_ui.py index f537227..8ed5273 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -111,7 +111,7 @@ def test_add_feature_collection_form(qgis_iface, form_input, expected_form_outpu "end_date": None, "extent": None, "viz_color_hex": "#000000", - "use_util": False, + "as_vector": False, } callback.assert_called_once_with(**{**default_outputs, **expected_form_output}) From 34d9236b02b46a6689546ecbf6047329b66600da Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 13 Feb 2025 10:28:14 -0800 Subject: [PATCH 35/43] Refactor translation handling in the plugin by introducing a utility function and updating UI elements to use it for localization. --- ee_plugin/ee_plugin.py | 3 +-- ee_plugin/ui/forms.py | 42 +++++++++++++++++++++++++++--------------- ee_plugin/utils.py | 8 ++++++++ 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/ee_plugin/ee_plugin.py b/ee_plugin/ee_plugin.py index e5bd043..5e6ef0d 100644 --- a/ee_plugin/ee_plugin.py +++ b/ee_plugin/ee_plugin.py @@ -89,8 +89,7 @@ def tr(self, message): :returns: Translated version of message. :rtype: QString """ - # noinspection PyTypeChecker,PyArgumentList,PyCallByClass - return QCoreApplication.translate("GoogleEarthEngine", message) + return utils.translate(message) def initGui(self): """Initialize the plugin GUI.""" diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py index 4289046..151a79b 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/forms.py @@ -10,6 +10,7 @@ ) from .. import Map, utils +from ..utils import translate as _ def DefaultNullQgsDateEdit( @@ -36,53 +37,61 @@ def add_feature_collection_form( ) -> QtWidgets.QDialog: """Add a GEE Feature Collection to the map.""" dialog = build_vbox_dialog( - windowTitle="Add Feature Collection", + windowTitle=_("Add Feature Collection"), widgets=[ build_form_group_box( - title="Source", + title=_("Source"), rows=[ ( QtWidgets.QLabel( + toolTip=_("The Earth Engine Feature Collection ID."), text="
".join( [ - "Add GEE Feature Collection to Map", + _("Feature Collection ID"), "e.g. USGS/WBD/2017/HUC06", ] ), ), QtWidgets.QLineEdit( objectName="feature_collection_id", - whatsThis=( + ), + ), + ( + QtWidgets.QLabel( + _("Retain as a vector layer"), + toolTip=_( + "Store as a vector layer rather than WMS Raster layer." + ), + whatsThis=_( "Attempt to retain the layer as a vector layer, running " "the risk of encountering Earth Engine API limitations if " "the layer is large. Otherwise, the layer will be added as " "a WMS raster layer." ), ), - ), - ( - "Retain as a vector layer", - QtWidgets.QCheckBox(objectName="as_vector"), + QtWidgets.QCheckBox( + objectName="as_vector", + ), ), ], ), build_form_group_box( - title="Filter by Properties", + title=_("Filter by Properties"), collapsable=True, collapsed=True, rows=[ ( - "Name", + _("Name"), QtWidgets.QLineEdit(objectName="filter_name"), ), ( - "Value", + _("Value"), QtWidgets.QLineEdit(objectName="filter_value"), ), ], ), build_form_group_box( - title="Filter by Dates", + title=_("Filter by Dates"), collapsable=True, collapsed=True, rows=[ @@ -98,15 +107,18 @@ def add_feature_collection_form( ), gui.QgsExtentGroupBox( objectName="extent", - title="Filter by Coordinates", + title=_("Filter by Coordinates"), collapsed=True, ), build_form_group_box( - title="Visualization", + title=_("Visualization"), collapsable=True, collapsed=True, rows=[ - ("Color", gui.QgsColorButton(objectName="viz_color_hex")), + ( + _("Color"), + gui.QgsColorButton(objectName="viz_color_hex"), + ), ], ), ], diff --git a/ee_plugin/utils.py b/ee_plugin/utils.py index 1b9650c..ccc02b8 100644 --- a/ee_plugin/utils.py +++ b/ee_plugin/utils.py @@ -10,6 +10,7 @@ import ee import qgis from qgis.core import QgsProject, QgsRasterLayer, QgsVectorLayer, QgsMapLayer +from qgis.PyQt.QtCore import QCoreApplication class VisualizeParams(TypedDict, total=False): @@ -293,3 +294,10 @@ def check_version() -> None: Check if we have the latest plugin version. """ qgis.utils.plugins["ee_plugin"].check_version() + + +def translate(message: str) -> str: + """ + Helper to translate messages. + """ + return QCoreApplication.translate("GoogleEarthEngine", message) From e9d9779e3db8a21c002a94c3bda6b523873d3d4b Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 13 Feb 2025 11:15:10 -0800 Subject: [PATCH 36/43] Move DefaultNullQgsDateEdit function to utils and simplify form handling --- ee_plugin/ui/forms.py | 20 ++------------------ ee_plugin/ui/utils.py | 21 ++++++++++++++++++++- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/forms.py index 151a79b..408dcdf 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/forms.py @@ -1,35 +1,19 @@ from typing import Optional, Callable from qgis import gui -from qgis.PyQt import QtWidgets, QtCore +from qgis.PyQt import QtWidgets import ee from .utils import ( build_form_group_box, build_vbox_dialog, call_func_with_values, + DefaultNullQgsDateEdit, ) from .. import Map, utils from ..utils import translate as _ -def DefaultNullQgsDateEdit( - *, date: Optional[QtCore.QDate] = None, displayFormat="yyyy-MM-dd", **kwargs -) -> gui.QgsDateEdit: - """Build a QgsDateEdit widget, with null default capability.""" - # NOTE: Specifying a displayFormat guarantees that the date will be formatted as - # expected across different runtime environments. - d = gui.QgsDateEdit(**kwargs, displayFormat=displayFormat) - # NOTE: It would be great to remove this helper and just use the built-in QgsDateEdit - # class but at this time it's not clear how to make a DateEdit widget that initializes - # with a null value. This is a workaround. - if date is None: - d.clear() - else: - d.setDate(date) - return d - - def add_feature_collection_form( iface: gui.QgisInterface, accepted: Optional[Callable] = None, diff --git a/ee_plugin/ui/utils.py b/ee_plugin/ui/utils.py index e24152a..6ef1df0 100644 --- a/ee_plugin/ui/utils.py +++ b/ee_plugin/ui/utils.py @@ -1,7 +1,9 @@ import inspect from dataclasses import field -from typing import Callable, List, Tuple, Union +from typing import Callable, List, Tuple, Union, Optional +from qgis import gui +from qgis.PyQt import QtCore from qgis.PyQt.QtWidgets import ( QWidget, QGroupBox, @@ -120,3 +122,20 @@ def call_func_with_values(func: Callable, dialog: QDialog): dialog_values = get_dialog_values(dialog) kwargs = {k: v for k, v in dialog_values.items() if k in func_kwargs} return func(**kwargs) + + +def DefaultNullQgsDateEdit( + *, date: Optional[QtCore.QDate] = None, displayFormat="yyyy-MM-dd", **kwargs +) -> gui.QgsDateEdit: + """Build a QgsDateEdit widget, with null default capability.""" + # NOTE: Specifying a displayFormat guarantees that the date will be formatted as + # expected across different runtime environments. + d = gui.QgsDateEdit(**kwargs, displayFormat=displayFormat) + # NOTE: It would be great to remove this helper and just use the built-in QgsDateEdit + # class but at this time it's not clear how to make a DateEdit widget that initializes + # with a null value. This is a workaround. + if date is None: + d.clear() + else: + d.setDate(date) + return d From 864a1e50cdb9f18bc75707f4f4484fe2018c3cbd Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 13 Feb 2025 11:21:27 -0800 Subject: [PATCH 37/43] Rename forms module to add_feature_collection This commit attempts to establish a new pattern where every form is written as a separate module in ee_plugin.ui with a form and callback function. --- ee_plugin/ee_plugin.py | 6 ++++-- ee_plugin/ui/{forms.py => add_feature_collection.py} | 4 ++-- test/test_ui.py | 12 ++++++------ 3 files changed, 12 insertions(+), 10 deletions(-) rename ee_plugin/ui/{forms.py => add_feature_collection.py} (98%) diff --git a/ee_plugin/ee_plugin.py b/ee_plugin/ee_plugin.py index 5e6ef0d..d3aaaae 100644 --- a/ee_plugin/ee_plugin.py +++ b/ee_plugin/ee_plugin.py @@ -21,7 +21,7 @@ import ee from . import provider, config, ee_auth, utils -from .ui import forms +from .ui import add_feature_collection PLUGIN_DIR = os.path.dirname(__file__) @@ -115,7 +115,9 @@ def initGui(self): add_fc_button = QtWidgets.QAction( text=self.tr("Add Feature Collection"), parent=self.iface.mainWindow(), - triggered=lambda: forms.add_feature_collection_form(self.iface), + triggered=lambda: add_feature_collection.form( + self.iface, accepted=add_feature_collection.callback + ), ) # Initialize plugin menu diff --git a/ee_plugin/ui/forms.py b/ee_plugin/ui/add_feature_collection.py similarity index 98% rename from ee_plugin/ui/forms.py rename to ee_plugin/ui/add_feature_collection.py index 408dcdf..5ab0807 100644 --- a/ee_plugin/ui/forms.py +++ b/ee_plugin/ui/add_feature_collection.py @@ -14,7 +14,7 @@ from ..utils import translate as _ -def add_feature_collection_form( +def form( iface: gui.QgisInterface, accepted: Optional[Callable] = None, **dialog_kwargs, @@ -115,7 +115,7 @@ def add_feature_collection_form( return dialog -def add_feature_collection( +def callback( feature_collection_id: str, filter_name: str, filter_value: str, diff --git a/test/test_ui.py b/test/test_ui.py index 8ed5273..6f85305 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -4,13 +4,13 @@ import pytest from qgis import gui from qgis.PyQt import QtWidgets, QtGui -from ee_plugin.ui import utils, forms +from ee_plugin.ui import utils, add_feature_collection def test_get_values(): - dialog = forms.build_vbox_dialog( + dialog = utils.build_vbox_dialog( widgets=[ - forms.build_form_group_box( + utils.build_form_group_box( rows=[ ( "Label", @@ -83,8 +83,8 @@ def test_get_values(): ], ) def test_add_feature_collection_form(qgis_iface, form_input, expected_form_output): - callback = create_autospec(forms.add_feature_collection) - dialog = forms.add_feature_collection_form(iface=qgis_iface, accepted=callback) + mock_callback = create_autospec(add_feature_collection.callback) + dialog = add_feature_collection.form(iface=qgis_iface, accepted=mock_callback) # Populate dialog with form_input for key, value in form_input.items(): @@ -114,4 +114,4 @@ def test_add_feature_collection_form(qgis_iface, form_input, expected_form_outpu "as_vector": False, } - callback.assert_called_once_with(**{**default_outputs, **expected_form_output}) + mock_callback.assert_called_once_with(**{**default_outputs, **expected_form_output}) From 6b2a2f9f7b8ac4d0e9ed51f9f29974bb7397ae51 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 13 Feb 2025 11:22:28 -0800 Subject: [PATCH 38/43] Rm button comments --- ee_plugin/ee_plugin.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/ee_plugin/ee_plugin.py b/ee_plugin/ee_plugin.py index d3aaaae..aa5d238 100644 --- a/ee_plugin/ee_plugin.py +++ b/ee_plugin/ee_plugin.py @@ -129,14 +129,9 @@ def initGui(self): # Initialize toolbar menu self.toolButton = QtWidgets.QToolButton() - self.toolButton.setToolButtonStyle( - Qt.ToolButtonStyle.ToolButtonIconOnly - # Qt.ToolButtonStyle.ToolButtonTextBesideIcon - ) + self.toolButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) self.toolButton.setPopupMode( - # QtWidgets.QToolButton.ToolButtonPopupMode.DelayedPopup # Button is only for triggering action - QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup # Button is only for opening dropdown menu - # QtWidgets.QToolButton.ToolButtonPopupMode.MenuButtonPopup # Button is split into action and dropdown menu + QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup ) self.toolButton.setMenu(QtWidgets.QMenu()) self.toolButton.setDefaultAction( From 9fe2ba2dfb8ceb45128119e49f4eee71a83a616e Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 13 Feb 2025 13:48:20 -0800 Subject: [PATCH 39/43] Mv form to form dir --- ee_plugin/ee_plugin.py | 5 +++-- ee_plugin/ui/{ => forms}/add_feature_collection.py | 6 +++--- test/test_ui.py | 3 ++- 3 files changed, 8 insertions(+), 6 deletions(-) rename ee_plugin/ui/{ => forms}/add_feature_collection.py (98%) diff --git a/ee_plugin/ee_plugin.py b/ee_plugin/ee_plugin.py index aa5d238..e637f5d 100644 --- a/ee_plugin/ee_plugin.py +++ b/ee_plugin/ee_plugin.py @@ -21,7 +21,7 @@ import ee from . import provider, config, ee_auth, utils -from .ui import add_feature_collection +from .ui.forms import add_feature_collection PLUGIN_DIR = os.path.dirname(__file__) @@ -116,7 +116,8 @@ def initGui(self): text=self.tr("Add Feature Collection"), parent=self.iface.mainWindow(), triggered=lambda: add_feature_collection.form( - self.iface, accepted=add_feature_collection.callback + self.iface, + accepted=add_feature_collection.callback, ), ) diff --git a/ee_plugin/ui/add_feature_collection.py b/ee_plugin/ui/forms/add_feature_collection.py similarity index 98% rename from ee_plugin/ui/add_feature_collection.py rename to ee_plugin/ui/forms/add_feature_collection.py index 5ab0807..4888f85 100644 --- a/ee_plugin/ui/add_feature_collection.py +++ b/ee_plugin/ui/forms/add_feature_collection.py @@ -3,15 +3,15 @@ from qgis.PyQt import QtWidgets import ee -from .utils import ( +from ..utils import ( build_form_group_box, build_vbox_dialog, call_func_with_values, DefaultNullQgsDateEdit, ) -from .. import Map, utils -from ..utils import translate as _ +from ... import Map, utils +from ...utils import translate as _ def form( diff --git a/test/test_ui.py b/test/test_ui.py index 6f85305..03e5cb2 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -2,9 +2,10 @@ from unittest.mock import create_autospec import pytest +from ee_plugin.ui.forms import add_feature_collection from qgis import gui from qgis.PyQt import QtWidgets, QtGui -from ee_plugin.ui import utils, add_feature_collection +from ee_plugin.ui import utils def test_get_values(): From d606826e007d82d04a68464dec5619ebda629f58 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 14 Feb 2025 16:10:45 -0800 Subject: [PATCH 40/43] Add failing test --- test/test_ui.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/test_ui.py b/test/test_ui.py index 03e5cb2..b1ef398 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -116,3 +116,20 @@ def test_add_feature_collection_form(qgis_iface, form_input, expected_form_outpu } mock_callback.assert_called_once_with(**{**default_outputs, **expected_form_output}) + + +def test_callback(qgis_iface): + add_feature_collection.callback( + feature_collection_id="USGS/WBD/2017/HUC06", + filter_name=None, + filter_value=None, + start_date=None, + end_date=None, + extent=None, + viz_color_hex="#000000", + as_vector=False, + ) + + assert len(qgis_iface.mapCanvas().layers()) == 1 + assert qgis_iface.mapCanvas().layers()[0].name() == "USGS/WBD/2017/HUC06" + assert qgis_iface.mapCanvas().layers()[0].dataProvider().name() == "EE" From 3e76ba0eadddab0236f2dde920b32d70f2f4d646 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 14 Feb 2025 19:40:50 -0800 Subject: [PATCH 41/43] Clear layers between tests --- test/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/conftest.py b/test/conftest.py index 0c0fce1..cd6eeeb 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,5 +1,6 @@ import ee from pytest import fixture +from qgis.gui import QgisInterface from qgis.utils import plugins from PyQt5.QtCore import QSettings, QCoreApplication @@ -33,3 +34,8 @@ def load_ee_plugin(qgis_app, setup_ee, ee_config): plugins["ee_plugin"] = plugin plugin.check_version() yield qgis_app + + +@fixture(autouse=True) +def clean_qgis_iface(qgis_iface: QgisInterface): + qgis_iface.mapCanvas().setLayers([]) From c57a16c135abec8eccaafa0a1eed146d7c8693c8 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 14 Feb 2025 20:54:34 -0800 Subject: [PATCH 42/43] Fix layer name in UI test assertion --- test/test_ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_ui.py b/test/test_ui.py index b1ef398..97bd066 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -131,5 +131,5 @@ def test_callback(qgis_iface): ) assert len(qgis_iface.mapCanvas().layers()) == 1 - assert qgis_iface.mapCanvas().layers()[0].name() == "USGS/WBD/2017/HUC06" + assert qgis_iface.mapCanvas().layers()[0].name() == "FC: SGS/WBD/2017/HUC06" assert qgis_iface.mapCanvas().layers()[0].dataProvider().name() == "EE" From 58d486e88fe713e5722aa695084be346d1a90c76 Mon Sep 17 00:00:00 2001 From: Zac Deziel Date: Sat, 15 Feb 2025 18:35:17 -0800 Subject: [PATCH 43/43] Fix typo in USGS --- test/test_ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_ui.py b/test/test_ui.py index 97bd066..020cafa 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -131,5 +131,5 @@ def test_callback(qgis_iface): ) assert len(qgis_iface.mapCanvas().layers()) == 1 - assert qgis_iface.mapCanvas().layers()[0].name() == "FC: SGS/WBD/2017/HUC06" + assert qgis_iface.mapCanvas().layers()[0].name() == "FC: USGS/WBD/2017/HUC06" assert qgis_iface.mapCanvas().layers()[0].dataProvider().name() == "EE"