diff --git a/python/PyQt6/core/auto_additions/qgsstringutils.py b/python/PyQt6/core/auto_additions/qgsstringutils.py index 41be3aab41d1..434be7ce67b8 100644 --- a/python/PyQt6/core/auto_additions/qgsstringutils.py +++ b/python/PyQt6/core/auto_additions/qgsstringutils.py @@ -18,5 +18,6 @@ QgsStringUtils.htmlToMarkdown = staticmethod(QgsStringUtils.htmlToMarkdown) QgsStringUtils.qRegExpEscape = staticmethod(QgsStringUtils.qRegExpEscape) QgsStringUtils.truncateMiddleOfString = staticmethod(QgsStringUtils.truncateMiddleOfString) + QgsStringUtils.containsByWord = staticmethod(QgsStringUtils.containsByWord) except (NameError, AttributeError): pass diff --git a/python/PyQt6/core/auto_generated/qgsstringutils.sip.in b/python/PyQt6/core/auto_generated/qgsstringutils.sip.in index 2bfe944f4fab..6a95feb4628d 100644 --- a/python/PyQt6/core/auto_generated/qgsstringutils.sip.in +++ b/python/PyQt6/core/auto_generated/qgsstringutils.sip.in @@ -328,6 +328,19 @@ will be truncated by removing characters from the middle of the string and replacing them with a horizontal ellipsis character. .. versionadded:: 3.22 +%End + + static bool containsByWord( const QString &candidate, const QString &words, Qt::CaseSensitivity sensitivity = Qt::CaseInsensitive ); +%Docstring +Given a ``candidate`` string, returns ``True`` if the ``candidate`` contains +all the individual words from another string, regardless of their order. + +.. note:: + + The search does NOT need to match whole words in the ``candidate`` string, + so eg a candidate string of "Worldmap_Winkel_II" will return ``True`` for ``words`` "winkle world" + +.. versionadded:: 3.42 %End }; diff --git a/python/PyQt6/gui/auto_additions/qgscoordinatereferencesystemmodel.py b/python/PyQt6/gui/auto_additions/qgscoordinatereferencesystemmodel.py index a500659953b6..5186c3be4a78 100644 --- a/python/PyQt6/gui/auto_additions/qgscoordinatereferencesystemmodel.py +++ b/python/PyQt6/gui/auto_additions/qgscoordinatereferencesystemmodel.py @@ -33,6 +33,12 @@ QgsCoordinateReferenceSystemModel.Roles.RoleProj = QgsCoordinateReferenceSystemModel.CustomRole.Proj QgsCoordinateReferenceSystemModel.RoleProj.is_monkey_patched = True QgsCoordinateReferenceSystemModel.RoleProj.__doc__ = "The coordinate reference system's PROJ representation. This is only used for non-standard CRS (i.e. those not present in the database)." +QgsCoordinateReferenceSystemModel.Group = QgsCoordinateReferenceSystemModel.CustomRole.Group +QgsCoordinateReferenceSystemModel.Group.is_monkey_patched = True +QgsCoordinateReferenceSystemModel.Group.__doc__ = "Group name. \n.. versionadded:: 3.42" +QgsCoordinateReferenceSystemModel.Projection = QgsCoordinateReferenceSystemModel.CustomRole.Projection +QgsCoordinateReferenceSystemModel.Projection.is_monkey_patched = True +QgsCoordinateReferenceSystemModel.Projection.__doc__ = "Projection name. \n.. versionadded:: 3.42" QgsCoordinateReferenceSystemModel.CustomRole.__doc__ = """Custom model roles. .. note:: @@ -73,6 +79,14 @@ Available as ``QgsCoordinateReferenceSystemModel.RoleProj`` in older QGIS releases. +* ``Group``: Group name. + + .. versionadded:: 3.42 + +* ``Projection``: Projection name. + + .. versionadded:: 3.42 + """ # -- diff --git a/python/PyQt6/gui/auto_generated/proj/qgscoordinatereferencesystemmodel.sip.in b/python/PyQt6/gui/auto_generated/proj/qgscoordinatereferencesystemmodel.sip.in index 84439bd990f1..50eb03d26b8d 100644 --- a/python/PyQt6/gui/auto_generated/proj/qgscoordinatereferencesystemmodel.sip.in +++ b/python/PyQt6/gui/auto_generated/proj/qgscoordinatereferencesystemmodel.sip.in @@ -34,6 +34,8 @@ A tree model for display of known coordinate reference systems. GroupId, Wkt, Proj, + Group, + Projection, }; QgsCoordinateReferenceSystemModel( QObject *parent /TransferThis/ = 0 ); diff --git a/python/core/auto_additions/qgsstringutils.py b/python/core/auto_additions/qgsstringutils.py index 41be3aab41d1..434be7ce67b8 100644 --- a/python/core/auto_additions/qgsstringutils.py +++ b/python/core/auto_additions/qgsstringutils.py @@ -18,5 +18,6 @@ QgsStringUtils.htmlToMarkdown = staticmethod(QgsStringUtils.htmlToMarkdown) QgsStringUtils.qRegExpEscape = staticmethod(QgsStringUtils.qRegExpEscape) QgsStringUtils.truncateMiddleOfString = staticmethod(QgsStringUtils.truncateMiddleOfString) + QgsStringUtils.containsByWord = staticmethod(QgsStringUtils.containsByWord) except (NameError, AttributeError): pass diff --git a/python/core/auto_generated/qgsstringutils.sip.in b/python/core/auto_generated/qgsstringutils.sip.in index 2bfe944f4fab..6a95feb4628d 100644 --- a/python/core/auto_generated/qgsstringutils.sip.in +++ b/python/core/auto_generated/qgsstringutils.sip.in @@ -328,6 +328,19 @@ will be truncated by removing characters from the middle of the string and replacing them with a horizontal ellipsis character. .. versionadded:: 3.22 +%End + + static bool containsByWord( const QString &candidate, const QString &words, Qt::CaseSensitivity sensitivity = Qt::CaseInsensitive ); +%Docstring +Given a ``candidate`` string, returns ``True`` if the ``candidate`` contains +all the individual words from another string, regardless of their order. + +.. note:: + + The search does NOT need to match whole words in the ``candidate`` string, + so eg a candidate string of "Worldmap_Winkel_II" will return ``True`` for ``words`` "winkle world" + +.. versionadded:: 3.42 %End }; diff --git a/python/gui/auto_additions/qgscoordinatereferencesystemmodel.py b/python/gui/auto_additions/qgscoordinatereferencesystemmodel.py index 0c7857ea34bf..4be21881d161 100644 --- a/python/gui/auto_additions/qgscoordinatereferencesystemmodel.py +++ b/python/gui/auto_additions/qgscoordinatereferencesystemmodel.py @@ -33,6 +33,12 @@ QgsCoordinateReferenceSystemModel.Roles.RoleProj = QgsCoordinateReferenceSystemModel.CustomRole.Proj QgsCoordinateReferenceSystemModel.RoleProj.is_monkey_patched = True QgsCoordinateReferenceSystemModel.RoleProj.__doc__ = "The coordinate reference system's PROJ representation. This is only used for non-standard CRS (i.e. those not present in the database)." +QgsCoordinateReferenceSystemModel.Group = QgsCoordinateReferenceSystemModel.CustomRole.Group +QgsCoordinateReferenceSystemModel.Group.is_monkey_patched = True +QgsCoordinateReferenceSystemModel.Group.__doc__ = "Group name. \n.. versionadded:: 3.42" +QgsCoordinateReferenceSystemModel.Projection = QgsCoordinateReferenceSystemModel.CustomRole.Projection +QgsCoordinateReferenceSystemModel.Projection.is_monkey_patched = True +QgsCoordinateReferenceSystemModel.Projection.__doc__ = "Projection name. \n.. versionadded:: 3.42" QgsCoordinateReferenceSystemModel.CustomRole.__doc__ = """Custom model roles. .. note:: @@ -73,6 +79,14 @@ Available as ``QgsCoordinateReferenceSystemModel.RoleProj`` in older QGIS releases. +* ``Group``: Group name. + + .. versionadded:: 3.42 + +* ``Projection``: Projection name. + + .. versionadded:: 3.42 + """ # -- diff --git a/python/gui/auto_generated/proj/qgscoordinatereferencesystemmodel.sip.in b/python/gui/auto_generated/proj/qgscoordinatereferencesystemmodel.sip.in index 4c2fe8568402..cf46c2250585 100644 --- a/python/gui/auto_generated/proj/qgscoordinatereferencesystemmodel.sip.in +++ b/python/gui/auto_generated/proj/qgscoordinatereferencesystemmodel.sip.in @@ -34,6 +34,8 @@ A tree model for display of known coordinate reference systems. GroupId, Wkt, Proj, + Group, + Projection, }; QgsCoordinateReferenceSystemModel( QObject *parent /TransferThis/ = 0 ); diff --git a/src/core/qgsstringutils.cpp b/src/core/qgsstringutils.cpp index 37deeded3ef0..cd5736463533 100644 --- a/src/core/qgsstringutils.cpp +++ b/src/core/qgsstringutils.cpp @@ -745,6 +745,23 @@ QString QgsStringUtils::truncateMiddleOfString( const QString &string, int maxLe #endif } +bool QgsStringUtils::containsByWord( const QString &candidate, const QString &words, Qt::CaseSensitivity sensitivity ) +{ + if ( candidate.trimmed().isEmpty() ) + return false; + + const thread_local QRegularExpression rxWhitespace( QStringLiteral( "\\s+" ) ); + const QStringList parts = words.split( rxWhitespace, Qt::SkipEmptyParts ); + if ( parts.empty() ) + return false; + for ( const QString &word : parts ) + { + if ( !candidate.contains( word, sensitivity ) ) + return false; + } + return true; +} + QgsStringReplacement::QgsStringReplacement( const QString &match, const QString &replacement, bool caseSensitive, bool wholeWordOnly ) : mMatch( match ) , mReplacement( replacement ) diff --git a/src/core/qgsstringutils.h b/src/core/qgsstringutils.h index 9f6e31277db5..7278ba79a3a8 100644 --- a/src/core/qgsstringutils.h +++ b/src/core/qgsstringutils.h @@ -317,6 +317,17 @@ class CORE_EXPORT QgsStringUtils */ static QString truncateMiddleOfString( const QString &string, int maxLength ); + /** + * Given a \a candidate string, returns TRUE if the \a candidate contains + * all the individual words from another string, regardless of their order. + * + * \note The search does NOT need to match whole words in the \a candidate string, + * so eg a candidate string of "Worldmap_Winkel_II" will return TRUE for \a words "winkle world" + * + * \since QGIS 3.42 + */ + static bool containsByWord( const QString &candidate, const QString &words, Qt::CaseSensitivity sensitivity = Qt::CaseInsensitive ); + }; #endif //QGSSTRINGUTILS_H diff --git a/src/gui/proj/qgscoordinatereferencesystemmodel.cpp b/src/gui/proj/qgscoordinatereferencesystemmodel.cpp index d593f9f5de33..daf8fba5b3b1 100644 --- a/src/gui/proj/qgscoordinatereferencesystemmodel.cpp +++ b/src/gui/proj/qgscoordinatereferencesystemmodel.cpp @@ -18,6 +18,7 @@ #include "moc_qgscoordinatereferencesystemmodel.cpp" #include "qgscoordinatereferencesystemutils.h" #include "qgsapplication.h" +#include "qgsstringutils.h" #include @@ -157,6 +158,12 @@ QVariant QgsCoordinateReferenceSystemModel::data( const QModelIndex &index, int case static_cast( CustomRole::Proj ): return crsNode->proj(); + case static_cast( CustomRole::Group ): + return crsNode->group(); + + case static_cast( CustomRole::Projection ): + return crsNode->projection(); + default: break; } @@ -423,6 +430,7 @@ QgsCoordinateReferenceSystemModelCrsNode *QgsCoordinateReferenceSystemModel::add break; } } + crsNode->setGroup( groupName ); if ( QgsCoordinateReferenceSystemModelGroupNode *group = parentNode->getChildGroupNode( groupId ) ) { @@ -440,6 +448,8 @@ QgsCoordinateReferenceSystemModelCrsNode *QgsCoordinateReferenceSystemModel::add QString projectionName = QgsCoordinateReferenceSystemUtils::translateProjection( record.projectionAcronym ); if ( projectionName.isEmpty() ) projectionName = tr( "Other" ); + else + crsNode->setProjection( projectionName ); if ( QgsCoordinateReferenceSystemModelGroupNode *group = parentNode->getChildGroupNode( record.projectionAcronym ) ) { @@ -706,7 +716,15 @@ bool QgsCoordinateReferenceSystemProxyModel::filterAcceptsRow( int sourceRow, co if ( !mFilterString.trimmed().isEmpty() ) { const QString name = sourceModel()->data( sourceIndex, static_cast( QgsCoordinateReferenceSystemModel::CustomRole::Name ) ).toString(); - if ( !( name.contains( mFilterString, Qt::CaseInsensitive ) + QString candidate = name; + const QString groupName = sourceModel()->data( sourceIndex, static_cast( QgsCoordinateReferenceSystemModel::CustomRole::Group ) ).toString(); + if ( !groupName.isEmpty() ) + candidate += ' ' + groupName; + const QString projectionName = sourceModel()->data( sourceIndex, static_cast( QgsCoordinateReferenceSystemModel::CustomRole::Projection ) ).toString(); + if ( !projectionName.isEmpty() ) + candidate += ' ' + projectionName; + + if ( !( QgsStringUtils::containsByWord( candidate, mFilterString ) || authid.contains( mFilterString, Qt::CaseInsensitive ) ) ) return false; } diff --git a/src/gui/proj/qgscoordinatereferencesystemmodel.h b/src/gui/proj/qgscoordinatereferencesystemmodel.h index 505a29d57fbc..6e7327dc632e 100644 --- a/src/gui/proj/qgscoordinatereferencesystemmodel.h +++ b/src/gui/proj/qgscoordinatereferencesystemmodel.h @@ -194,10 +194,44 @@ class GUI_EXPORT QgsCoordinateReferenceSystemModelCrsNode : public QgsCoordinate */ QString proj() const { return mProj; } + /** + * Sets the CRS's group name. + * + * \see group() + * \since QGIS 3.42 + */ + void setGroup( const QString &group ) { mGroup = group; } + + /** + * Returns the CRS's group name. + * + * \see setGroup() + * \since QGIS 3.42 + */ + QString group() const { return mGroup; } + + /** + * Sets the CRS's projection name. + * + * \see projection() + * \since QGIS 3.42 + */ + void setProjection( const QString &projection ) { mProjection = projection; } + + /** + * Returns the CRS's projection name. + * + * \see setProjection() + * \since QGIS 3.42 + */ + QString projection() const { return mProjection; } + private: const QgsCrsDbRecord mRecord; QString mWkt; QString mProj; + QString mGroup; + QString mProjection; }; #endif @@ -232,6 +266,8 @@ class GUI_EXPORT QgsCoordinateReferenceSystemModel : public QAbstractItemModel GroupId SIP_MONKEYPATCH_COMPAT_NAME( RoleGroupId ) = Qt::UserRole + 5, //!< The node ID (for group nodes) Wkt SIP_MONKEYPATCH_COMPAT_NAME( RoleWkt ) = Qt::UserRole + 6, //!< The coordinate reference system's WKT representation. This is only used for non-standard CRS (i.e. those not present in the database). Proj SIP_MONKEYPATCH_COMPAT_NAME( RoleProj ) = Qt::UserRole + 7, //!< The coordinate reference system's PROJ representation. This is only used for non-standard CRS (i.e. those not present in the database). + Group = Qt::UserRole + 8, //!< Group name. \since QGIS 3.42 + Projection = Qt::UserRole + 9, //!< Projection name. \since QGIS 3.42 }; Q_ENUM( CustomRole ) // *INDENT-ON* diff --git a/src/gui/proj/qgsprojectionselectiontreewidget.cpp b/src/gui/proj/qgsprojectionselectiontreewidget.cpp index 121254c9de3d..9882bb8d5636 100644 --- a/src/gui/proj/qgsprojectionselectiontreewidget.cpp +++ b/src/gui/proj/qgsprojectionselectiontreewidget.cpp @@ -88,6 +88,8 @@ QgsProjectionSelectionTreeWidget::QgsProjectionSelectionTreeWidget( QWidget *par connect( leSearch, &QgsFilterLineEdit::textChanged, this, [=]( const QString &filter ) { mCrsModel->setFilterString( filter ); mRecentCrsModel->setFilterString( filter ); + if ( filter.length() >= 3 ) + lstCoordinateSystems->expandAll(); } ); mAreaCanvas->setVisible( mShowMap ); diff --git a/tests/src/python/test_qgscoordinatereferencesystemmodel.py b/tests/src/python/test_qgscoordinatereferencesystemmodel.py index 11d9ff794ba4..9e6dc222e581 100644 --- a/tests/src/python/test_qgscoordinatereferencesystemmodel.py +++ b/tests/src/python/test_qgscoordinatereferencesystemmodel.py @@ -299,6 +299,16 @@ def test_model(self): epsg_3577_index, QgsCoordinateReferenceSystemModel.Roles.RoleProj ) ) + self.assertEqual( + model.data(epsg_3577_index, QgsCoordinateReferenceSystemModel.Roles.Group), + "Projected", + ) + self.assertEqual( + model.data( + epsg_3577_index, QgsCoordinateReferenceSystemModel.Roles.Projection + ), + "Albers Equal Area", + ) # check that same result is returned by authIdToIndex self.assertEqual(model.authIdToIndex("EPSG:3577"), epsg_3577_index) @@ -765,6 +775,35 @@ def test_proxy_model(self): ][0] self.assertTrue(epsg_4347_index.isValid()) + model.setFilterString("equal gda2020 Area") + projected_index = [ + model.index(row, 0, QModelIndex()) + for row in range(model.rowCount(QModelIndex())) + if model.data( + model.index(row, 0, QModelIndex()), + QgsCoordinateReferenceSystemModel.Roles.RoleGroupId, + ) + == "Projected" + ][0] + aae_index = [ + model.index(row, 0, projected_index) + for row in range(model.rowCount(projected_index)) + if model.data( + model.index(row, 0, projected_index), + Qt.ItemDataRole.DisplayRole, + ) + == "Albers Equal Area" + ][0] + epsg_9473_index = [ + model.index(row, 0, aae_index) + for row in range(model.rowCount(aae_index)) + if model.data( + model.index(row, 0, aae_index), + QgsCoordinateReferenceSystemModel.Roles.RoleAuthId, + ) + == "EPSG:9473" + ][0] + model.setFilterString("") # set filtered list of crs to show diff --git a/tests/src/python/test_qgsstringutils.py b/tests/src/python/test_qgsstringutils.py index 4fa4fbd176b6..c7efd14b3675 100644 --- a/tests/src/python/test_qgsstringutils.py +++ b/tests/src/python/test_qgsstringutils.py @@ -10,6 +10,7 @@ __date__ = "30/08/2016" __copyright__ = "Copyright 2016, The QGIS Project" +from qgis.PyQt.QtCore import Qt from qgis.PyQt.QtXml import QDomDocument from qgis.core import ( QgsStringReplacement, @@ -371,6 +372,84 @@ def test_truncate_from_middle(self): QgsStringUtils.truncateMiddleOfString("this is a test", 0), "…" ) + def test_contains_by_word(self): + """ + Test QgsStringUtils.containsByWord + """ + self.assertTrue(QgsStringUtils.containsByWord("Hello World", "world hello")) + self.assertTrue(QgsStringUtils.containsByWord("Hello World", "hello\tworld")) + self.assertTrue(QgsStringUtils.containsByWord("Hello World", "hello")) + self.assertTrue(QgsStringUtils.containsByWord("Hello World", "world")) + self.assertFalse(QgsStringUtils.containsByWord("Hello World", "goodbye")) + + # Case insensitive (default) + self.assertTrue(QgsStringUtils.containsByWord("Hello World", "WORLD")) + self.assertTrue(QgsStringUtils.containsByWord("HELLO WORLD", "world")) + + # Case sensitive + self.assertFalse( + QgsStringUtils.containsByWord( + "Hello World", "WORLD", Qt.CaseSensitivity.CaseSensitive + ) + ) + self.assertTrue( + QgsStringUtils.containsByWord( + "Hello World", "World", Qt.CaseSensitivity.CaseSensitive + ) + ) + + # Test that parts of words can match + self.assertTrue( + QgsStringUtils.containsByWord("Worldmap_Winkel_II", "winkel world") + ) + self.assertTrue(QgsStringUtils.containsByWord("HelloWorld", "hello world")) + self.assertTrue( + QgsStringUtils.containsByWord("SuperCalifragilistic", "super cal fragi") + ) + + # empty strings + self.assertFalse(QgsStringUtils.containsByWord("Hello World", "")) + self.assertFalse(QgsStringUtils.containsByWord("Hello World", " ")) + self.assertFalse(QgsStringUtils.containsByWord("", "hello")) + self.assertFalse(QgsStringUtils.containsByWord(" ", "hello")) + self.assertFalse(QgsStringUtils.containsByWord("", "")) + self.assertFalse(QgsStringUtils.containsByWord(" ", "")) + self.assertFalse(QgsStringUtils.containsByWord(" ", " ")) + + self.assertTrue(QgsStringUtils.containsByWord("Hello, World!", "hello world")) + self.assertTrue(QgsStringUtils.containsByWord("Hello-World", "hello world")) + self.assertTrue(QgsStringUtils.containsByWord("Hello_World", "hello world")) + self.assertTrue(QgsStringUtils.containsByWord("Hello\tWorld\n", "hello world")) + + # test multiple words in different orders + self.assertTrue( + QgsStringUtils.containsByWord("The Quick Brown Fox", "fox quick") + ) + self.assertTrue( + QgsStringUtils.containsByWord("The Quick Brown Fox", "brown the fox") + ) + self.assertFalse( + QgsStringUtils.containsByWord("The Quick Brown Fox", "fox quick jumping") + ) + + # test handling of unicode characters""" + self.assertTrue(QgsStringUtils.containsByWord("École Primaire", "école")) + self.assertTrue(QgsStringUtils.containsByWord("München Stadt", "münchen")) + self.assertTrue(QgsStringUtils.containsByWord("北京市", "北京")) + + # test handling of various whitespace scenarios + self.assertTrue(QgsStringUtils.containsByWord("Hello World", "hello world")) + self.assertTrue(QgsStringUtils.containsByWord("Hello\tWorld", "hello world")) + self.assertTrue(QgsStringUtils.containsByWord("Hello\nWorld", "hello world")) + self.assertTrue( + QgsStringUtils.containsByWord(" Hello World ", "hello world") + ) + + # Test handling of word boundaries + self.assertTrue(QgsStringUtils.containsByWord("HelloWorld", "hello")) + self.assertTrue(QgsStringUtils.containsByWord("WorldHello", "hello")) + self.assertTrue(QgsStringUtils.containsByWord("TheHelloWorld", "hello world")) + if __name__ == "__main__": unittest.main()