diff --git a/code-check-wrapper.sh b/code-check-wrapper.sh
index b77d903de..2fb781a37 100755
--- a/code-check-wrapper.sh
+++ b/code-check-wrapper.sh
@@ -55,6 +55,7 @@ for I in \
course_file_format.cpp \
crs_ \
duplicate_equals_t.cpp \
+ dynamic_object_query.cpp \
file_dialog.cpp \
/file_format.cpp \
file_format_t.cpp \
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 10c595c63..aeed1a2b4 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -110,6 +110,7 @@ set(Mapper_Common_SRCS
core/virtual_path.cpp
core/objects/boolean_tool.cpp
+ core/objects/dynamic_object_query.cpp
core/objects/object.cpp
core/objects/object_mover.cpp
core/objects/object_query.cpp
diff --git a/src/core/map.cpp b/src/core/map.cpp
index 4ad563ece..664ea79f9 100644
--- a/src/core/map.cpp
+++ b/src/core/map.cpp
@@ -1114,6 +1114,10 @@ void Map::ensureVisibilityOfSelectedObjects(SelectionVisibility visibility)
widget->ensureVisibilityOfRect(rect, MapWidget::DiscreteZoom);
break;
+ case CenterFullVisibility:
+ widget->ensureVisibilityOfRect(rect, MapWidget::DiscreteZoom, true);
+ break;
+
case IgnoreVisibilty:
break; // Do nothing
diff --git a/src/core/map.h b/src/core/map.h
index 0807dcdd9..bfec1cf82 100644
--- a/src/core/map.h
+++ b/src/core/map.h
@@ -121,6 +121,7 @@ friend class XMLFileExporter;
{
FullVisibility,
PartialVisibility,
+ CenterFullVisibility,
IgnoreVisibilty
};
diff --git a/src/core/objects/dynamic_object_query.cpp b/src/core/objects/dynamic_object_query.cpp
new file mode 100644
index 000000000..2b0d10c31
--- /dev/null
+++ b/src/core/objects/dynamic_object_query.cpp
@@ -0,0 +1,544 @@
+/*
+ * Copyright 2026 Matthias Kühlewein
+ *
+ * This file is part of OpenOrienteering.
+ *
+ * OpenOrienteering is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * OpenOrienteering is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with OpenOrienteering. If not, see .
+ */
+
+#include "dynamic_object_query.h"
+
+#include
+#include
+
+#include
+#include
+#include
+
+#include "core/map.h"
+#include "core/map_part.h"
+#include "core/virtual_path.h"
+#include "core/objects/object.h"
+#include "core/symbols/symbol.h"
+
+namespace {
+
+struct CompOpStruct {
+ const QString op;
+ const std::function fn;
+};
+
+static const CompOpStruct numerical_compare_operations[6] = {
+ { QLatin1String("<="), [](double op1, double op2) { return op1 <= op2; } },
+ { QLatin1String(">="), [](double op1, double op2) { return op1 >= op2; } },
+ { QLatin1String("=="), [](double op1, double op2) { return op1 == op2; } },
+ { QLatin1String("!="), [](double op1, double op2) { return op1 != op2; } },
+ { QLatin1String("<"), [](double op1, double op2) { return op1 < op2; } },
+ { QLatin1String(">"), [](double op1, double op2) { return op1 > op2; } },
+};
+
+bool NumericalComparison(bool compare, QString element, double op1 = 0.0)
+{
+ for (const auto& comp : numerical_compare_operations)
+ {
+ if (element.startsWith(comp.op))
+ {
+ const auto valuestr = element.remove(comp.op).trimmed();
+ bool ok;
+ const double op2 = valuestr.toDouble(&ok);
+ if (!compare)
+ return ok;
+ return (comp.fn)(op1, op2);
+ }
+ }
+ return false;
+}
+
+bool EvaluateAndOrOperation(const QString& element, int& and_or_operation)
+{
+ if (element == QLatin1String("AND"))
+ and_or_operation = 0;
+ else if (element == QLatin1String("OR"))
+ and_or_operation = 1;
+ else
+ return false;
+ return true;
+}
+
+bool EvaluatePaperReal(const QString& element, int& type)
+{
+ if (element == QLatin1String("PAPER"))
+ type = 0;
+ else if (element == QLatin1String("REAL"))
+ type = 1;
+ else
+ return false;
+ return true;
+}
+
+bool EvaluateSymbolType(QString element, int symbol_type, bool& result)
+{
+ const QStringList symbol_types = {QLatin1String("Point"), QLatin1String("Line"), QLatin1String("Area"), QLatin1String("Text"), QLatin1String("Combined")};
+ int operation;
+
+ if (element.startsWith(QLatin1String("==")))
+ operation = 1;
+ else if (element.startsWith(QLatin1String("!=")))
+ operation = 0;
+ else
+ return false;
+
+ const auto valuestr = element.remove(0, 2).trimmed();
+ //auto index = symbol_types.indexOf(valuestr, 0, Qt::CaseInsensitive); // Qt 6.7
+ auto match = std::find_if(symbol_types.begin(), symbol_types.end(), [&valuestr](const auto& item) {
+ return !item.compare(valuestr, Qt::CaseInsensitive);
+ });
+ if (match != symbol_types.end())
+ {
+ auto index = match - symbol_types.begin();
+ result = operation ? (1< 3)
+ return false;
+
+ for (auto i = 0; i < value_elements.size(); ++i)
+ {
+ if (i >= symbol_id_elements.size())
+ {
+ result = !(bool)operation;
+ return true;
+ }
+ if (value_elements.at(i) == QLatin1Char('*'))
+ {
+ result = (bool)operation;
+ return true;
+ }
+ if (value_elements.at(i) != symbol_id_elements.at(i))
+ {
+ result = !(bool)operation;
+ return true;
+ }
+ }
+ result = (bool)operation;
+ return true;
+}
+
+} // namespace
+
+
+namespace OpenOrienteering {
+
+const QStringList DynamicObjectQueryManager::keywords = {QLatin1String{"LINE"}, QLatin1String{"AREA"}, QLatin1String{"SYMBOL"}, QLatin1String{"OBJECT"}};
+
+DynamicObjectQueryManager::DynamicObjectQueryManager()
+{
+ // nothing else
+}
+
+DynamicObjectQuery::~DynamicObjectQuery()= default;
+
+DynamicObjectQuery* DynamicObjectQueryManager::parse(QStringRef token_text, QStringRef token_attributes_text)
+{
+ if (token_text == keywords[DynamicObjectQuery::AreaObjectQuery])
+ {
+ return new AreaObjectQuery(token_attributes_text);
+ }
+ else if (token_text == keywords[DynamicObjectQuery::LineObjectQuery])
+ {
+ return new LineObjectQuery(token_attributes_text);
+ }
+ else if (token_text == keywords[DynamicObjectQuery::SymbolQuery])
+ {
+ return new SymbolQuery(token_attributes_text);
+ }
+ else if (token_text == keywords[DynamicObjectQuery::GeneralObjectQuery])
+ {
+ return new GeneralObjectQuery(token_attributes_text);
+ }
+ return nullptr;
+}
+
+bool DynamicObjectQueryManager::performDynamicQuery(const Object* object, const DynamicObjectQuery* dynamic_query)
+{
+ switch (dynamic_query->getType())
+ {
+ case DynamicObjectQuery::AreaObjectQuery:
+ if (object->getType() == Object::Path)
+ {
+ const auto& path_object = static_cast(object);
+ const auto symbol = path_object->getSymbol();
+ if (symbol && symbol->getContainedTypes() & Symbol::Area)
+ return static_cast(dynamic_query)->performQuery(path_object);
+ }
+ return false;
+ case DynamicObjectQuery::LineObjectQuery:
+ if (object->getType() == Object::Path)
+ {
+ const auto& path_object = static_cast(object);
+ return static_cast(dynamic_query)->performQuery(path_object);
+ }
+ return false;
+ case DynamicObjectQuery::SymbolQuery:
+ {
+ const auto symbol = object->getSymbol();
+ const auto map = object->getMap();
+ return symbol && map ? static_cast(dynamic_query)->performQuery(map, symbol) : false;
+ }
+ case DynamicObjectQuery::GeneralObjectQuery:
+ {
+ const auto map = object->getMap();
+ return map ? static_cast(dynamic_query)->performQuery(map, object) : false;
+ }
+ default:
+ return false; // we should not get here
+ }
+
+ Q_UNREACHABLE();
+}
+
+const QStringList DynamicObjectQueryManager::getContextKeywords(const QString& text, int position, bool& append)
+{
+ int keyword_found = -1;
+ int i = position - 1;
+ for ( ; i > 3; --i) // consider shortest keyword
+ {
+ if (text.at(i) == QLatin1Char(')'))
+ {
+ break;
+ }
+ else if (text.at(i) == QLatin1Char('('))
+ {
+ for (auto j = 0; j < keywords.size(); ++j)
+ {
+ auto match = text.lastIndexOf(keywords[j], i-1);
+ if (match != -1 && match == i - keywords[j].length())
+ {
+ keyword_found = j;
+ break;
+ }
+ }
+ }
+ }
+
+ if (keyword_found == -1)
+ {
+ QStringList context_keywords;
+ for (auto keyword : keywords)
+ context_keywords.append(keyword.append(QLatin1String("()")));
+ append = true;
+ return context_keywords;
+ }
+
+ auto parameters = text.mid(i, position - i).trimmed();
+
+ switch (keyword_found)
+ {
+ case DynamicObjectQuery::LineObjectQuery:
+ case DynamicObjectQuery::AreaObjectQuery:
+ for (const auto& comp : numerical_compare_operations)
+ {
+ if (parameters.endsWith(comp.op))
+ return QStringList();
+ }
+ {
+ QStringList keywords;
+ if (keyword_found == DynamicObjectQuery::LineObjectQuery)
+ {
+ keywords = QString(QLatin1String("ISTOOSHORT; ISCLOSED; ISOPEN;")).split(QLatin1Char(' '));
+ }
+ else
+ {
+ keywords.append(QLatin1String("ISTOOSMALL;"));
+ }
+
+ keywords += QString(QLatin1String("PAPER; REAL; AND; OR;")).split(QLatin1Char(' '));
+ for (const auto& comp : numerical_compare_operations)
+ keywords += comp.op;
+ return keywords;
+ }
+
+ case DynamicObjectQuery::SymbolQuery:
+ if (parameters.endsWith(QLatin1String("==")) || parameters.endsWith(QLatin1String("!=")))
+ {
+ const auto find_type_keyword = parameters.lastIndexOf(QLatin1String("TYPE"));
+ const auto find_id_keyword = parameters.lastIndexOf(QLatin1String("ID"));
+ if (find_type_keyword >= find_id_keyword) // if both keywords are not found the '=' part is needed for the -1 values
+ {
+ return QString(QLatin1String("Point; Line; Area; Text; Combined;")).split(QLatin1Char(' '));
+ }
+ return QStringList();
+ }
+ return QString(QLatin1String("ISUNDEFINED; TYPE; ID; AND; OR; == !=")).split(QLatin1Char(' '));
+
+ case DynamicObjectQuery::GeneralObjectQuery:
+ return QString(QLatin1String("IGNORESYMBOL; IGNORETAGS; ISDUPLICATE;")).split(QLatin1Char(' '));
+
+ default:
+ return QStringList(); // we should not get here
+ }
+
+ Q_UNREACHABLE();
+}
+
+
+AreaObjectQuery::AreaObjectQuery(QStringRef token_attributes_text)
+: DynamicObjectQuery(DynamicObjectQuery::AreaObjectQuery)
+{
+ parseTokenAttributes(token_attributes_text);
+ valid = performQuery(nullptr); // dry run
+}
+
+bool AreaObjectQuery::performQuery(const PathObject* path_object) const
+{
+ int area_type = 0; // 0 = PAPER, 1 = REAL
+ int and_or_operation = 1; // 0 = AND, 1 = OR
+ double object_value;
+ int object_value_status = -1; // -1 = undefined, 0 = PAPER value, 1 = REAL value
+
+ bool result = false;
+ for (auto& element : attributes)
+ {
+ if (EvaluateAndOrOperation(element, and_or_operation))
+ continue;
+ else if (EvaluatePaperReal(element, area_type))
+ continue;
+ else if (element == QLatin1String("ISTOOSMALL"))
+ {
+ const bool is_too_small = path_object ? path_object->isAreaTooSmall() : false;
+ result = and_or_operation ? (result || is_too_small) : (result && is_too_small);
+ and_or_operation = 0; // default after first operation is AND operation
+ }
+ else if (NumericalComparison(false, element))
+ {
+ if (object_value_status != area_type)
+ {
+ object_value = path_object ? (area_type ? path_object->calculateRealArea() : path_object->calculatePaperArea()) : 0.0;
+ object_value_status = area_type;
+ }
+ const bool comparison_result = NumericalComparison(true, element, object_value);
+ result = and_or_operation ? (result || comparison_result) : (result && comparison_result);
+ and_or_operation = 0; // default after first operation is AND operation
+ }
+ else // unknown element or failure in NumericalComparison()
+ {
+ if (!path_object)
+ return false; // dry run failed
+ break;
+ }
+ }
+ if (path_object)
+ return result;
+ return true; // dry run was successful
+}
+
+
+LineObjectQuery::LineObjectQuery(QStringRef token_attributes_text)
+: DynamicObjectQuery(DynamicObjectQuery::LineObjectQuery)
+{
+ parseTokenAttributes(token_attributes_text);
+ valid = performQuery(nullptr); // dry run
+}
+
+bool LineObjectQuery::performQuery(const PathObject* path_object) const
+{
+ int line_type = 0; // 0 = PAPER, 1 = REAL
+ int and_or_operation = 1; // 0 = AND, 1 = OR
+ double object_value;
+ int object_value_status = -1; // -1 = undefined, 0 = PAPER value, 1 = REAL value
+
+ bool result = false;
+ for (auto& element : attributes)
+ {
+ if (EvaluateAndOrOperation(element, and_or_operation))
+ continue;
+ else if (EvaluatePaperReal(element, line_type))
+ continue;
+ else if (element == QLatin1String("ISTOOSHORT"))
+ {
+ const bool is_too_short = path_object ? path_object->isLineTooShort() : false;
+ result = and_or_operation ? (result || is_too_short) : (result && is_too_short);
+ and_or_operation = 0; // default after first operation is AND operation
+ }
+ else if (NumericalComparison(false, element))
+ {
+ if (object_value_status != line_type)
+ {
+ object_value = path_object ? (line_type ? path_object->getRealLength() : path_object->getPaperLength()) : 0.0;
+ object_value_status = line_type;
+ }
+ const bool comparison_result = NumericalComparison(true, element, object_value);
+ result = and_or_operation ? (result || comparison_result) : (result && comparison_result);
+ and_or_operation = 0; // default after first operation is AND operation
+ }
+ else if (element == QLatin1String("ISCLOSED"))
+ {
+ const bool is_closed = path_object ? path_object->parts().front().isClosed() : false;
+ result = and_or_operation ? (result || is_closed) : (result && is_closed);
+ and_or_operation = 0; // default after first operation is AND operation
+ }
+ else if (element == QLatin1String("ISOPEN"))
+ {
+ const bool is_open = path_object ? !path_object->parts().front().isClosed() : false;
+ result = and_or_operation ? (result || is_open) : (result && is_open);
+ and_or_operation = 0; // default after first operation is AND operation
+ }
+ else // unknown element or failure in NumericalComparison()
+ {
+ if (!path_object)
+ return false; // dry run failed
+ break;
+ }
+ }
+ if (path_object)
+ return result;
+ return true; // dry run was successful
+}
+
+
+SymbolQuery::SymbolQuery(QStringRef token_attributes_text)
+: DynamicObjectQuery(DynamicObjectQuery::SymbolQuery)
+{
+ parseTokenAttributes(token_attributes_text);
+ valid = performQuery(nullptr, nullptr); // dry run
+}
+
+
+bool SymbolQuery::performQuery(const Map* map, const Symbol* symbol) const
+{
+ int and_or_operation = 1; // 0 = AND, 1 = OR
+ int symbol_property = 0; // 0 = TYPE, 1 = ID
+
+ bool result = false;
+ bool eval_symbol_result;
+ for (auto& element : attributes)
+ {
+ if (EvaluateAndOrOperation(element, and_or_operation))
+ continue;
+ else if (element == QLatin1String("TYPE"))
+ symbol_property = 0;
+ else if (element == QLatin1String("ID"))
+ symbol_property = 1;
+ else if (element == QLatin1String("ISUNDEFINED"))
+ {
+ const bool is_undefined = map && symbol ? (map->findSymbolIndex(symbol) < 0) : false;
+ result = and_or_operation ? (result || is_undefined) : (result && is_undefined);
+ and_or_operation = 0; // default after first operation is AND operation
+ }
+ else if (symbol_property == 0 && EvaluateSymbolType(element, (symbol ? symbol->getType() : 0), eval_symbol_result))
+ {
+ result = and_or_operation ? (result || eval_symbol_result) : (result && eval_symbol_result);
+ and_or_operation = 0; // default after first operation is AND operation
+ }
+ else if (symbol_property == 1 && EvaluateSymbolId(element, (symbol ? symbol->getNumberAsString() : QLatin1String("1")), eval_symbol_result))
+ {
+ result = and_or_operation ? (result || eval_symbol_result) : (result && eval_symbol_result);
+ and_or_operation = 0; // default after first operation is AND operation
+ }
+ else // unknown element or failure in EvaluateSymbolType() or EvaluateSymbolId()
+ {
+ if (!symbol)
+ return false; // dry run failed
+ break;
+ }
+ }
+ if (symbol)
+ return result;
+ return true; // dry run was successful
+}
+
+
+GeneralObjectQuery::GeneralObjectQuery(QStringRef token_attributes_text)
+: DynamicObjectQuery(DynamicObjectQuery::GeneralObjectQuery)
+{
+ parseTokenAttributes(token_attributes_text);
+ valid = performQuery(nullptr, nullptr); // dry run
+}
+
+bool GeneralObjectQuery::performQuery(const Map* map, const Object* object) const
+{
+ int and_or_operation = 1; // 0 = AND, 1 = OR
+ bool ignore_symbols = false;
+ bool ignore_tags = false;
+
+ bool result = false;
+ for (auto& element : attributes)
+ {
+ if (EvaluateAndOrOperation(element, and_or_operation))
+ continue;
+ else if (element == QLatin1String("IGNORESYMBOL"))
+ ignore_symbols = true;
+ else if (element == QLatin1String("IGNORETAGS"))
+ ignore_tags = true;
+ else if (element == QLatin1String("ISDUPLICATE"))
+ {
+ if (!map || !object)
+ return true;
+ const bool isduplicate_result = map->getCurrentPart()->existsObject([object, ignore_symbols, ignore_tags](auto const* o)
+ { return object != o && object->equals(o, !ignore_symbols, !ignore_tags); }
+ );
+ result = and_or_operation ? (result || isduplicate_result) : (result && isduplicate_result);
+ and_or_operation = 0; // default after first operation is AND operation
+ }
+ else // unknown element
+ {
+ if (!object)
+ return false; // dry run failed
+ break;
+ }
+ }
+ if (object)
+ return result;
+ return true; // dry run was successful
+}
+
+
+DynamicObjectQuery::DynamicObjectQuery(Type type) noexcept
+: type { type }
+{
+ // nothing else
+}
+
+void DynamicObjectQuery::parseTokenAttributes(QStringRef token_attributes_text)
+{
+#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)
+ attributes = token_attributes_text.toString().split(QLatin1Char(';'), Qt::SkipEmptyParts);
+#else
+ attributes = token_attributes_text.toString().split(QLatin1Char(';'), QString::SkipEmptyParts);
+#endif
+ for (auto& item : attributes)
+ item = item.trimmed();
+}
+
+
+} // namespace OpenOrienteering
diff --git a/src/core/objects/dynamic_object_query.h b/src/core/objects/dynamic_object_query.h
new file mode 100644
index 000000000..f70b04b25
--- /dev/null
+++ b/src/core/objects/dynamic_object_query.h
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2026 Matthias Kühlewein
+ *
+ * This file is part of OpenOrienteering.
+ *
+ * OpenOrienteering is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * OpenOrienteering is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with OpenOrienteering. If not, see .
+ */
+
+#ifndef DYNAMIC_OBJECT_QUERY_H
+#define DYNAMIC_OBJECT_QUERY_H
+
+#include
+
+#include
+#include
+#include
+
+namespace OpenOrienteering {
+
+class Map;
+class Object;
+class PathObject;
+class Symbol;
+
+class DynamicObjectQuery
+{
+public:
+ /**
+ * Enumeration of all possible dynamic query types.
+ */
+ enum Type
+ {
+ LineObjectQuery = 0,
+ AreaObjectQuery = 1,
+ SymbolQuery = 2,
+ GeneralObjectQuery = 3
+ };
+
+ DynamicObjectQuery(Type type) noexcept;
+ ~DynamicObjectQuery();
+
+ Type getType() const { return type; }
+ bool IsValid() const { return valid; }
+
+protected:
+ void parseTokenAttributes(QStringRef token_attributes_text);
+
+ QStringList attributes;
+ bool valid;
+
+private:
+ Type type;
+};
+
+
+class AreaObjectQuery : public DynamicObjectQuery
+{
+public:
+ AreaObjectQuery(QStringRef token_attributes_text);
+
+ bool performQuery(const PathObject* path_object) const;
+};
+
+
+class LineObjectQuery : public DynamicObjectQuery
+{
+public:
+ LineObjectQuery(QStringRef token_attributes_text);
+
+ bool performQuery(const PathObject* path_object) const;
+};
+
+
+class SymbolQuery : public DynamicObjectQuery
+{
+public:
+ SymbolQuery(QStringRef token_attributes_text);
+
+ bool performQuery(const Map* map, const Symbol* symbol) const;
+};
+
+
+class GeneralObjectQuery : public DynamicObjectQuery
+{
+public:
+ GeneralObjectQuery(QStringRef token_attributes_text);
+
+ bool performQuery(const Map* map, const Object* object) const;
+};
+
+
+class DynamicObjectQueryManager
+{
+public:
+ DynamicObjectQueryManager();
+
+ static bool performDynamicQuery(const Object* object, const DynamicObjectQuery* dynamic_query);
+ static const QStringList getContextKeywords(const QString& text, int position, bool& append);
+
+ DynamicObjectQuery* parse(QStringRef token_text, QStringRef token_attributes_text);
+
+private:
+ static const QStringList keywords;
+};
+
+
+} // namespace OpenOrienteering
+
+#endif // DYNAMIC_OBJECT_QUERY_H
diff --git a/src/core/objects/object.cpp b/src/core/objects/object.cpp
index 7343886f7..e01eb12e5 100644
--- a/src/core/objects/object.cpp
+++ b/src/core/objects/object.cpp
@@ -133,7 +133,7 @@ void Object::copyFrom(const Object& other)
extent = other.extent;
}
-bool Object::equals(const Object* other, bool compare_symbol) const
+bool Object::equals(const Object* other, bool compare_symbol, bool compare_tags) const
{
if (type != other->type)
return false;
@@ -186,11 +186,15 @@ bool Object::equals(const Object* other, bool compare_symbol) const
return false;
}
- if (object_tags.empty())
- return other->object_tags.empty();
+ if (compare_tags)
+ {
+ if (object_tags.empty())
+ return other->object_tags.empty();
- using std::begin; using std::end;
- return std::is_permutation(object_tags.begin(), object_tags.end(), other->object_tags.begin(), other->object_tags.end());
+ using std::begin; using std::end;
+ return std::is_permutation(object_tags.begin(), object_tags.end(), other->object_tags.begin(), other->object_tags.end());
+ }
+ return true;
}
diff --git a/src/core/objects/object.h b/src/core/objects/object.h
index 816ad8aa8..9cfb5f9ee 100644
--- a/src/core/objects/object.h
+++ b/src/core/objects/object.h
@@ -124,9 +124,10 @@ friend class XMLImportExport;
* Checks for equality with another object.
*
* If compare_symbol is set, also the symbols are compared for having the same properties.
+ * If compare_tags is set, also the tags are compared for being equal.
* Note that the map property is not compared.
*/
- bool equals(const Object* other, bool compare_symbol) const;
+ bool equals(const Object* other, bool compare_symbol, bool compare_tags = true) const;
virtual bool validate() const;
diff --git a/src/core/objects/object_query.cpp b/src/core/objects/object_query.cpp
index c5f182e9c..bc72050b9 100644
--- a/src/core/objects/object_query.cpp
+++ b/src/core/objects/object_query.cpp
@@ -1,6 +1,7 @@
/*
* Copyright 2016 Mitchell Krome
* Copyright 2017-2024 Kai Pastor
+ * Copyright 2026 Matthias Kühlewein
*
* This file is part of OpenOrienteering.
*
@@ -32,7 +33,6 @@
#include
#include
#include
-#include
#include
#include "core/map.h"
@@ -146,7 +146,7 @@ class ObjectQueryNesting
* Create a cheap placeholder ObjectQuery.
*
* During object query parsing, operands right of operators are unknown
- * at construction time. However, ObjectQuery does not allowed to construct
+ * at construction time. However, ObjectQuery does not allow to construct
* logical query instances with invalid operands. This function creates valid
* placeholders which can be replaced with an invalid operand in a second step.
*/
@@ -210,7 +210,7 @@ ObjectQuery::ObjectQuery(const ObjectQuery& query)
{
; // nothing
}
- else if (op < 16)
+ else if (IsLogicalOperator(op))
{
new (&subqueries) LogicalOperands(query.subqueries);
}
@@ -222,6 +222,10 @@ ObjectQuery::ObjectQuery(const ObjectQuery& query)
{
symbol = query.symbol;
}
+ else if (op == ObjectQuery::OperatorDynamic)
+ {
+ dynamic_query = query.dynamic_query;
+ }
}
@@ -249,7 +253,7 @@ ObjectQuery& ObjectQuery::operator=(ObjectQuery&& proto) noexcept
return *this;
reset();
- consume(std::move(proto));
+ consume(std::move(proto));
return *this;
}
@@ -266,10 +270,8 @@ ObjectQuery::ObjectQuery(const QString& key, ObjectQuery::Operator op, const QSt
{
// Can't have an empty key (but can have empty value)
// Must be a key/value operator
- Q_ASSERT(op >= 16);
- Q_ASSERT(op <= 18);
- if (op < 16 || op > 18
- || key.length() == 0)
+ Q_ASSERT(IsTagOperator(op)); // 16..18
+ if (!IsTagOperator(op) || key.length() == 0)
{
reset();
}
@@ -280,12 +282,10 @@ ObjectQuery::ObjectQuery(ObjectQuery::Operator op, const QString& value)
: op { op }
, tags { {}, value }
{
- // Can't have an empty key (but can have empty value)
- // Must be a key/value operator
- Q_ASSERT(op >= 19);
- Q_ASSERT(op <= 20);
- if (op < 19 || op > 20
- || value.length() == 0)
+ // Can't have an empty value (but can have empty key)
+ // Must be a value operator
+ Q_ASSERT(IsValueOperator(op)); // 19..20
+ if (!IsValueOperator(op) || value.length() == 0)
{
reset();
}
@@ -298,10 +298,8 @@ ObjectQuery::ObjectQuery(const ObjectQuery& first, ObjectQuery::Operator op, con
{
// Both sub-queries must be valid.
// Must be a logical operator
- Q_ASSERT(op >= 1);
- Q_ASSERT(op <= 3);
- if (op < 1 || op > 3
- || !first || !second)
+ Q_ASSERT(IsLogicalOperator(op)); // 1..3
+ if (!IsLogicalOperator(op) || !first || !second)
{
reset();
}
@@ -319,10 +317,8 @@ ObjectQuery::ObjectQuery(ObjectQuery&& first, ObjectQuery::Operator op, ObjectQu
{
// Both sub-queries must be valid.
// Must be a logical operator
- Q_ASSERT(op >= 1);
- Q_ASSERT(op <= 3);
- if (op < 1 || op > 3
- || !first || !second)
+ Q_ASSERT(IsLogicalOperator(op)); // 1..3
+ if (!IsLogicalOperator(op) || !first || !second)
{
reset();
}
@@ -342,6 +338,14 @@ ObjectQuery::ObjectQuery(const Symbol* symbol) noexcept
}
+ObjectQuery::ObjectQuery(const DynamicObjectQuery* dynamic_query) noexcept
+: op { ObjectQuery::OperatorDynamic }
+, dynamic_query { dynamic_query }
+{
+ // nothing else
+}
+
+
// static
ObjectQuery ObjectQuery::negation(ObjectQuery query) noexcept
{
@@ -385,6 +389,10 @@ QString ObjectQuery::labelFor(ObjectQuery::Operator op)
//: Very short label
return tr("Symbol");
+ case OperatorDynamic:
+ //: Very short label
+ return tr("Dynamic");
+
case OperatorInvalid:
//: Very short label
return tr("invalid");
@@ -394,7 +402,6 @@ QString ObjectQuery::labelFor(ObjectQuery::Operator op)
}
-
bool ObjectQuery::operator()(const Object* object) const
{
switch(op)
@@ -427,7 +434,7 @@ bool ObjectQuery::operator()(const Object* object) const
return false;
case OperatorObjectText:
if (object->getType() == Object::Text)
- return static_cast(object)->getText().contains(tags.value, Qt::CaseInsensitive);
+ return static_cast(object)->getText().contains(tags.value, Qt::CaseInsensitive);
return false;
case OperatorAnd:
@@ -440,6 +447,10 @@ bool ObjectQuery::operator()(const Object* object) const
case OperatorSymbol:
return object->getSymbol() == symbol;
+ case OperatorDynamic:
+ Q_ASSERT(dynamic_query);
+ return DynamicObjectQueryManager::performDynamicQuery(object, dynamic_query);
+
case OperatorInvalid:
return false;
}
@@ -452,14 +463,13 @@ bool ObjectQuery::operator()(const Object* object) const
const ObjectQuery::LogicalOperands* ObjectQuery::logicalOperands() const
{
const LogicalOperands* result = nullptr;
- if (op >= 1 && op <= 3)
+ if (IsLogicalOperator(op))
{
result = &subqueries;
}
else
{
- Q_ASSERT(op >= 1);
- Q_ASSERT(op <= 3);
+ Q_ASSERT(IsLogicalOperator(op));
}
return result;
}
@@ -468,14 +478,13 @@ const ObjectQuery::LogicalOperands* ObjectQuery::logicalOperands() const
const ObjectQuery::StringOperands* ObjectQuery::tagOperands() const
{
const StringOperands* result = nullptr;
- if (op >= 16 && op <= 20)
+ if (IsStringOperator(op)) // 16..20
{
result = &tags;
}
else
{
- Q_ASSERT(op >= 16);
- Q_ASSERT(op <= 20);
+ Q_ASSERT(IsStringOperator(op));
}
return result;
}
@@ -538,6 +547,7 @@ QString ObjectQuery::toString() const
ret = QLatin1String("SYMBOL \"") + (symbol ? symbol->getNumberAsString() : QString{}) + QLatin1Char('\"');
break;
+ case OperatorDynamic: //TODO?
case OperatorInvalid:
// Default empty string is sufficient
break;
@@ -554,7 +564,7 @@ void ObjectQuery::reset()
{
; // nothing
}
- else if (op < 16)
+ else if (IsLogicalOperator(op))
{
subqueries.~LogicalOperands();
op = ObjectQuery::OperatorInvalid;
@@ -568,6 +578,13 @@ void ObjectQuery::reset()
{
op = ObjectQuery::OperatorInvalid;
}
+ else if (op == ObjectQuery::OperatorDynamic)
+ {
+ Q_ASSERT(dynamic_query);
+ if (dynamic_query)
+ delete dynamic_query;
+ op = ObjectQuery::OperatorInvalid;
+ }
}
@@ -579,7 +596,7 @@ void ObjectQuery::consume(ObjectQuery&& other)
{
; // nothing else
}
- else if (op < 16)
+ else if (IsLogicalOperator(op))
{
new (&subqueries) ObjectQuery::LogicalOperands(std::move(other.subqueries));
other.subqueries.~LogicalOperands();
@@ -593,6 +610,10 @@ void ObjectQuery::consume(ObjectQuery&& other)
{
symbol = other.symbol;
}
+ else if (op == ObjectQuery::OperatorDynamic)
+ {
+ dynamic_query = other.dynamic_query;
+ }
other.op = ObjectQuery::OperatorInvalid;
}
@@ -627,6 +648,9 @@ bool operator==(const ObjectQuery& lhs, const ObjectQuery& rhs)
case ObjectQuery::OperatorSymbol:
return lhs.symbol == rhs.symbol;
+ case ObjectQuery::OperatorDynamic:
+ return lhs.dynamic_query == rhs.dynamic_query;
+
case ObjectQuery::OperatorInvalid:
return false;
}
@@ -785,6 +809,11 @@ ObjectQuery ObjectQueryParser::parse(const QString& text)
}
getToken();
}
+ else if (token == TokenDynamicQuery && !*current)
+ {
+ *current = ObjectQuery{dynamic_token};
+ getToken();
+ }
else
{
// Invalid input
@@ -896,6 +925,27 @@ void ObjectQueryParser::getToken()
token = TokenAnd;
else if (token_text == QLatin1String("NOT"))
token = TokenNot;
+ else if (current == QLatin1Char('('))
+ {
+ int token_attributes_start = ++pos;
+ for ( ; pos < input.length(); ++pos)
+ {
+ if (input.at(pos) == QLatin1Char(')'))
+ {
+ token_attributes_text = input.mid(token_attributes_start, pos - token_attributes_start);
+ dynamic_token = dynamic_object_manager.parse(token_text, token_attributes_text); //TODO: make smartptr?
+ if (dynamic_token)
+ {
+ if (dynamic_token->IsValid())
+ token = TokenDynamicQuery;
+ else
+ delete dynamic_token; // TODO: show information about failure?
+ }
+ ++pos;
+ break;
+ }
+ }
+ }
else if (token_text == QLatin1String("SYMBOL"))
token = TokenSymbol;
else
diff --git a/src/core/objects/object_query.h b/src/core/objects/object_query.h
index 611a5061f..d263476dd 100644
--- a/src/core/objects/object_query.h
+++ b/src/core/objects/object_query.h
@@ -1,6 +1,7 @@
/*
* Copyright 2016 Mitchell Krome
* Copyright 2017-2024 Kai Pastor
+ * Copyright 2026 Matthias Kühlewein
*
* This file is part of OpenOrienteering.
*
@@ -23,6 +24,8 @@
#include
+#include "core/objects/dynamic_object_query.h"
+
#include
#include
#include
@@ -52,6 +55,7 @@ class ObjectQuery
Q_DECLARE_TR_FUNCTIONS(OpenOrienteering::ObjectQuery)
public:
+ // Important: When adding operators update the Is... functions below
enum Operator {
// Operators 1 .. 15 operate on other queries
OperatorAnd = 1, ///< And-chains two object queries
@@ -62,11 +66,13 @@ class ObjectQuery
OperatorIs = 16, ///< Tests an existing tag for equality with the given value (case-sensitive)
OperatorIsNot = 17, ///< Tests an existing tag for inequality with the given value (case-sensitive)
OperatorContains = 18, ///< Tests an existing tag for containing the given value (case-sensitive)
+
OperatorSearch = 19, ///< Tests if the symbol name, a tag key or a tag value contains the given value (case-insensitive)
OperatorObjectText = 20, ///< Text object content (case-insensitive)
// More operators, 32 ..
OperatorSymbol = 32, ///< Test the symbol for equality.
+ OperatorDynamic = 33, ///< Processing dynamic object properties
OperatorInvalid = 0 ///< Marks an invalid query
};
@@ -136,6 +142,10 @@ class ObjectQuery
*/
ObjectQuery(const Symbol* symbol) noexcept;
+ /**
+ * Constructs a query for a dynamic object property.
+ */
+ ObjectQuery(const DynamicObjectQuery* dynamic_query) noexcept;
/**
* Returns a query which is the negation of the sub-query.
@@ -201,6 +211,11 @@ class ObjectQuery
*/
void consume(ObjectQuery&& other);
+ bool IsLogicalOperator(Operator op) const { return op >= 1 && op <= 3; }
+ bool IsTagOperator(Operator op) const { return op >= 16 && op <= 18; }
+ bool IsValueOperator(Operator op) const { return op >= 19 && op <= 20; }
+ bool IsStringOperator(Operator op) const { return op >= 16 && op <= 20; }
+
using SymbolOperand = const Symbol*;
Operator op;
@@ -208,8 +223,9 @@ class ObjectQuery
union
{
LogicalOperands subqueries;
- StringOperands tags;
+ StringOperands tags;
SymbolOperand symbol;
+ const DynamicObjectQuery* dynamic_query;
};
};
@@ -280,6 +296,7 @@ class ObjectQueryParser
TokenNot,
TokenLeftParen,
TokenRightParen,
+ TokenDynamicQuery,
};
private:
@@ -289,10 +306,13 @@ class ObjectQueryParser
const Symbol* findSymbol(const QString& key) const;
+ DynamicObjectQueryManager dynamic_object_manager;
const Map* map = nullptr;
QStringRef input;
QStringRef token_text;
+ QStringRef token_attributes_text;
TokenType token;
+ DynamicObjectQuery* dynamic_token;
int token_start = -1;
int pos;
};
@@ -317,4 +337,4 @@ bool operator!=(const ObjectQuery::StringOperands& lhs, const ObjectQuery::Strin
Q_DECLARE_METATYPE(OpenOrienteering::ObjectQuery::Operator)
-#endif
+#endif // OPENORIENTEERING_OBJECT_QUERY_H
diff --git a/src/gui/map/map_find_feature.cpp b/src/gui/map/map_find_feature.cpp
index 023bb10a9..1e02d2f0c 100644
--- a/src/gui/map/map_find_feature.cpp
+++ b/src/gui/map/map_find_feature.cpp
@@ -1,5 +1,6 @@
/*
* Copyright 2017-2020, 2024, 2025 Kai Pastor
+ * Copyright 2026 Matthias Kühlewein
*
* This file is part of OpenOrienteering.
*
@@ -22,22 +23,46 @@
#include
+#include
#include
#include
+#include
+#include
+#include
+#include
#include
#include
+#include
#include
+#include
+#include
+#include
+#include
#include // IWYU pragma: keep
+#include
+#include
+#include
+#include
+#include
+#include
#include
+#include
+#include
#include
-#include
+#include
+#include
+#include
+#include
#include
+#include
#include "core/map.h"
#include "core/map_part.h"
+#include "core/objects/dynamic_object_query.h"
#include "core/objects/object.h"
#include "core/objects/object_query.h"
#include "core/symbols/symbol.h"
+#include "gui/file_dialog.h"
#include "gui/main_window.h"
#include "gui/util_gui.h"
#include "gui/map/map_editor.h"
@@ -66,6 +91,7 @@ MapFindFeature::MapFindFeature(MapEditorController& controller)
show_action->setMenuRole(QAction::NoRole);
// QKeySequence::Find may be Ctrl+F, which conflicts with "Fill / Create Border"
//show_action->setShortcut(QKeySequence::Find);
+ show_action->setShortcut(QKeySequence(tr("Ctrl+Shift+F")));
//action->setStatusTip(tr_tip);
show_action->setWhatsThis(Util::makeWhatThis("edit_menu.html"));
connect(show_action, &QAction::triggered, this, &MapFindFeature::showDialog);
@@ -110,17 +136,39 @@ void MapFindFeature::showDialog()
find_dialog = new QDialog(window);
find_dialog->setWindowTitle(tr("Find objects"));
- text_edit = new QTextEdit;
+ text_edit = new MapFindTextEdit;
text_edit->setLineWrapMode(QTextEdit::WidgetWidth);
tag_selector = new TagSelectWidget;
- auto find_next = new QPushButton(tr("&Find next"));
- connect(find_next, &QPushButton::clicked, this, &MapFindFeature::findNext);
+ selected_objects = new QLabel(); // initialization by objectSelectionChanged() below
+
+ auto* query_collection_box = new QHBoxLayout();
+ query_collection = new QComboBox();
+ query_collection->setEnabled(false);
+ connect(query_collection, QOverload::of(&QComboBox::currentIndexChanged), this, &MapFindFeature::querySelected);
+ query_collection_box->addWidget(new QLabel(tr("Query collection")));
+ query_collection_box->addWidget(query_collection, 1);
+
+ auto* load_query_collection = new QPushButton(QIcon(QString::fromLatin1(":/images/open.png")), QLatin1String{});
+ query_collection_box->addWidget(load_query_collection);
+ connect(load_query_collection, &QPushButton::clicked, this, &MapFindFeature::loadQueryCollection);
auto find_all = new QPushButton(tr("Find &all"));
connect(find_all, &QPushButton::clicked, this, &MapFindFeature::findAll);
+ auto find_next = new QPushButton(tr("&Find next"));
+ connect(find_next, &QPushButton::clicked, this, &MapFindFeature::findNext);
+
+ delete_find_next = new QPushButton(tr("Delete && Find next"));
+ connect(delete_find_next, &QPushButton::clicked, this, &MapFindFeature::deleteAndFindNext);
+ connect(controller.getMap(), &Map::objectSelectionChanged, this, &MapFindFeature::objectSelectionChanged);
+ objectSelectionChanged();
+
+ center_view = new QCheckBox(tr("Center view"));
+ center_view->setChecked(true);
+ connect(center_view, &QCheckBox::stateChanged, this, &MapFindFeature::centerView);
+
auto tags_button = new QPushButton(tr("Query editor"));
tags_button->setCheckable(true);
@@ -138,12 +186,16 @@ void MapFindFeature::showDialog()
connect(tags_button, &QAbstractButton::toggled, this, &MapFindFeature::tagSelectorToggled);
auto layout = new QGridLayout;
- layout->addLayout(editor_stack, 0, 0, 6, 1);
- layout->addWidget(find_next, 0, 1, 1, 1);
- layout->addWidget(find_all, 1, 1, 1, 1);
- layout->addWidget(tags_button, 3, 1, 1, 1);
- layout->addWidget(tag_selector_buttons, 5, 1, 1, 1);
- layout->addWidget(button_box, 6, 0, 1, 2);
+ layout->addLayout(editor_stack, 0, 0, 7, 1);
+ layout->addWidget(find_all, 0, 1, 1, 1);
+ layout->addWidget(find_next, 1, 1, 1, 1);
+ layout->addWidget(delete_find_next, 2, 1, 1, 1);
+ layout->addWidget(center_view, 3, 1, 1, 1);
+ layout->addWidget(tags_button, 5, 1, 1, 1);
+ layout->addWidget(selected_objects, 7, 0, 1, 1);
+ layout->addWidget(tag_selector_buttons, 7, 1, 1, 1);
+ layout->addLayout(query_collection_box, 8, 0, 1, 1);
+ layout->addWidget(button_box, 9, 0, 1, 2);
find_dialog->setLayout(layout);
}
@@ -154,7 +206,6 @@ void MapFindFeature::showDialog()
}
-
ObjectQuery MapFindFeature::makeQuery() const
{
auto query = ObjectQuery{};
@@ -170,7 +221,6 @@ ObjectQuery MapFindFeature::makeQuery() const
query = ObjectQuery{ ObjectQuery(ObjectQuery::OperatorSearch, text),
ObjectQuery::OperatorOr,
ObjectQuery(ObjectQuery::OperatorObjectText, text) };
-
}
}
else
@@ -190,17 +240,22 @@ ObjectQuery MapFindFeature::makeQuery() const
void MapFindFeature::findNext()
{
if (auto query = makeQuery())
- findNextMatchingObject(controller, query);
+ {
+ // remember current selected object as start point in case next object found is deleted by 'Delete & Find Next'
+ previous_object = controller.getMap()->getFirstSelectedObject();
+ findNextMatchingObject(controller, query, center_view->isChecked());
+ }
}
// static
-void MapFindFeature::findNextMatchingObject(MapEditorController& controller, const ObjectQuery& query)
+void MapFindFeature::findNextMatchingObject(MapEditorController& controller, const ObjectQuery& query, bool center_selection_visibility)
{
auto* map = controller.getMap();
Object* first_match = nullptr; // the first match in all objects
Object* pivot_object = map->getFirstSelectedObject();
Object* next_match = nullptr; // the next match after pivot_object
+
map->clearObjectSelection(false);
auto search = [&](Object* object) {
@@ -227,21 +282,34 @@ void MapFindFeature::findNextMatchingObject(MapEditorController& controller, con
map->addObjectToSelection(next_match, false);
map->emitSelectionChanged();
- map->ensureVisibilityOfSelectedObjects(Map::FullVisibility);
+ map->ensureVisibilityOfSelectedObjects(center_selection_visibility ? Map::CenterFullVisibility : Map::FullVisibility);
if (!map->selectedObjects().empty())
controller.setEditTool();
}
+void MapFindFeature::deleteAndFindNext()
+{
+ auto map = controller.getMap();
+ map->deleteSelectedObjects();
+ // restore start point for search in findNextMatchingObject() but only if the object still exists.
+ if (previous_object && map->getCurrentPart()->contains(previous_object))
+ {
+ map->addObjectToSelection(previous_object, false);
+ }
+ findNext();
+}
+
+
void MapFindFeature::findAll()
{
if (auto query = makeQuery())
- findAllMatchingObjects(controller, query);
+ findAllMatchingObjects(controller, query, center_view->isChecked());
}
// static
-void MapFindFeature::findAllMatchingObjects(MapEditorController& controller, const ObjectQuery& query)
+void MapFindFeature::findAllMatchingObjects(MapEditorController& controller, const ObjectQuery& query, bool center_selection_visibility)
{
auto map = controller.getMap();
map->clearObjectSelection(false);
@@ -252,7 +320,7 @@ void MapFindFeature::findAllMatchingObjects(MapEditorController& controller, con
}, std::cref(query));
map->emitSelectionChanged();
- map->ensureVisibilityOfSelectedObjects(Map::FullVisibility);
+ map->ensureVisibilityOfSelectedObjects(center_selection_visibility ? Map::CenterFullVisibility : Map::FullVisibility);
controller.getWindow()->showStatusBarMessage(OpenOrienteering::TagSelectWidget::tr("%n object(s) selected", nullptr, map->getNumSelectedObjects()), 2000);
if (!map->selectedObjects().empty())
@@ -260,6 +328,25 @@ void MapFindFeature::findAllMatchingObjects(MapEditorController& controller, con
}
+void MapFindFeature::objectSelectionChanged()
+{
+ auto map = controller.getMap();
+ delete_find_next->setEnabled(map->getNumSelectedObjects() == 1);
+ selected_objects->setText(tr("Number of selected objects: %1").arg(map->getNumSelectedObjects()));
+}
+
+
+void MapFindFeature::centerView()
+{
+ if (center_view->isChecked())
+ {
+ auto map = controller.getMap();
+ if (map->getNumSelectedObjects())
+ map->ensureVisibilityOfSelectedObjects(Map::CenterFullVisibility);
+ }
+}
+
+
void MapFindFeature::showHelp() const
{
Util::showHelp(controller.getWindow(), "find_objects.html");
@@ -278,5 +365,231 @@ void MapFindFeature::tagSelectorToggled(bool active)
}
}
+void MapFindFeature::querySelected()
+{
+ const auto index = query_collection->currentIndex();
+ if (index > 0)
+ {
+ text_edit->insertPlainText(query_collection_list.at(index - 1).query);
+ }
+}
+
+void MapFindFeature::showUnsupportedElementWarning(QXmlStreamReader& xml) const
+{
+ QMessageBox::warning(find_dialog,
+ tr("Warning"),
+ tr("Unsupported element: %1 (line %2 column %3)")
+ .arg(xml.name().toString())
+ .arg(xml.lineNumber())
+ .arg(xml.columnNumber())
+ );
+}
+
+void MapFindFeature::loadQueryCollection()
+{
+ auto const filter = QString{QLatin1String{"(*txt *.xml)"}};
+ auto const filepath = FileDialog::getOpenFileName(find_dialog,
+ tr("Open Query collection file..."),
+ QLatin1String{},
+ filter);
+ if (filepath.isEmpty())
+ return;
+
+ QFile query_collection_file(filepath);
+ if (!query_collection_file.open(QFile::ReadOnly))
+ {
+ QMessageBox::warning(find_dialog,
+ tr("Error"),
+ ::OpenOrienteering::MainWindow::tr("Cannot open file:\n%1\n\n%2")
+ .arg(filepath, query_collection_file.errorString())
+ );
+ return;
+ }
+
+ query_collection_list.clear();
+
+ if (filepath.endsWith(QLatin1String(".xml"), Qt::CaseInsensitive))
+ {
+ QXmlStreamReader xml(&query_collection_file);
+ if (xml.readNextStartElement())
+ {
+ while (xml.readNextStartElement())
+ {
+ if (xml.name() == QLatin1String("object_query"))
+ {
+ QueryCollectionItem query_collection_item;
+
+ while (xml.readNextStartElement())
+ {
+ auto value = xml.readElementText();
+ if (xml.name() == QLatin1String("name"))
+ {
+ query_collection_item.name = value;
+ }
+ else if (xml.name() == QLatin1String("query"))
+ {
+ query_collection_item.query = value;
+ }
+ else if (xml.name() == QLatin1String("hint"))
+ {
+ query_collection_item.hint = value;
+ }
+ else
+ {
+ showUnsupportedElementWarning(xml);
+ }
+ }
+ if (!query_collection_item.name.isEmpty() && !query_collection_item.query.isEmpty())
+ {
+ query_collection_list.push_back(query_collection_item);
+ }
+ }
+ else
+ {
+ xml.skipCurrentElement();
+ showUnsupportedElementWarning(xml);
+ }
+ }
+ }
+ }
+ else // .txt
+ {
+ QTextStream stream(&query_collection_file);
+ QString line;
+ QueryCollectionItem query_collection_item;
+
+ while (stream.readLineInto(&line, 1000)) // arbitrary limit
+ {
+ if (line.startsWith(QLatin1String("name=")))
+ {
+ line.remove(0, 5);
+ if (!query_collection_item.name.isEmpty())
+ {
+ if (query_collection_item.query.isEmpty())
+ {
+ QMessageBox::warning(find_dialog,
+ tr("Warning"),
+ tr("Missing query for: %1")
+ .arg(query_collection_item.name)
+ );
+ }
+ else
+ {
+ query_collection_list.push_back(query_collection_item);
+ query_collection_item.name.clear();
+ query_collection_item.query.clear();
+ query_collection_item.hint.clear();
+ }
+ }
+ query_collection_item.name = line;
+ }
+ else if (line.startsWith(QLatin1String("query=")))
+ {
+ line.remove(0, 6);
+ query_collection_item.query += line;
+ }
+ else if (line.startsWith(QLatin1String("hint=")))
+ {
+ line.remove(0, 5);
+ query_collection_item.hint += line;
+ }
+ else
+ {
+ // skip empty lines, lines with comments and anything else (silently)
+ }
+ }
+ if (!query_collection_item.name.isEmpty())
+ {
+ if (query_collection_item.query.isEmpty())
+ {
+ QMessageBox::warning(find_dialog,
+ tr("Warning"),
+ tr("Missing query for: %1")
+ .arg(query_collection_item.name)
+ );
+ }
+ else
+ query_collection_list.push_back(query_collection_item);
+ }
+ }
+
+ QSignalBlocker block(query_collection);
+
+ query_collection->clear();
+ if (!query_collection_list.size())
+ {
+ query_collection->setEnabled(false);
+ return;
+ }
+ query_collection->addItem(QLatin1String("---"));
+ for (auto i = 0; i < (int)query_collection_list.size(); ++i)
+ {
+ query_collection->addItem(query_collection_list.at(i).name);
+ if (!query_collection_list.at(i).hint.isEmpty())
+ query_collection->setItemData(i + 1, query_collection_list.at(i).hint, Qt::ToolTipRole);
+ }
+ query_collection->setEnabled(true);
+}
+
+
+// slot
+void MapFindTextEdit::insertKeyword(QAction* action)
+{
+ const auto keyword = action->data().toString();
+ insertPlainText(keyword);
+ if (keyword.endsWith(QLatin1Char(')')))
+ {
+ auto position = textCursor();
+ if (position.movePosition(QTextCursor::Left))
+ setTextCursor(position);
+ }
+}
+
+// override
+void MapFindTextEdit::contextMenuEvent(QContextMenuEvent* event)
+{
+ showCustomContextMenu(event->globalPos());
+}
+
+// override
+void MapFindTextEdit::keyPressEvent(QKeyEvent* event)
+{
+ if (event->key() == tr("K") && (event->modifiers() & Qt::ControlModifier))
+ {
+ showCustomContextMenu(viewport()->mapToGlobal(cursorRect().center()));
+ }
+ else
+ {
+ QTextEdit::keyPressEvent(event);
+ }
+}
+
+void MapFindTextEdit::showCustomContextMenu(const QPoint& globalPos)
+{
+ QMenu* menu = createStandardContextMenu(globalPos);
+ menu->addSeparator();
+ auto* insert_menu = new QMenu(tr("Insert keyword...\tCtrl+K"), menu);
+ insert_menu->menuAction()->setMenuRole(QAction::NoRole);
+ auto* keyword_actions_group = new QActionGroup(this);
+
+ bool append = false;
+ auto keywords = DynamicObjectQueryManager::getContextKeywords(toPlainText(), textCursor().position(), append);
+ if (append)
+ {
+ keywords.append({QLatin1String(" SYMBOL "), QLatin1String(" AND " ), QLatin1String(" OR "), QLatin1String(" NOT ")});
+ }
+ for (auto& keyword : keywords)
+ {
+ auto* action = new QAction(keyword, this);
+ action->setData(QVariant(keyword));
+ keyword_actions_group->addAction(action);
+ insert_menu->addAction(action);
+ }
+ menu->addMenu(insert_menu);
+ connect(keyword_actions_group, &QActionGroup::triggered, this, &MapFindTextEdit::insertKeyword);
+
+ menu->exec(globalPos);
+ delete menu;
+}
} // namespace OpenOrienteering
diff --git a/src/gui/map/map_find_feature.h b/src/gui/map/map_find_feature.h
index f4af33672..4fa94dd3f 100644
--- a/src/gui/map/map_find_feature.h
+++ b/src/gui/map/map_find_feature.h
@@ -1,5 +1,6 @@
/*
* Copyright 2017-2019, 2025 Kai Pastor
+ * Copyright 2026 Matthias Kühlewein
*
* This file is part of OpenOrienteering.
*
@@ -20,23 +21,52 @@
#ifndef OPENORIENTEERING_MAP_FIND_FEATURE_H
#define OPENORIENTEERING_MAP_FIND_FEATURE_H
+#include
+
#include
#include
#include
#include
+#include
class QAction;
+class QCheckBox;
+class QComboBox;
+class QContextMenuEvent;
class QDialog;
+class QKeyEvent;
+class QLabel;
+class QPoint;
+class QPushButton;
class QStackedLayout;
-class QTextEdit;
class QWidget;
+class QXmlStreamReader;
namespace OpenOrienteering {
class MapEditorController;
+class Object;
class ObjectQuery;
class TagSelectWidget;
+/**
+ * The context menu (right click or Ctrl+K) is extended by the possibility
+ * to select and insert one of the keywords (e.g., SYMBOL, AND...)
+ */
+class MapFindTextEdit : public QTextEdit
+{
+ Q_OBJECT
+
+private:
+ void contextMenuEvent(QContextMenuEvent* event) override;
+ void keyPressEvent(QKeyEvent* event) override;
+ void showCustomContextMenu(const QPoint& globalPos);
+
+private slots:
+ void insertKeyword(QAction* action);
+};
+
+
/**
* Provides an interactive feature for finding objects in the map.
*
@@ -54,13 +84,13 @@ class MapFindFeature : public QObject
void setEnabled(bool enabled);
- QAction* showDialogAction() { return show_action; }
+ QAction* showDialogAction() const { return show_action; }
- QAction* findNextAction() { return find_next_action; }
+ QAction* findNextAction() const { return find_next_action; }
- static void findNextMatchingObject(MapEditorController& controller, const ObjectQuery& query);
+ static void findNextMatchingObject(MapEditorController& controller, const ObjectQuery& query, bool center_selection_visibility = false);
- static void findAllMatchingObjects(MapEditorController& controller, const ObjectQuery& query);
+ static void findAllMatchingObjects(MapEditorController& controller, const ObjectQuery& query, bool center_selection_visibility = false);
private:
void showDialog();
@@ -69,26 +99,50 @@ class MapFindFeature : public QObject
void findNext();
+ void deleteAndFindNext();
+
void findAll();
+ void objectSelectionChanged();
+
+ void centerView();
+
void showHelp() const;
void tagSelectorToggled(bool active);
+ void querySelected();
+
+ void loadQueryCollection();
+
+ void showUnsupportedElementWarning(QXmlStreamReader& xml) const;
MapEditorController& controller;
QPointer find_dialog; // child of controller's window
QStackedLayout* editor_stack = nullptr; // child of find_dialog
- QTextEdit* text_edit = nullptr; // child of find_dialog
+ MapFindTextEdit* text_edit = nullptr; // child of find_dialog
TagSelectWidget* tag_selector = nullptr; // child of find_dialog
QWidget* tag_selector_buttons = nullptr; // child of find_dialog
+ QPushButton* delete_find_next = nullptr; // child of find_dialog
+ QCheckBox* center_view = nullptr; // child of find_dialog
+ QLabel* selected_objects = nullptr; // child of find_dialog
+ QComboBox* query_collection = nullptr; // child of find_dialog
QAction* show_action = nullptr; // child of this
QAction* find_next_action = nullptr; // child of this
+ Object* previous_object = nullptr;
+
+ struct QueryCollectionItem {
+ QString name;
+ QString query;
+ QString hint = {};
+ };
+
+ std::vector query_collection_list;
+
Q_DISABLE_COPY(MapFindFeature)
};
-
} // namespace OpenOrienteering
#endif // OPENORIENTEERING_MAP_FIND_FEATURE_H
diff --git a/src/gui/map/map_widget.cpp b/src/gui/map/map_widget.cpp
index 69bd9acd4..8439b2bed 100644
--- a/src/gui/map/map_widget.cpp
+++ b/src/gui/map/map_widget.cpp
@@ -1,6 +1,6 @@
/*
* Copyright 2012-2014 Thomas Schöps
- * Copyright 2013-2020 Kai Pastor
+ * Copyright 2013-2020, 2026 Kai Pastor
*
* This file is part of OpenOrienteering.
*
@@ -426,7 +426,7 @@ void MapWidget::moveMap(int steps_x, int steps_y)
}
}
-void MapWidget::ensureVisibilityOfRect(QRectF map_rect, ZoomOption zoom_option)
+void MapWidget::ensureVisibilityOfRect(QRectF map_rect, ZoomOption zoom_option, bool center_view)
{
// Amount in pixels that is scrolled "too much" if the rect is not completely visible
// TODO: change to absolute size using dpi value
@@ -436,7 +436,11 @@ void MapWidget::ensureVisibilityOfRect(QRectF map_rect, ZoomOption zoom_option)
// TODO: this method assumes that the viewport is not rotated.
if (rect().contains(viewport_rect.topLeft()) && rect().contains(viewport_rect.bottomRight()))
+ {
+ if (center_view)
+ view->setCenter(MapCoord{ map_rect.center() });
return;
+ }
auto offset = MapCoordF{ 0, 0 };
@@ -451,7 +455,12 @@ void MapWidget::ensureVisibilityOfRect(QRectF map_rect, ZoomOption zoom_option)
offset.ry() = view->pixelToLength(viewport_rect.bottom() - height() + pixel_border) / 1000.0;
if (!qIsNull(offset.lengthSquared()))
- view->setCenter(view->center() + offset);
+ {
+ if (center_view)
+ view->setCenter(MapCoord{ map_rect.center() });
+ else
+ view->setCenter(view->center() + offset);
+ }
// If the rect is still not completely in view, we have to zoom out
viewport_rect = mapToViewport(map_rect).toAlignedRect();
diff --git a/src/gui/map/map_widget.h b/src/gui/map/map_widget.h
index 0b19eb34a..fc9cff9fe 100644
--- a/src/gui/map/map_widget.h
+++ b/src/gui/map/map_widget.h
@@ -1,6 +1,6 @@
/*
* Copyright 2012-2014 Thomas Schöps
- * Copyright 2013-2020 Kai Pastor
+ * Copyright 2013-2020, 2026 Kai Pastor
*
* This file is part of OpenOrienteering.
*
@@ -214,7 +214,7 @@ friend class MapView;
/**
* Adjusts the viewport so the given rect is inside the view.
*/
- void ensureVisibilityOfRect(QRectF map_rect, ZoomOption zoom_option); // clazy:exclude=function-args-by-ref
+ void ensureVisibilityOfRect(QRectF map_rect, ZoomOption zoom_option, bool center_view = false); // clazy:exclude=function-args-by-ref
/**
* Sets the view so the rect is centered and zooomed to fill the widget.
@@ -584,4 +584,4 @@ MapWidget::CoordsType MapWidget::getCoordsDisplay() const
} // namespace OpenOrienteering
-#endif
+#endif // OPENORIENTEERING_MAP_WIDGET_H