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<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